fix: treat a failed c2patool run as inconclusive, not as "no C2PA" (#156)

c2patool exits non-zero both when an asset carries no manifest and when the
binary itself fails, and run_optional_tools only ever substring-matched the
output. A probe that died before main() produced has_manifest: False with no
other signal, so inspect reported has_c2pa: false -- the same answer it gives
for a genuinely clean asset.

Reproduced on Apple Silicon: service/Dockerfile pins a multi-arch base digest,
so an arm64 host builds an arm64 image while still installing the
x86_64-unknown-linux-gnu c2patool release (upstream publishes no linux-aarch64
build). c2patool dies with "rosetta error: failed to open elf at
/lib64/ld-linux-x86-64.so.2" and exit -5, and /capabilities kept reporting
c2patool: true because which() only finds the file.

Exposure is largest for PDF, where inspect_pdf documents exiftool and c2patool
as the more reliable detectors. For JPEG/PNG the native APP11 and PNG-chunk
scans still catch hard-bound C2PA, so there it costs corroboration rather than
the only signal.

- run_optional_tools marks a run conclusive only when it found a manifest or
  said in so many words that there is none; anything else sets ok: False
- inspect_image and inspect_pdf surface that as an inconclusive note, worded
  to land in the informational confidence bucket
- /capabilities probes each tool's version flag instead of trusting which()

Issues #1 and #3 fixed the opposite direction of this same function ("No claim
found" matching "claim"). This closes the false-negative side.

Co-authored-by: Italo Rodrigues <italo@pvwi.com.br>
Co-authored-by: Guillaume Meyer (The Opinionated Man) <1385518+guillaumemeyer@users.noreply.github.com>
This commit is contained in:
Italo Rodrigues
2026-08-19 10:34:43 -07:00
committed by GitHub
co-authored by Italo Rodrigues Guillaume Meyer
parent 47252419e4
commit ce90a71c9b
6 changed files with 216 additions and 8 deletions
+21
View File
@@ -408,6 +408,27 @@ def classify_finding_confidence(finding: str) -> str:
return "informational"
def c2patool_probe_note(tools: dict[str, Any]) -> str | None:
"""Describe an inconclusive c2patool run, or None when it answered.
c2patool exits non-zero both for an asset with no manifest and for a
binary that failed to run, so a caller that only reads `has_manifest`
cannot tell "this asset is clean" from "the probe never ran". Reporting
the second as the first is the dangerous direction: `has_c2pa: False`
beside a dead probe reads as a clean bill of health on the one check a
user would trust.
The wording deliberately avoids the substring "c2patool reports", which
classify_finding_confidence() maps to `confirmed`; "not fully inspected"
puts it in the `informational` bucket instead.
"""
ct = tools.get("c2patool") or {}
if not ct.get("available") or ct.get("ok", True):
return None
detail = ct.get("error") or "no usable verdict"
return f"c2patool probe inconclusive ({detail}); C2PA not fully inspected by this tool"
def cleaned_path(src: Path, suffix: str = ".cleaned") -> Path:
"""path/to/file.ext -> path/to/file.cleaned.ext"""
return src.with_name(f"{src.stem}{suffix}{src.suffix}")
+4
View File
@@ -17,6 +17,7 @@ from pathlib import Path
from typing import Any
from common import (
c2patool_probe_note,
classify_finding_confidence,
safe_arg,
safe_write_bytes,
@@ -1794,6 +1795,9 @@ def inspect_pdf(path: Path, data: bytes) -> tuple[bool, bool, list[str], dict]:
if ct.get("has_manifest"):
has_c2pa = True
findings.append("c2patool reports C2PA-related manifest")
probe_note = c2patool_probe_note(tools)
if probe_note:
findings.append(probe_note)
return has_c2pa, has_ai or has_c2pa, findings, {"tools": tools}
+27 -4
View File
@@ -22,6 +22,7 @@ from typing import Any
from urllib.parse import urlparse
from common import (
c2patool_probe_note,
classify_finding_confidence,
safe_arg,
safe_write_bytes,
@@ -1308,15 +1309,34 @@ def run_optional_tools(path: Path) -> dict[str, Any]:
# missing manifest as "Error: No claim found", which contains
# the substring "claim" and would otherwise read as a hit.
no_manifest = "no claim" in low or "no jumbf" in low
tools["c2patool"] = {
has_manifest = (
"claim" in low or "c2pa" in low or "manifest" in low
) and not no_manifest
# c2patool exits non-zero for a missing manifest too, so the exit
# code alone cannot separate "asset is clean" from "the probe
# never ran". Treat the run as conclusive only when it either
# found a manifest or said in so many words that there is none.
# Anything else -- a crash before main(), a kill, an unrecognized
# error -- leaves the C2PA question unanswered, and callers must
# not read that as a negative.
conclusive = has_manifest or no_manifest
entry: dict[str, Any] = {
"available": True,
"returncode": r.returncode,
"snippet": out[:2000],
"has_manifest": ("claim" in low or "c2pa" in low or "manifest" in low)
and not no_manifest,
"has_manifest": has_manifest,
"ok": conclusive,
}
if not conclusive:
entry["error"] = f"exit {r.returncode}, unrecognized output"
tools["c2patool"] = entry
except Exception as e:
tools["c2patool"] = {"available": True, "error": str(e)}
tools["c2patool"] = {
"available": True,
"ok": False,
"has_manifest": False,
"error": str(e),
}
else:
tools["c2patool"] = {"available": False}
@@ -1675,6 +1695,9 @@ def inspect_image(
if ct.get("has_manifest"):
has_c2pa = True
findings.append("c2patool reports a C2PA-related manifest")
probe_note = c2patool_probe_note(tools)
if probe_note:
notes.append(probe_note)
return ImageInspectReport(
path=str(path),
+40 -3
View File
@@ -39,8 +39,10 @@ import base64
import binascii
import json
import os
import subprocess
import sys
import tempfile
from functools import cache
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
@@ -54,6 +56,7 @@ from common import (
MAX_INPUT_BYTES,
eprint,
looks_binary,
subprocess_preexec_fn,
which,
)
from container_meta import clean_container, inspect_container
@@ -94,13 +97,47 @@ def _json_ok(payload: dict[str, Any]) -> bytes:
return json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
# Flag that makes each tool print its version and exit 0. They disagree:
# exiftool treats `--version` as an unknown option and prints usage instead.
_VERSION_FLAG = {"c2patool": "--version", "exiftool": "-ver", "qpdf": "--version"}
@cache
def _tool_usable(cmd: str) -> bool:
"""True only when the tool is on PATH *and* can actually execute.
`which` alone answers the wrong question. A binary built for another
architecture sits on PATH and still dies before main() -- the published
image pins a multi-arch base digest, so an arm64 host gets an arm64 image
carrying the x86_64-only c2patool release. Advertising that as available
is what lets a probe which never ran read as a clean verdict downstream.
Cached: a container's tool set cannot change while the process lives.
"""
path = which(cmd)
if not path:
return False
try:
r = subprocess.run(
[path, _VERSION_FLAG.get(cmd, "--version")],
capture_output=True,
text=True,
timeout=10,
preexec_fn=subprocess_preexec_fn,
check=False,
)
except Exception:
return False
return r.returncode == 0
def capabilities() -> dict[str, Any]:
return {
"version": VERSION,
"tools": {
"c2patool": which("c2patool") is not None,
"exiftool": which("exiftool") is not None,
"qpdf": which("qpdf") is not None,
"c2patool": _tool_usable("c2patool"),
"exiftool": _tool_usable("exiftool"),
"qpdf": _tool_usable("qpdf"),
},
"pixel_backends": {
"ctrlregen": bool(os.environ.get("NOAI_WATERMARK_DIR")),