| """ |
| Image integrity forensics provider. |
| |
| Delegates SHA-256 hashing to cores.vision.hashing and Pillow parsing to |
| cores.metadata.extractor — no duplicated hashing or Pillow-open logic. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from typing import Any |
|
|
| import numpy as np |
|
|
| from config.settings import Settings, settings as _default_settings |
| from cores.metadata import extract_all |
| from cores.vision import sha256_bytes |
| from pipeline.feature_extraction import PipelineOutput |
| from providers.base import BaseProvider, ProviderCapability |
|
|
|
|
| class ImageIntegrityProvider(BaseProvider): |
| name = "image_integrity" |
| capability = ProviderCapability.FORENSICS |
|
|
| def __init__(self, settings: Settings | None = None) -> None: |
| super().__init__(settings=settings or _default_settings) |
|
|
| def is_available(self) -> bool: |
| return True |
|
|
| def _run(self, pipeline_output: PipelineOutput) -> tuple[dict, dict]: |
| original = pipeline_output.original_bytes |
| img = pipeline_output.image |
| h, w = img.shape[:2] |
|
|
| checks: dict[str, Any] = {} |
| integrity_issues: list[str] = [] |
|
|
| |
| meta = extract_all(original) if original else {"error": "no bytes", "format": None} |
| pillow_ok = meta.get("error") is None |
| pillow_format = meta.get("format") |
| if not pillow_ok: |
| integrity_issues.append(f"Pillow decode: {meta.get('error')}") |
| checks["pillow_decode"] = pillow_ok |
| checks["pillow_format"] = pillow_format |
|
|
| |
| checks["opencv_dimensions"] = {"width": w, "height": h} |
|
|
| |
| size_bytes = len(original) if original else 0 |
| pixels = w * h |
| if pixels > 0 and size_bytes > 0: |
| bpp = size_bytes / pixels |
| if bpp < 0.1: |
| integrity_issues.append(f"Unusually compressed: {bpp:.3f} bytes/pixel") |
| elif bpp > 50: |
| integrity_issues.append(f"Unusually large: {bpp:.2f} bytes/pixel (possible embedded payload)") |
| checks["size_bytes"] = size_bytes |
| checks["bytes_per_pixel"] = round(size_bytes / pixels, 4) if pixels else 0 |
|
|
| |
| sha256 = sha256_bytes(original) if original else None |
| checks["sha256"] = sha256 |
|
|
| |
| app_markers = _count_jpeg_app_markers(original) |
| checks["jpeg_app_markers"] = app_markers |
| if app_markers > 5: |
| integrity_issues.append(f"High APP markers ({app_markers}) — possible embedded data") |
|
|
| integrity_score = max(0.0, 1.0 - 0.15 * len(integrity_issues)) |
|
|
| raw = { |
| "checks": checks, |
| "integrity_issues": integrity_issues, |
| "integrity_score": integrity_score, |
| } |
| normalized = { |
| "integrity_score": round(integrity_score, 4), |
| "manipulation_indicators": integrity_issues, |
| "details": { |
| "sha256": sha256, |
| "format": pillow_format, |
| "size_bytes": size_bytes, |
| "bytes_per_pixel": checks["bytes_per_pixel"], |
| "app_markers": app_markers, |
| }, |
| } |
| return raw, normalized |
|
|
|
|
| def _count_jpeg_app_markers(data: bytes | None) -> int: |
| """Count JPEG APPn markers — quick steganography heuristic.""" |
| if not data or data[:2] != b"\xff\xd8": |
| return 0 |
| count = 0 |
| i = 2 |
| while i < len(data) - 1: |
| if data[i] != 0xFF: |
| break |
| marker = data[i + 1] |
| if marker in (0xD8, 0xD9): |
| i += 2 |
| continue |
| if marker == 0x00 or 0xD0 <= marker <= 0xD7: |
| i += 2 |
| continue |
| if i + 4 > len(data): |
| break |
| seg_len = (data[i + 2] << 8) | data[i + 3] |
| if 0xE0 <= marker <= 0xEF: |
| count += 1 |
| i += 2 + seg_len |
| return count |
|
|