Skip to content

Analyzer feat: add stegoveritas analyzer with grouped image output and GIF frame extraction - #187

Open
aradhyacp wants to merge 12 commits into
Zeecka:mainfrom
aradhyacp:analyzer/stegoveritas
Open

aradhyacp wants to merge 12 commits into
Zeecka:mainfrom
aradhyacp:analyzer/stegoveritas

Conversation

@aradhyacp

@aradhyacp aradhyacp commented Feb 28, 2026

Copy link
Copy Markdown
Collaborator

PR Body

Summary

Integrates stegoveritas as a new analyzer, adding image-transform and GIF frame-extraction capabilities alongside the existing decomposer. Includes Dockerfile changes to resolve a Python 3.14 compatibility regression introduced by the pip-packaged binwalk dependency.

What changed

New analyzer — stegoveritas (183 LOC)

File Change
aperisolve/analyzers/stegoveritas.py New file — full SubprocessAnalyzer implementation
aperisolve/workers.py Wire analyze_stegoveritas into the parallel worker thread pool
aperisolve/config.py Add "stegoveritas" to WORKER_FILES
aperisolve/static/js/aperisolve.js Add "stegoveritas" to TOOL_ORDER; refactor image-channel rendering
Dockerfile Install stegoveritas + deps in both builder & runtime stages; fix binwalk/imp regression

Analyzer features

  • -imageTransform — generates filter/transform images (edge-enhance, sharpness, solarize, autocontrast, equalize, etc.) plus per-channel bit-planes (Red 0-7, Green 0-7, Blue 0-7).
  • -extract_frames — extracts individual frames from animated GIFs.
  • RGBA / LA / P → RGB conversion_prepare_input() converts palette and alpha-channel images to RGB before running stegoveritas filters (which crash on non-RGB). Animated GIFs are left untouched to preserve frames.
  • Grouped image output_classify_image() parses stegoveritas filenames via regex and categorises them into named groups (Red, Green, Blue, Alpha, Transforms, Frames), ordered via _GROUP_ORDER. The JSON emitted matches decomposer's {"images": {"Group": [url, …]}} structure, so the frontend renders <h3> headings per group automatically.

Frontend — dynamic channel rendering

The old JS hardcoded channel lists for decomposer (["Superimposed", "Red", "Green", "Blue", "Alpha"]) and used length-based heuristics to pick which set. This broke for stegoveritas (which emits different group names like Transforms, Frames).

Replaced with a generic approach:

var allKeys = Object.keys(result[tool]["images"]);
var preferredOrder = ["Superimposed", "Red", "Green", "Blue", "Alpha"];
var channels = preferredOrder.filter((k) => allKeys.includes(k));
var remaining = allKeys.filter((k) => !preferredOrder.includes(k));
channels = channels.concat(remaining);

Known channels appear in preferred order first; any additional groups (from stegoveritas or future analyzers) are appended. Fully backward-compatible with decomposer and color_remapping.

Dockerfile — binwalk / Python 3.14 regression fix

Root cause: Installing stegoveritas via pip pulled a pip-packaged binwalk as a transitive dependency. That pip binwalk:

  1. Placed a CLI wrapper at /usr/local/bin/binwalk, shadowing the working apt/system binary at /usr/bin/binwalk.
  2. Imports the legacy stdlib module imp (removed in Python 3.14), causing ModuleNotFoundError at runtime.

On main there is no stegoveritas, so the system binwalk is used and everything works under 3.14.

Fix (two layers):

  1. rm -f /usr/local/bin/binwalk — after pip install stegoveritas, remove the pip entrypoint so the runtime resolves to the known-good apt binary at /usr/bin/binwalk.
  2. imp.py shim — a minimal compatibility shim written to /usr/local/lib/python3.14/imp.py implementing load_source via importlib. Acts as a safety backstop for any remaining code paths in site-packages that import imp.

Applied in both builder (Stage 1) and runtime (Stage 2) stages.

Benchmark — decomposer vs stegoveritas

Tested on a 2048 × 2048 RGB JPEG (~3 MB) inside the Docker worker container:

Decomposer Stegoveritas
Time 13.72 s 21.01 s
Images produced 32 50
Groups Superimposed, Red, Green, Blue Red, Green, Blue, Transforms
Ratio 1.0× (baseline) 1.5×
Wall-clock delta +7.29 s

Analysis

  • Stegoveritas is ~1.5× slower than decomposer. The extra time comes from the external stegoveritas CLI subprocess applying 26 additional Pillow image filters (sharpness variants, edge-enhance, solarize, autocontrast, etc.) on top of the 24 channel bit-planes.
  • Stegoveritas produces 50 images (24 bit-planes + 26 transforms) vs decomposer's 32 (8 superimposed + 24 channel bit-planes). The 18 extra images are unique transforms that decomposer does not provide.
  • No wall-clock impact in production — both analyzers run in parallel threads inside the worker. The actual submission processing time is max(decomposer, stegoveritas) ≈ 21 s, not sum ≈ 35 s. The stegoveritas overhead only matters if it becomes the bottleneck (which it already is by ~7 s on large images).
  • The extra 7 s buys meaningful additional forensic coverage (filter inversions, edge detection, sharpness sweeps) that decomposer's pure bit-plane approach cannot provide.

Checklist

  • ruff check aperisolve/ — all checks passed
  • ruff format aperisolve/ --check — all files already formatted
  • Tested with small (300×168) and large (2048×2048) images
  • Animated GIF frame extraction verified
  • RGBA/LA/P image conversion verified
  • Grouped image headings render correctly in the UI
  • Binwalk still works after stegoveritas installation
  • Backward-compatible with decomposer / color_remapping frontend rendering

@aradhyacp

Copy link
Copy Markdown
Collaborator Author

closes #159

@aradhyacp aradhyacp linked an issue Feb 28, 2026 that may be closed by this pull request

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new stegoveritas analyzer to Aperi’Solve to expand image-forensics coverage (image transforms + GIF frame extraction) and updates the UI/Docker image to support the new analyzer’s outputs and dependencies.

Changes:

  • Introduce StegoveritasAnalyzer (subprocess-based) with grouped image output and optional RGB preprocessing for non-RGB inputs.
  • Wire the new analyzer into the worker pool, download whitelist, and UI tool ordering + generalized image-group rendering.
  • Update Docker image to install stegoveritas and mitigate a binwalk/Python 3.14 compatibility regression.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
aperisolve/analyzers/stegoveritas.py New analyzer implementation, grouping/copying images and handling GIF frames.
aperisolve/workers.py Adds stegoveritas to parallel analyzer execution list.
aperisolve/config.py Allows stegoveritas archives to be downloadable via WORKER_FILES.
aperisolve/static/js/aperisolve.js Adds tool ordering and makes image-group rendering key-driven.
Dockerfile Installs stegoveritas + deps and adds binwalk/imp compatibility workarounds.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread Dockerfile
Comment on lines +38 to +40
RUN pip3 install stegoveritas
RUN stegoveritas_install_deps
RUN rm -f /usr/local/bin/binwalk

Copilot AI Feb 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description mentions the binwalk/imp compatibility fix being applied in both builder and runtime stages, but the imp.py shim is only added in the runtime stage in this diff. Either update the PR description or apply the same shim strategy to the builder stage as well (if the builder stage needs to run any Python code paths that import imp).

Copilot uses AI. Check for mistakes.
Comment on lines +88 to +92
return
if img.mode in {"RGBA", "LA", "P"}:
converted = img.convert("RGB")
converted_path = self.output_dir / f"{self.input_img.stem}_stegoveritas_rgb.png"
converted.save(converted_path)

Copilot AI Feb 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When converting non-RGB inputs, the converted PNG is saved into output_dir and left behind permanently. This can increase per-submission disk usage even though the converted file is not returned in results or included in the archive; consider writing it into the extracted/results directory (so it gets zipped/cleaned) or deleting it after stegoveritas finishes.

Copilot uses AI. Check for mistakes.
Comment thread Dockerfile
Comment on lines +38 to +40
RUN pip3 install stegoveritas
RUN stegoveritas_install_deps
RUN rm -f /usr/local/bin/binwalk

Copilot AI Feb 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The builder stage installs stegoveritas, runs stegoveritas_install_deps, and removes /usr/local/bin/binwalk, but none of these artifacts are copied into the runtime stage. This adds significant build time and network/apt churn without affecting the final image; consider removing these RUN steps from the builder stage and keeping stegoveritas installation only in the runtime stage (or copying only the needed artifacts intentionally).

Suggested change
RUN pip3 install stegoveritas
RUN stegoveritas_install_deps
RUN rm -f /usr/local/bin/binwalk

Copilot uses AI. Check for mistakes.
Comment thread Dockerfile
Comment on lines +75 to +87
# Provide a minimal imp shim for binwalk on Python 3.14
RUN cat <<'PY' > /usr/local/lib/python3.14/imp.py
"""Minimal imp compatibility shim for legacy libraries."""

from importlib.machinery import SourceFileLoader
from importlib.util import module_from_spec, spec_from_loader

def load_source(name, pathname):
loader = SourceFileLoader(name, pathname)
spec = spec_from_loader(name, loader)
module = module_from_spec(spec)
loader.exec_module(module)
return module

Copilot AI Feb 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The imp.py shim is written to a hard-coded /usr/local/lib/python3.14/imp.py path. This will break if the base image Python minor version changes (e.g., 3.15) or if the stdlib path differs; consider computing the target directory via Python (e.g., sysconfig.get_paths()) during build before writing the shim.

Suggested change
# Provide a minimal imp shim for binwalk on Python 3.14
RUN cat <<'PY' > /usr/local/lib/python3.14/imp.py
"""Minimal imp compatibility shim for legacy libraries."""
from importlib.machinery import SourceFileLoader
from importlib.util import module_from_spec, spec_from_loader
def load_source(name, pathname):
loader = SourceFileLoader(name, pathname)
spec = spec_from_loader(name, loader)
module = module_from_spec(spec)
loader.exec_module(module)
return module
# Provide a minimal imp shim for binwalk; place it in the active Python stdlib directory
RUN python - <<'PY'
import sysconfig
from pathlib import Path
stdlib_dir = Path(sysconfig.get_paths()["stdlib"])
imp_path = stdlib_dir / "imp.py"
imp_source = """\"\"\"Minimal imp compatibility shim for legacy libraries.\"\"\"\n
from importlib.machinery import SourceFileLoader\n
from importlib.util import module_from_spec, spec_from_loader\n
\n
def load_source(name, pathname):\n
loader = SourceFileLoader(name, pathname)\n
spec = spec_from_loader(name, loader)\n
module = module_from_spec(spec)\n
loader.exec_module(module)\n
return module\n
"""
imp_path.write_text(imp_source)

Copilot uses AI. Check for mistakes.
Comment thread Dockerfile
Comment on lines +38 to +40
RUN pip3 install stegoveritas
RUN stegoveritas_install_deps
RUN rm -f /usr/local/bin/binwalk

Copilot AI Feb 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These RUN lines install the stegoveritas package from PyPI without pinning a version or verifying integrity. Because this third-party CLI is later executed inside the analyzer container with access to submissions and application configuration/secrets, a compromised or hijacked stegoveritas release would provide an attacker with code execution in your environment. Pin this dependency to an immutable version (and ideally verify its hash/signature) instead of relying on the latest mutable package from PyPI.

Copilot uses AI. Check for mistakes.
Comment thread Dockerfile
Comment on lines +72 to +73
RUN pip install --no-cache-dir stegoveritas && stegoveritas_install_deps \
&& rm -f /usr/local/bin/binwalk

Copilot AI Feb 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runtime-stage pip install stegoveritas pulls the latest package from PyPI at build time without any version pinning or integrity checking. Since the resulting stegoveritas binary is invoked on every user submission, a malicious or compromised package version would gain arbitrary code execution inside the worker container and could exfiltrate data or abuse attached credentials. Pin stegoveritas to a specific version (and, where possible, enforce a checksum/signature) to eliminate this mutable supply-chain risk.

Copilot uses AI. Check for mistakes.
@aradhyacp

Copy link
Copy Markdown
Collaborator Author

Just for Reference im dropping the code used to benchmark between two analyzers

"""Benchmark: decomposer vs stegoveritas on a large image."""

import shutil
import tempfile
import time
from pathlib import Path

SRC_IMG = Path(
    "/app/aperisolve/results/"
    "9c705c4e4369c6535b561e73cb404621/"
    "9c705c4e4369c6535b561e73cb404621.jpg"
)


def make_workspace(src: Path) -> tuple[Path, Path]:
    """Create a parent/child dir layout that mirrors production.

    stegoveritas resolves the image as ``../filename`` relative to output_dir,
    so the image must sit in the parent of the output directory.
    """
    parent = Path(tempfile.mkdtemp())
    img_copy = parent / src.name
    shutil.copy2(src, img_copy)
    out = parent / "bench_run"
    out.mkdir()
    return img_copy, out


# --- Benchmark decomposer ---
from aperisolve.analyzers.decomposer import DecomposerAnalyzer  # noqa: E402

img1, tmp1 = make_workspace(SRC_IMG)
t0 = time.perf_counter()
d = DecomposerAnalyzer(img1, tmp1)
res_d = d.get_results()
t_decomposer = time.perf_counter() - t0
n_decomp_imgs = sum(len(v) for v in res_d.get("images", {}).values())
groups_d = list(res_d.get("images", {}).keys())
shutil.rmtree(tmp1.parent, ignore_errors=True)

# --- Benchmark stegoveritas ---
from aperisolve.analyzers.stegoveritas import StegoveritasAnalyzer  # noqa: E402

img2, tmp2 = make_workspace(SRC_IMG)
t0 = time.perf_counter()
s = StegoveritasAnalyzer(img2, tmp2)
res_s = s.get_results()
t_stegoveritas = time.perf_counter() - t0
n_stego_imgs = sum(len(v) for v in res_s.get("images", {}).values())
groups_s = list(res_s.get("images", {}).keys())
shutil.rmtree(tmp2.parent, ignore_errors=True)

print("=" * 60)
print("Image: 2048x2048 RGB JPEG  (~3 MB)")
print("=" * 60)
print()
print("Decomposer:")
print(f"  Time:   {t_decomposer:.2f}s")
print(f"  Images: {n_decomp_imgs}")
print(f"  Groups: {groups_d}")
print()
print("Stegoveritas (-imageTransform -extract_frames):")
print(f"  Time:   {t_stegoveritas:.2f}s")
print(f"  Images: {n_stego_imgs}")
print(f"  Groups: {groups_s}")
print()
print(f"Ratio:    stegoveritas is {t_stegoveritas / t_decomposer:.1f}x decomposer")
print(f"Delta:    +{t_stegoveritas - t_decomposer:.2f}s")

@aradhyacp

Copy link
Copy Markdown
Collaborator Author

@Zeecka any issues with this PR ? let me know if any changes required :)

@aradhyacp

Copy link
Copy Markdown
Collaborator Author

@Zeecka Friendly reminder about this PR :)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Comment thread Dockerfile
Comment on lines +79 to +86
from importlib.machinery import SourceFileLoader
from importlib.util import module_from_spec, spec_from_loader

def load_source(name, pathname):
loader = SourceFileLoader(name, pathname)
spec = spec_from_loader(name, loader)
module = module_from_spec(spec)
loader.exec_module(module)
Comment on lines +94 to +95
except Exception: # noqa: BLE001, S110
pass # Fall back to the original input if conversion fails.
Comment thread README.md
- [outguess](https://www.rbcafe.com/software/outguess/) (extraction with password)
- [pngcheck](https://www.libpng.org/pub/png/apps/pngcheck.html)
- [steghide](https://steghide.sourceforge.net/) (extraction with password)
- [stegoveritas](https://github.com/bannsec/stegoVeritas) (image transformation and GIF frames extraction)
@Zeecka

Zeecka commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Thanks @aradhyacp — the grouped-image output and GIF -extract_frames idea are great, and I'd love to land this. It needs a rebase and a few changes first, because main moved to the declarative analyzer API and because of the RGB-duplication point we discussed on #159.

1. Base-class API drift (blocking — this won't import against main).
SubprocessAnalyzer no longer takes name/has_archive in the constructor; those are class attributes now, and registration is automatic via __init_subclass__. So instead of:

def __init__(self, input_img, output_dir):
    super().__init__("stegoveritas", input_img, output_dir, has_archive=True)

declare:

class StegoveritasAnalyzer(SubprocessAnalyzer):
    name = "stegoveritas"
    has_archive = True
    needs_password = True   # only if you actually pass the password through
    def __init__(self, input_img, output_dir):
        super().__init__(input_img, output_dir)
        ...

Also generate_archive now has the signature generate_archive(self, extracted_dir=None) — the call self.generate_archive(self.output_dir, extracted_dir) passes one arg too many. And most of your get_results override duplicates the base implementation; consider setting self.cmd/overriding build_cmd + get_extracted_dir + process_output and reusing the inherited get_results where you can.

2. rm -f /usr/local/bin/binwalk (blocking).
That deletes the binary the existing binwalk.py analyzer runs, so it silently breaks binwalk. Whatever conflict stegoveritas_install_deps introduces, please resolve it without removing binwalk (e.g. pin/isolate stegoveritas' deps, or install it in its own venv). The imp.py shim under /usr/local/lib/python3.14 is also fragile — happy to help find a cleaner route.

3. RGB channels — please exclude them (per #159).
We agreed to keep Decomposer for the per-channel bit planes and use stegoVeritas only for the extra transforms, to avoid showing the same Red/Green/Blue planes twice. Right now _classify_image buckets _Red_/_Green_/_Blue_/_Alpha_ into channel groups — those should be filtered out, leaving Transforms + Frames.

4. Rebase. The branch is based on an old commit; workers.py has since been rewritten (analyzers now run via the declarative execute() classmethod and snapshot ORM attributes before threading), so please rebase onto current main and re-check the workers.py/aperisolve.js changes against it.

Once it's rebased with the RGB planes dropped and binwalk preserved, I'll do a full pass. Thanks again for pushing this forward. 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Analyzer] "stegoVeritas" Analyzer

4 participants