Files
275a087a07 feat: audio/video AI/C2PA metadata stripping (MP4/MOV, WAV, MP3) (#139)
The file-cleaners layer covered 15 formats -- all image, document, or
text -- and zero audio/video. That gap gets more expensive every month:
Sora, Veo, ElevenLabs, and Suno all embed provenance through the same
mechanisms image generators do, just in different containers.

New av_meta.py adds inspect/clean for:
- MP4/MOV/M4A/M4V: top-level C2PA (jumb/c2pa box) and XMP (uuid box)
  detection/stripping reuse inspect_isobmff()/strip_isobmff() from
  image_meta.py unchanged -- that's exactly the mechanism the C2PA spec
  defines for ISOBMFF-family containers, already proven for AVIF/HEIC.
  moov/udta (where generator/tool tags live) is handled separately since
  it's MP4-specific.
- WAV: RIFF LIST INFO chunk + embedded id3 chunk.
- MP3: ID3v2 frames, per-frame for v2.3/v2.4, whole-tag fallback for
  v2.2 (3-byte frame IDs are detected but not decomposed, so a partial
  rewrite is never attempted there).

Every box/chunk/frame is either kept byte-identical or dropped whole --
nothing does a partial in-place rewrite of a payload, so a container can
never come out semantically mangled. Default strip_all_metadata=True
matches this project's existing default (privacy-first: drop everything,
--keep-non-ai-metadata narrows to only AI-flagged content), same as the
image cleaners.

Wired through the full dispatch stack so the feature isn't a half
integration: format_dispatch.py (new "av" Kind), inspect_file.py /
clean_file.py (--as av), audit_lib.py (so audit_dir.py's CI/SARIF path
and the pre-commit hooks from #135 both cover audio/video too), and
server.py (HTTP /inspect and /clean).

Closes #134

Co-authored-by: Guillaume Meyer (The Opinionated Man) <1385518+guillaumemeyer@users.noreply.github.com>
2026-08-18 07:47:43 -07:00

139 lines
4.7 KiB
Python

#!/usr/bin/env python3
"""Unified inspect: text, images, and document containers."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from av_meta import inspect_av
from common import (
MAX_INPUT_BYTES,
ROUTER_ADVICE,
classify_finding_confidence,
emit_json,
eprint,
read_text_input,
)
from container_meta import inspect_container
from format_dispatch import classify
from image_meta import inspect_image
from text_unicode import human_report, inspect_text
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("path", type=Path, help="File to inspect")
p.add_argument("--json", action="store_true")
p.add_argument("--aggressive", action="store_true", help="Text: flag confusables")
p.add_argument(
"--as",
dest="force_type",
choices=("text", "image", "container", "av", "auto"),
default="auto",
)
p.add_argument(
"--force-text",
action="store_true",
help="Scan as text even when the bytes look like a binary container",
)
args = p.parse_args()
if not args.path.is_file():
eprint(f"not a file: {args.path}")
return 2
if args.path.stat().st_size > MAX_INPUT_BYTES:
eprint(f"refusing input larger than {MAX_INPUT_BYTES} bytes: {args.path}")
return 2
kind = args.force_type if args.force_type != "auto" else classify(args.path)
file_label = str(args.path.resolve())
# "unknown" means the bytes match no supported format. Inspect does not
# mutate, so report it as-is; --as / --force-text override to text.
if kind == "unknown":
if args.force_type == "text" or args.force_text:
kind = "text"
else:
note = (
"unrecognized format; pass --as text|image|container|av or --force-text to override"
)
if args.json:
emit_json({"kind": "unknown", "path": file_label, "note": note})
else:
print(f"File: {file_label}")
print("Kind: unknown")
print(note)
return 0
if kind == "text":
text = read_text_input(
str(args.path),
allow_binary=args.force_text,
advice=ROUTER_ADVICE,
)
report = inspect_text(text, aggressive=args.aggressive)
if args.json:
emit_json({"kind": "text", "path": file_label, **report.to_dict()})
else:
print(f"File: {file_label}")
print("Kind: text")
print(human_report(report))
return 0 if report.suspicious_total == 0 else 1
if kind == "image":
report = inspect_image(args.path)
if args.json:
emit_json({"kind": "image", "path": file_label, **report.to_dict()})
else:
print(f"File: {file_label}")
print("Kind: image")
print(f"Path: {report.path}")
print(f"Format: {report.format}")
print(f"C2PA: {report.has_c2pa}")
print(f"AI metadata: {report.has_ai_metadata}")
for f in report.findings:
print(f" - [{classify_finding_confidence(f)}] {f}")
return 0 if not (report.has_c2pa or report.has_ai_metadata) else 1
if kind == "av":
report = inspect_av(args.path)
if args.json:
emit_json({"kind": "av", "path": file_label, **report.to_dict()})
else:
print(f"File: {file_label}")
print("Kind: av")
print(f"Path: {report.path}")
print(f"Format: {report.format}")
print(f"C2PA: {report.has_c2pa}")
print(f"AI metadata: {report.has_ai_metadata}")
for f in report.findings:
print(f" - [{classify_finding_confidence(f)}] {f}")
return 0 if not (report.has_c2pa or report.has_ai_metadata) else 1
report = inspect_container(args.path)
if args.json:
emit_json({"kind": "container", "path": file_label, **report.to_dict()})
else:
print(f"File: {file_label}")
print("Kind: container")
print(f"Path: {report.path}")
print(f"Format: {report.format}")
print(f"C2PA: {report.has_c2pa}")
print(f"AI metadata: {report.has_ai_metadata}")
for f in report.findings:
print(f" - [{classify_finding_confidence(f)}] {f}")
# layer_a_total counts body-text carriers that clean will strip; the text
# branch above already exits non-zero for those, so containers match.
if report.has_c2pa or report.has_ai_metadata or report.layer_a_total:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())