Merge branch 'main' into fix_70

This commit is contained in:
Zeecka
2025-12-30 17:12:04 +01:00
committed by GitHub
8 changed files with 124 additions and 57 deletions
+39 -31
View File
@@ -20,7 +20,6 @@ Support Aperi'Solve:
<img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" height="40" /> <img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" height="40" />
</a> </a>
--- ---
Aperi'Solve is an open-source steganalysis web platform that performs automated analysis on images to detect and extract hidden data using common steganography tools and techniques. Aperi'Solve is an open-source steganalysis web platform that performs automated analysis on images to detect and extract hidden data using common steganography tools and techniques.
@@ -41,15 +40,15 @@ Table of contents
- Visualize each bit layer (LSB and other layers) per image channel (R/G/B/Alpha). - Visualize each bit layer (LSB and other layers) per image channel (R/G/B/Alpha).
- Browse and download each bit-layer image. - Browse and download each bit-layer image.
- Integrates and displays outputs from: - Integrates and displays outputs from:
- zsteg (LSB text/data extraction) - [zsteg](https://github.com/zed-0xff/zsteg) (LSB text/data extraction)
- steghide (extraction with password) - [steghide](https://steghide.sourceforge.net/) (extraction with password)
- outguess (extraction with password) - [outguess](https://www.rbcafe.com/software/outguess/) (extraction with password)
- openstego (extraction with password) - [openstego](https://www.openstego.com/) (extraction with password)
- exiftool (metadata and geolocation) - [exiftool](https://exiftool.org/) (metadata and geolocation)
- binwalk (embedded archives) - [binwalk](https://github.com/ReFirmLabs/binwalk) (embedded archives)
- foremost (carved files) - [foremost](https://foremost.sourceforge.net/) (carved files)
- pngcheck - [pngcheck](https://www.libpng.org/pub/png/apps/pngcheck.html)
- strings - [strings](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/strings.html)
- Worker queue architecture for offloading heavy/slow analyzers (Redis + background workers). - Worker queue architecture for offloading heavy/slow analyzers (Redis + background workers).
- Results stored for later browsing and download. - Results stored for later browsing and download.
@@ -87,10 +86,12 @@ docker exec -it aperisolve-web bash
docker exec -it postgres psql -U aperiuser -d aperisolve docker exec -it postgres psql -U aperiuser -d aperisolve
``` ```
Note: If switching between dev and production compose files, remove the `results` directory or mounted volume to avoid conflicts: > [!WARNING]
```bash > If switching between dev and production compose files, remove the `results` directory or mounted volume to avoid conflicts:
rm -rf aperisolve/results > ```bash
``` > rm -rf aperisolve/results
> ```
### Adding a new analyzer ### Adding a new analyzer
Adding a custom analyzer is straightforward: Adding a custom analyzer is straightforward:
@@ -129,9 +130,9 @@ Adding a custom analyzer is straightforward:
4. Test locally: run the worker and submit jobs to ensure outputs are produced and displayed. 4. Test locally: run the worker and submit jobs to ensure outputs are produced and displayed.
Tips: > [!TIP]
- Keep analyzers idempotent and write outputs to the provided `results_dir`. > - Keep analyzers idempotent and write outputs to the provided `results_dir`.
- Return structured JSON so the frontend can render links/downloads automatically. > - Return structured JSON so the frontend can render links/downloads automatically.
## Configuration & environment variables ## Configuration & environment variables
@@ -147,7 +148,8 @@ Main environment variables (examples):
- SECRET_KEY=change_me - SECRET_KEY=change_me
- FLASK_ENV=production/development - FLASK_ENV=production/development
If using Docker Compose, defaults are set in the compose files. For production deployments, set secure secrets via your orchestrator or environment. > [!NOTE]
> If using Docker Compose, defaults are set in the compose files. For production deployments, set secure secrets via your orchestrator or environment.
## Architecture ## Architecture
@@ -169,11 +171,16 @@ This separation keeps heavy tools (binwalk, foremost, zsteg, etc.) isolated and
## Roadmap ## Roadmap
- [ ] zsteg: full extraction (--all) and download of discovered files (mp3, etc.) - [ ] **[Bug]** Error 500 when file already exist on disc
- [ ] Mobile-friendly UI / test on mobile - [ ] **[Bug]** Duplicate key value violates unique constraint "submission_pkey"
- [ ] i18n (internationalization) - [ ] **[Bug]** Binwalk Extractor error
- [ ] Rootless / unprivileged analyzers - [ ] **[Feature]** Implement healthcheck and auto reboot
- [ ] Improve analyzer sandboxing (e.g., per-analyzer containers) - [ ] **[Feature]** Implement Sentry for better error handling
- [ ] **[Feature]** Zsteg: full extraction (--all) and download of discovered files (mp3, etc.)
- [ ] **[Feature]** Mobile-friendly UI / test on mobile
- [ ] **[Feature]** i18n (internationalization)
- [ ] **[Feature]** Rootless / unprivileged analyzers
- [ ] **[Feature]** Improve analyzer sandboxing (e.g., per-analyzer containers)
## Contributing ## Contributing
@@ -182,20 +189,21 @@ Contributions are welcome! Please:
2. Create a feature branch 2. Create a feature branch
3. Open a pull request describing your change 3. Open a pull request describing your change
Please follow the code style and run linters before submitting. > [!IMPORTANT]
The project adheres to: > Please follow the code style and run linters before submitting. The project adheres to:
- Black > - Black
- Flake8 (ignoring E203, E501, W503) > - Flake8 (ignoring E203, E501, W503)
- Pylint (ignoring W0718, R0903, R0801) > - Pylint (ignoring W0718, R0903, R0801)
- Mypy (ignoring unused-awaitable) > - Mypy (ignoring unused-awaitable)
CI will run these checks on each PR. CI will run these checks on each PR.
## Security ## Security
- If you discover a security issue, please report it privately to the repository owner instead of opening a public issue. > [!WARNING]
> If you discover a security issue, please report it privately to the repository owner instead of opening a public issue.
## Credits ## Credits
Acknowledgements: Acknowledgements:
- Thanks to contributors and the open-source community for the tools integrated (zsteg, steghide, openstego, binwalk, foremost, exiftool, ...). - Thanks to contributors and the open-source community for the tools integrated ([zsteg](https://github.com/zed-0xff/zsteg), [steghide](https://steghide.sourceforge.net/), [outguess](https://www.rbcafe.com/software/outguess/), [openstego](https://www.openstego.com/), [exiftool](https://exiftool.org/), [binwalk](https://github.com/ReFirmLabs/binwalk), [foremost](https://foremost.sourceforge.net/), [pngcheck](https://www.libpng.org/pub/png/apps/pngcheck.html), [strings](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/strings.html), ...).
+18 -9
View File
@@ -11,6 +11,12 @@ from .utils import update_data
def analyze_decomposer(input_img: Path, output_dir: Path) -> None: def analyze_decomposer(input_img: Path, output_dir: Path) -> None:
"""Analyze an image submission using bits decomposition.""" """Analyze an image submission using bits decomposition."""
img = Image.open(input_img) img = Image.open(input_img)
converted = False
if img.mode == "P":
img = img.convert("RGB")
converted = True
img_np = np.array(img) img_np = np.array(img)
# Handle grayscale, RGB, RGBA, etc. # Handle grayscale, RGB, RGBA, etc.
@@ -58,12 +64,15 @@ def analyze_decomposer(input_img: Path, output_dir: Path) -> None:
bit_img.save(out_path) bit_img.save(out_path)
image_json[channel_label] = channel_json image_json[channel_label] = channel_json
update_data( output = {
output_dir, "decomposer": {
{ "status": "ok",
"decomposer": { "images": image_json,
"status": "ok", }
"images": image_json, }
} if converted:
}, output["decomposer"][
) "note"
] = "Image contains a color palette and was converted to RGB for processing."
update_data(output_dir, output)
+12 -9
View File
@@ -18,21 +18,24 @@ def analyze_exiftool(input_img: Path, output_dir: Path) -> None:
timeout=MAX_PENDING_TIME, timeout=MAX_PENDING_TIME,
) )
if data.stderr:
update_data(
output_dir, {"exiftool": {"status": "error", "error": data.stderr}}
)
return
metadata: dict[str, str] = {} metadata: dict[str, str] = {}
for line in data.stdout.split("\n"): for line in data.stdout.split("\n"):
if ":" in line: if ":" in line:
key, value = line.split(":", 1) key, value = line.split(":", 1)
metadata[key.strip()] = value.strip() metadata[key.strip()] = value.strip()
# exiftool_dir = output_dir / "exiftool" if data.stderr:
# exiftool_dir.mkdir(parents=True, exist_ok=True) update_data(
# update_data(exiftool_dir, metadata, "data.json") output_dir,
{
"exiftool": {
"status": "error",
"output": metadata,
"error": data.stderr,
}
},
)
return
update_data( update_data(
output_dir, output_dir,
+11
View File
@@ -146,6 +146,17 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None:
logs.append(f"Target CRC found: 0x{png.crc:08x}") logs.append(f"Target CRC found: 0x{png.crc:08x}")
if png.crc == png.compute_crc():
note = (
f"PNG is already valid with dimensions {png.width}x{png.height} "
f"and crc 0x{png.crc:08x}."
)
output_no_compute: dict[str, Any] = {
"image_resize": {"status": "ok", "note": note}
}
update_data(output_dir, output_no_compute)
return
saved_img_urls = [] saved_img_urls = []
db_matches = lookup_crc(png) db_matches = lookup_crc(png)
+13
View File
@@ -24,6 +24,19 @@ def analyze_pngcheck(input_img: Path, output_dir: Path) -> None:
# ) # )
# return # return
if "this is neither a PNG or JNG image nor a MNG stream" in data.stdout:
update_data(
output_dir,
{
"pngcheck": {
"status": "error",
"error": "ERRORS DETECTED: this is neither a PNG or JNG image nor a "
"MNG stream.",
}
},
)
return
data_pngcheck = data.stdout.split("\n") if data else [] data_pngcheck = data.stdout.split("\n") if data else []
data_pngcheck = [s for s in data_pngcheck if s] data_pngcheck = [s for s in data_pngcheck if s]
+12 -5
View File
@@ -180,17 +180,24 @@ class PNG:
self.height = size[1] self.height = size[1]
self.bit_depth = bit_depth self.bit_depth = bit_depth
self.color_type = color_type self.color_type = color_type
self.crc = ( self.interlace = interlace
crc self.crc = crc or self.compute_crc()
or zlib.crc32(
def compute_crc(self) -> int:
"""Compute the CRC checksum for the PNG IHDR chunk.
Returns:
int: The computed CRC checksum.
"""
return (
zlib.crc32(
b"IHDR" b"IHDR"
+ self.width.to_bytes(4, "big") + self.width.to_bytes(4, "big")
+ self.height.to_bytes(4, "big") + self.height.to_bytes(4, "big")
+ bytes((bit_depth, color_type, 0, 0, interlace)) + bytes((self.bit_depth, self.color_type, 0, 0, self.interlace))
) )
& 0xFFFFFFFF & 0xFFFFFFFF
) )
self.interlace = interlace
@classmethod @classmethod
def from_packed(cls, packed: int, crc: int | None = None) -> Self: def from_packed(cls, packed: int, crc: int | None = None) -> Self:
+14 -1
View File
@@ -6,6 +6,7 @@ import os
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from shutil import rmtree
from typing import Any, Optional from typing import Any, Optional
from flask import Flask, Response, abort, jsonify, render_template, request, send_file from flask import Flask, Response, abort, jsonify, render_template, request, send_file
@@ -117,10 +118,22 @@ def upload_image() -> tuple[Response, int]:
submission: Submission = Submission.query.filter_by( submission: Submission = Submission.query.filter_by(
hash=submission_hash hash=submission_hash
).first() ).first()
return jsonify({"submission_hash": submission.hash}), 200
if submission is not None: # File exist but not in DB (should not happen),
rmtree(submission_path) # Removing file and continue processing as new
db.session.delete(submission) # pylint: disable=no-member
db.session.commit() # pylint: disable=no-member
else:
return jsonify({"submission_hash": submission.hash}), 200
# If image is new, create a new Image entry # If image is new, create a new Image entry
new_img_path = RESULT_FOLDER / img_hash / image_name new_img_path = RESULT_FOLDER / img_hash / image_name
sub_img = Image.query.filter_by(hash=img_hash).first() # type: ignore
if new_img_path.parent.exists() and sub_img is None:
# Inconsistent state: image file exists but no DB entry
rmtree(new_img_path.parent) # Remove the existing files and continue
if not new_img_path.parent.exists(): if not new_img_path.parent.exists():
new_img_path.parent.mkdir(parents=True, exist_ok=True) new_img_path.parent.mkdir(parents=True, exist_ok=True)
with open(new_img_path, "wb") as f: # Write file to disk with open(new_img_path, "wb") as f: # Write file to disk
+5 -2
View File
@@ -326,10 +326,13 @@ function parseResult(result) {
} }
} }
// Parse errors if (result[tool]["error"] && (/[^\s]/.test(result[tool]["error"]))) {
if (result[tool]["status"] === "error") {
showDanger(result[tool]["error"]); showDanger(result[tool]["error"]);
} }
if (result[tool]["note"] && (/[^\s]/.test(result[tool]["note"]))) {
showInfo(result[tool]["note"]);
}
} }
} }