starshield-forensics 0.3.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """Packaged clean-room specification test fixtures."""
@@ -0,0 +1,73 @@
1
+ {
2
+ "_meta": {
3
+ "dataset": "StarShield v0.3.5 Clean-Room Specification Fixture (Fictional 2025 UK Supplier Invoice Scenario)",
4
+ "tlp": "TLP:CLEAR",
5
+ "clean_room_provenance": "Independently authored specification test vectors using ISO-13616 official example IBANs (GB82WEST12345698765432, NO9386011117947), RFC/ISO Luhn check-digit test strings (6200000000000005), synthetic test sort code 00-00-00, and arbitrary 2025-03-18 timestamps (post-PSR SD20 effective date). Zero correspondence to any real person, organization, or incident."
6
+ },
7
+ "subject_sender_id": "user:accounts_payable_02",
8
+ "messages": [
9
+ {
10
+ "id": "spec_msg_2025_01",
11
+ "from": "user:impersonated_supplier_01",
12
+ "composetime": "2025-03-18T10:00:00Z",
13
+ "content": "Please remit Invoice INV-2025-0100 (GBP 18,400.00) via UK Faster Payments to Sort Code 00-00-00 Account 00000000 or reserve IBAN GB82WEST12345698765432 (SWIFT routing NWBKGB2LXXX).",
14
+ "properties": {
15
+ "deletetime": "1742292600000"
16
+ }
17
+ },
18
+ {
19
+ "id": "spec_msg_2025_02",
20
+ "from": "user:accounts_payable_02",
21
+ "composetime": "2025-03-18T10:02:00Z",
22
+ "content": "Received"
23
+ },
24
+ {
25
+ "id": "spec_msg_2025_03",
26
+ "from": "user:accounts_payable_02",
27
+ "composetime": "2025-03-18T10:05:00Z",
28
+ "content": "Checking PDF"
29
+ },
30
+ {
31
+ "id": "spec_msg_2025_04",
32
+ "from": "user:impersonated_supplier_01",
33
+ "composetime": "2025-03-18T11:30:00Z",
34
+ "content": "Updated secondary IBAN NO9386011117947. Please attend WORKSHOP review on 20250401.",
35
+ "properties": {
36
+ "deletetime": "1742298000000",
37
+ "edittime": "1742297700000"
38
+ }
39
+ },
40
+ {
41
+ "id": "spec_msg_2025_05",
42
+ "from": "user:accounts_payable_02",
43
+ "composetime": "2025-03-18T11:32:00Z",
44
+ "content": "Queued now"
45
+ },
46
+ {
47
+ "id": "spec_msg_2025_06",
48
+ "from": "user:impersonated_supplier_01",
49
+ "composetime": "2025-03-18T14:30:00Z",
50
+ "content": "Luhn test card 6200000000000005 vs invalid check-digit candidate 6200000000000001.",
51
+ "properties": {
52
+ "deletetime": "1742308800000"
53
+ }
54
+ }
55
+ ],
56
+ "session_metadata": [
57
+ {
58
+ "session_id": "spec-sess-01",
59
+ "edge_pop_tag": "pop-eu-west-example-01"
60
+ },
61
+ {
62
+ "session_id": "spec-sess-02",
63
+ "edge_pop_tag": "pop-us-east-example-02"
64
+ }
65
+ ],
66
+ "timestamp_pairs": [
67
+ {
68
+ "label": "synthetic_attachment_ack_interval",
69
+ "start_utc": "2025-03-18T10:00:00.000Z",
70
+ "end_utc": "2025-03-18T10:00:45.000Z"
71
+ }
72
+ ]
73
+ }
@@ -0,0 +1,436 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Structural PNG (RFC 2083) & JPEG (ISO/IEC 10918-1) Metadata & ICC Profile Inspector.
5
+
6
+ Replaces naive byte-substring matching with strict binary container framing:
7
+ - Verifies PNG 8-byte signature (`\\x89PNG\\r\\n\\x1a\\n`), enforces `IHDR` first chunk,
8
+ at least one `IDAT` chunk, and terminal `IEND` chunk with zero trailing bytes,
9
+ validates IEEE 802.3 CRC-32 per chunk, zlib-decompresses `iCCP` chunks, and parses
10
+ the 128-byte ISO 15076-1 ICC Profile Header (`acsp` signature at offset 36..40).
11
+ - Verifies JPEG `SOI` (`\\xff\\xd8`), iterates segment markers (`SOFn`, `APP1` EXIF/XMP,
12
+ multi-chunk `APP2` `ICC_PROFILE\\x00[seq][total]`, `APP13` `Photoshop 3.0\\x00` / `8BIM`),
13
+ scans entropy-coded scan data after `SOS` (`\\xff\\xda`) for byte-stuffed `\\xff\\x00` /
14
+ `RSTn` markers until `EOI` (`\\xff\\xd9`), and rejects truncated containers.
15
+
16
+ Epistemic Discipline:
17
+ - NEVER claims "mathematical proof of forgery". Outputs strictly `OBSERVED` / `DERIVED`
18
+ container metadata alongside mandatory `alternative_explanations`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import struct
24
+ import zlib
25
+ from typing import Any, Dict, List, Tuple
26
+
27
+ PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
28
+ JPEG_SOI = b"\xff\xd8"
29
+ MAX_ICC_DECOMPRESSED_BYTES = 16 * 1024 * 1024 # 16 MiB anti-zip-bomb limit
30
+
31
+
32
+ def _decompress_bounded(comp_bytes: bytes, max_len: int = MAX_ICC_DECOMPRESSED_BYTES) -> bytes:
33
+ dobj = zlib.decompressobj()
34
+ out = dobj.decompress(comp_bytes, max_len + 1)
35
+ if len(out) > max_len or dobj.unconsumed_tail:
36
+ raise zlib.error(f"Decompressed payload exceeds {max_len} byte safety limit")
37
+ out += dobj.flush()
38
+ return out
39
+
40
+
41
+ def _parse_icc_profile_bytes(icc_bytes: bytes, source_offset: int) -> Dict[str, Any]:
42
+ if len(icc_bytes) < 128:
43
+ return {
44
+ "valid_icc_header": False,
45
+ "source_offset": source_offset,
46
+ "reason": f"ICC payload shorter than 128-byte ISO 15076-1 header ({len(icc_bytes)} bytes)",
47
+ }
48
+
49
+ prof_size = struct.unpack_from(">I", icc_bytes, 0)[0]
50
+ cmm_type = icc_bytes[4:8].decode("ascii", errors="replace").strip()
51
+ color_space = icc_bytes[16:20].decode("ascii", errors="replace").strip()
52
+ pcs = icc_bytes[20:24].decode("ascii", errors="replace").strip()
53
+ signature = icc_bytes[36:40]
54
+ primary_platform = icc_bytes[40:44].decode("ascii", errors="replace").strip()
55
+ creator = icc_bytes[80:84].decode("ascii", errors="replace").strip()
56
+
57
+ if signature != b"acsp":
58
+ return {
59
+ "valid_icc_header": False,
60
+ "source_offset": source_offset,
61
+ "reason": f"Missing ISO 15076-1 'acsp' magic at ICC offset 36 (got {signature!r})",
62
+ }
63
+
64
+ ascii_view = icc_bytes.decode("latin-1", errors="ignore")
65
+ profile_desc_hint = None
66
+ for candidate in ("Display P3", "sRGB IEC61966-2.1", "Adobe RGB (1998)", "Generic RGB Profile"):
67
+ if candidate in ascii_view:
68
+ profile_desc_hint = candidate
69
+ break
70
+
71
+ return {
72
+ "valid_icc_header": True,
73
+ "source_offset": source_offset,
74
+ "declared_profile_size": prof_size,
75
+ "actual_profile_bytes": len(icc_bytes),
76
+ "preferred_cmm": cmm_type,
77
+ "color_space": color_space,
78
+ "pcs": pcs,
79
+ "primary_platform": primary_platform,
80
+ "profile_creator": creator,
81
+ "description_hint": profile_desc_hint,
82
+ }
83
+
84
+
85
+ def _build_synthetic_icc_bytes(profile_desc: str = "Display P3", platform: str = "APPL") -> bytes:
86
+ icc = bytearray(160)
87
+ struct.pack_into(">I", icc, 0, 160)
88
+ icc[4:8] = platform.encode("ascii")[:4].ljust(4, b" ")
89
+ icc[16:20] = b"RGB "
90
+ icc[20:24] = b"XYZ "
91
+ icc[36:40] = b"acsp"
92
+ icc[40:44] = platform.encode("ascii")[:4].ljust(4, b" ")
93
+ desc_bytes = profile_desc.encode("ascii")[:28]
94
+ icc[128 : 128 + len(desc_bytes)] = desc_bytes
95
+ return bytes(icc)
96
+
97
+
98
+ def build_valid_png_with_iccp(
99
+ profile_desc: str = "Display P3",
100
+ platform: str = "APPL",
101
+ software_text: str | None = None,
102
+ iccp_name: str = "EmbeddedProfile",
103
+ ) -> bytes:
104
+ """Construct a valid RFC 2083 PNG with CRC-verified IHDR, iCCP (ISO 15076-1), optional tEXt, IDAT, and IEND."""
105
+ def make_chunk(ctype: bytes, data: bytes) -> bytes:
106
+ crc = zlib.crc32(ctype + data) & 0xFFFFFFFF
107
+ return struct.pack(">I", len(data)) + ctype + data + struct.pack(">I", crc)
108
+
109
+ ihdr_data = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
110
+ icc = _build_synthetic_icc_bytes(profile_desc, platform)
111
+ iccp_prefix = iccp_name.encode("latin-1", errors="replace")[:79]
112
+ iccp_data = iccp_prefix + b"\x00\x00" + zlib.compress(icc)
113
+ text_chunk = make_chunk(b"tEXt", software_text.encode("utf-8")) if software_text else b""
114
+ raw_scanline = zlib.compress(b"\x00\xff\xff\xff")
115
+
116
+ return (
117
+ PNG_MAGIC
118
+ + make_chunk(b"IHDR", ihdr_data)
119
+ + make_chunk(b"iCCP", iccp_data)
120
+ + text_chunk
121
+ + make_chunk(b"IDAT", raw_scanline)
122
+ + make_chunk(b"IEND", b"")
123
+ )
124
+
125
+
126
+ def build_valid_jpeg_with_icc_and_8bim(
127
+ profile_desc: str = "Display P3",
128
+ platform: str = "APPL",
129
+ split_icc_chunks: int = 2,
130
+ ) -> bytes:
131
+ """Construct a valid ISO/IEC 10918-1 JPEG with multi-chunk APP2 ICC, APP13 8BIM, SOF0, SOS, and EOI."""
132
+ def make_seg(marker: int, payload: bytes) -> bytes:
133
+ return bytes([0xFF, marker]) + struct.pack(">H", len(payload) + 2) + payload
134
+
135
+ icc = _build_synthetic_icc_bytes(profile_desc, platform)
136
+ app2_segs = bytearray()
137
+ total = max(1, split_icc_chunks)
138
+ step = (len(icc) + total - 1) // total
139
+ for idx in range(total):
140
+ part = icc[idx * step : (idx + 1) * step]
141
+ header = b"ICC_PROFILE\x00" + bytes([idx + 1, total])
142
+ app2_segs.extend(make_seg(0xE2, header + part))
143
+
144
+ app13_payload = b"Photoshop 3.0\x008BIM\x04\x04\x00\x00\x00\x00\x00\x04test"
145
+ sof0_payload = struct.pack(">BHHB", 8, 1, 1, 1) + bytes([1, 0x11, 0])
146
+ sos_header = bytes([1, 1, 0x00, 0, 63, 0])
147
+ # Include byte-stuffed 0xFF 0x00 in compressed scan data before EOI 0xFFD9
148
+ entropy_stream = b"\x7f\xff\x00\x3a"
149
+
150
+ return (
151
+ JPEG_SOI
152
+ + make_seg(0xE0, b"JFIF\x00\x01\x02\x00\x00\x01\x00\x01\x00\x00")
153
+ + bytes(app2_segs)
154
+ + make_seg(0xED, app13_payload)
155
+ + make_seg(0xC0, sof0_payload)
156
+ + make_seg(0xDA, sos_header)
157
+ + entropy_stream
158
+ + b"\xff\xd9"
159
+ )
160
+
161
+
162
+ def inspect_image_container(raw: bytes, label: str = "artifact") -> Dict[str, Any]:
163
+ """Parse PNG or JPEG binary container structure without substring false positives."""
164
+ if raw.startswith(PNG_MAGIC):
165
+ return _inspect_png(raw, label)
166
+ if raw.startswith(JPEG_SOI):
167
+ return _inspect_jpeg(raw, label)
168
+
169
+ return {
170
+ "artifact": label,
171
+ "container_format": "UNSUPPORTED_OR_NOT_AN_IMAGE",
172
+ "epistemic_tier": "OBSERVED",
173
+ "valid_container": False,
174
+ "metadata_observations": [],
175
+ "note": "Input does not start with RFC 2083 PNG magic or ISO 10918 JPEG SOI; skipped byte scanning to prevent text false positives.",
176
+ }
177
+
178
+
179
+ def _inspect_png(raw: bytes, label: str) -> Dict[str, Any]:
180
+ pos = 8
181
+ chunks_summary: List[Dict[str, Any]] = []
182
+ crc_errors: List[Dict[str, Any]] = []
183
+ container_errors: List[Dict[str, Any]] = []
184
+ icc_profiles: List[Dict[str, Any]] = []
185
+ text_software_tags: List[Dict[str, Any]] = []
186
+ saw_iend = False
187
+
188
+ while pos + 8 <= len(raw):
189
+ chunk_offset = pos
190
+ length = struct.unpack_from(">I", raw, pos)[0]
191
+ ctype_bytes = raw[pos + 4 : pos + 8]
192
+ ctype = ctype_bytes.decode("latin-1", errors="replace")
193
+ data_start = pos + 8
194
+ data_end = data_start + length
195
+ crc_end = data_end + 4
196
+
197
+ if crc_end > len(raw):
198
+ crc_errors.append({"byte_offset": chunk_offset, "chunk": ctype, "reason": "Truncated PNG chunk"})
199
+ pos = len(raw)
200
+ break
201
+
202
+ chunk_data = raw[data_start:data_end]
203
+ stored_crc = struct.unpack_from(">I", raw, data_end)[0]
204
+ calc_crc = zlib.crc32(ctype_bytes + chunk_data) & 0xFFFFFFFF
205
+ crc_ok = stored_crc == calc_crc
206
+
207
+ chunks_summary.append({"chunk": ctype, "byte_offset": chunk_offset, "length": length, "crc_valid": crc_ok})
208
+ if not crc_ok:
209
+ crc_errors.append(
210
+ {
211
+ "byte_offset": chunk_offset,
212
+ "chunk": ctype,
213
+ "reason": f"CRC32 mismatch (stored=0x{stored_crc:08x}, calc=0x{calc_crc:08x})",
214
+ }
215
+ )
216
+ else:
217
+ if ctype_bytes == b"IHDR":
218
+ if length != 13:
219
+ container_errors.append({"byte_offset": chunk_offset, "reason": f"Invalid IHDR length {length} != 13"})
220
+ else:
221
+ w, h = struct.unpack_from(">II", chunk_data, 0)
222
+ if w == 0 or h == 0:
223
+ container_errors.append({"byte_offset": chunk_offset, "reason": "IHDR zero width or height"})
224
+ elif ctype_bytes == b"iCCP" and b"\x00" in chunk_data:
225
+ nul_idx = chunk_data.index(b"\x00")
226
+ prof_name = chunk_data[:nul_idx].decode("latin-1", errors="replace")
227
+ if nul_idx + 2 <= len(chunk_data) and chunk_data[nul_idx + 1] == 0:
228
+ try:
229
+ decompressed = _decompress_bounded(chunk_data[nul_idx + 2 :])
230
+ parsed_icc = _parse_icc_profile_bytes(decompressed, chunk_offset)
231
+ parsed_icc["png_iccp_name"] = prof_name
232
+ icc_profiles.append(parsed_icc)
233
+ except zlib.error as exc:
234
+ crc_errors.append(
235
+ {"byte_offset": chunk_offset, "chunk": "iCCP", "reason": f"zlib error: {exc}"}
236
+ )
237
+ elif ctype_bytes in (b"tEXt", b"iTXt") and any(
238
+ k in chunk_data for k in (b"Software", b"Creator", b"Author", b"Comment", b"Adobe", b"Photoshop")
239
+ ):
240
+ text_software_tags.append(
241
+ {
242
+ "byte_offset": chunk_offset,
243
+ "chunk": ctype,
244
+ "preview": chunk_data[:120].decode("utf-8", errors="replace"),
245
+ }
246
+ )
247
+
248
+ pos = crc_end
249
+ if ctype_bytes == b"IEND":
250
+ if length != 0:
251
+ container_errors.append({"byte_offset": chunk_offset, "reason": f"Non-zero IEND length {length}"})
252
+ saw_iend = True
253
+ break
254
+
255
+ if not chunks_summary or chunks_summary[0]["chunk"] != "IHDR":
256
+ container_errors.append({"byte_offset": 8, "reason": "Missing mandatory leading IHDR chunk"})
257
+ has_idat = any(c["chunk"] == "IDAT" and c["crc_valid"] for c in chunks_summary)
258
+ if not has_idat:
259
+ container_errors.append({"byte_offset": pos, "reason": "Missing mandatory IDAT image data chunk"})
260
+ if not saw_iend:
261
+ container_errors.append({"byte_offset": pos, "reason": "Missing mandatory terminal IEND chunk (truncated PNG)"})
262
+ elif pos < len(raw):
263
+ container_errors.append({"byte_offset": pos, "reason": f"Unexpected trailing bytes after IEND ({len(raw) - pos} bytes)"})
264
+
265
+ valid_container = bool(chunks_summary) and has_idat and saw_iend and not crc_errors and not container_errors
266
+ return {
267
+ "artifact": label,
268
+ "container_format": "PNG",
269
+ "valid_container": valid_container,
270
+ "epistemic_tier": "DERIVED",
271
+ "chunks_parsed": len(chunks_summary),
272
+ "crc_errors": crc_errors,
273
+ "container_errors": container_errors,
274
+ "icc_profiles": icc_profiles,
275
+ "software_text_chunks": text_software_tags,
276
+ "alternative_explanations": [
277
+ "Display P3 / APPL ICC profiles are standard in legitimate macOS/iOS screenshots and camera exports.",
278
+ "Editing software metadata indicates pipeline touch, not standalone proof of fraudulent content; compare against the sender's claimed document provenance.",
279
+ ],
280
+ }
281
+
282
+
283
+ def _scan_jpeg_entropy_until_marker(raw: bytes, start_pos: int) -> Tuple[int, int | None]:
284
+ """Scan compressed JPEG scan data skipping byte-stuffed 0xFF00 and RST0..RST7 markers."""
285
+ n = len(raw)
286
+ i = start_pos
287
+ while i < n:
288
+ if raw[i] != 0xFF:
289
+ i += 1
290
+ continue
291
+ while i < n and raw[i] == 0xFF:
292
+ i += 1
293
+ if i >= n:
294
+ return n, None
295
+ marker = raw[i]
296
+ if marker == 0x00:
297
+ i += 1
298
+ continue
299
+ if 0xD0 <= marker <= 0xD7:
300
+ i += 1
301
+ continue
302
+ return i - 1, marker
303
+ return n, None
304
+
305
+
306
+ def _inspect_jpeg(raw: bytes, label: str) -> Dict[str, Any]:
307
+ pos = 2
308
+ segments: List[Dict[str, Any]] = []
309
+ container_errors: List[Dict[str, Any]] = []
310
+ icc_profiles: List[Dict[str, Any]] = []
311
+ icc_chunks: Dict[int, Tuple[int, int, bytes]] = {}
312
+ icc_declared_total: int | None = None
313
+ photoshop_8bim_offsets: List[int] = []
314
+
315
+ saw_sof = False
316
+ saw_sos = False
317
+ saw_eoi = False
318
+
319
+ while pos < len(raw):
320
+ if raw[pos] != 0xFF:
321
+ container_errors.append({"byte_offset": pos, "reason": "Expected 0xFF JPEG marker prefix"})
322
+ break
323
+ while pos < len(raw) and raw[pos] == 0xFF:
324
+ pos += 1
325
+ if pos >= len(raw):
326
+ container_errors.append({"byte_offset": pos - 1, "reason": "Truncated marker at EOF"})
327
+ break
328
+
329
+ marker = raw[pos]
330
+ seg_offset = pos - 1
331
+ pos += 1
332
+
333
+ if marker == 0xD9: # EOI
334
+ segments.append({"marker": "0xFFD9", "byte_offset": seg_offset, "length": 0})
335
+ saw_eoi = True
336
+ if pos < len(raw):
337
+ container_errors.append(
338
+ {"byte_offset": pos, "reason": f"Trailing bytes after JPEG EOI ({len(raw) - pos} bytes)"}
339
+ )
340
+ break
341
+
342
+ if marker == 0xD8 or (0xD0 <= marker <= 0xD7) or marker == 0x01:
343
+ continue
344
+
345
+ if pos + 2 > len(raw):
346
+ container_errors.append({"byte_offset": seg_offset, "reason": "Truncated JPEG segment length"})
347
+ break
348
+
349
+ seg_len = struct.unpack_from(">H", raw, pos)[0]
350
+ if seg_len < 2 or pos + seg_len > len(raw):
351
+ container_errors.append(
352
+ {"byte_offset": seg_offset, "reason": f"Invalid or truncated JPEG segment length {seg_len}"}
353
+ )
354
+ break
355
+
356
+ seg_data = raw[pos + 2 : pos + seg_len]
357
+ segments.append({"marker": f"0xFF{marker:02X}", "byte_offset": seg_offset, "length": seg_len})
358
+
359
+ if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
360
+ saw_sof = True
361
+ elif marker == 0xE2 and seg_data.startswith(b"ICC_PROFILE\x00"):
362
+ if len(seg_data) < 14:
363
+ container_errors.append({"byte_offset": seg_offset, "reason": "Truncated APP2 ICC_PROFILE header"})
364
+ else:
365
+ seq_no = seg_data[12]
366
+ tot_no = seg_data[13]
367
+ if seq_no < 1 or tot_no < 1 or seq_no > tot_no:
368
+ container_errors.append(
369
+ {"byte_offset": seg_offset, "reason": f"Invalid APP2 ICC chunk numbering {seq_no}/{tot_no}"}
370
+ )
371
+ elif seq_no in icc_chunks:
372
+ container_errors.append(
373
+ {"byte_offset": seg_offset, "reason": f"Duplicate APP2 ICC chunk sequence {seq_no}"}
374
+ )
375
+ else:
376
+ if icc_declared_total is None:
377
+ icc_declared_total = tot_no
378
+ elif icc_declared_total != tot_no:
379
+ container_errors.append(
380
+ {"byte_offset": seg_offset, "reason": "Inconsistent APP2 ICC total_chunks count"}
381
+ )
382
+ icc_chunks[seq_no] = (seg_offset, tot_no, seg_data[14:])
383
+ elif marker == 0xED and b"Photoshop 3.0\x00" in seg_data and b"8BIM" in seg_data:
384
+ photoshop_8bim_offsets.append(seg_offset)
385
+
386
+ pos += seg_len
387
+
388
+ if marker == 0xDA: # SOS: advance across entropy-coded scan until next real marker
389
+ saw_sos = True
390
+ next_marker_offset, next_marker = _scan_jpeg_entropy_until_marker(raw, pos)
391
+ if next_marker is None:
392
+ container_errors.append(
393
+ {"byte_offset": seg_offset, "reason": "Truncated JPEG entropy scan: missing EOI (0xFFD9)"}
394
+ )
395
+ pos = len(raw)
396
+ break
397
+ pos = next_marker_offset
398
+
399
+ if icc_declared_total is not None:
400
+ expected_keys = list(range(1, icc_declared_total + 1))
401
+ if sorted(icc_chunks.keys()) != expected_keys:
402
+ container_errors.append(
403
+ {
404
+ "byte_offset": segments[0]["byte_offset"] if segments else 2,
405
+ "reason": f"Incomplete multi-segment APP2 ICC profile (got {sorted(icc_chunks.keys())} of 1..{icc_declared_total})",
406
+ }
407
+ )
408
+ else:
409
+ first_offset = icc_chunks[1][0]
410
+ assembled = b"".join(icc_chunks[k][2] for k in expected_keys)
411
+ parsed_icc = _parse_icc_profile_bytes(assembled, first_offset)
412
+ parsed_icc["jpeg_app2_chunks_reassembled"] = icc_declared_total
413
+ icc_profiles.append(parsed_icc)
414
+
415
+ if not saw_sof:
416
+ container_errors.append({"byte_offset": pos, "reason": "Missing JPEG Start-of-Frame (SOFn) segment"})
417
+ if not saw_sos:
418
+ container_errors.append({"byte_offset": pos, "reason": "Missing JPEG Start-of-Scan (SOS) segment"})
419
+ if not saw_eoi:
420
+ container_errors.append({"byte_offset": pos, "reason": "Missing JPEG End-of-Image (EOI 0xFFD9) marker"})
421
+
422
+ valid_container = saw_sof and saw_sos and saw_eoi and not container_errors
423
+ return {
424
+ "artifact": label,
425
+ "container_format": "JPEG",
426
+ "valid_container": valid_container,
427
+ "epistemic_tier": "DERIVED",
428
+ "segments_parsed": len(segments),
429
+ "container_errors": container_errors,
430
+ "icc_profiles": icc_profiles,
431
+ "photoshop_app13_8bim_offsets": photoshop_8bim_offsets,
432
+ "alternative_explanations": [
433
+ "APP13/8BIM blocks show Adobe/compatible image pipeline processing, which may occur in benign format conversion as well as document manipulation.",
434
+ "Metadata provenance is an investigation triage lead, not standalone proof of falsification.",
435
+ ],
436
+ }