PDF fraud signals: the field guide

Every signal family Tamperlens reports, what it actually measures, why it fires on honest documents, and how severity is decided.

Nineteen signal families run on every document. The half that matters is the benign causes. Every family here fires on honest files, and a queue built without knowing why is a queue your operations team learns to ignore.

Each family is documented with the same four headings. What it detects, the evidence it returns, its benign causes, and its severity logic, and they are grouped onto four pages below, by which part of the file they read.

Signals, not verdicts. Every finding here is a structural fact about a file. "This document was modified after generation" is true or false about the bytes; "this document is fraudulent" is a judgement about a person, and Tamperlens does not make it. The API returns no boolean verdict field, by design.

The four pages of this guide are four places in the file, and which place a family reads decides what can erase it.
ONE PDF, READ FOUR WAYS %PDF-1.x header · Info dictionary · XMP packet · trailer /ID origin · 5 families body objects: page content streams, fonts, embedded images, annotations what the document actually paints, and in what order page content · 6 families appended revisions: each one a new xref, a new trailer, sometimes a signature a signature's /ByteRange names exactly which of these bytes it protects signature · 3 families the figures printed on the page: check digits, balance columns, where a glyph sits a property of the claims, not of the container numbers · 4 families Re-save the whole file through another tool and the top three bands are regenerated as if the document were new.

Severity vocabulary

Four severities, in a strict hierarchy. They describe how much a single finding establishes on its own, not how likely fraud is.

  • high On its own establishes that the document changed after generation, or that a tool with editing intent was involved.
  • medium A real deviation from "written once by one tool", with common benign explanations.
  • low Weak or absence-based; removes a corroborating fact rather than adding one.
  • info Context. Not counted in signalCount and contributes nothing to the score.
The ladder ranks how much a single finding settles on its own: not how likely fraud is, which is a question about a person and not about a file.
high on its own establishes that the document changed after generation or that a tool with editing intent was involved weight 70 medium a real deviation from “written once, by one tool” with common benign explanations weight 25 low weak, or absence-based removes a corroborating fact rather than adding one weight 10 info context: visible in the report, invisible to the count reported so the observation stays auditable rather than silent weight 0

One report contains at most one signal per family, that is a report invariant. A family either fires once, with all its findings collected into a single evidence object, or it does not appear at all. Signals are sorted most severe first, then alphabetically by id, so reports are byte-stable across runs.

One family is an exception, and it is named rather than hidden. redaction-exposure reports a filled box drawn over text and a picture drawn over text as two separate signals, because they are not the same finding: a box is conclusive and scores high, a picture has never once been shown to be a failed redaction across 1,727 published documents and scores info. Merging them was the earlier behaviour and it was wrong, it announced four exposed lines at high severity when three of them were a letterhead. Every other family obeys the invariant, and a test holds all three engines to it on every fixture in the repository.

Encrypted documents: what the report tells you it did not do

When the trailer references an /Encrypt dictionary, the strings and streams in the file are ciphertext. Metadata values, embedded font names and page content streams cannot be read without the password, which Tamperlens does not hold and never asks for.

The naive behaviour would be to run every signal family anyway and report whatever fires on the noise. Instead, the five content-dependent families are not run at all and the omission is disclosed as a first-class part of the report:

  • metadata-mismatch
  • date-anomalies
  • producer-fingerprint
  • font-anomalies
  • hybrid-page

The disclosure arrives as the structure-warnings signal, titled "Encrypted document: content not analysed", carrying evidence.code: "encrypted-content-not-analysed", the explicit suppressedFamilies list, contentAnalysed: false and structuralAnalysisRan: true. It rides on structure-warnings rather than arriving as its own signal because of the one-signal-per-family invariant.

Structural analysis is unaffected and does run. Revision count, the trailer /ID pair, signature byte ranges and cross-reference consistency are all readable without the password, as is the detection of action dictionaries, dictionary keys are not encrypted either. Metadata that happens to read as plain text still gets reported: a producer may leave the XMP packet in the clear via /EncryptMetadata false, and a text-likeness gate lets that through while dropping fields whose bytes are not readable text. Any fields dropped that way are named in metadataFieldsSuppressed.

An encrypted document's report is missing five families, and the report says which five: so a quiet report cannot be mistaken for a clean one.
THE TRAILER REFERENCES AN /Encrypt DICTIONARY CIPHERTEXT · NOT RUN AT ALL metadata-mismatch date-anomalies producer-fingerprint font-anomalies hybrid-page strings and streams need the password we do not hold DICTIONARY KEYS · STILL READABLE revision count and the /Prev chain the trailer /ID pair signature /ByteRange arithmetic cross-reference consistency action dictionaries keys are not encrypted, so structural analysis is unaffected structure-warnings carries suppressedFamilies, contentAnalysed: false: the omission is the finding

Read this carefully when triaging an encrypted file. The absence of a content-level signal in an encrypted document's report says nothing whatsoever about that document. Those checks did not run. Treat the report as structural-only rather than as a clean bill of health, and note that many issuers password-protect statements as a matter of routine, so encryption is not itself a signal.

How signals become a score

summary.riskScore is a weighted aggregation on 0-100, not a probability and not a confidence. The rules are short enough to state completely, which is the point. You should be able to reconstruct the number from the signal list.

  • Weights. high 70, medium 25, low 10, info 0.
  • Floors. One high signal forces the score to at least 70; one medium forces at least 30.
  • Diminishing returns. The first two signals of a given severity contribute their full weight; beyond that each further signal of the same severity contributes a sharply decaying fraction. A pile-up of weak findings converges rather than adding up indefinitely.
  • The high band is reserved. With no high signal present the score is clamped to 69, whatever the arithmetic says. So riskBand === "high" is exactly equivalent to "at least one finding that on its own establishes a change".
  • Bands. Below 30 low, 30-69 elevated, 70 and above high.
  • signalCount counts signals above info. An informational finding is visible in the report but invisible to the count and the score.
The high band is reserved, not earned by arithmetic: with no high signal the score is clamped at 69 however many weak findings pile up.
summary.riskScore · 0-100 · NOT A PROBABILITY low elevated high 0 30 70 100 one medium floors the score here one high floors the score here no high signal anywhere → the score is clamped to 69, whatever adds up riskBand === "high" therefore means exactly one thing: at least one finding that, on its own, establishes that the document changed.

Weights are versioned by engineVersion: changing them is a version bump, so a score you recorded last quarter still means what it meant. The engine is deterministic (identical bytes produce an identical report, apart from the per-call id), which means you can pin fixtures and regression-test your own thresholds against us.

Using this honestly

Three habits that separate a useful screening step from an angry-customer generator:

  1. Baseline before you threshold. Run documents you believe are genuine through the engine, grouped by issuer, and look at which signals fire normally in your population. Producer strings and revision habits vary wildly between institutions.
  2. Report the fact, not the inference. "This file contains two revisions and names an online PDF editor" is defensible and specific. "Our system flagged this as fraudulent" is neither, and it is the sentence that ends up in a complaint.
  3. Correlate. A single medium signal is a question. A consumer editor plus a content-overwriting revision plus two subsets of one typeface is a coherent story, and the story is what a human reviewer can act on.

See these families fire on a document

Open the “Saved again, page changed” sample: a precomputed report where incremental-updates escalates because a later revision overwrote a content object, which is the severity rule on this page with the evidence attached. Your own file goes into the same checker: it is sent over HTTPS, parsed in memory and never written to disk. No account, no quota. To run it in your own pipeline, see the API quickstart.

Tamperlens reports risk signals, not authenticity verdicts. Signals can have benign causes; combine them with your own decision logic.

Related reading