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 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())