Expand provenance metadata discovery

This commit is contained in:
Victor Kuznetsov committed 2026-09-12 13:36:46 -07:00
1 parent 927429d349
commit c4edc2820d
17 files changed
+446 -79

No files matched your search

+2
View File
@@ -464,6 +464,8 @@ class TestIdentifyNonPng:
@pytest.mark.parametrize(
("claim_generator", "platform"),
[
("Grok Imagine", "xAI Grok Imagine"),
("Suno", "Suno"),
("Higgsfield AI", "Higgsfield AI"),
("recraft.ai", "Recraft"),
("Topaz Labs Image API", "Topaz Labs"),
+106
View File
@@ -945,6 +945,30 @@ class TestExifGenerator:
def test_clean_image_is_none(self, tmp_clean_png: Path):
assert exif_generator(tmp_clean_png) is None
def test_nested_generation_model_in_webp_user_comment(self, tmp_path: Path):
import json
payload = json.dumps(
{
"generationParams": {
"modelName": "Nano Banana synthetic fixture",
"prompt": "private prompt must not become the platform label",
}
}
)
exif = piexif.dump({"Exif": {piexif.ExifIFD.UserComment: payload.encode("utf-8")}})
source = tmp_path / "generated.webp"
Image.new("RGB", (32, 32)).save(source, "WEBP", lossless=True, exif=exif)
assert exif_generator(source) == "Nano Banana synthetic fixture"
assert (
identify(source, check_visible=False).platform == "Nano Banana synthetic fixture (embedded generator tag)"
)
output = tmp_path / "clean.webp"
remove_ai_metadata(source, output)
assert exif_generator(output) is None
_FAKE_SIG = "A" * 120 # 64+ base64 chars; real Grok payloads are 300-1004
_FAKE_UUID = "12345678-1234-1234-1234-123456789abc"
@@ -970,12 +994,48 @@ def _grok_jpeg(tmp_path: Path, *, signature: str = _FAKE_SIG, artist: str = _FAK
return path
def _grok_imagemagick_png(tmp_path: Path) -> Path:
"""Write the ImageMagick raw-profile EXIF form seen on converted PNGs."""
exif = piexif.dump(
{
"0th": {
piexif.ImageIFD.ImageDescription: f"Signature: {_FAKE_SIG}".encode("latin1"),
piexif.ImageIFD.Artist: _FAKE_UUID.encode("latin1"),
piexif.ImageIFD.Make: b"Canon",
},
"Exif": {},
"GPS": {},
"1st": {},
}
)
profile = (
"\nexif\n"
+ f"{len(exif):8d}\n"
+ "\n".join(exif.hex()[start : start + 72] for start in range(0, len(exif) * 2, 72))
)
info = PngInfo()
info.add_text("Raw profile type exif", profile)
path = tmp_path / "grok-imagemagick.png"
Image.new("RGB", (64, 64), (70, 80, 90)).save(path, pnginfo=info)
return path
class TestXaiSignature:
"""xAI / Grok's EXIF Signature + UUID-Artist provenance scheme."""
def test_signature_plus_uuid_detected(self, tmp_path: Path):
assert xai_signature(_grok_jpeg(tmp_path)) is True
def test_imagemagick_png_raw_exif_profile_detected(self, tmp_path: Path):
path = _grok_imagemagick_png(tmp_path)
with Image.open(path) as image:
assert "exif" not in image.info
assert image.getexif()
assert xai_signature(path) is True
assert "xai_signature" in get_ai_metadata(path)
def test_real_grok_sample_detected(self):
# Real committed Grok download (data/fixtures/provenance/grok-1.jpg); the EXIF
# Signature + UUID-Artist pair is the only AI signal it carries.
@@ -1169,6 +1229,21 @@ class TestRemoveAiExif:
assert xai_signature(out) is False
assert has_ai_metadata(out) is False
def test_grok_raw_profile_stripped_on_png_output(self, tmp_path: Path):
src = _grok_imagemagick_png(tmp_path)
out = tmp_path / "clean.png"
remove_ai_metadata(src, out)
assert xai_signature(out) is False
with Image.open(out) as image:
assert image.getexif().get(piexif.ImageIFD.Make) == "Canon"
def test_non_text_exif_value_does_not_break_app_metadata_scrub(self):
from remove_ai_watermarks.metadata import _app_metadata_evidence
assert _app_metadata_evidence((1, 2)) == (None, None)
def test_generator_make_token_stripped(self, tmp_path: Path):
# Ideogram's EXIF Make="Ideogram AI" must be scrubbed on removal.
exif = piexif.dump({"0th": {piexif.ImageIFD.Make: b"Ideogram AI"}, "Exif": {}, "GPS": {}, "1st": {}})
@@ -1391,6 +1466,37 @@ class TestAIGCLabel:
assert info["Label"] == "1"
assert info["ContentProducer"] == "001191440300708461136T1308L"
def test_parses_lower_camel_case_tc260_fields(self, tmp_path: Path):
import json
from remove_ai_watermarks.metadata import aigc_label
p = tmp_path / "aigc-lower-camel.jpg"
payload = json.dumps(
{
"data": {
"AIGC": {
"label": "1",
"contentProducer": "synthetic-producer",
"produceId": "synthetic-id",
}
}
}
)
exif = {"Exif": {piexif.ExifIFD.UserComment: payload.encode("ascii")}}
Image.new("RGB", (32, 32)).save(p, exif=piexif.dump(exif))
info = aigc_label(p)
assert info == {
"Label": "1",
"ContentProducer": "synthetic-producer",
"ProduceID": "synthetic-id",
}
out = tmp_path / "clean-lower-camel.jpg"
remove_ai_metadata(p, out)
assert aigc_label(out) is None
def test_has_ai_metadata_detects_raw_json_exif_form(self, tmp_path: Path):
assert has_ai_metadata(self._aigc_exif_jpeg(tmp_path))
+49
View File
@@ -15,6 +15,7 @@ from pathlib import Path
import numpy as np
import pytest
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from remove_ai_watermarks.identify import (
PROVENANCE_REPORT_SCHEMA_VERSION,
@@ -157,6 +158,54 @@ class TestPlacementsTheRecordCouldDrop:
assert "xai_signature" in {s.name for s in identify(path, check_visible=False, check_invisible=False).signals}
_assert_same_verdict(path)
def test_an_imagemagick_raw_exif_profile_survives(self, tmp_path: Path):
"""Pillow decodes this PNG EXIF profile through getexif, not img.info."""
import piexif
exif = piexif.dump(
{
"0th": {
piexif.ImageIFD.ImageDescription: b"Signature: " + b"A" * 80,
piexif.ImageIFD.Artist: b"3f2504e0-4f89-11d3-9a0c-0305e82c3301",
}
}
)
profile = (
"\nexif\n"
+ f"{len(exif):8d}\n"
+ "\n".join(exif.hex()[start : start + 72] for start in range(0, len(exif) * 2, 72))
)
info = PngInfo()
info.add_text("Raw profile type exif", profile)
path = tmp_path / "grok-converted.png"
Image.fromarray(np.zeros((64, 64, 3), dtype=np.uint8)).save(path, "PNG", pnginfo=info)
assert "xai_signature" in {s.name for s in identify(path, check_visible=False, check_invisible=False).signals}
_assert_same_verdict(path)
def test_isobmff_generation_tags_survive(self, tmp_path: Path):
def box(kind: bytes, payload: bytes) -> bytes:
return (8 + len(payload)).to_bytes(4, "big") + kind + payload
def key(name: bytes) -> bytes:
return box(b"mdta", name)
def value(index: int, payload: bytes) -> bytes:
return box(index.to_bytes(4, "big"), box(b"data", b"\x00" * 8 + payload))
keys = box(b"keys", b"\x00" * 4 + (2).to_bytes(4, "big") + key(b"workflow") + key(b"title"))
ilst = box(b"ilst", value(1, b'{"app":"ComfyUI"}') + value(2, b"standard title"))
meta = box(b"meta", b"\x00" * 4 + keys + ilst)
path = tmp_path / "workflow.mp4"
path.write_bytes(
b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom"
+ box(b"mdat", b"synthetic-video")
+ box(b"moov", box(b"udta", meta))
)
assert "gen_params" in {s.name for s in identify(path, check_visible=False, check_invisible=False).signals}
_assert_same_verdict(path)
class TestRecordShape:
def test_the_record_survives_json(self, tmp_path: Path):
+10 -1
View File
@@ -25,7 +25,16 @@ def _report(**overrides):
def test_gap_markers_are_case_insensitive():
assert "c2pa" in gap._marker_hits(b"UPPERCASE C2PA MANIFEST")
assert "ComfyUI" in gap._marker_hits(b"COMFYUI WORKFLOW")
def test_gap_markers_require_structural_provenance_evidence():
noise = b"c2pa digitalSourceType Samsung Galaxy Doubao PhotoEditor_Re_Edit Signature: " + b"A" * 80
assert gap._marker_hits(noise) == []
tc260 = b'{"AIGC":{"label":"1","contentProducer":"synthetic"}}'
assert "TC260 AIGC" in gap._marker_hits(tc260)
assert "Samsung genAIType" in gap._marker_hits(b'PhotoEditor_Re_Edit_Data{"genAIType":1}')
def test_gap_candidates_cover_more_than_blind_unknowns():
+42
View File
@@ -65,6 +65,30 @@ def _video_with_tc260(path: Path, *, media_payload: bytes = _VIDEO_PAYLOAD) -> P
return path
def _video_with_comfyui_metadata(path: Path, *, media_payload: bytes = _VIDEO_PAYLOAD) -> Path:
workflow = b'{"nodes":[{"type":"KSampler"}],"app":"ComfyUI"}'
prompt = b'{"1":{"class_type":"KSampler"}}'
keys = _box(
b"keys",
b"\x00\x00\x00\x00"
+ (4).to_bytes(4, "big")
+ _metadata_key(b"workflow")
+ _metadata_key(b"prompt")
+ _metadata_key(b"title")
+ _metadata_key(b"software"),
)
ilst = _box(
b"ilst",
_metadata_value(1, workflow)
+ _metadata_value(2, prompt)
+ _metadata_value(3, b"standard title")
+ _metadata_value(4, b"ordinary editor"),
)
meta = _box(b"meta", b"\x00\x00\x00\x00" + keys + ilst)
path.write_bytes(_MP4_FTYP + _box(b"mdat", media_payload) + _box(b"moov", _box(b"udta", meta)))
return path
def _hdlr(handler: bytes) -> bytes:
# version/flags + pre_defined + handler_type + reserved[3]; the TC260 walker
# never parses it, but a real-shaped hdlr keeps the fixture honest.
@@ -564,6 +588,24 @@ class TestVideoDependencies:
class TestVideoMetadataApi:
def test_inspects_and_removes_comfyui_mp4_metadata(self, tmp_path: Path):
from remove_ai_watermarks.video import inspect_video_metadata, remove_video_metadata
source = _video_with_comfyui_metadata(tmp_path / "source.mp4")
output = tmp_path / "clean.mp4"
report = inspect_video_metadata(source)
assert set(report.markers) >= {"workflow", "prompt"}
result = remove_video_metadata(source, output)
cleaned = output.read_bytes()
assert result.remaining == {}
assert len(cleaned) == source.stat().st_size
assert _VIDEO_PAYLOAD in cleaned
assert b"standard title" in cleaned
assert b"ordinary editor" in cleaned
assert b"ComfyUI" not in cleaned
def test_top_level_api_is_lazy_exported(self):
import remove_ai_watermarks as raiw