Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ the summary exactly.

### Exit codes

`0` success · `1` a verdict reached the `--fail-on` level · `2` input error (not found, unsupported, unreadable)
`0` success · `1` a verdict reached the `--fail-on` level, or (with `--fail-on-incomplete`) an analysis was incomplete or a file could not be analysed · `2` input error (not found, unsupported, unreadable)

---

Expand All @@ -226,7 +226,7 @@ Requires Python 3.10 or later.
git clone https://github.com/overjoyde/pdf-forensics-kit.git
cd pdf-forensics-kit
python3 -m venv .venv
.venv/bin/pip install -e '.[signatures]' # add ,dev for the test suite
.venv/bin/pip install -e '.[signatures]' # add ,report for PDF reports, ,dev for the test suite
.venv/bin/pdfforensics --version
```

Expand All @@ -235,6 +235,7 @@ python3 -m venv .venv
| `pikepdf` (qpdf) | yes | Low-level PDF structure | MPL-2.0 |
| `pypdf` | yes | Text of each PDF revision | BSD |
| `pyhanko` | optional (`[signatures]`) | Cryptographic PDF signature validation | MIT |
| `reportlab` | optional (`[report]`) | PDF report output | BSD |
| Poppler `pdfsig` | optional (`brew install poppler` / `apt install poppler-utils`) | Second signature validator: trust and revocation | GPL (separate program, called as a tool) |

Office analysis uses only the Python standard library. There is no AGPL code and nothing phones home.
Expand Down Expand Up @@ -279,10 +280,12 @@ pdfforensics analyze ./inbox --json - --fail-on high
| `--out-dir DIR` | Write JSON, report and summary per file (plus `batch-summary.md`) |
| `--json FILE` / `--markdown FILE` | Write the JSON or Markdown report (`-` for stdout) |
| `--summary [FILE]` | Output only the plain-language summary |
| `--pdf-report PATH` | Write a summarised PDF report with the verdict, key findings and the edit timeline (a directory when several files are analysed). Needs `pip install 'pdf-forensics-kit[report]'` |
| `--hide-info` | Leave info-level findings out of the Markdown |
| `--save-report` | Save `<document>.forensics-report.md` next to each document without asking |
| `--no-prompt` | Never ask whether to save a report (for scripts). In a terminal, `analyze` otherwise asks after the analysis |
| `--fail-on LEVEL` | Exit with 1 if any verdict is at or above `low`/`medium`/`high`/`critical` |
| `--fail-on-incomplete` | Exit with 1 if any analysis is incomplete (a check failed) or a file could not be analysed. Legacy .doc/.xls/.ppt files are always incomplete |
| `--password PW` | User password for encrypted PDFs |
| `--no-external-tools` | Do not run Poppler `pdfsig`, even if installed |
| `--online-revocation` | Let `pdfsig` contact OCSP servers to check revocation (**network access**, off by default) |
Expand Down Expand Up @@ -425,8 +428,9 @@ Known limits:
| input (all formats) | `extension-mismatch`, `changed-after-capture` |
| PDF structure | `data-before-header`, `data-after-eof`, `extra-eof-markers`, `repaired`, `unreachable-objects`, `encrypted`, `xref-chain-broken`, `unlinked-revision` |
| PDF revisions | `content-changed` (with text diff), `annotation-or-form-update`, `metadata-update`, `signature-update`, `other-update`, `not-all-compared` |
| PDF signatures (pyHanko) | `intact`, `broken`, `disallowed-modification`, `bytes-after-last-signature`, `malformed-byterange`, `usage-rights`, `not-validated`, `validation-error` |
| PDF signatures (pdfsig, optional) | `integrity-ok`, `integrity-failure`, `certificate-revoked`, `certificate-expired`; `signature.validators-disagree` |
| Timeline | `timeline.inconsistent-times` |
| PDF signatures (pyHanko) | `intact`, `broken`, `disallowed-modification`, `bytes-after-last-signature`, `malformed-byterange`, `usage-rights`, `not-validated`, `validation-error`, `unparseable` |
| PDF signatures (pdfsig, optional) | `integrity-ok`, `integrity-failure`, `integrity-unknown`, `certificate-revoked`, `certificate-expired`; `signature.validators-disagree` |
| PDF metadata | `modified-before-created`, `future-date`, `info-xmp-date-mismatch`, `producer-mismatch`, `editor-tool`, `manipulation-library`, `xmp-history`, `absent` |
| PDF content | `invisible-text`, `ocr-text-layer`, `print-only-annotations`, `hidden-annotations`, `layer-view-print-differs`, `layers-hidden-by-default` |
| PDF active content | `javascript`, `launch-action`, `submit-or-import`, `remote-goto`, `multimedia`, `xfa`, `embedded-files`, `e-invoice-attachment`, `additional-actions`, `uris` |
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ dependencies = [

[project.optional-dependencies]
signatures = ["pyhanko>=0.25"] # MIT, cryptographic signature validation
report = ["reportlab>=4.0"] # BSD, PDF report output (--pdf-report)
dev = ["pytest>=8.0"]

[project.scripts]
Expand Down
14 changes: 14 additions & 0 deletions src/pdfforensics/analyzers/pdfsig.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ def parse_output(text: str) -> dict[str, Any]:
elif key_l == "certificate validation":
s["trust"] = _match(val, TRUST)
s["trust_text"] = val
elif "form field is not signed" in line.lower():
s["signed"] = False
elif line.lower() == "total document signed":
s["covers_whole_document"] = True
elif line.lower() == "not total document signed":
Expand Down Expand Up @@ -163,6 +165,8 @@ def analyze(doc: Document) -> AnalyzerResult:

ph = {v.get("field"): v for v in doc._cache.get("pyhanko_results", []) if v.get("field")}
for s in r["signatures"]:
if s.get("signed") is False:
continue # an empty signature field: nothing to validate
label = s.get("field") or f"#{s['index']}"
ev = {k: s.get(k) for k in ("field", "signer", "signing_time", "subfilter", "signed_ranges",
"integrity_text", "trust_text", "covers_whole_document")}
Expand All @@ -172,6 +176,16 @@ def analyze(doc: Document) -> AnalyzerResult:
severity=Severity.CRITICAL, confidence=Confidence.HIGH, category="signatures",
explanation="Poppler reports a digest mismatch or invalid signature. The signed bytes were altered.",
evidence=ev))
elif s["integrity"] == "unknown" and "intact" not in ph.get(s.get("field"), {}):
# Only a doubt if pyHanko did not settle it either; Poppler does not verify document
# timestamps, for example.
res.findings.append(Finding(
id="pdfsig.integrity-unknown", title=f"pdfsig: integrity of signature '{label}' could not be determined",
severity=Severity.MEDIUM, confidence=Confidence.LOW, category="signatures",
explanation=("Poppler did not verify this signature, so this validator says nothing about whether the "
"signed bytes are intact. A damaged or unsupported signature container causes this."),
evidence=ev,
benign_explanations=["Signature format Poppler does not support"]))
if s["trust"] == "revoked":
res.findings.append(Finding(
id="pdfsig.certificate-revoked", title=f"pdfsig: signing certificate for '{label}' is revoked",
Expand Down
29 changes: 28 additions & 1 deletion src/pdfforensics/analyzers/revisions.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@

from pdfforensics.document import Document, open_bytes
from pdfforensics.model import AnalyzerResult, Confidence, Finding, Severity
from pdfforensics.pdfutil import object_digest, role_map
from pdfforensics.analyzers.metadata import read_xmp
from pdfforensics.pdfutil import iso, object_digest, parse_pdf_date, parse_xmp_date, role_map, s

logging.getLogger("pypdf").setLevel(logging.ERROR)

Expand Down Expand Up @@ -121,6 +122,25 @@ def _matches_end(rev_end: int, sig_end: int, data: bytes) -> bool:
return hi - lo <= 4 and data[lo:hi].strip(b" \r\n\x00") == b""


def _claimed_times(pdf: pikepdf.Pdf) -> dict[str, Any]:
"""Save times a revision states about itself. Written by software, so forgeable."""
out: dict[str, Any] = {"info_mod": None, "info_created": None, "xmp_modify": None, "producer": ""}
try:
info = pdf.docinfo
out["info_mod"] = iso(parse_pdf_date(s(info.get("/ModDate"))))
out["info_created"] = iso(parse_pdf_date(s(info.get("/CreationDate"))))
out["producer"] = s(info.get("/Producer"), 200)
except Exception:
pass
try:
xmp, _ = read_xmp(pdf)
if xmp and xmp.get("modify_date"):
out["xmp_modify"] = iso(parse_xmp_date(xmp["modify_date"]))
except Exception:
pass
return out


def analyze(doc: Document) -> AnalyzerResult:
res = AnalyzerResult(name="revisions")
rs = doc.raw
Expand Down Expand Up @@ -159,6 +179,10 @@ def analyze(doc: Document) -> AnalyzerResult:
"Two PDF files concatenated during transfer"],
))
if len(revisions) <= 1:
try:
res.facts["revision_times"] = [{"revision": 1, "end": len(doc.data), **_claimed_times(doc.pdf)}]
except Exception:
pass
return res

sig_ends = signature_ends(doc)
Expand Down Expand Up @@ -186,6 +210,7 @@ def analyze(doc: Document) -> AnalyzerResult:
updates_prev_end = 0
prev_page_core: dict = {}

revision_times: list[dict[str, Any]] = []
for rev in to_analyse:
chunk = doc.data[:rev.end]
try:
Expand All @@ -196,6 +221,7 @@ def analyze(doc: Document) -> AnalyzerResult:
continue
try:
digests, page_core, truncated = _snapshot(pdf, doc.options.max_objects)
revision_times.append({"revision": rev.index, "end": rev.end, **_claimed_times(pdf)})
try:
text = extract_text_by_page(chunk, doc.options.max_pages)
except Exception as exc:
Expand Down Expand Up @@ -237,6 +263,7 @@ def role_of(og: tuple[int, int]) -> str:
pdf.close()

res.facts["updates"] = updates
res.facts["revision_times"] = revision_times
last_sig_end = max(sig_ends) if sig_ends else None

for u in updates:
Expand Down
95 changes: 82 additions & 13 deletions src/pdfforensics/analyzers/signatures.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@

try: # optional dependency
from pyhanko.pdf_utils.reader import PdfFileReader
from pyhanko.sign.validation import validate_pdf_signature
from pyhanko.sign.fields import SigSeedSubFilter, enumerate_sig_fields
from asn1crypto import cms
from pyhanko.sign.validation import EmbeddedPdfSignature, validate_pdf_signature, validate_pdf_timestamp
from pyhanko.sign.validation.generic_cms import extract_self_reported_ts
from pyhanko.sign.validation.pdf_embedded import extract_contents, extract_signer_info
from pyhanko_certvalidator import ValidationContext

HAVE_PYHANKO = True
Expand Down Expand Up @@ -77,16 +81,54 @@ def _coverage(sig: pikepdf.Dictionary, data: bytes) -> dict[str, Any]:
return info


def _pyhanko_validate(data: bytes) -> list[dict[str, Any]]:
results = []
def _embedded_signatures(reader: Any) -> tuple[list[Any], list[dict[str, Any]]]:
"""Load each signature on its own, so one unparseable signature cannot hide the others.

pyHanko's reader.embedded_signatures raises on the first bad one. Signatures with a SubFilter
pyHanko does not support are skipped, as pyHanko does; they are reported as not validated.
"""
supported = {x.value for x in SigSeedSubFilter}
loaded, broken = [], []
for fq_name, sig_obj, sig_field in enumerate_sig_fields(reader, filled_status=True):
try:
sig = sig_obj.get_object()
if str(sig.get("/SubFilter", "")) not in supported:
continue
except Exception as exc:
broken.append({"field": str(fq_name), "error": f"{type(exc).__name__}: {exc}"})
continue
try: # the CMS container itself: failing here means it cannot be a valid signature
extract_signer_info(cms.ContentInfo.load(extract_contents(sig))["content"])
except Exception as exc:
broken.append({"field": str(fq_name), "unparseable": True, "error": f"{type(exc).__name__}: {exc}"})
continue
try:
loaded.append(EmbeddedPdfSignature(reader, sig_field, fq_name))
except Exception as exc:
broken.append({"field": str(fq_name), "error": f"{type(exc).__name__}: {exc}"})
loaded.sort(key=lambda e: e.signed_revision)
return loaded, broken


def _pyhanko_validate(data: bytes, password: str = "") -> list[dict[str, Any]]:
reader = PdfFileReader(io.BytesIO(data), strict=False)
for emb in reader.embedded_signatures:
r: dict[str, Any] = {"field": getattr(emb, "field_name", None)}
if reader.encrypted:
reader.decrypt(password or "") # an empty user password opens owner-password-only files
embedded, results = _embedded_signatures(reader)
for emb in embedded:
# plain str: for encrypted files pyHanko hands out decrypted proxy objects
field_name = getattr(emb, "field_name", None)
r: dict[str, Any] = {"field": str(field_name) if field_name is not None else None}
try:
# No trust anchors on purpose: this checks integrity, not signer trust. Without an explicit
# list pyHanko falls back to the OS TLS roots, which is deprecated and not a document-signing
# trust source anyway. Trust is reported by pdfsig against its own store.
st = validate_pdf_signature(emb, signer_validation_context=ValidationContext(trust_roots=[]))
context = ValidationContext(trust_roots=[])
if emb.sig_object_type == "/DocTimeStamp":
r["kind"] = "document-timestamp"
st = validate_pdf_timestamp(emb, validation_context=context)
else:
st = validate_pdf_signature(emb, signer_validation_context=context)
try:
signer = st.signing_cert.subject.human_friendly
except Exception:
Expand All @@ -101,6 +143,23 @@ def _pyhanko_validate(data: bytes) -> list[dict[str, Any]]:
"signer": signer,
"summary": st.summary() if hasattr(st, "summary") else "",
})
try:
br = [int(x) for x in emb.byte_range]
r["signed_end"] = br[2] + br[3]
except Exception:
pass
# pyHanko's signer_reported_dt falls back to /M; only the signed signingTime attribute counts here
try:
reported = extract_self_reported_ts(emb.signer_info)
except Exception:
reported = None
if reported is not None:
r["signer_reported_time"] = reported.isoformat()
tsv = getattr(st, "timestamp_validity", None)
if tsv is not None and getattr(tsv, "timestamp", None) is not None:
r["signature_timestamp_time"] = tsv.timestamp.isoformat()
if r.get("kind") == "document-timestamp" and getattr(st, "timestamp", None) is not None:
r["timestamp_time"] = st.timestamp.isoformat()
except Exception as exc:
r["error"] = f"{type(exc).__name__}: {exc}"
results.append(r)
Expand Down Expand Up @@ -166,10 +225,11 @@ def analyze(doc: Document) -> AnalyzerResult:

if HAVE_PYHANKO:
try:
ph = _pyhanko_validate(doc.data)
ph = _pyhanko_validate(doc.data, doc.options.password)
except Exception as exc:
ph = []
res.facts["pyhanko_error"] = f"{type(exc).__name__}: {exc}"
res.error = f"pyHanko could not read the signatures: {type(exc).__name__}: {exc}"
res.facts["validation"] = ph
doc._cache["pyhanko_results"] = ph # lets the pdfsig analyser cross-check
usage_rights = [c for c in cov if c["purpose"] == "usage-rights"]
Expand All @@ -182,21 +242,30 @@ def analyze(doc: Document) -> AnalyzerResult:
evidence={"signatures": [{k: c.get(k) for k in ("object", "subfilter", "byte_range")}
for c in usage_rights]}))
doc_sigs = [c for c in cov if c["purpose"] != "usage-rights"]
if len(ph) < len(doc_sigs):
if len(ph) < len(doc_sigs) and "pyhanko_error" not in res.facts:
res.findings.append(Finding(
id="signature.not-validated",
title=f"{len(doc_sigs) - len(ph)} signature(s) could not be validated cryptographically",
severity=Severity.LOW, confidence=Confidence.HIGH, category="signatures",
explanation=("pyHanko skipped these signatures, typically because they use a legacy or unsupported "
"format (for example adbe.x509.rsa_sha1) or sit outside the form field tree. Their "
"integrity is unknown. Only the ByteRange coverage checks above apply."),
explanation=("pyHanko skipped these signatures because they use a legacy or unsupported format "
"(for example adbe.x509.rsa_sha1) or sit outside the form field tree. Their integrity "
"is unknown. Only the ByteRange coverage checks above apply."),
evidence={"subfilters": sorted({c.get("subfilter") or "?" for c in doc_sigs}),
"found": len(doc_sigs), "validated": len(ph)}))
for r in ph:
if "error" in r:
if r.get("unparseable"):
res.findings.append(Finding(
id="signature.unparseable",
title=f"Signature '{r.get('field')}' is not a readable signature container",
severity=Severity.HIGH, confidence=Confidence.HIGH, category="signatures",
explanation=("The signature's /Contents does not hold a well-formed CMS signature, so it cannot be "
"valid. Either the signature was damaged or its bytes were replaced after signing."),
evidence=r,
benign_explanations=["File corrupted during transfer (check other structural findings)"]))
elif "error" in r:
res.findings.append(Finding(
id="signature.validation-error", title=f"Signature '{r.get('field')}' could not be validated",
severity=Severity.MEDIUM, confidence=Confidence.LOW, category="signatures",
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM, category="signatures",
explanation="The cryptographic check failed with an error, so integrity is unknown.",
evidence=r))
elif not r["intact"]:
Expand Down
Loading
Loading