pdforensic-authentic-check 0.2.0__py3-none-win_amd64.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.
- pdforensic/__init__.py +25 -0
- pdforensic/_bin/pdfresurrect.exe +0 -0
- pdforensic/_pdf.py +445 -0
- pdforensic/_version.py +11 -0
- pdforensic/check_eof_markers.py +46 -0
- pdforensic/check_versions.py +50 -0
- pdforensic/cli.py +46 -0
- pdforensic/forensics.py +1563 -0
- pdforensic/retrieve_all_versions.py +65 -0
- pdforensic/retrieve_metadata.py +67 -0
- pdforensic/utils.py +34 -0
- pdforensic_authentic_check-0.2.0.dist-info/METADATA +210 -0
- pdforensic_authentic_check-0.2.0.dist-info/RECORD +18 -0
- pdforensic_authentic_check-0.2.0.dist-info/WHEEL +5 -0
- pdforensic_authentic_check-0.2.0.dist-info/entry_points.txt +6 -0
- pdforensic_authentic_check-0.2.0.dist-info/licenses/LICENSE +21 -0
- pdforensic_authentic_check-0.2.0.dist-info/licenses/src/pdfresurrect/LICENSE +9 -0
- pdforensic_authentic_check-0.2.0.dist-info/top_level.txt +1 -0
pdforensic/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pdforensic - PDF forensics: metadata, revision history, tamper indicators and provenance.
|
|
3
|
+
|
|
4
|
+
analyze() and recover_revisions() are the analyzer, built on qpdf. The four
|
|
5
|
+
original functions wrap the bundled pdfresurrect C tool.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._version import __version__
|
|
9
|
+
from .check_eof_markers import count_pdf_eof_markers
|
|
10
|
+
from .check_versions import check_no_of_versions
|
|
11
|
+
from .forensics import PdfError, analyze, format_report, recover_revisions
|
|
12
|
+
from .retrieve_all_versions import recover_pdf_versions
|
|
13
|
+
from .retrieve_metadata import extract_pdf_metadata
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"__version__",
|
|
17
|
+
"analyze",
|
|
18
|
+
"recover_revisions",
|
|
19
|
+
"format_report",
|
|
20
|
+
"PdfError",
|
|
21
|
+
"extract_pdf_metadata",
|
|
22
|
+
"recover_pdf_versions",
|
|
23
|
+
"check_no_of_versions",
|
|
24
|
+
"count_pdf_eof_markers"
|
|
25
|
+
]
|
|
Binary file
|
pdforensic/_pdf.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""Raw, byte-level view of a PDF file. Standard library only.
|
|
2
|
+
|
|
3
|
+
qpdf (via pikepdf) does the object-level work in forensics.py, but like every
|
|
4
|
+
PDF library it presents the current document and hides the file's history.
|
|
5
|
+
This module recovers what it hides: where each revision ends, which
|
|
6
|
+
cross-reference section closes it, and every object header physically
|
|
7
|
+
present in the file. Section numbers refer to ISO 32000-1:2008 (PDF 1.7).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import binascii
|
|
13
|
+
import bisect
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
WS = b"\x00\t\n\x0c\r " # 7.2.2 white-space characters
|
|
17
|
+
_W = rb"[\x00\t\n\x0c\r ]"
|
|
18
|
+
_REG = rb"[^\x00\t\n\x0c\r ()<>\[\]{}/%]" # 7.2.2 regular characters
|
|
19
|
+
_NOT_REGULAR = frozenset(WS + b"()<>[]{}/%")
|
|
20
|
+
|
|
21
|
+
MAX_DEPTH = 100 # cap on array/dictionary nesting
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PdfError(ValueError):
|
|
25
|
+
"""The bytes do not form the PDF construct that was expected."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Name(str):
|
|
29
|
+
"""A PDF name object (/Type). Compares equal to the plain string."""
|
|
30
|
+
|
|
31
|
+
__slots__ = ()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Ref(tuple):
|
|
35
|
+
"""An indirect reference (12 0 R)."""
|
|
36
|
+
|
|
37
|
+
__slots__ = ()
|
|
38
|
+
|
|
39
|
+
def __new__(cls, num, gen=0):
|
|
40
|
+
return tuple.__new__(cls, (num, gen))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Stream:
|
|
44
|
+
"""A stream object: its dictionary plus the byte span of its raw data."""
|
|
45
|
+
|
|
46
|
+
__slots__ = ("dict", "start", "end")
|
|
47
|
+
|
|
48
|
+
def __init__(self, dict_, start, end):
|
|
49
|
+
self.dict, self.start, self.end = dict_, start, end
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_SKIP_RE = re.compile(rb"(?:" + _W + rb"+|%[^\r\n]*)*") # white space and comments
|
|
53
|
+
_NUM_RE = re.compile(rb"[+-]?(?:\d+\.\d*|\.\d+|\d+)")
|
|
54
|
+
_REF_RE = re.compile(rb"(\d+)" + _W + rb"+(\d+)" + _W + rb"+R(?!" + _REG + rb")")
|
|
55
|
+
_NAME_RE = re.compile(rb"/(" + _REG + rb"*)")
|
|
56
|
+
_KW_RE = re.compile(_REG + rb"+")
|
|
57
|
+
_NAME_ESC = re.compile(rb"#([0-9A-Fa-f]{2})")
|
|
58
|
+
_OCTAL_RE = re.compile(rb"[0-7]{1,3}")
|
|
59
|
+
_STR_ESC = {0x6E: 10, 0x72: 13, 0x74: 9, 0x62: 8, 0x66: 12, 0x28: 0x28, 0x29: 0x29, 0x5C: 0x5C}
|
|
60
|
+
_OBJ_RE = re.compile(
|
|
61
|
+
rb"(?<![0-9])(\d{1,10})" + _W + rb"+(\d{1,5})" + _W + rb"+obj(?!" + _REG + rb")"
|
|
62
|
+
)
|
|
63
|
+
_OBJ_BACK = re.compile(rb"(\d{1,10})" + _W + rb"+(\d{1,5})" + _W + rb"+obj\Z")
|
|
64
|
+
_XREF_KEYWORD = re.compile(rb"(?<![A-Za-z])xref(?=" + _W + rb")")
|
|
65
|
+
_TERM_RE = re.compile(rb"startxref" + _W + rb"+(\d{1,20})" + _W + rb"*%%EOF(\r\n|\n|\r)?")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def unescape_name(raw):
|
|
69
|
+
"""Resolve the #xx escapes of a name (7.3.5)."""
|
|
70
|
+
return _NAME_ESC.sub(lambda h: bytes([int(h.group(1), 16)]), raw)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def skip_ws(data, pos):
|
|
74
|
+
"""Offset of the first byte at or after pos that is not white space or a comment."""
|
|
75
|
+
return _SKIP_RE.match(data, pos).end()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Parser:
|
|
79
|
+
"""Parses PDF objects (7.3) out of a byte buffer."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, data, pos=0):
|
|
82
|
+
self.data, self.pos = data, pos
|
|
83
|
+
|
|
84
|
+
def skip_ws(self):
|
|
85
|
+
self.pos = skip_ws(self.data, self.pos)
|
|
86
|
+
|
|
87
|
+
def parse(self, depth=0):
|
|
88
|
+
if depth > MAX_DEPTH:
|
|
89
|
+
raise PdfError("objects nested deeper than %d levels" % MAX_DEPTH)
|
|
90
|
+
self.skip_ws()
|
|
91
|
+
d, p = self.data, self.pos
|
|
92
|
+
c = d[p : p + 1]
|
|
93
|
+
if not c:
|
|
94
|
+
raise PdfError("unexpected end of data")
|
|
95
|
+
if c == b"/":
|
|
96
|
+
m = _NAME_RE.match(d, p)
|
|
97
|
+
self.pos = m.end()
|
|
98
|
+
return Name(unescape_name(m.group(1)).decode("utf-8", "replace"))
|
|
99
|
+
if c == b"(":
|
|
100
|
+
return self._literal()
|
|
101
|
+
if c == b"<":
|
|
102
|
+
if d[p + 1 : p + 2] == b"<":
|
|
103
|
+
return self._dict(depth)
|
|
104
|
+
end = d.find(b">", p)
|
|
105
|
+
if end < 0:
|
|
106
|
+
raise PdfError("unterminated hexadecimal string at offset %d" % p)
|
|
107
|
+
digits = d[p + 1 : end].translate(None, WS)
|
|
108
|
+
self.pos = end + 1
|
|
109
|
+
try:
|
|
110
|
+
return binascii.unhexlify(digits + b"0" * (len(digits) % 2))
|
|
111
|
+
except binascii.Error:
|
|
112
|
+
raise PdfError("invalid hexadecimal string at offset %d" % p) from None
|
|
113
|
+
if c == b"[":
|
|
114
|
+
self.pos = p + 1
|
|
115
|
+
out = []
|
|
116
|
+
while True:
|
|
117
|
+
self.skip_ws()
|
|
118
|
+
if d[self.pos : self.pos + 1] == b"]":
|
|
119
|
+
self.pos += 1
|
|
120
|
+
return out
|
|
121
|
+
out.append(self.parse(depth + 1))
|
|
122
|
+
if c in b"+-.0123456789":
|
|
123
|
+
m = _REF_RE.match(d, p)
|
|
124
|
+
try:
|
|
125
|
+
if m:
|
|
126
|
+
self.pos = m.end()
|
|
127
|
+
return Ref(int(m.group(1)), int(m.group(2)))
|
|
128
|
+
m = _NUM_RE.match(d, p)
|
|
129
|
+
if m:
|
|
130
|
+
self.pos = m.end()
|
|
131
|
+
return float(m.group()) if b"." in m.group() else int(m.group())
|
|
132
|
+
except ValueError: # absurdly long digit runs
|
|
133
|
+
pass
|
|
134
|
+
raise PdfError("invalid number at offset %d" % p)
|
|
135
|
+
m = _KW_RE.match(d, p)
|
|
136
|
+
if m and m.group() in (b"true", b"false", b"null"):
|
|
137
|
+
self.pos = m.end()
|
|
138
|
+
return {b"true": True, b"false": False, b"null": None}[m.group()]
|
|
139
|
+
raise PdfError("unexpected token %r at offset %d" % (d[p : p + 12], p))
|
|
140
|
+
|
|
141
|
+
def _literal(self): # 7.3.4.2 literal strings
|
|
142
|
+
d, p, n = self.data, self.pos + 1, len(self.data)
|
|
143
|
+
out, depth = bytearray(), 1
|
|
144
|
+
while p < n:
|
|
145
|
+
c = d[p]
|
|
146
|
+
if c == 0x5C: # backslash escape
|
|
147
|
+
p += 1
|
|
148
|
+
if p >= n:
|
|
149
|
+
break
|
|
150
|
+
e = d[p]
|
|
151
|
+
if e in _STR_ESC:
|
|
152
|
+
out.append(_STR_ESC[e])
|
|
153
|
+
p += 1
|
|
154
|
+
elif 0x30 <= e <= 0x37:
|
|
155
|
+
m = _OCTAL_RE.match(d, p)
|
|
156
|
+
out.append(int(m.group(), 8) & 0xFF)
|
|
157
|
+
p = m.end()
|
|
158
|
+
elif e == 0x0D: # line continuation
|
|
159
|
+
p += 2 if d[p + 1 : p + 2] == b"\n" else 1
|
|
160
|
+
elif e == 0x0A:
|
|
161
|
+
p += 1
|
|
162
|
+
else:
|
|
163
|
+
out.append(e)
|
|
164
|
+
p += 1
|
|
165
|
+
elif c == 0x0D: # an unescaped end-of-line is read as LF
|
|
166
|
+
out.append(0x0A)
|
|
167
|
+
p += 2 if d[p + 1 : p + 2] == b"\n" else 1
|
|
168
|
+
elif c == 0x28:
|
|
169
|
+
depth += 1
|
|
170
|
+
out.append(c)
|
|
171
|
+
p += 1
|
|
172
|
+
elif c == 0x29:
|
|
173
|
+
depth -= 1
|
|
174
|
+
p += 1
|
|
175
|
+
if depth == 0:
|
|
176
|
+
self.pos = p
|
|
177
|
+
return bytes(out)
|
|
178
|
+
out.append(c)
|
|
179
|
+
else:
|
|
180
|
+
out.append(c)
|
|
181
|
+
p += 1
|
|
182
|
+
raise PdfError("unterminated literal string at offset %d" % self.pos)
|
|
183
|
+
|
|
184
|
+
def _dict(self, depth):
|
|
185
|
+
d = self.data
|
|
186
|
+
self.pos += 2
|
|
187
|
+
out = {}
|
|
188
|
+
while True:
|
|
189
|
+
self.skip_ws()
|
|
190
|
+
if d.startswith(b">>", self.pos):
|
|
191
|
+
self.pos += 2
|
|
192
|
+
return out
|
|
193
|
+
key = self.parse(depth + 1)
|
|
194
|
+
if not isinstance(key, Name):
|
|
195
|
+
raise PdfError("dictionary key is not a name near offset %d" % self.pos)
|
|
196
|
+
out[key] = self.parse(depth + 1)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def parse_indirect(data, pos):
|
|
200
|
+
"""Parse 'N G obj ... endobj' at pos. Returns (num, gen, value, end_offset)."""
|
|
201
|
+
m = _OBJ_RE.match(data, pos)
|
|
202
|
+
if not m:
|
|
203
|
+
raise PdfError("no indirect object at offset %d" % pos)
|
|
204
|
+
p = Parser(data, m.end())
|
|
205
|
+
value = p.parse()
|
|
206
|
+
p.skip_ws()
|
|
207
|
+
if isinstance(value, dict) and data.startswith(b"stream", p.pos): # 7.3.8
|
|
208
|
+
s = p.pos + 6
|
|
209
|
+
if data.startswith(b"\r\n", s):
|
|
210
|
+
s += 2
|
|
211
|
+
elif data[s : s + 1] in (b"\n", b"\r"):
|
|
212
|
+
s += 1
|
|
213
|
+
e = None
|
|
214
|
+
length = value.get("Length")
|
|
215
|
+
if isinstance(length, int) and not isinstance(length, bool) and 0 <= length <= len(data) - s:
|
|
216
|
+
if data[s + length : s + length + 16].lstrip(WS).startswith(b"endstream"):
|
|
217
|
+
e = s + length
|
|
218
|
+
if e is None:
|
|
219
|
+
# ponytail: an indirect or wrong /Length falls back to searching for
|
|
220
|
+
# "endstream". Resolve /Length through the xref instead if raw stream
|
|
221
|
+
# data that itself contains "endstream" ever causes a wrong cut.
|
|
222
|
+
e = data.find(b"endstream", s)
|
|
223
|
+
if e < 0:
|
|
224
|
+
raise PdfError("stream at offset %d has no endstream" % pos)
|
|
225
|
+
if e - 2 >= s and data[e - 2 : e] == b"\r\n":
|
|
226
|
+
e -= 2
|
|
227
|
+
elif e - 1 >= s and data[e - 1 : e] in (b"\n", b"\r"):
|
|
228
|
+
e -= 1
|
|
229
|
+
value = Stream(value, s, e)
|
|
230
|
+
p.pos = data.find(b"endstream", e) + 9
|
|
231
|
+
p.skip_ws()
|
|
232
|
+
end = p.pos
|
|
233
|
+
if data.startswith(b"endobj", end):
|
|
234
|
+
end += 6
|
|
235
|
+
return int(m.group(1)), int(m.group(2)), value, end
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class Revision:
|
|
239
|
+
"""The original file body or one incremental update (7.5.6)."""
|
|
240
|
+
|
|
241
|
+
__slots__ = ("index", "start", "end", "startxref", "kind", "trailer", "prev", "error")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class RawPdf:
|
|
245
|
+
"""Header, object headers, %%EOF trailers and revision boundaries."""
|
|
246
|
+
|
|
247
|
+
def __init__(self, data):
|
|
248
|
+
self.data = data
|
|
249
|
+
self.warnings = []
|
|
250
|
+
m = re.search(rb"%PDF-(\d)\.(\d)", data[:1024])
|
|
251
|
+
if not m:
|
|
252
|
+
raise PdfError("not a PDF: no %PDF-M.m header in the first 1024 bytes")
|
|
253
|
+
self.header_offset = m.start()
|
|
254
|
+
self.version = "%s.%s" % (m.group(1).decode(), m.group(2).decode())
|
|
255
|
+
if self.header_offset:
|
|
256
|
+
self.warnings.append("%d bytes precede the %%PDF header" % self.header_offset)
|
|
257
|
+
|
|
258
|
+
# Every "N G obj" header in the file. Stream data is not skipped here,
|
|
259
|
+
# so a header can be a false hit inside a stream; callers check a
|
|
260
|
+
# header against the cross-reference data before trusting it.
|
|
261
|
+
self.headers, self.header_ids = [], []
|
|
262
|
+
find, p, n = data.find, data.find(b"obj"), len(data)
|
|
263
|
+
while p >= 0:
|
|
264
|
+
if p and data[p - 1] in WS and (p + 3 == n or data[p + 3] in _NOT_REGULAR):
|
|
265
|
+
h = _OBJ_BACK.search(data, max(0, p - 32), p + 3)
|
|
266
|
+
if h and (h.start() == 0 or not 0x30 <= data[h.start() - 1] <= 0x39):
|
|
267
|
+
self.headers.append(h.start())
|
|
268
|
+
self.header_ids.append((int(h.group(1)), int(h.group(2))))
|
|
269
|
+
p = find(b"obj", p + 3)
|
|
270
|
+
|
|
271
|
+
# (offset of "startxref", its value, offset after %%EOF, offset after the EOL)
|
|
272
|
+
self.candidates = [
|
|
273
|
+
(t.start(), int(t.group(1)), t.end() - len(t.group(2) or b""), t.end())
|
|
274
|
+
for t in _TERM_RE.finditer(data)
|
|
275
|
+
]
|
|
276
|
+
self.eof_markers = data.count(b"%%EOF")
|
|
277
|
+
self._ends = {}
|
|
278
|
+
self.header_at = dict(zip(self.headers, self.header_ids))
|
|
279
|
+
|
|
280
|
+
# Linearization parameter dictionary: the first object, within the
|
|
281
|
+
# first 1024 bytes (Annex F.3.3).
|
|
282
|
+
self.linearized = None
|
|
283
|
+
if self.headers and self.headers[0] < 1024 + self.header_offset:
|
|
284
|
+
try:
|
|
285
|
+
value = parse_indirect(data, self.headers[0])[2]
|
|
286
|
+
if isinstance(value, dict) and "Linearized" in value:
|
|
287
|
+
self.linearized = value
|
|
288
|
+
except (PdfError, RecursionError):
|
|
289
|
+
pass
|
|
290
|
+
|
|
291
|
+
def _span(self, offset):
|
|
292
|
+
"""(where the dictionary of the object at `offset` ends, where the object ends)."""
|
|
293
|
+
if offset not in self._ends:
|
|
294
|
+
try:
|
|
295
|
+
_, _, value, end = parse_indirect(self.data, offset)
|
|
296
|
+
self._ends[offset] = (value.start if isinstance(value, Stream) else end, end)
|
|
297
|
+
except (PdfError, RecursionError):
|
|
298
|
+
self._ends[offset] = (len(self.data), offset) # unparsable: assume all of it is dictionary
|
|
299
|
+
return self._ends[offset]
|
|
300
|
+
|
|
301
|
+
def end_of(self, offset):
|
|
302
|
+
"""Offset just past the object that starts at `offset`."""
|
|
303
|
+
return self._span(offset)[1]
|
|
304
|
+
|
|
305
|
+
def dict_end(self, offset):
|
|
306
|
+
"""Offset where the stream data of the object at `offset` begins (its end, if it is not a stream)."""
|
|
307
|
+
return self._span(offset)[0]
|
|
308
|
+
|
|
309
|
+
def header_before(self, pos):
|
|
310
|
+
"""(offset, (num, gen)) of the last object header at or before pos."""
|
|
311
|
+
i = bisect.bisect_right(self.headers, pos) - 1
|
|
312
|
+
return (self.headers[i], self.header_ids[i]) if i >= 0 else (None, None)
|
|
313
|
+
|
|
314
|
+
def inside(self, pos, objects):
|
|
315
|
+
"""True if pos lies inside one of the objects starting at the sorted offsets `objects`."""
|
|
316
|
+
i = bisect.bisect_right(objects, pos) - 1
|
|
317
|
+
return i >= 0 and pos < self.end_of(objects[i])
|
|
318
|
+
|
|
319
|
+
def build_revisions(self, objects=()):
|
|
320
|
+
"""Split the file into revisions. `objects` holds the sorted offsets of
|
|
321
|
+
objects known to be real; a startxref/%%EOF pair inside one of them
|
|
322
|
+
(an embedded PDF, say) is data and does not end a revision."""
|
|
323
|
+
data = self.data
|
|
324
|
+
terms = [t for t in self.candidates if not self.inside(t[0], objects)]
|
|
325
|
+
|
|
326
|
+
# A linearized file is written with two cross-reference sections and
|
|
327
|
+
# two %%EOF markers (7.5.4); both belong to the first revision. /L is
|
|
328
|
+
# the file length at the time of linearization (Table F.1).
|
|
329
|
+
self.linearized_intact = None
|
|
330
|
+
groups = [[t] for t in terms]
|
|
331
|
+
if self.linearized is not None:
|
|
332
|
+
length = self.linearized.get("L")
|
|
333
|
+
self.linearized_intact = length == len(data)
|
|
334
|
+
k = next((i for i, t in enumerate(terms) if length in (t[2], t[3])), None)
|
|
335
|
+
if k is None:
|
|
336
|
+
self.warnings.append(
|
|
337
|
+
"linearization dictionary /L (%r) does not match any revision boundary; "
|
|
338
|
+
"the file is not treated as linearized" % (length,)
|
|
339
|
+
)
|
|
340
|
+
self.linearized = None
|
|
341
|
+
else:
|
|
342
|
+
groups = [terms[: k + 1]] + [[t] for t in terms[k + 1 :]]
|
|
343
|
+
|
|
344
|
+
self.revisions = []
|
|
345
|
+
self.hybrid = False
|
|
346
|
+
if not groups:
|
|
347
|
+
self.warnings.append("no startxref/%%EOF trailer found; the file is truncated or damaged")
|
|
348
|
+
groups = [[(len(data), None, len(data), len(data))]]
|
|
349
|
+
start = 0
|
|
350
|
+
for index, group in enumerate(groups):
|
|
351
|
+
rev = Revision()
|
|
352
|
+
rev.index, rev.start, rev.end = index, start, group[-1][3]
|
|
353
|
+
rev.startxref = group[-1][1]
|
|
354
|
+
rev.kind, rev.trailer, rev.prev, rev.error = None, {}, None, None
|
|
355
|
+
if rev.startxref is None:
|
|
356
|
+
rev.error = "no cross-reference section"
|
|
357
|
+
else:
|
|
358
|
+
self._read_section(rev)
|
|
359
|
+
if self.revisions and self._closes_hybrid(rev):
|
|
360
|
+
# 7.5.8.4 hybrid-reference file, as Microsoft Office writes it: a
|
|
361
|
+
# table and trailer for PDF 1.4 readers, then an empty table whose
|
|
362
|
+
# trailer points at a cross-reference stream. Two %%EOF markers,
|
|
363
|
+
# one revision.
|
|
364
|
+
last = self.revisions[-1]
|
|
365
|
+
last.end, last.startxref, last.trailer = rev.end, rev.startxref, rev.trailer
|
|
366
|
+
last.kind = "+".join(sorted((set((last.kind or "").split("+")) | set(rev.kind.split("+"))) - {""}))
|
|
367
|
+
self.hybrid = True
|
|
368
|
+
start = rev.end
|
|
369
|
+
continue
|
|
370
|
+
rev.index = len(self.revisions)
|
|
371
|
+
if rev.error:
|
|
372
|
+
self.warnings.append("revision %d: %s" % (rev.index + 1, rev.error))
|
|
373
|
+
else:
|
|
374
|
+
expected = self.revisions[-1].startxref if self.revisions else None
|
|
375
|
+
if rev.prev != expected:
|
|
376
|
+
self.warnings.append(
|
|
377
|
+
"revision %d: /Prev is %r but the previous cross-reference section is at %r"
|
|
378
|
+
% (rev.index + 1, rev.prev, expected)
|
|
379
|
+
)
|
|
380
|
+
self.revisions.append(rev)
|
|
381
|
+
start = rev.end
|
|
382
|
+
|
|
383
|
+
tail = data[self.revisions[-1].end :]
|
|
384
|
+
self.trailing_bytes = len(tail) if tail.strip(WS) else 0
|
|
385
|
+
return self.revisions
|
|
386
|
+
|
|
387
|
+
def _closes_hybrid(self, rev):
|
|
388
|
+
"""True if this section adds no objects and only attaches a
|
|
389
|
+
cross-reference stream that already lies in the earlier bytes."""
|
|
390
|
+
stream_at = rev.trailer.get("XRefStm")
|
|
391
|
+
return (
|
|
392
|
+
rev.error is None
|
|
393
|
+
and isinstance(stream_at, int) and not isinstance(stream_at, bool) and stream_at < rev.start
|
|
394
|
+
and bisect.bisect_left(self.headers, rev.start) == bisect.bisect_left(self.headers, rev.end)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
def _section_at(self, offset):
|
|
398
|
+
"""(kind, trailer dictionary) of the cross-reference section at offset."""
|
|
399
|
+
d = self.data
|
|
400
|
+
# A table a little before or after the stated offset is still found,
|
|
401
|
+
# as readers do, and the discrepancy is reported.
|
|
402
|
+
# (Offset 0 is the placeholder a linearized file's first trailer carries, not a near miss.)
|
|
403
|
+
near = sorted((m.start() for m in _XREF_KEYWORD.finditer(d, max(0, offset - 1024), offset + 1024)),
|
|
404
|
+
key=lambda at: abs(at - offset)) if offset else []
|
|
405
|
+
for base in dict.fromkeys((offset, offset + self.header_offset, *near[:1])):
|
|
406
|
+
if base >= len(d):
|
|
407
|
+
continue
|
|
408
|
+
p = Parser(d, base)
|
|
409
|
+
p.skip_ws()
|
|
410
|
+
if d.startswith(b"xref", p.pos): # 7.5.4 table followed by 7.5.5 trailer
|
|
411
|
+
t = d.find(b"trailer", p.pos)
|
|
412
|
+
if t < 0:
|
|
413
|
+
raise PdfError("cross-reference table at offset %d has no trailer" % offset)
|
|
414
|
+
trailer = Parser(d, t + 7).parse()
|
|
415
|
+
if not isinstance(trailer, dict):
|
|
416
|
+
raise PdfError("trailer at offset %d is not a dictionary" % t)
|
|
417
|
+
if p.pos - offset not in (0, self.header_offset) and d[min(offset, p.pos) : max(offset, p.pos)].strip(WS):
|
|
418
|
+
self.warnings.append("startxref %d is off by %d bytes: the cross-reference table is at %d"
|
|
419
|
+
% (offset, p.pos - offset, p.pos))
|
|
420
|
+
return "table", trailer
|
|
421
|
+
if _OBJ_RE.match(d, p.pos): # 7.5.8 cross-reference stream
|
|
422
|
+
value = parse_indirect(d, p.pos)[2]
|
|
423
|
+
if isinstance(value, Stream) and value.dict.get("Type") == "XRef":
|
|
424
|
+
return "stream", value.dict
|
|
425
|
+
raise PdfError("startxref offset %d does not point to a cross-reference section" % offset)
|
|
426
|
+
|
|
427
|
+
def _read_section(self, rev):
|
|
428
|
+
kinds, seen, nxt = [], set(), rev.startxref
|
|
429
|
+
try:
|
|
430
|
+
while isinstance(nxt, int) and not isinstance(nxt, bool) and nxt not in seen:
|
|
431
|
+
seen.add(nxt)
|
|
432
|
+
kind, trailer = self._section_at(nxt)
|
|
433
|
+
if not kinds:
|
|
434
|
+
rev.trailer = trailer
|
|
435
|
+
kinds.append(kind)
|
|
436
|
+
if kind == "table" and isinstance(trailer.get("XRefStm"), int):
|
|
437
|
+
kinds.append("hybrid-stream") # 7.5.8.4 hybrid-reference file
|
|
438
|
+
nxt = trailer.get("Prev")
|
|
439
|
+
# Sections chained inside this revision (a linearized file's two) belong to it.
|
|
440
|
+
if not (isinstance(nxt, int) and rev.start <= nxt < rev.end):
|
|
441
|
+
rev.prev = nxt
|
|
442
|
+
break
|
|
443
|
+
except (PdfError, RecursionError) as exc:
|
|
444
|
+
rev.error = str(exc)
|
|
445
|
+
rev.kind = "+".join(sorted(set(kinds))) if kinds else None
|
pdforensic/_version.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""The package version. The single source is the VERSION file at the top of
|
|
2
|
+
the repository; the build copies it into the package metadata read here."""
|
|
3
|
+
|
|
4
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("pdforensic-authentic-check")
|
|
9
|
+
except PackageNotFoundError: # running from a source checkout that was never installed
|
|
10
|
+
_file = Path(__file__).resolve().parent.parent / "VERSION"
|
|
11
|
+
__version__ = _file.read_text(encoding="utf-8").strip() if _file.is_file() else "0+unknown"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def count_pdf_eof_markers(pdf_path):
|
|
8
|
+
"""
|
|
9
|
+
Count the number of %%EOF markers in a PDF file.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
pdf_path (str): Path to the PDF file.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
int: Number of %%EOF markers.
|
|
16
|
+
-1: If file not found or unreadable.
|
|
17
|
+
"""
|
|
18
|
+
try:
|
|
19
|
+
with open(pdf_path, 'rb') as f:
|
|
20
|
+
content = f.read()
|
|
21
|
+
|
|
22
|
+
matches = re.findall(rb"%%EOF", content)
|
|
23
|
+
return len(matches)
|
|
24
|
+
|
|
25
|
+
except FileNotFoundError:
|
|
26
|
+
return -1
|
|
27
|
+
except Exception as e:
|
|
28
|
+
print(f"Error reading PDF: {e}")
|
|
29
|
+
return -1
|
|
30
|
+
|
|
31
|
+
def cli():
|
|
32
|
+
"""
|
|
33
|
+
Command-line interface for counting %%EOF markers in a PDF file.
|
|
34
|
+
"""
|
|
35
|
+
parser = argparse.ArgumentParser(description="Count %%EOF markers in a PDF file")
|
|
36
|
+
parser.add_argument("pdf_path", type=str, help="Path to the PDF file")
|
|
37
|
+
args = parser.parse_args()
|
|
38
|
+
|
|
39
|
+
count = count_pdf_eof_markers(args.pdf_path)
|
|
40
|
+
if count == -1:
|
|
41
|
+
print("❌ Could not open or read the PDF.")
|
|
42
|
+
else:
|
|
43
|
+
print(f"Number of %%EOF markers found: {count}")
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
cli()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
from .utils import get_pdfresurrect_path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check_no_of_versions(pdf_path):
|
|
10
|
+
"""
|
|
11
|
+
Check the number of versions of a PDF file using the pdfresurrect tool.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
pdf_path (str): The path to the PDF file.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
int: The number of versions found in the PDF file.
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
pdfresurrect_path = get_pdfresurrect_path()
|
|
21
|
+
result = subprocess.run(
|
|
22
|
+
[pdfresurrect_path, '-q', pdf_path],
|
|
23
|
+
capture_output=True,
|
|
24
|
+
text=True,
|
|
25
|
+
check=True
|
|
26
|
+
)
|
|
27
|
+
output = result.stdout.strip()
|
|
28
|
+
if ':' in output:
|
|
29
|
+
version_str = output.rsplit(':', 1)[1].strip() # the file name may contain ':'
|
|
30
|
+
if version_str.isdigit():
|
|
31
|
+
return int(version_str)
|
|
32
|
+
print(f"Unexpected output: {output}")
|
|
33
|
+
return 0
|
|
34
|
+
except subprocess.CalledProcessError as e:
|
|
35
|
+
print(f"Error checking PDF versions: {e}")
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
def cli():
|
|
39
|
+
"""
|
|
40
|
+
Command-line interface for checking number of PDF versions.
|
|
41
|
+
"""
|
|
42
|
+
parser = argparse.ArgumentParser(description="Check the number of PDF versions using pdfresurrect")
|
|
43
|
+
parser.add_argument("pdf_path", type=str, help="Path to the PDF file")
|
|
44
|
+
args = parser.parse_args()
|
|
45
|
+
|
|
46
|
+
versions = check_no_of_versions(args.pdf_path)
|
|
47
|
+
print(f"Number of versions found: {versions}")
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
cli()
|
pdforensic/cli.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Command line: pdforensic analyze FILE [--json] | pdforensic recover FILE [-o DIR]."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from ._version import __version__
|
|
8
|
+
from .forensics import PdfError, analyze, format_report, recover_revisions
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv=None):
|
|
12
|
+
parser = argparse.ArgumentParser(prog="pdforensic", description="Forensic analysis of PDF files.")
|
|
13
|
+
parser.add_argument("--version", action="version", version="pdforensic " + __version__)
|
|
14
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
15
|
+
a = sub.add_parser("analyze", help="report metadata, revisions, tamper indicators and provenance")
|
|
16
|
+
a.add_argument("pdf")
|
|
17
|
+
a.add_argument("--json", action="store_true", help="print the full report as JSON")
|
|
18
|
+
a.add_argument("--password", default="", help="password for an encrypted file")
|
|
19
|
+
r = sub.add_parser("recover", help="write every earlier revision of the file to a directory")
|
|
20
|
+
r.add_argument("pdf")
|
|
21
|
+
r.add_argument("-o", "--out-dir", help="output directory (default: <name>-revisions)")
|
|
22
|
+
r.add_argument("--all", action="store_true", help="also write the current revision")
|
|
23
|
+
r.add_argument("--password", default="", help="password for an encrypted file")
|
|
24
|
+
args = parser.parse_args(argv)
|
|
25
|
+
|
|
26
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
27
|
+
sys.stdout.reconfigure(errors="replace") # consoles that cannot show every character
|
|
28
|
+
try:
|
|
29
|
+
if args.command == "analyze":
|
|
30
|
+
report = analyze(args.pdf, password=args.password)
|
|
31
|
+
print(json.dumps(report, indent=2) if args.json else format_report(report))
|
|
32
|
+
else:
|
|
33
|
+
written = recover_revisions(args.pdf, args.out_dir, include_current=args.all, password=args.password)
|
|
34
|
+
for item in written:
|
|
35
|
+
print("revision %d %d bytes sha256 %s %s" % (
|
|
36
|
+
item["revision"], item["size"], item["sha256"], item["path"]))
|
|
37
|
+
if not written:
|
|
38
|
+
print("The file has a single revision; nothing to recover.")
|
|
39
|
+
except (OSError, PdfError) as exc:
|
|
40
|
+
print("pdforensic: %s" % exc, file=sys.stderr)
|
|
41
|
+
return 2
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
sys.exit(main())
|