mirror of
https://github.com/guillaumemeyer/watermarks-remover.git
synced 2026-08-22 13:11:57 +02:00
* fix: rewrite ODT/EPUB manifests and measure real zip bytes (#122) Two container correctness/security fixes from issue #122: - clean_odt dropped marker-bearing parts while leaving their entries in META-INF/manifest.xml, so readers flagged the package as damaged. It is now two-pass: compute the dropped set, then rewrite the manifest attribute-order-independently, and write each part exactly once. The same bug class in clean_epub (dropped parts left in the OPF manifest, plus dangling spine itemrefs) gets the same two-pass treatment. - The zip budget trusted ZipInfo.file_size from the archive's own central directory, so a crafted DOCX/ODT could declare a tiny size and still expand via zf.read. Budgets are now charged on actual decompressed bytes via _read_zip_member (streaming, cap enforced mid-read), with the declared size kept only as a fast-path pre-reject. * fix: classify unrecognized bytes as "unknown", not text (#122) Two classification defects from issue #122: - format_dispatch.classify_bytes fell back to "text" for any unrecognized file, so a binary with valid UTF-8 runs could be decoded and written back mangled (corrupted with --in-place) in clean_file auto mode. Unrecognized bytes now classify as "unknown"; clean_file refuses them in auto mode (exit 2, no write, router advice) and --as text / --force-text are the explicit opt-ins. inspect_file reports kind "unknown" (exit 0), audit_lib records a non-actionable item, and the HTTP server answers /inspect with kind "unknown" but rejects /clean of unknown formats (400). - classify(path) read the whole file to sniff a header, and only a full read could detect zip containers. It now routes known extensions without reading, sniffs a 4096-byte header once for images and prefix-based containers, and reads the whole file only when the header is a zip local header (PK), where the container signature lives in the central directory. * feat: distinct exit code for partial audits (#122) audit_dir and audit_website reported success (0) even when some files or URLs could not be scanned; the exit status was computed only over the items that succeeded. A scan that is missing items is not a clean scan. - common.EXIT_PARTIAL = 3, with precedence: partial (3) > actionable (1) > clean (0) — an incomplete audit is the more important CI signal. - audit_dir returns 3 when any file was skipped/failed; audit_website returns 3 when any URL failed to fetch or inspect. Both are independent of the output format (human/json/sarif already share one return). * fix: verify the pinned upstream ref on existing checkouts (#122) setup_ctrlregen.sh/setup_synthid.sh (and their .ps1 twins) only verified the pinned commit in the fresh-clone branch; an existing checkout at an unknown or drifted revision was silently reused, defeating the commit pin. All four scripts now check HEAD against the pinned ref in the existing-checkout branch too, and repair by fetch + detach checkout (re-applying the sparse-checkout set), failing hard if the ref cannot be reached or the re-pin does not land on it. * docs: unknown-format behavior, audit exit codes, backend isolation (#122) - README: clean_file no longer auto-cleans unrecognized formats (--as text / --force-text are the opt-ins), and the CtrlRegen bootstrap documents the isolation expectation for its research-era dependency pins plus the new re-pin check on existing checkouts. - SKILL.md: audit exit codes (0/1/2/3, partial=3) and a note that /clean requires a name with a known extension. - audit_website: document why stdlib ElementTree is used (stdlib-first) and that defusedxml is the fallback if that policy changes (DTD rejection stays). - requirements-ctrlregen.txt: advisory/isolation note for the pinned research dependencies. * test: ODT manifest and EPUB OPF dangling-ref regressions (#122) - clean_odt: dropped marker-bearing parts remove their META-INF/manifest.xml file-entry (attribute-order-independent), exactly one manifest entry, root and surviving entries kept, and the manifest is byte-identical when nothing is dropped. - clean_epub: dropped non-content parts lose their <item> entry in the OPF manifest, so the book no longer references removed members.
167 lines
4.7 KiB
Python
167 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Aggregate AI-provenance audit over a directory tree.
|
|
|
|
Recursively inspects supported text/image/container files and emits one
|
|
summary plus a per-file finding list with confidence classifications.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
|
from audit_lib import aggregate, format_sarif, print_human_report, scan_file
|
|
from common import EXIT_PARTIAL, MAX_INPUT_BYTES, emit_json, eprint
|
|
|
|
DEFAULT_SKIP_DIRS = {
|
|
".git",
|
|
".hg",
|
|
".svn",
|
|
"node_modules",
|
|
"__pycache__",
|
|
".venv",
|
|
"venv",
|
|
".tox",
|
|
".mypy_cache",
|
|
".pytest_cache",
|
|
"dist",
|
|
"build",
|
|
".next",
|
|
"target",
|
|
".cache",
|
|
}
|
|
|
|
|
|
def walk_files(root: Path, skip_dirs: set[str]):
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
dirnames[:] = sorted(d for d in dirnames if d not in skip_dirs and not d.startswith("."))
|
|
for fn in sorted(filenames):
|
|
path = Path(dirpath) / fn
|
|
if path.is_file():
|
|
yield path
|
|
|
|
|
|
def _scan_worker(path: Path, check_stylometry: bool) -> tuple[dict | None, dict | None]:
|
|
try:
|
|
if path.stat().st_size > MAX_INPUT_BYTES:
|
|
return None, {"path": str(path), "reason": "too large"}
|
|
return scan_file(path, check_stylometry=check_stylometry), None
|
|
except Exception as e: # keep the audit going on one bad file
|
|
return None, {"path": str(path), "reason": str(e)}
|
|
|
|
|
|
def main() -> int:
|
|
p = argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument("path", type=Path, help="Directory to audit recursively")
|
|
p.add_argument(
|
|
"--format",
|
|
choices=["human", "json", "sarif"],
|
|
default="human",
|
|
help="Output format (default: human)",
|
|
)
|
|
p.add_argument(
|
|
"--json", action="store_true", help="Emit a JSON report (alias for --format json)"
|
|
)
|
|
p.add_argument(
|
|
"--sarif",
|
|
action="store_true",
|
|
help="Emit an OASIS SARIF 2.1.0 report (alias for --format sarif)",
|
|
)
|
|
p.add_argument(
|
|
"-j",
|
|
"--jobs",
|
|
type=int,
|
|
default=min(32, (os.cpu_count() or 1) + 4),
|
|
help="Number of concurrent worker threads (default: CPU cores + 4)",
|
|
)
|
|
p.add_argument(
|
|
"--check-stylometry",
|
|
action="store_true",
|
|
help="Also evaluate text files for AI statistical & stylometric signals",
|
|
)
|
|
p.add_argument(
|
|
"--skip",
|
|
default="",
|
|
help="Comma-separated extra directory names to skip",
|
|
)
|
|
args = p.parse_args()
|
|
|
|
root = args.path
|
|
if not root.is_dir():
|
|
eprint(f"not a directory: {root}")
|
|
return 2
|
|
|
|
skip_dirs = set(DEFAULT_SKIP_DIRS)
|
|
for raw_part in args.skip.split(","):
|
|
part = raw_part.strip()
|
|
if part:
|
|
skip_dirs.add(part)
|
|
|
|
paths = list(walk_files(root, skip_dirs))
|
|
files: list[dict] = []
|
|
skipped: list[dict] = []
|
|
|
|
if args.jobs <= 1:
|
|
for path in paths:
|
|
f, s = _scan_worker(path, args.check_stylometry)
|
|
if f is not None:
|
|
files.append(f)
|
|
if s is not None:
|
|
skipped.append(s)
|
|
else:
|
|
with ThreadPoolExecutor(max_workers=args.jobs) as pool:
|
|
futures = [pool.submit(_scan_worker, p, args.check_stylometry) for p in paths]
|
|
for fut in futures:
|
|
f, s = fut.result()
|
|
if f is not None:
|
|
files.append(f)
|
|
if s is not None:
|
|
skipped.append(s)
|
|
|
|
files.sort(key=lambda x: str(x.get("path", "")))
|
|
skipped.sort(key=lambda x: str(x.get("path", "")))
|
|
|
|
summary = aggregate(files)
|
|
report = {
|
|
"root": str(root),
|
|
"files_scanned": len(files),
|
|
"files_skipped": skipped,
|
|
"summary": summary,
|
|
"files": files,
|
|
}
|
|
|
|
out_format = args.format
|
|
if args.json:
|
|
out_format = "json"
|
|
elif args.sarif:
|
|
out_format = "sarif"
|
|
|
|
if out_format == "json":
|
|
emit_json(report)
|
|
elif out_format == "sarif":
|
|
sarif_doc = format_sarif(report)
|
|
emit_json(sarif_doc)
|
|
else:
|
|
print_human_report(
|
|
files,
|
|
summary,
|
|
extra_header={
|
|
"Root": report["root"],
|
|
"Files skipped": str(len(skipped)),
|
|
},
|
|
)
|
|
|
|
# A partial scan (one or more files could not be scanned) is reported
|
|
# with a distinct code in every output format: incomplete audits are
|
|
# the more important CI signal and take precedence over actionable.
|
|
return EXIT_PARTIAL if skipped else (1 if summary["actionable_files"] else 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|