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.
- starshield/__init__.py +3 -0
- starshield/cli.py +743 -0
- starshield/data/__init__.py +1 -0
- starshield/data/synthetic_case_benchmark.json +73 -0
- starshield/image_containers.py +436 -0
- starshield/ioc_validators.py +229 -0
- starshield/leveldb_log.py +795 -0
- starshield_forensics-0.3.7.dist-info/METADATA +119 -0
- starshield_forensics-0.3.7.dist-info/RECORD +13 -0
- starshield_forensics-0.3.7.dist-info/WHEEL +5 -0
- starshield_forensics-0.3.7.dist-info/entry_points.txt +2 -0
- starshield_forensics-0.3.7.dist-info/licenses/LICENSE +21 -0
- starshield_forensics-0.3.7.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
}
|