<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Haruo</title>
    <description>The latest articles on DEV Community by Haruo (@haruodev).</description>
    <link>https://dev.to/haruodev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4049958%2Fe6d836af-a2df-42d1-a0cf-c7b38e0ff9cc.png</url>
      <title>DEV Community: Haruo</title>
      <link>https://dev.to/haruodev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/haruodev"/>
    <language>en</language>
    <item>
      <title>Inscribe says AI is under 5% of document fraud, AppZen 70.8%. Both are right.</title>
      <dc:creator>Haruo</dc:creator>
      <pubDate>Tue, 08 Sep 2026 15:50:26 +0000</pubDate>
      <link>https://dev.to/haruodev/inscribe-says-ai-is-under-5-of-document-fraud-appzen-708-both-are-right-3cg3</link>
      <guid>https://dev.to/haruodev/inscribe-says-ai-is-under-5-of-document-fraud-appzen-708-both-are-right-3cg3</guid>
      <description>&lt;p&gt;Two companies that detect document fraud published numbers this year that&lt;br&gt;
appear to contradict each other completely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inscribe&lt;/strong&gt; (20 January 2026, &lt;a href="https://www.inscribe.ai/reports/2026-document-fraud-report" rel="noopener noreferrer"&gt;2026 State of Document Fraud Report&lt;/a&gt;):&lt;br&gt;
AI-generated documents were &lt;strong&gt;less than 5%&lt;/strong&gt; of the fraudulent documents it&lt;br&gt;
flagged in lending during 2025. Their words: "the majority of document fraud&lt;br&gt;
is still template-based."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AppZen&lt;/strong&gt; (26 June 2026, &lt;a href="https://www.pymnts.com/news/artificial-intelligence/2026/ai-generated-fake-receipts-now-make-up-71percent-of-expense-fraud/" rel="noopener noreferrer"&gt;via PYMNTS&lt;/a&gt;):&lt;br&gt;
&lt;strong&gt;70.8%&lt;/strong&gt; of flagged fraudulent expense receipts were AI-generated by mid-May&lt;br&gt;
2026: up from 0% in March 2025. That's 1,471 AI-generated fake receipts,&lt;br&gt;
submitted by 745 employees, at 174 companies.&lt;/p&gt;

&lt;p&gt;Less than 5%. Or 70.8%. Pick your headline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Both are right
&lt;/h2&gt;

&lt;p&gt;The numbers measure different populations, and once you see the denominators&lt;br&gt;
the contradiction disappears.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inscribe's denominator is every fraudulent document flagged across its&lt;br&gt;
lending customers&lt;/strong&gt;: bank statements, pay stubs, tax forms, business filings.&lt;br&gt;
That's a world where forgery already worked before generative models arrived.&lt;br&gt;
Inscribe's own measure of it: template-based fraud was 1 in 5 flagged&lt;br&gt;
documents in 2025, up from 1 in 14 in 2024. The usual explanation, which I&lt;br&gt;
went looking for a primary source on and could not find, is that template&lt;br&gt;
farms sell editable pay stubs and bank statements that look right because&lt;br&gt;
they started from the real thing; the share is measured, the explanation is a&lt;br&gt;
hypothesis. Either way, against that installed base, AI generation is a small&lt;br&gt;
new entrant. If you're underwriting loans, most of the fake documents you'll&lt;br&gt;
see this year are still edited or templated, not generated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AppZen's denominator is flagged fraudulent receipts in expense reports.&lt;/strong&gt;&lt;br&gt;
A receipt is the easiest document in the world to generate: low resolution&lt;br&gt;
expected, no issuer database to cross-check, thousands of legitimate layout&lt;br&gt;
variations. The base rate of &lt;em&gt;sophisticated&lt;/em&gt; receipt forgery was always near&lt;br&gt;
zero, because nobody needed sophistication to fake a $40 dinner. When image&lt;br&gt;
models made generation free, it took over that category almost instantly, 0%&lt;br&gt;
to 70.8% in fourteen months.&lt;/p&gt;

&lt;p&gt;Same industry. Different documents, different fraud economics, different&lt;br&gt;
numbers. Anyone quoting either figure without its denominator is misleading&lt;br&gt;
you, and both figures are being recirculated stripped of exactly that&lt;br&gt;
context. "71% of expense fraud is AI-generated" is already travelling through&lt;br&gt;
AI-written content farms; it isn't what AppZen measured (70.8% of &lt;em&gt;flagged&lt;br&gt;
fraudulent receipts&lt;/em&gt;, not of expense fraud, not of receipts).&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that should actually worry you
&lt;/h2&gt;

&lt;p&gt;Here's the finding that doesn't depend on any vendor's platform data, from&lt;br&gt;
&lt;a href="https://arxiv.org/abs/2602.20569" rel="noopener noreferrer"&gt;AIForge-Doc (arXiv 2602.20569)&lt;/a&gt;, an&lt;br&gt;
academic benchmark of tampering detectors against AI-inpainted documents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DocTamper&lt;/strong&gt;, a specialised detector, scores &lt;strong&gt;0.563 AUC&lt;/strong&gt; out of
distribution, against 0.98 in-distribution. Trained defenses collapse on
fakes they weren't trained on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TruFor&lt;/strong&gt;: 0.751, against 0.96 in-distribution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPT-4o as a detector: 0.509&lt;/strong&gt;, a coin flip.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The detectors built for the previous era score at chance on the new one.&lt;br&gt;
That is independently measured, it is not marketing, and it's the reason we&lt;br&gt;
treat &lt;em&gt;every&lt;/em&gt; vendor detection-rate claim (including any we might be tempted&lt;br&gt;
to publish ourselves) as unverifiable until someone shows a methodology.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means if you handle documents
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ask for denominators.&lt;/strong&gt; "X% of fraud is AI" means nothing until you know
fraud &lt;em&gt;of what&lt;/em&gt;, flagged &lt;em&gt;by whom&lt;/em&gt;, out of &lt;em&gt;what population&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match the defense to the document.&lt;/strong&gt; Expense receipts and bank statements
are different threat models. One is being flooded by generation; the other
is still dominated by editing and templates, which leave structural traces
(revisions appended after creation, producer strings that changed, metadata
that disagrees with itself) that don't depend on how the visual layer was
made.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distrust round detection rates.&lt;/strong&gt; Nobody in this market publishes a
false-positive rate on legitimate documents. We publish ours, measured on
uncurated public document populations, engine-version-stamped:
&lt;a href="https://tamperlens.com/evidence?utm_campaign=devto-two-numbers" rel="noopener noreferrer"&gt;tamperlens.com/evidence&lt;/a&gt;.
We'd genuinely like not to be the only ones.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;Tamperlens reads document structure and returns fraud signals as evidence, never an approve/deny verdict. Free checker, no signup:&lt;br&gt;
&lt;a href="https://tamperlens.com/?utm_campaign=devto-two-numbers" rel="noopener noreferrer"&gt;tamperlens.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>fraud</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Our status page froze for four days because the CI minutes ran out</title>
      <dc:creator>Haruo</dc:creator>
      <pubDate>Tue, 08 Sep 2026 15:48:23 +0000</pubDate>
      <link>https://dev.to/haruodev/our-status-page-froze-for-four-days-because-the-ci-minutes-ran-out-1m37</link>
      <guid>https://dev.to/haruodev/our-status-page-froze-for-four-days-because-the-ci-minutes-ran-out-1m37</guid>
      <description>&lt;p&gt;On 19 August our GitHub Actions hosted-minutes quota ran out.&lt;/p&gt;

&lt;p&gt;Everything you would expect to break broke, loudly, and was fixed inside two days. The thing that broke quietly was the uptime probe, which ran on a schedule on those same hosted runners. Every scheduled run since then died in about three seconds with no runner and no logs. By the time anyone looked, that was &lt;strong&gt;165 consecutive failures&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Two consequences, and the second is the one worth the post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The status page froze.&lt;/strong&gt; &lt;code&gt;status.tamperlens.com&lt;/code&gt; still said "Last checked" on 19 August at 17:13 UTC. Four days later it still said it. A frozen status page does not look broken. It looks like a service that is fine and nobody has poked recently, which is a strictly worse failure than a page that is down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The probe was also the alarm.&lt;/strong&gt; A failing run emailed the operator. Once the runs stopped starting, nothing was checking the API at all, so &lt;strong&gt;if production had gone down, nothing would have told anyone.&lt;/strong&gt; That is the more urgent half, and it is the half that a "status page frozen" ticket does not describe.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious fix does not work here
&lt;/h2&gt;

&lt;p&gt;We already had a self-hosted runner on the production box, and the two workflows that mattered moved onto it two days later.&lt;/p&gt;

&lt;p&gt;The uptime probe could not follow, for a reason that is not about our setup: &lt;strong&gt;that runner is the box being watched.&lt;/strong&gt; A machine cannot meaningfully report its own outage. Worse, the status page's own copy makes the claim that it is probed from outside our network and served from outside it too, so it stays up when we do not. Moving the probe onto the box would have made the page &lt;em&gt;lie&lt;/em&gt; rather than fixed it.&lt;/p&gt;

&lt;p&gt;That constraint is the whole design. A status page is only worth having if the thing that measures and the thing that serves are both independent of the thing being measured. Ours had one of those properties (the page was served elsewhere) and had been getting the other from a CI provider, for free, as a side effect.&lt;/p&gt;

&lt;p&gt;Which is the general lesson, and it applies well beyond monitoring: &lt;strong&gt;if a property of your system is a side effect of somebody's free tier, you have not designed for it, you have been lent it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The move: a Worker with a cron
&lt;/h2&gt;

&lt;p&gt;The probe now lives in a Cloudflare Worker with a scheduled trigger, writing history to KV. Cloudflare's edge is neither the box being watched nor the CI that deploys it, so the premise survives.&lt;/p&gt;

&lt;p&gt;Two decisions inside that are more interesting than the migration itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The renderer did not move.&lt;/strong&gt; The page is rendered by a module both the Worker and the existing Node CLI import, so the served page cannot drift from the one the CLI produces out of the same history. Migrating a monitor is a great way to end up with two subtly different renderings of the same data, and the fix is boring: extract the pure part first, make it depend on no filesystem, no clock and no process, and have both callers import it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;History moved from an orphan git branch to KV, and the reason is credentials.&lt;/strong&gt; The old design kept &lt;code&gt;history.json&lt;/code&gt; on an orphan branch, which was auditable and cheap to clone. A Worker cannot push to git without carrying a token that can write to the repository, and &lt;strong&gt;a monitor holding repo-write credentials is a worse trade than losing the git audit trail.&lt;/strong&gt; The raw data stays public and one fetch away at &lt;code&gt;/history.json&lt;/code&gt;, served from KV instead of GitHub.&lt;/p&gt;

&lt;p&gt;The thing you are tempted to preserve during a migration is usually the thing whose cost you have not re-priced. A write credential in a component whose only job is to read is worth more to an attacker than the audit trail is worth to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What got worse, said out loud
&lt;/h2&gt;

&lt;p&gt;The Worker's checker component is &lt;strong&gt;weaker&lt;/strong&gt; than the Node probe it replaces, and that belongs in the post rather than in a footnote.&lt;/p&gt;

&lt;p&gt;The Node probe fetches every script the page loads, parses each one, requires something to assign the bootstrap globals, and then &lt;strong&gt;executes them against a DOM&lt;/strong&gt; and requires the demo report to actually render, plus a leg that drives a fixture PDF through a live &lt;code&gt;POST /api/v1/inspect&lt;/code&gt;. A Worker has no DOM and no &lt;code&gt;eval&lt;/code&gt;, so it stops after the parse-and-assign layer.&lt;/p&gt;

&lt;p&gt;That layer is not the consolation prize, and here is why.&lt;/p&gt;

&lt;p&gt;On 4 August a syntax error in one client script left every checker page dead &lt;strong&gt;for two days&lt;/strong&gt;, while the origin kept answering &lt;code&gt;200&lt;/code&gt; for the HTML. Four probes were green throughout, correctly: they asserted one HTTP status on one HTML document. One of them was even &lt;em&gt;labelled&lt;/em&gt; "Website and free checker". A page whose scripts do not parse returns that status perfectly happily.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The monitor was watching the wrapper.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So the component that came out of that outage does something narrower and much more useful. It fetches the page, reads the &lt;code&gt;&amp;lt;script src&amp;gt;&lt;/code&gt; list &lt;strong&gt;out of the HTML the origin actually served&lt;/strong&gt; rather than from a hardcoded list, fetches every one, asserts each parses, and asserts that the bootstrap globals are &lt;em&gt;assigned&lt;/em&gt; somewhere across them.&lt;/p&gt;

&lt;p&gt;That last word is doing real work. &lt;code&gt;const T = window.Tamperlens&lt;/code&gt; is a &lt;strong&gt;read&lt;/strong&gt;, and it does not count. Every consumer script contains a line like it, so counting reads would make the check pass on exactly the day the file that does the assigning stopped loading.&lt;/p&gt;

&lt;p&gt;The parse-and-assign layer is the layer that failed in the real outage. Losing the DOM execution on top of it costs something. Losing the layer underneath would have cost the only outage this component has ever had to catch.&lt;/p&gt;

&lt;p&gt;The deeper probe did not get deleted, either. It kept a manual trigger and still runs the whole thing on the self-hosted runner on request, for use before a release or when the cheap probe says the checker is down and you want to know how far the damage goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that keeps two vantage points honest
&lt;/h2&gt;

&lt;p&gt;There are now two implementations of "does this script parse": &lt;code&gt;node:vm&lt;/code&gt; in the CLI, and a parser bundled into the Worker, because Workers have no &lt;code&gt;vm&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two parsers means two opinions, and two opinions about the same file means the repo and the edge can report different verdicts about it. So the load-bearing test in the Worker suite asserts that the two agree on what parses, &lt;strong&gt;including the exact historical breakage&lt;/strong&gt; that caused the two-day outage.&lt;/p&gt;

&lt;p&gt;That is the shape I would reuse anywhere a check gets reimplemented for a second runtime: do not test the new implementation against your expectations, test it against the old implementation, on the input that made you write the check in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part the move did not fix, for one more day
&lt;/h2&gt;

&lt;p&gt;The Worker as first deployed logged a failing probe with &lt;code&gt;console.error&lt;/code&gt;, which reaches &lt;code&gt;wrangler tail&lt;/code&gt; and the Workers logs, and nothing else. The old workflow at least emailed somebody when it failed. So for a day the move had &lt;strong&gt;restored the page and not the alarm&lt;/strong&gt;, and that deserves saying out loud: the four-day outage above was invisible precisely because nobody was watching the watcher, and a fix that left that property intact would have been the same mistake with newer infrastructure.&lt;/p&gt;

&lt;p&gt;I had two Cloudflare routes in mind for closing it and both were wrong for the job. Workers alerts fire on invocation errors, and a failing probe is not one: the Worker ran perfectly, the site did not, so &lt;code&gt;console.error&lt;/code&gt; never trips it. Logpush is an Enterprise feature, which is why the menu item could not be found.&lt;/p&gt;

&lt;p&gt;What got wired instead, on 25 August, is the dead man's switch we already run for the nightly backup and for the box's own guard script. The cron pings a healthchecks.io check when everything is up, pings its &lt;code&gt;/fail&lt;/code&gt; URL with the failing component names when something is down, and, the part neither Cloudflare option offered, &lt;strong&gt;alerts by silence if the Worker itself stops firing&lt;/strong&gt;. That last one is the exact failure mode that produced this post. The ping URL is a secret, and with it unset the Worker behaves as before, so it was safe to deploy before the check existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this landed
&lt;/h2&gt;

&lt;p&gt;On 23 August the code was done and the cutover was not: creating the KV namespace, deploying, watching one real probe land, moving the custom domain off the old project. Five dashboard steps and one CLI call, which needed credentials and a DNS change that code cannot do for itself.&lt;/p&gt;

&lt;p&gt;They happened that night. The first probe landed at 23:07 UTC with all five components up, on the same quarter-hour cadence the old workflow had asked for, and the four probes that ran before midnight all came back clean. &lt;code&gt;status.tamperlens.com&lt;/code&gt; moved onto the Worker route at about 02:30 UTC on 24 August, and the "Last checked" line has read a timestamp from the last fifteen minutes ever since. It has also recorded a handful of short timeouts on its own since then, which is the page doing the thing the frozen one could not.&lt;/p&gt;

&lt;p&gt;I wrote most of this before any of that, and I am posting it with the ending attached rather than rewriting it as a migration story, because "we migrated our status page" is a much less useful post than "our status page was frozen for four days and here is the whole reason it could be".&lt;/p&gt;

&lt;h2&gt;
  
  
  The four things I would check on your own setup
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is your probe also your alarm?&lt;/strong&gt; If the same job both measures and notifies, its failure is silent by construction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does anything watch the watcher?&lt;/strong&gt; A dead man's switch that expects a regular ping is a few lines and would have caught this on day one. Ours has one now, and it took the whole episode to get it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is your monitor asserting on the wrapper?&lt;/strong&gt; An HTTP 200 on an HTML document says nothing about whether the page works. Assert on the thing that actually broke last time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which of your invariants are on loan?&lt;/strong&gt; Independence, retention, alerting: if the property comes from someone else's free tier, write down what happens when the tier ends. Ours ended on a Wednesday.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;What our monitoring commits to, what it deliberately does not, and the limits the status page states about its own measurement are on the &lt;a href="https://tamperlens.com/security?utm_campaign=devto-status" rel="noopener noreferrer"&gt;security page&lt;/a&gt;. The product it watches is a document trust API: drop a PDF into the &lt;a href="https://tamperlens.com/?utm_campaign=devto-status" rel="noopener noreferrer"&gt;free checker&lt;/a&gt; and it returns the structural findings with the evidence under each one, no account, nothing stored.&lt;/p&gt;

&lt;p&gt;If you have a monitor that stayed green through a real outage, I would like to hear which layer it was asserting on. Mine was asserting on the wrapper for months.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>monitoring</category>
      <category>cloudflare</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Six commands that beat my own PDF fraud detector, ranked by how well</title>
      <dc:creator>Haruo</dc:creator>
      <pubDate>Tue, 08 Sep 2026 15:44:11 +0000</pubDate>
      <link>https://dev.to/haruodev/six-commands-that-beat-my-own-pdf-fraud-detector-ranked-by-how-well-3348</link>
      <guid>https://dev.to/haruodev/six-commands-that-beat-my-own-pdf-fraud-detector-ranked-by-how-well-3348</guid>
      <description>&lt;p&gt;Every vendor in this space publishes what it catches. I have not found one that publishes what makes it stop working. So here is that second list for my own engine, measured rather than reasoned about.&lt;/p&gt;

&lt;p&gt;The engine reads PDF structure and returns fraud signals. Nineteen families run on every document; a twentieth, &lt;code&gt;issuer-mismatch&lt;/code&gt;, only fires when the caller supplies a baseline, so a bench that inspects a file on its own can never reach it. The six evasions below are one command each, they are all on the first page of results for "how to remove pdf metadata", and anybody selling forged statements already owns them.&lt;/p&gt;

&lt;p&gt;The run: 98 PDF fixtures, engine 1.37.0, qpdf 12.4.0, exiftool 13.55, Ghostscript 10.07.1, and a Quartz &lt;code&gt;CGPDFContext&lt;/code&gt; print path. One script, checked in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;npx tsx scripts/evasion-bench.mjs --in test/fixtures
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The ranking, with the column that decides whether anyone does it
&lt;/h2&gt;

&lt;p&gt;A family counts as silenced when every firing the bench could measure went quiet.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Evasion&lt;/th&gt;
&lt;th&gt;families silenced, of 19&lt;/th&gt;
&lt;th&gt;what it costs the sender&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;exiftool -all=&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;nothing, and it appends a revision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qpdf --linearize&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;any signature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;print-to-PDF&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;the whole revision history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gs -sDEVICE=pdfwrite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;any signature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qpdf --encrypt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gs -sDEVICE=pdfimage24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;the text layer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The right-hand column is the one that matters and it is the one nobody prints. An evasion that costs the sender something is an evasion most senders will not use. Flattening every page to an image is the strongest move on the board and I have nothing that beats it, but it also breaks every downstream pipeline that reads text: no selection, no copy-paste, no extraction, no screen reader. Income parsers and accounting imports fail on arrival, before anyone reaches the question of whether the document is real.&lt;/p&gt;

&lt;p&gt;Encryption is the interesting row, because it silences thirteen and costs nothing. An owner password does not stop anyone opening the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four results I did not expect
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. A metadata strip makes four families louder
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;exiftool -all=&lt;/code&gt; on a PDF is not a delete. It is an &lt;strong&gt;append&lt;/strong&gt;: a new revision in which the metadata is blank, with the original bytes left exactly where they were. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;incremental-updates&lt;/code&gt; goes from one revision to two, which is the thing it looks for.&lt;/li&gt;
&lt;li&gt;On a signed document the appended region sits past the end of the signed &lt;code&gt;/ByteRange&lt;/code&gt;, which is the highest-severity finding the engine has.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;id-inconsistency&lt;/code&gt; fires on a file that had nothing to say before. The appended trailer keeps the original &lt;code&gt;/ID[0]&lt;/code&gt; and writes a fresh &lt;code&gt;/ID[1]&lt;/code&gt;, so a file whose two halves matched comes out with two that do not. &lt;strong&gt;The strip manufactures the finding it was run to remove.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;font-anomalies&lt;/code&gt; reads &lt;code&gt;info&lt;/code&gt; on a one-save file and worse on an edited one, so it went &lt;code&gt;info&lt;/code&gt; to &lt;code&gt;high&lt;/code&gt; on five of the six fixtures it fires on. Nothing about the fonts changed. The file simply stopped being a one-save document.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tidying a PDF before sending it makes the report worse, not better. That is not clever detection. That is the file format.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. "Whole-file rewrite" is not one column
&lt;/h3&gt;

&lt;p&gt;qpdf and Ghostscript are both described as whole-file rewriters, and they disagree on fifteen of the nineteen rows. qpdf silences one family. Ghostscript silences eleven.&lt;/p&gt;

&lt;p&gt;The mechanism is the difference between copying and re-rendering. A structural rewriter copies through what it does not need to understand: the Info dictionary, the XMP packet, the font objects. A re-renderer regenerates all of it from what it drew. So Ghostscript rewrites &lt;code&gt;/Producer&lt;/code&gt; and copies &lt;code&gt;/Creator&lt;/code&gt; through verbatim, meaning an editor that named itself in both survives a full re-render. It re-subsets fonts without merging them, so two subsets of one typeface stay two subsets. And it does not remove active content so much as move it: an &lt;code&gt;/OpenAction&lt;/code&gt; came out the far side as an &lt;code&gt;/A&lt;/code&gt; action on the catalog, which satisfies anyone grepping for the old key.&lt;/p&gt;

&lt;p&gt;There is no answer to "what does a whole-file rewrite do to my signals" that does not name the tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Destroying a signature is quieter than invalidating it
&lt;/h3&gt;

&lt;p&gt;Read the three &lt;code&gt;signature-*&lt;/code&gt; rows across instead of down and the usual direction reverses.&lt;/p&gt;

&lt;p&gt;Of nine signed fixtures that read above &lt;code&gt;low&lt;/code&gt; as built:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the four evasions that &lt;strong&gt;keep&lt;/strong&gt; the signature object (strip, qpdf rewrite, encrypt, and the two-step below) launder &lt;strong&gt;0 of 9&lt;/strong&gt;. A qpdf rewrite takes &lt;code&gt;signature-coverage&lt;/code&gt; to &lt;code&gt;high&lt;/code&gt; on all thirteen signed fixtures, because invalidating a signature is the loudest thing this engine reports.&lt;/li&gt;
&lt;li&gt;the three that &lt;strong&gt;re-render&lt;/strong&gt; the page (Ghostscript, print-to-PDF, raster) launder &lt;strong&gt;9 of 9&lt;/strong&gt;. All three signature families go quiet, because the signature does not survive rendering at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which exposes the largest hole in my own engine, and it is worth stating plainly: &lt;strong&gt;there is no signal for "this document should carry a signature and carries none."&lt;/strong&gt; An invalidated signature is a finding. An absent one is an ordinary PDF, and most PDFs are unsigned. So against a signed document the forger's answer is not to compose evasions harder, it is to print.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Printing from Chrome trips nothing, and that is deliberate
&lt;/h3&gt;

&lt;p&gt;The consumer-editor list the engine matches producer strings against has around fifty entries. &lt;code&gt;Quartz PDFContext&lt;/code&gt; is on it. &lt;code&gt;Microsoft Print to PDF&lt;/code&gt; is on it. &lt;code&gt;Skia/PDF&lt;/code&gt;, which is what Chrome writes when you print a page, is not.&lt;/p&gt;

&lt;p&gt;I know how to close that and I am not going to. When we parsed 213 PDFs published by US banks, two Bank of America online-banking agreements came back as &lt;code&gt;Skia/PDF m130&lt;/code&gt; and &lt;code&gt;Skia/PDF m135&lt;/code&gt;: printed straight out of a browser by whoever maintained the page. Adding Skia to that list turns a real bank's real document into a finding.&lt;/p&gt;

&lt;p&gt;So printing from Chrome is the cheapest evasion in the table. It costs one keystroke and my engine says nothing about the tool that did it. That is a false-positive rate I chose to pay for in recall, and I would rather write the sentence than have someone discover it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The column a forger actually runs
&lt;/h2&gt;

&lt;p&gt;Every column above is one command, which answers the wrong question. Forgers compose. One pair composes into something neither half is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;exiftool &lt;span class="nt"&gt;-all&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;.pdf
qpdf &lt;span class="nt"&gt;--linearize&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;.pdf out.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The strip appends a revision and leaves the freed metadata objects in the byte stream, which is why its column reads "louder". The rewrite re-serialises from the object graph, dropping unreferenced objects and the revision chain, but on its own it carries the metadata disagreements through. Composed, the rewrite erases the revision the strip manufactured and the strip empties the stores the rewrite would have preserved.&lt;/p&gt;

&lt;p&gt;This is not hypothetical. &lt;code&gt;guillaumemeyer/watermarks-remover&lt;/code&gt;, an MIT-licensed tool for removing AI provenance marks, ships exactly this pair behind one command, in a Docker image, with a README, and warns in its own source that exiftool alone leaves "the original metadata bytes recoverable".&lt;/p&gt;

&lt;p&gt;What it does to one document, in four states. This is a fictional proof of address whose page content was replaced after generation, and its clean twin:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;th&gt;What the report says&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;clean, untouched&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0 low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no signals at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;forged, untouched&lt;/td&gt;
&lt;td&gt;&lt;code&gt;70 high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;incremental-updates&lt;/code&gt;: page content replaced after generation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;forged, strip only&lt;/td&gt;
&lt;td&gt;&lt;code&gt;100 high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the above, plus the two the strip manufactured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;forged, strip then rewrite&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10 low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;producer-fingerprint&lt;/code&gt; low, &lt;code&gt;id-inconsistency&lt;/code&gt; info&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;clean, strip then rewrite&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10 low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the same two, identically&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On an unsigned document the two-step takes a real forgery from &lt;code&gt;high&lt;/code&gt; to &lt;code&gt;low&lt;/code&gt;, and the laundered forgery is indistinguishable from the laundered clean file.&lt;/p&gt;

&lt;p&gt;It is also not the best evasion available, which I got wrong for about an hour on the day I first measured it. Over the 35 unsigned fixtures that read above &lt;code&gt;low&lt;/code&gt; as built: a raster flatten launders &lt;strong&gt;34&lt;/strong&gt;, &lt;code&gt;qpdf --encrypt&lt;/code&gt; &lt;strong&gt;32&lt;/strong&gt;, a Ghostscript rewrite &lt;strong&gt;24&lt;/strong&gt;, print-to-PDF &lt;strong&gt;18&lt;/strong&gt;, the two-step &lt;strong&gt;13&lt;/strong&gt;, &lt;code&gt;qpdf --linearize&lt;/code&gt; alone &lt;strong&gt;11&lt;/strong&gt;, and &lt;code&gt;exiftool -all=&lt;/code&gt; alone &lt;strong&gt;0&lt;/strong&gt;. Fifth of seven. What stands is the mechanism, and the fact that it beats either half alone.&lt;/p&gt;

&lt;p&gt;Three things it cannot do, and they are why this is not a rout:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It does not produce an untouched-looking file.&lt;/strong&gt; Both laundered files land on the same two residual signals. A PDF that declares no producing tool and has mismatched &lt;code&gt;/ID&lt;/code&gt; halves has been through a rewriter. For document classes where the issuer always names its software, and statement and payroll pipelines do, "declares no producing tool" is the anomaly rather than the absence of one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is catastrophic on anything signed.&lt;/strong&gt; All 13 signed fixtures came out &lt;code&gt;high&lt;/code&gt;, including one that reads &lt;code&gt;0 low&lt;/code&gt; untouched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It does not touch the page.&lt;/strong&gt; The text under the black box is still there, the balance column still fails to add up, the check digit is still wrong.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The bench was counting its own failures as results
&lt;/h2&gt;

&lt;p&gt;The part of this work I would most like other people to check their own harness for. It came out of the first time the checked-in bench was re-run, on 24 August, over the 92 fixtures the repository had that day.&lt;/p&gt;

&lt;p&gt;Nine of those 92 are things a tool can legitimately refuse: one is not a PDF at all, one is already encrypted and cannot be re-encrypted, and Quartz will not open six files whose whole purpose is to be malformed. When a derivation failed there was no report to read, the summary read &lt;code&gt;families?.[family]&lt;/code&gt; as &lt;code&gt;undefined&lt;/code&gt;, and &lt;code&gt;verdict(before, undefined)&lt;/code&gt; returned &lt;strong&gt;&lt;code&gt;gone&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So a failed tool run was published as a silenced signal family. Nine cells were inflated by it. One cell, &lt;code&gt;document-injection-markers&lt;/code&gt; under print-to-PDF, was published as &lt;code&gt;gone&lt;/code&gt; on the strength of a single fixture Quartz refuses to open, which means that cell never had a measurement in it at all. It reads &lt;code&gt;unmeasured&lt;/code&gt; now.&lt;/p&gt;

&lt;p&gt;The irony is that the script already carried the rule it was breaking. Its own header says a variant whose tool is missing is &lt;code&gt;UNMEASURED&lt;/code&gt;, "absent, not zero", because "a silently missing column is how a matrix starts lying". It enforced that per column and not per file.&lt;/p&gt;

&lt;p&gt;Two general shapes fall out of that, and neither is about PDFs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An absent measurement and a negative measurement must not share a representation.&lt;/strong&gt; &lt;code&gt;undefined&lt;/code&gt; flowing into a comparison that returns a meaningful verdict is the whole bug, in one line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A rule enforced at the wrong granularity reads as a rule enforced.&lt;/strong&gt; Nobody re-reads a guard that is present.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The engine, for what it is worth, is not responsible for any of it: running the whole bench on every build from 1.29.0 to 1.32.0 over the same fixtures produces byte-identical tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I take from the table
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Nothing in the unconditional set survives all six.&lt;/strong&gt; Every one of the nineteen has at least one column in which every firing the bench could measure went quiet. The one family that never goes quiet is &lt;code&gt;issuer-mismatch&lt;/code&gt;, and it is the one the bench cannot draw, because it compares the file to documents from the institution it claims to come from rather than reading the file's own account of itself.&lt;/p&gt;

&lt;p&gt;That is the asymmetry in one sentence: &lt;strong&gt;self-description is defeated by regeneration, and comparison to an outside reference is not.&lt;/strong&gt; The price is that you have to hold the reference.&lt;/p&gt;

&lt;p&gt;And the operational reading, if you are the one receiving documents: a clean structural report means the bytes are clean. It does not mean the document is genuine. Those are different claims and the entire risk lives in the gap between them. Which is also why I think the only defensible output is signals rather than verdicts. "This file contains two revisions, a later revision replaced a page content stream, and the Info dictionary names an online PDF editor that the XMP packet does not" is a statement about bytes. "Our system detected fraud" is a statement about a person, and a parser does not get to make it.&lt;/p&gt;




&lt;p&gt;The full matrix, cell by cell, with the three dated passes of corrections to the cells I got wrong, is at &lt;a href="https://tamperlens.com/blog/what-defeats-every-signal?utm_campaign=devto-evasion" rel="noopener noreferrer"&gt;what defeats every signal&lt;/a&gt;. There is a free checker at &lt;a href="https://tamperlens.com/?utm_campaign=devto-evasion" rel="noopener noreferrer"&gt;tamperlens.com&lt;/a&gt; if you want to drop a document in and read its own report. Nothing is stored, and no account is involved.&lt;/p&gt;

&lt;p&gt;If you run one of these evasions against a document you personally know the history of and get a result that contradicts mine, that is the comment I most want.&lt;/p&gt;

</description>
      <category>security</category>
      <category>pdf</category>
      <category>testing</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Find out whether a PDF was edited, with one curl and no signup</title>
      <dc:creator>Haruo</dc:creator>
      <pubDate>Wed, 02 Sep 2026 12:39:26 +0000</pubDate>
      <link>https://dev.to/haruodev/find-out-whether-a-pdf-was-edited-with-one-curl-and-no-signup-pbe</link>
      <guid>https://dev.to/haruodev/find-out-whether-a-pdf-was-edited-with-one-curl-and-no-signup-pbe</guid>
      <description>&lt;p&gt;The thing that took me longest to internalise about this file format: &lt;strong&gt;a PDF is not a picture of a document.&lt;/strong&gt; It is an append-only container with a linked list of revisions in it, and it keeps receipts about its own history whether or not anyone wanted it to.&lt;/p&gt;

&lt;p&gt;Which means "was this edited after it was made?" is often answerable from the bytes, deterministically, with no model involved. Here is how to ask, in one command, without an account.&lt;/p&gt;

&lt;h2&gt;
  
  
  The call
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://tamperlens.com/api/v1/inspect &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;@statement.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;Authorization&lt;/code&gt; header. Anonymous callers get the full report, rate limited to 10 documents per hour per IP. The cap is 10 MB per document, the bytes are parsed in memory and discarded when the response is written, and there is no human review step.&lt;/p&gt;

&lt;p&gt;A raw body works too, if your HTTP client makes multipart expensive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://tamperlens.com/api/v1/inspect &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/pdf"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @statement.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The format is decided by sniffing the bytes, never from &lt;code&gt;Content-Type&lt;/code&gt; or the filename. A phone that renamed a JPEG to &lt;code&gt;.pdf&lt;/code&gt; is analysed as the JPEG it is. Word, Excel and PowerPoint files and JPEG, PNG, WebP, HEIC and AVIF images go to the same endpoint and come back in the same report shape, with a &lt;code&gt;mediaType&lt;/code&gt; discriminator.&lt;/p&gt;

&lt;h2&gt;
  
  
  What comes back
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"insp_2f1e9c04-..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"engineVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.37.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"riskScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"riskBand"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"low"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"signalCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"revisions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"signals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"producer-fingerprint"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"low"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PDF editing tool in the production chain (iLovePDF)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The document's metadata names iLovePDF in its production chain. ..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"evidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"tools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"iLovePDF"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"origin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pdf-editor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"producer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iLovePDF"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"creator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iText 7.2.5"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"revisions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"document"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"pages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"producer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iLovePDF"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"creator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iText 7.2.5"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"creationDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-01-04T10:02:00.000Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"modDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-01-06T18:41:00.000Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"encrypted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"signed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"revisions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sizeBytes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;184320&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"disclaimer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tamperlens reports risk signals, not authenticity verdicts. ..."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to know before you write any code against it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;riskBand&lt;/code&gt; is &lt;code&gt;low&lt;/code&gt; under 30, &lt;code&gt;elevated&lt;/code&gt; 30 to 69, &lt;code&gt;high&lt;/code&gt; at 70 and above&lt;/strong&gt;, and the &lt;code&gt;high&lt;/code&gt; band is narrow on purpose: it is reserved for reports containing at least one high-severity signal, so a pile-up of weak findings is clamped at 69 and can never reach it. That is a contract, not an accident of the arithmetic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;signals[].id&lt;/code&gt; is a stable string.&lt;/strong&gt; Branch on ids rather than on the score when you know your own domain, and you will: if your flow legitimately involves customers signing documents, you want &lt;code&gt;incremental-updates&lt;/code&gt; weighted down (a signature &lt;em&gt;is&lt;/em&gt; an incremental update) and &lt;code&gt;signature-coverage&lt;/code&gt; weighted up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;signals[].evidence&lt;/code&gt; is the part a human reviewer needs.&lt;/strong&gt; It is small, it contains no document content, and it is per-family and additive, so read keys defensively. Store it next to the decision it produced.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build a positive before you trust a negative
&lt;/h2&gt;

&lt;p&gt;This is the part I would actually do first, and it takes about four minutes.&lt;/p&gt;

&lt;p&gt;Take a PDF whose history you personally know: something you generated, or a statement you downloaded straight from a bank. Inspect it. Then run the same file through any free browser-based PDF editor, change one number, download it, and inspect &lt;em&gt;that&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;You can watch the mechanism before the API even answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'%%EOF'&lt;/span&gt; statement.pdf
2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A PDF ends with &lt;code&gt;%%EOF&lt;/code&gt;. Two of them is the format's own record that something was appended after the original save. Every appended cross-reference section's trailer carries &lt;code&gt;/Prev&lt;/code&gt;, the byte offset of the previous one, which is a backwards linked list you can walk to the original document.&lt;/p&gt;

&lt;p&gt;The count alone is weak evidence, and you should treat it that way: signing a document is an incremental update, so is filling in a form field, and a linearised "fast web view" file legitimately has two markers from a single save. The strong version is what the appended revision &lt;em&gt;replaced&lt;/em&gt;. A later revision that replaces an object which already existed, and that carries page or content-stream data, has no reading compatible with "the issuer generated this and nobody touched it."&lt;/p&gt;

&lt;p&gt;Watching which signals light up on a file whose history you know is worth more than any vendor's page, including mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Send your decision rule with the request
&lt;/h2&gt;

&lt;p&gt;You will otherwise reimplement &lt;code&gt;if (riskScore &amp;gt;= 70)&lt;/code&gt; on your side, slightly differently from everyone else, and invisibly to whoever you ask about it later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://tamperlens.com/api/v1/inspect &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'X-Tamperlens-Policy: {"review":30,"reject":70,"rejectOn":["redaction-exposure"]}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/pdf"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @statement.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response gains one block and nothing else changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"policy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"reject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"riskScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"thresholds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"review"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"reject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"signal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"triggeredBy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"redaction-exposure"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four behaviours worth knowing, because each is a decision:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;defaults are the engine's own band boundaries&lt;/strong&gt;, so an unconfigured policy agrees with &lt;code&gt;riskBand&lt;/code&gt; instead of quietly disagreeing with it.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;named family beats a threshold&lt;/strong&gt;, and &lt;code&gt;rejectOn&lt;/code&gt; beats &lt;code&gt;reviewOn&lt;/code&gt;. Naming a family means it matters regardless of what the arithmetic came to, and a threshold that could veto that would make the rule advisory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unknown signal ids are accepted and never match.&lt;/strong&gt; A family we rename must not be able to take your pipeline down.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;malformed policy is a 422, never a silent fallback to our defaults.&lt;/strong&gt; A typo that leaves you believing a rule is enforced when it is not is the one failure mode this must not have.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Errors worth writing code for
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Body&lt;/th&gt;
&lt;th&gt;Do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;413&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"error":"file_too_large","maxMb":10}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;check size client-side first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;422&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"error":"analysis_timeout","timeoutMs":15000}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;do not retry, the input is the problem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;429&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"error":"rate_limited","retryAfterSeconds":60}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;you called too fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;429&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"error":"quota_exceeded","quota":25,"used":25}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;queue, do not drop, the counter resets monthly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;503&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"error":"busy","retryAfterSeconds":5}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;retry with backoff, &lt;code&gt;Retry-After&lt;/code&gt; is set&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;rate_limited&lt;/code&gt; and &lt;code&gt;quota_exceeded&lt;/code&gt; are deliberately distinct: a quota is the plan you bought, a rate limit is the pace you called at.&lt;/p&gt;

&lt;p&gt;And the one that catches people out: &lt;strong&gt;a file the parser cannot make sense of is not an error.&lt;/strong&gt; Unparseable and hostile input degrades into a &lt;code&gt;200&lt;/code&gt; carrying a &lt;code&gt;structure-warnings&lt;/code&gt; signal. A garbage PDF is a finding, not a failure, and treating it as a 5xx would mean throwing away the most interesting document of the day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two properties you can build tests on
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It is deterministic.&lt;/strong&gt; Identical bytes produce an identical report apart from &lt;code&gt;id&lt;/code&gt;. Pin your fixtures, snapshot the reports, and regression-test the vendor: that is an explicitly supported use of the free tier. Pin &lt;code&gt;engineVersion&lt;/code&gt; while you are there, because a bump means the scoring weights may have moved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing is stored.&lt;/strong&gt; No document is written to disk, no document content is logged, no third party is in the analysis path, and no outbound call happens while inspecting. What persists is a usage row that holds no document.&lt;/p&gt;

&lt;h2&gt;
  
  
  If the caller is an agent
&lt;/h2&gt;

&lt;p&gt;An agent that opens an attachment has already read whatever the attachment says, and a PDF is a convenient place to put a sentence addressed to the model rather than to the person. So the useful order is &lt;strong&gt;inspect, then read&lt;/strong&gt;: one call before the bytes reach your model's context, branching on the injection families exactly as you would on any other signal.&lt;/p&gt;

&lt;p&gt;The payload does not have to travel with the finding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s1"&gt;'https://tamperlens.com/api/v1/inspect?redact=payload'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @resume.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every attacker-authored string is elided and the signal gains &lt;code&gt;evidence.payloadRedacted: true&lt;/code&gt;, so "nothing was found" stays distinguishable from "you were not given it". The score, the band and every count are identical.&lt;/p&gt;

&lt;p&gt;There is an MCP server over the same endpoints, &lt;code&gt;npx tamperlens-mcp&lt;/code&gt;, which redacts unconditionally, because an MCP tool result &lt;em&gt;is&lt;/em&gt; a model's context.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a clean report does not mean
&lt;/h2&gt;

&lt;p&gt;The honest limit, and I would rather write it than have you find it.&lt;/p&gt;

&lt;p&gt;The engine reports what is true of the bytes. A structural signal can be defeated: flattening a page to a raster image throws away almost everything there is to read, printing the file from a browser rewrites its whole account of itself, and stripping the metadata and then rewriting the file removes both the metadata and the history in one go. I published the whole matrix of what defeats each of my own signals, with the cells I got wrong, because the alternative is a reader who sees a clean report and hears "this document is genuine".&lt;/p&gt;

&lt;p&gt;Those are different claims. A clean report means the bytes are clean.&lt;/p&gt;

&lt;p&gt;So the report is a set of signals and never a verdict. "This file contains two revisions, a later revision replaced a page content stream, and the Info dictionary names an online PDF editor that the XMP packet does not" is a statement about bytes that a human can act on. "This document is fraudulent" is a statement about a person, and a parser does not get to make it.&lt;/p&gt;




&lt;p&gt;The one-screen quickstart is at &lt;a href="https://tamperlens.com/api?utm_campaign=devto-quickstart" rel="noopener noreferrer"&gt;tamperlens.com/api&lt;/a&gt;, the full schema and every error code at &lt;code&gt;/api-reference&lt;/code&gt;, and the machine-readable spec with a try-it console at &lt;code&gt;/docs&lt;/code&gt;. If you would rather drag a file into a page than write a request, the &lt;a href="https://tamperlens.com/?utm_campaign=devto-quickstart" rel="noopener noreferrer"&gt;free checker&lt;/a&gt; returns the same report, with the raw evidence under every finding and no account involved.&lt;/p&gt;

&lt;p&gt;If a signal fires on something you know to be completely innocent, that is the comment I want most. Those are the interesting ones.&lt;/p&gt;

</description>
      <category>api</category>
      <category>tutorial</category>
      <category>security</category>
      <category>pdf</category>
    </item>
    <item>
      <title>A published invariant is a test you have not written yet</title>
      <dc:creator>Haruo</dc:creator>
      <pubDate>Tue, 01 Sep 2026 18:36:56 +0000</pubDate>
      <link>https://dev.to/haruodev/a-published-invariant-is-a-test-you-have-not-written-yet-4i3</link>
      <guid>https://dev.to/haruodev/a-published-invariant-is-a-test-you-have-not-written-yet-4i3</guid>
      <description>&lt;p&gt;Three places in our product said the same thing about a report:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;One report contains &lt;strong&gt;at most one signal per family&lt;/strong&gt;. A family either fires once, with all its findings collected into a single &lt;code&gt;evidence&lt;/code&gt; object, or it does not appear at all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The public field guide says it. &lt;code&gt;docs/ARCHITECTURE.md&lt;/code&gt; restates it. And &lt;code&gt;engine/message.ts&lt;/code&gt; relies on it, mapping a family id to a single explanation on the strength of it.&lt;/p&gt;

&lt;p&gt;Nothing enforced it. Three families had never obeyed it, on our own checked-in fixtures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;office-doctype.docx            office-structure-anomalies [medium] [low] [info]
office-macro-template.docx     office-active-content      [high] [medium] [medium]
office-injection-markers.docx  office-hidden-content      [medium] [low]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three consumers had been built on a promise that nothing was keeping. This is what it cost, what the fix looked like, and the one violation we decided to publish instead of repair.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things went wrong at once, and none was visible from inside the family
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The score inflated.&lt;/strong&gt; &lt;code&gt;scoreSignals&lt;/code&gt; decays the second and later signals of a given severity, on the assumption that they are &lt;em&gt;different families corroborating each other&lt;/em&gt;. That is a good assumption and it is the reason the score is not just a sum. A family emitting three findings therefore collected three independent contributions for one finding. &lt;code&gt;office-macro-template.docx&lt;/code&gt; scored &lt;strong&gt;100&lt;/strong&gt; where the same evidence now scores &lt;strong&gt;70&lt;/strong&gt;: &lt;code&gt;high&lt;/code&gt; 70 plus &lt;code&gt;medium&lt;/code&gt; 25 plus &lt;code&gt;medium&lt;/code&gt; 25 is 120, clamped to 100. Two more fixtures moved with it, 100 to 95 and 35 to 30.&lt;/p&gt;

&lt;p&gt;Worth noting what did &lt;strong&gt;not&lt;/strong&gt; move: no fixture changes risk band. The fold always keeps the strongest finding, and the band turns on the presence of a severity rather than on how many findings carried it, so &lt;code&gt;high&lt;/code&gt; stays &lt;code&gt;high&lt;/code&gt;. That is the difference between a scoring bug and a detection bug, and it is why nobody noticed for as long as they did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One number meant two things.&lt;/strong&gt; &lt;code&gt;summary.signalCount&lt;/code&gt; is what the report headline prints. It counted findings on Office files and families everywhere else. A single API field with two definitions, decided by which engine ran.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The UI repeated itself.&lt;/strong&gt; The report card keys a signal's brief text and its "What this signal means" link &lt;strong&gt;by family&lt;/strong&gt;. So three signals of one family rendered three identical cards with three identical links.&lt;/p&gt;

&lt;p&gt;None of those three failures is visible from inside the family that caused them. They live in the scorer, in the summary, and in the renderer, which is the general shape of an invariant violation: the family that breaks it is never the code that pays.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fold, which is not a summary
&lt;/h2&gt;

&lt;p&gt;The fix is a shared helper, &lt;code&gt;src/engine/fold.ts&lt;/code&gt;, generalised from code one PDF family had used since engine 1.14.0.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;strongest&lt;/strong&gt; finding keeps its title and its whole paragraph, so a reader gets the full explanation of the thing that mattered most.&lt;/li&gt;
&lt;li&gt;Every other finding appends &lt;strong&gt;one sentence&lt;/strong&gt; through &lt;code&gt;notes&lt;/code&gt;, a mechanism two families already used.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No evidence is dropped.&lt;/strong&gt; Every check's evidence object is merged whole.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;evidence.checks&lt;/code&gt; names which checks fired, so a consumer can branch without reading prose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is the one I would carry to another codebase. A fold that summarises is a fold that loses data, and the loss shows up months later in a support thread. A fold that merges and then &lt;em&gt;names what it merged&lt;/em&gt; costs one array and turns "did the doctype check fire?" into a lookup instead of a string match against a title that copy review is free to rewrite.&lt;/p&gt;

&lt;p&gt;Making the merge lossless took two evidence-key renames: two different checks had both been writing &lt;code&gt;parts&lt;/code&gt;, so merging them would have silently dropped one. If you are folding evidence objects, the collisions are the work.&lt;/p&gt;

&lt;p&gt;One family did &lt;strong&gt;not&lt;/strong&gt; move onto the helper, deliberately. &lt;code&gt;embedded-image-anomalies&lt;/code&gt; keeps its own inline copy, because the strings in its &lt;code&gt;evidence.checks&lt;/code&gt; are evidence key names rather than check names. That is a shipped shape in a public API, and changing it would have been a breaking change with no defect behind it. Consistency inside the engine is worth less than a stable contract outside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The duplicate that no amount of folding could prevent
&lt;/h2&gt;

&lt;p&gt;Folding inside a family fixes a family that emits three. It does nothing about a &lt;strong&gt;registry&lt;/strong&gt; that appends a fourth.&lt;/p&gt;

&lt;p&gt;Ours did: a coverage-disclosure family was in the registry's loop &lt;em&gt;and&lt;/em&gt; was appended again by the loop's failure path, for any family that threw. So it ran twice on exactly the documents that had gone wrong.&lt;/p&gt;

&lt;p&gt;It left the loop. It now runs last, once, and is handed the list of families that threw. Which is what the comment beside it always claimed it did.&lt;/p&gt;

&lt;p&gt;The always-on coverage disclosure also repeats &lt;strong&gt;in full&lt;/strong&gt; when it becomes a note rather than being shortened, and that was a choice: an abbreviated statement of what was not looked at is not a disclosure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exception we published instead of fixing
&lt;/h2&gt;

&lt;p&gt;One family still emits two signals, knowingly.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;redaction-exposure&lt;/code&gt; reports a filled box drawn over text (&lt;code&gt;high&lt;/code&gt;, conclusive) and a picture drawn over text (&lt;code&gt;info&lt;/code&gt;) as separate findings. Merging them was the earlier behaviour and it was a &lt;em&gt;measured&lt;/em&gt; bug: one document announced four exposed text runs at high severity when three of them were a letterhead. The two findings also carry the same evidence keys at different scopes, so the fold above would silently drop one set of numbers.&lt;/p&gt;

&lt;p&gt;Resolving it properly means either nesting that family's evidence or changing the published sentence, and both are decisions about what the product promises rather than refactors. So the exception is now stated in all three of the places the invariant is stated: on the public page, in the architecture doc, and in the test that enforces it, each carrying the argument for why.&lt;/p&gt;

&lt;p&gt;The alternative was leaving a sentence on a public page that the engine disagrees with, which is the failure mode this whole exercise exists to fix. &lt;strong&gt;An exception you state is documentation. An exception you leave implicit is a lie with a timestamp on it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The test, and the two things it does that a normal test would not
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;test/engine/one-signal-per-family.test.ts&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It runs all three engines over every fixture in the repository&lt;/strong&gt;, enumerated from the directory rather than from a list. The defect was in the Office engine, and the test is not a case in the Office suite, because the property is about all three and the one that broke it was the one nobody was looking at. Enumerating the directory is the other half of that: a fixture added tomorrow is checked tomorrow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It collects every violation before asserting&lt;/strong&gt;, rather than stopping at the first. A test that fails on the first violation makes you fix and re-run once per instance, which is how you end up believing you are done.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each engine also carries a floor ("fewer than 60 PDF fixtures means the sweep stopped seeing them"), because a glob that silently matches nothing is a green test.&lt;/p&gt;

&lt;p&gt;It found a fourth violating fixture the review had not, which is how the &lt;code&gt;redaction-exposure&lt;/code&gt; exception came to light in the first place. It also pins that &lt;code&gt;summary.signalCount&lt;/code&gt; counts families rather than findings, on all three engines, because the invariant and the count are the same promise stated twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other half: a published number needs a derivation, not a reviewer
&lt;/h2&gt;

&lt;p&gt;Two counting bugs turned up next to this one, and they rhyme.&lt;/p&gt;

&lt;p&gt;A coverage test had been counting Office families &lt;strong&gt;with a regex over the source of a private array&lt;/strong&gt;. It read the right number on the day it was written. It would have read 7 the moment the disclosure family left that array, which is precisely what this refactor did. It imports an exported constant now. A published number that a refactor can quietly change is the failure this repository keeps rediscovering.&lt;/p&gt;

&lt;p&gt;And a family count was baked into a &lt;strong&gt;PNG&lt;/strong&gt;. The social card for the flagship guide said "All ten signal families" while the guide's own heading said eighteen, and that card is the &lt;code&gt;og:image&lt;/code&gt; on ten pages. The sweep that checks every count on the site could not see it twice over: the string lives in a build script outside the sweep's roots, and what ships is an image, which no text lint can read. So a test now reads the card generator's source and derives the expected number from the engine's own registry.&lt;/p&gt;

&lt;p&gt;The rule under all three: &lt;strong&gt;if you publish a number or an invariant, you owe it a derivation.&lt;/strong&gt; Not a reviewer, not a style note, not a comment. A reviewer does not count families across ninety-six pages, and a comment cannot fail CI.&lt;/p&gt;




&lt;p&gt;The invariant, the eighteen families it applies to and the one stated exception are in the &lt;a href="https://tamperlens.com/pdf-fraud-signals?utm_campaign=devto-invariant" rel="noopener noreferrer"&gt;fraud signals field guide&lt;/a&gt;. If you want to see the report shape the invariant is about, the &lt;a href="https://tamperlens.com/?utm_campaign=devto-invariant" rel="noopener noreferrer"&gt;free checker&lt;/a&gt; takes a PDF and gives back the same JSON the API returns, with the evidence under every finding. No account, and nothing is stored.&lt;/p&gt;

&lt;p&gt;If you have shipped a guard that turned out to be enforced at the wrong granularity, I would like to read about it. That is the class of bug I now assume I have more of.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>typescript</category>
      <category>architecture</category>
      <category>node</category>
    </item>
    <item>
      <title>The half of Claude's watermark you can actually check</title>
      <dc:creator>Haruo</dc:creator>
      <pubDate>Sat, 15 Aug 2026 17:29:47 +0000</pubDate>
      <link>https://dev.to/haruodev/the-half-of-claudes-watermark-you-can-actually-check-1482</link>
      <guid>https://dev.to/haruodev/the-half-of-claudes-watermark-you-can-actually-check-1482</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/sylwia-lask/the-end-of-undetectable-ai-text-claudes-new-watermark-explained-45g2"&gt;Sylwia Lask's post on Claude's watermark&lt;/a&gt; is the best-natured thing I have read on this topic in a while, and it is right about the two things people keep getting wrong: the EU AI Act asks for machine-detectability, not a ban, and a watermark hit tells you a model was &lt;em&gt;involved&lt;/em&gt;, not that it &lt;em&gt;wrote&lt;/em&gt; anything.&lt;/p&gt;

&lt;p&gt;I want to add to it rather than argue with it, because that announcement has two halves and the comment threads only ever reach one of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Half one is text.&lt;/strong&gt; A keyed statistical mark, spread across token choices. It is the half everybody is discussing and, as I will get to, the half you personally cannot test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Half two is files.&lt;/strong&gt; Anthropic began attaching C2PA content credentials to generated images on 11 August. C2PA is an open standard with an open reference tool, so this half is checkable by anyone, today, with no key and no vendor cooperation.&lt;/p&gt;

&lt;p&gt;I build a PDF signal engine, so the second half is my problem. On 14 August I put a signed image through every PDF generator on this laptop to find out what a downstream reader can still see. That is the measured part of this post. First, though, two things about the text half, because both keep coming up in that thread.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanism has a specification you can read
&lt;/h2&gt;

&lt;p&gt;The post reasons from first principles to statistical token watermarking, which is the right guess, and it is careful to label it as a guess. Since then the guess has been confirmed, in that post's own comment thread: on 14 August the author notes that Anthropic updated its documentation and Claude is indeed using SynthID. &lt;a href="https://support.claude.com/en/articles/16266773-how-claude-marks-ai-generated-content" rel="noopener noreferrer"&gt;Anthropic's page&lt;/a&gt; puts it as a version of the &lt;strong&gt;SynthID-Text&lt;/strong&gt; approach published by Google DeepMind.&lt;/p&gt;

&lt;p&gt;That fact deserves to be out of a comment thread and in front of people, because it changes the epistemic status of the whole conversation. There is one scheme here, not two, and it has a specification: &lt;a href="https://www.nature.com/articles/s41586-024-08025-4" rel="noopener noreferrer"&gt;Nature 634, 818-823&lt;/a&gt;. You do not have to guess at the mechanism, you can read it. As the model writes, it hashes the last few tokens with a secret key and uses that seed to bias which of several equally good next words it emits. In unmarked text those tokens arrive at chance rate, so counting them gives a z-score and an actual p-value. That is the real advantage over a classifier: a calibrated false-positive rate instead of a vibe.&lt;/p&gt;

&lt;p&gt;It also means the published attacks apply directly, which is the part most coverage still misses while calling the mechanism undisclosed. "Undisclosed proprietary magic" and "a published scheme with known attacks" support very different decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  It is not invisible characters, and stripping them does nothing
&lt;/h2&gt;

&lt;p&gt;The post gets this right and it is worth repeating, because stripping invisible characters is the first thing everyone reaches for: the mark is &lt;strong&gt;not&lt;/strong&gt; zero-width Unicode. Not &lt;code&gt;U+200B&lt;/code&gt;, not &lt;code&gt;U+FEFF&lt;/code&gt;, not the tag block.&lt;/p&gt;

&lt;p&gt;I know those characters well, because our text extraction strips them before anything else reads the text, for prompt-injection reasons that have nothing to do with watermarking. Which gives me the two-sided version of the myth:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Removing every invisible character from a paragraph does &lt;strong&gt;nothing&lt;/strong&gt; to a statistical watermark. The signal is in &lt;em&gt;which words were chosen&lt;/em&gt;, not in anything you can see or delete.&lt;/li&gt;
&lt;li&gt;Finding invisible characters in a document proves &lt;strong&gt;nothing&lt;/strong&gt; about Claude either. They arrive from copy-paste, from CMSs, from PDF extraction. The presence of a carrier is not evidence of a payload.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What does remove the mark is documented and cheap. Re-tokenisation, which is as simple as inserting a character between every word and deleting it again, destroys the n-gram contexts every hash-based scheme seeds from. OpenAI names that attack in its own writing on why it never shipped text watermarking. Paraphrase removes it outright below roughly 800 tokens. And &lt;a href="https://arxiv.org/abs/2402.19361" rel="noopener noreferrer"&gt;Jovanović, Staab and Vechev, ICML 2024&lt;/a&gt; showed that for under $50 an attacker can both spoof and scrub state-of-the-art schemes, with over 80% average success. Spoofing is the direction nobody plans for: forging a mark onto human text is the attack that hurts a person.&lt;/p&gt;

&lt;p&gt;Then there is the gate that ends the practical conversation. &lt;strong&gt;The scheme is symmetric.&lt;/strong&gt; Without the secret key you cannot reconstruct the seeds, and without the seeds there is nothing to count. For a third party holding a suspicious paragraph, the answer today is not "run the test and see". The test cannot be run at all.&lt;/p&gt;

&lt;p&gt;The fallback everyone reaches for instead, a commercial AI-text classifier, is worse than its reputation: 61.22% of human-written TOEFL essays flagged as AI by seven detectors in &lt;a href="https://arxiv.org/abs/2304.02819" rel="noopener noreferrer"&gt;Liang et al.&lt;/a&gt;, 78.3% of granted patent claims flagged by a leading open detector, and around two dozen universities that switched their detector off and none that switched it back on. I keep the receipts for that in a guide, &lt;a href="https://tamperlens.com/detect-ai-generated-text" rel="noopener noreferrer"&gt;how to detect AI-generated text&lt;/a&gt;, which mostly exists to explain why the honest answer to the question is "you cannot, and here is what you can ask instead".&lt;/p&gt;

&lt;p&gt;That is the whole text half. A sound scheme, a key you do not have, and known attacks. Which is why I spend my time on the other half.&lt;/p&gt;

&lt;h2&gt;
  
  
  The half you can check: what happened to nine PDFs
&lt;/h2&gt;

&lt;p&gt;C2PA is not an AI detector. It is a signed manifest inside the file with a hash binding the claim to the bytes, so it is a first-person statement you can verify. My question is narrower than "is this AI": it is &lt;strong&gt;does what a file declares about itself still validate&lt;/strong&gt;, and does the declaration survive the trip to wherever a reviewer opens it.&lt;/p&gt;

&lt;p&gt;Fraud review sees PDFs. Anthropic signs &lt;code&gt;.svg&lt;/code&gt;, &lt;code&gt;.png&lt;/code&gt; and &lt;code&gt;.jpg&lt;/code&gt;. So the question is what happens when a signed image goes into a document, and as far as I can tell nobody had measured it. I built 30 files and 9 PDFs on one machine on 14 August, with local tools only, using &lt;code&gt;c2patool&lt;/code&gt; 0.27.15's development certificate for signing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A signed JPEG survives PDF embedding, byte for byte.&lt;/strong&gt; Six of six paths: &lt;code&gt;img2pdf&lt;/code&gt;, Chrome print-to-PDF at natural size and scaled to 47%, Ghostscript two ways, and &lt;code&gt;qpdf --linearize&lt;/code&gt;. The DCT stream comes out of the PDF identical to the one that went in, and re-validates. I had assumed Chrome's print pipeline re-encoded and it does not: the scaling is applied by the content stream's transformation matrix instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PNG and SVG survive nothing.&lt;/strong&gt; Zero of two and zero of one. A PDF has no PNG container, so the pixels are re-emitted as a Flate raster and the &lt;code&gt;caBX&lt;/code&gt; chunk, which is a PNG chunk, has nowhere to go. Chrome draws an SVG as vector operators and produces no image object at all. Of the three formats Anthropic signs, exactly one carries its credential into a document, and it is the one document fraud actually uses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And here is the result I did not expect.&lt;/strong&gt; &lt;code&gt;c2patool&lt;/code&gt; returns &lt;code&gt;Error: No claim found&lt;/code&gt; on all nine PDFs, &lt;strong&gt;including the six whose embedded JPEG carries a fully valid manifest.&lt;/strong&gt; The official reader looks for a PDF-level manifest. The credential is sitting one indirection away, inside an image stream, intact and unread.&lt;/p&gt;

&lt;p&gt;So the honest summary of the file half is: the credential survives the trip, and the standard tool for reading credentials will tell you the document is unsigned. If you want to find it you have to walk the object graph, pull the image streams out and validate those.&lt;/p&gt;

&lt;p&gt;Two more numbers from the same run, both of which cut against the optimistic reading:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Five of five ordinary re-saves destroy the credential.&lt;/strong&gt; A JPEG quality change, a PNG re-save, a format conversion, a resize, and a screenshot of the image. Every marker gone, not degraded. The screenshot of the signed PNG came out the same size as the unsigned original, because a credential is not part of what a renderer renders. &lt;strong&gt;Absence is the overwhelming case and it means nothing at all.&lt;/strong&gt; Anyone building a rule on "no manifest, therefore suspicious" is building a rule that fires on the entire honest internet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Six of six tampers detected&lt;/strong&gt;, with two distinct codes that a report must not conflate: &lt;code&gt;assertion.dataHash.mismatch&lt;/code&gt; (the picture changed under a good claim) and &lt;code&gt;claimSignature.mismatch&lt;/code&gt; (the claim bytes themselves changed).&lt;/p&gt;

&lt;h2&gt;
  
  
  What this cannot tell you
&lt;/h2&gt;

&lt;p&gt;The limits belong up front, not in a footnote, so here are mine.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every validation above is against a development certificate.&lt;/strong&gt; No file signed by a real Anthropic chain exists in my corpus. "Claude" describes what generated the image, never what validated the signature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three survival paths are unmeasured, not zero&lt;/strong&gt;: LibreOffice export, Word to PDF, and a WhatsApp round-trip. Not installed, or a manual phone step. If you have any of the three, that is a genuinely useful thing to run and post.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Four of the five surfaces Anthropic lists are untested.&lt;/strong&gt; I checked one, Claude Code, where five of five generated images carried no manifest. The caveat that makes that honest: Claude Code has no image-generation model. It writes an SVG and drives your own C2PA-unaware local tools to rasterize it, so no signing step exists in that path. That is not "Anthropic failed to ship". It is what the support page's own hedge, &lt;em&gt;"some platforms or features may not support certain marking types"&lt;/em&gt;, costs a downstream verifier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;None of this says how often signed files arrive.&lt;/strong&gt; Our standing baseline over real documents is 0 of 4,902 embedded images. This is early infrastructure, not a present-day signal.&lt;/li&gt;
&lt;li&gt;One machine, one day, one Chrome build. A print pipeline with downsampling enabled, or Acrobat's "reduce file size", would each be a row I did not run.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I would actually do with a suspicious document
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Do not start with the prose.&lt;/strong&gt; The text test is key-gated and the classifiers have the false-positive rates above. Notice you are holding a file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask the file.&lt;/strong&gt; A PDF is an append-only container that keeps receipts about its own history: revisions appended after creation, the producing toolchain, an Info dictionary and an XMP packet that stopped agreeing, whether a signature still covers what it signed, and printed totals that do or do not add up. None of that is about the writing, and all of it is deterministic. That is the &lt;a href="https://tamperlens.com/detect-ai-generated-pdf" rel="noopener noreferrer"&gt;AI-generated PDF&lt;/a&gt; guide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check provenance where it exists, and read absence as nothing.&lt;/strong&gt; For images the &lt;a href="https://tamperlens.com/detect-ai-generated-image" rel="noopener noreferrer"&gt;AI-generated image&lt;/a&gt; guide covers what a valid manifest proves, what it never proves, and why absence is uninformative.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never let one signal become a verdict.&lt;/strong&gt; Signals about bytes are defensible. "Our system detected fraud" is the sentence that ends up in a complaint.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full write-up of the nine PDFs, with the survival matrix and the reproduction steps, is on my site: &lt;a href="https://tamperlens.com/blog/claude-c2pa-signature-inside-pdf?utm_campaign=devto-c2pa" rel="noopener noreferrer"&gt;A C2PA signature survives a PDF, and c2patool can't see it&lt;/a&gt;. There is a free checker on &lt;a href="https://tamperlens.com/?utm_campaign=devto-c2pa" rel="noopener noreferrer"&gt;tamperlens.com&lt;/a&gt; if you want to drop a document in and see what its own bytes say. Nothing is stored.&lt;/p&gt;

&lt;p&gt;If you run the LibreOffice, Word or WhatsApp path, I would genuinely like the result, including if it contradicts mine. And if you disagree with the framing that the text half is unusable today, the comments are the right place for it: that is the half I would most like to be wrong about.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>pdf</category>
      <category>watermark</category>
    </item>
    <item>
      <title>The black box in your PDF is a shape, not a delete key</title>
      <dc:creator>Haruo</dc:creator>
      <pubDate>Mon, 03 Aug 2026 15:44:04 +0000</pubDate>
      <link>https://dev.to/haruodev/the-black-box-in-your-pdf-is-a-shape-not-a-delete-key-1nc3</link>
      <guid>https://dev.to/haruodev/the-black-box-in-your-pdf-is-a-shape-not-a-delete-key-1nc3</guid>
      <description>&lt;p&gt;There are two ways to black out a name in a PDF.&lt;/p&gt;

&lt;p&gt;The first deletes the text and then draws a black rectangle where it used to be.&lt;br&gt;
The second just draws the black rectangle.&lt;/p&gt;

&lt;p&gt;On screen they are indistinguishable. In the file they are entirely different&lt;br&gt;
documents, and in the second one every character of the name is still there —&lt;br&gt;
selectable, copyable, and extractable by any PDF library in about one line of&lt;br&gt;
code.&lt;/p&gt;

&lt;p&gt;This mistake keeps reaching production in court filings, FOIA releases and&lt;br&gt;
regulatory submissions, from organisations that employ lawyers and document&lt;br&gt;
teams. It survives not because people are careless but because &lt;strong&gt;there is no&lt;br&gt;
feedback&lt;/strong&gt;: the person doing the redacting sees a black box either way, and&lt;br&gt;
nothing tells them which one they made until somebody else selects the text.&lt;/p&gt;
&lt;h2&gt;
  
  
  A PDF page is a program
&lt;/h2&gt;

&lt;p&gt;The reason the two operations look the same is worth understanding, because it&lt;br&gt;
is also the reason you can tell them apart.&lt;/p&gt;

&lt;p&gt;A page's content stream is a sequence of operators executed in order onto a blank&lt;br&gt;
canvas. A very small one looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BT /F1 12 Tf 76 660 Td (Dana Whitfield) Tj ET
0 0 0 rg
74 656 120 16 re f
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading it out: begin text, select font &lt;code&gt;F1&lt;/code&gt; at 12pt, move to (76, 660), show the&lt;br&gt;
string &lt;code&gt;Dana Whitfield&lt;/code&gt;, end text. Then set the non-stroking colour to black&lt;br&gt;
(&lt;code&gt;rg&lt;/code&gt;), build a rectangle at (74, 656) 120 wide and 16 high (&lt;code&gt;re&lt;/code&gt;), and fill it&lt;br&gt;
(&lt;code&gt;f&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;There is no z-index here, and no concept of one object being "above" another.&lt;br&gt;
There is only order. Later paints over earlier. The rectangle covers the name for&lt;br&gt;
the same reason a second coat of paint covers the first.&lt;/p&gt;

&lt;p&gt;Now swap the two halves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 0 0 rg
74 656 120 16 re f
BT /F1 12 Tf 76 660 Td (Dana Whitfield) Tj ET
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same objects, same coordinates, opposite order — and now the name is drawn &lt;em&gt;on&lt;br&gt;
top of&lt;/em&gt; the black box and is perfectly legible. Which is exactly what a table's&lt;br&gt;
shaded header row is: a filled rectangle, painted first, with text on it.&lt;/p&gt;

&lt;p&gt;That single fact is the whole of what follows.&lt;/p&gt;
&lt;h2&gt;
  
  
  Check it yourself in one line
&lt;/h2&gt;

&lt;p&gt;If you have a PDF with a black box in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pdftotext &lt;span class="nt"&gt;-layout&lt;/span&gt; suspect.pdf - | less
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the "redacted" words appear in that output, they were never removed. That is&lt;br&gt;
the entire test, and it is worth running on anything you are about to send.&lt;/p&gt;

&lt;p&gt;To look at the operators rather than the text, decompress the streams first —&lt;br&gt;
content streams are Flate-compressed, so &lt;code&gt;strings&lt;/code&gt; and &lt;code&gt;grep&lt;/code&gt; see nothing useful&lt;br&gt;
without this step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qpdf &lt;span class="nt"&gt;--qdf&lt;/span&gt; &lt;span class="nt"&gt;--object-streams&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;disable suspect.pdf decompressed.pdf
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;--text&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;' (re|f|Tj|TJ)$'&lt;/span&gt; decompressed.pdf | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-40&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you can read the page as the program it is, and see for yourself whether the&lt;br&gt;
&lt;code&gt;Tj&lt;/code&gt; comes before the &lt;code&gt;re f&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why "is there a dark rectangle near text" is the wrong check
&lt;/h2&gt;

&lt;p&gt;The naive detector — find dark filled rectangles, find text underneath, report —&lt;br&gt;
fires on an enormous number of completely honest documents. Every table with a&lt;br&gt;
shaded header. Every highlighted paragraph. Every coloured callout box.&lt;/p&gt;

&lt;p&gt;Paint order separates them cleanly, and it does so &lt;em&gt;by construction&lt;/em&gt; rather than&lt;br&gt;
by tuning: a background that was painted after its text would have hidden that&lt;br&gt;
text, so it would not be a background. If you can read the text, the shape came&lt;br&gt;
first. If a shape came second, it is hiding something.&lt;/p&gt;

&lt;p&gt;Three more guards matter in practice, and each one exists because the version&lt;br&gt;
without it produced false positives:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transparency.&lt;/strong&gt; A highlighter is a filled rectangle painted over text, and it&lt;br&gt;
hides nothing. The graphics state carries a non-stroking alpha (&lt;code&gt;ca&lt;/code&gt;, set through&lt;br&gt;
a named &lt;code&gt;ExtGState&lt;/code&gt;), so anything meaningfully translucent is ignored.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Size.&lt;/strong&gt; A shape covering more than about 40% of the page is a watermark, a&lt;br&gt;
stamp or a page background. Nobody redacts a name by covering half the page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invisible text.&lt;/strong&gt; Rendering mode 3 (&lt;code&gt;3 Tr&lt;/code&gt;) draws nothing. That is the OCR&lt;br&gt;
layer underneath a scanned page — text that is &lt;em&gt;designed&lt;/em&gt; to be invisible on&lt;br&gt;
screen and extractable by machine. Reporting it as a failed redaction would fire&lt;br&gt;
on every scanned document ever produced.&lt;/p&gt;
&lt;h2&gt;
  
  
  Getting the words back out is a separate problem
&lt;/h2&gt;

&lt;p&gt;Finding that a run of text sits under a box tells you there is a leak. Saying&lt;br&gt;
&lt;em&gt;what&lt;/em&gt; leaked needs one more step, because the bytes inside a &lt;code&gt;Tj&lt;/code&gt; string are&lt;br&gt;
character &lt;strong&gt;codes&lt;/strong&gt;, not characters, and what they mean depends on the font.&lt;/p&gt;

&lt;p&gt;A font can carry a &lt;code&gt;/ToUnicode&lt;/code&gt; CMap that maps them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 begincodespacerange
&amp;lt;00&amp;gt; &amp;lt;FF&amp;gt;
endcodespacerange
5 beginbfchar
&amp;lt;01&amp;gt; &amp;lt;0041&amp;gt;
&amp;lt;02&amp;gt; &amp;lt;0044&amp;gt;
&amp;lt;03&amp;gt; &amp;lt;004D&amp;gt;
&amp;lt;04&amp;gt; &amp;lt;0049&amp;gt;
&amp;lt;05&amp;gt; &amp;lt;004E&amp;gt;
endbfchar
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that, the content-stream bytes &lt;code&gt;\001\002\003\004\005&lt;/code&gt; — which are not text&lt;br&gt;
in any useful sense — resolve to &lt;code&gt;ADMIN&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Fonts without a &lt;code&gt;/ToUnicode&lt;/code&gt; map are common, and there the honest answer is "the&lt;br&gt;
coverage is real, the words could not be decoded here" rather than a guess.&lt;br&gt;
Simple fonts usually turn out to be WinAnsi-encoded, where the byte &lt;em&gt;is&lt;/em&gt; the&lt;br&gt;
character, which covers most of the practical cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that are not redaction
&lt;/h2&gt;

&lt;p&gt;All of these leave every character in the file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Drawing a filled rectangle over the text.&lt;/li&gt;
&lt;li&gt;Using a black highlighter annotation.&lt;/li&gt;
&lt;li&gt;Setting the text colour to match the background.&lt;/li&gt;
&lt;li&gt;Placing an image over the area.&lt;/li&gt;
&lt;li&gt;Adding a &lt;code&gt;/Redact&lt;/code&gt; &lt;strong&gt;annotation&lt;/strong&gt; and not applying it. This one is the cruellest,
because it is the &lt;em&gt;right tool used incompletely&lt;/em&gt; — the annotation is a mark
requesting redaction, the content only leaves when the tool applies the marks,
and most viewers draw the pending marks as solid black boxes. The half-finished
state looks exactly like the finished one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What does work: your tool's redaction feature, followed by actually applying it.&lt;br&gt;
Or exporting the page to a flattened image, which removes the text layer along&lt;br&gt;
with your searchable text.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this kind of check cannot see
&lt;/h2&gt;

&lt;p&gt;Worth being explicit, because a checker that implies more coverage than it has is&lt;br&gt;
worse than none:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Form XObjects.&lt;/strong&gt; Reusable content blocks are their own little content
streams. A covering box inside one is invisible unless you recurse into them,
so any count is a floor rather than a total.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Already-flattened pages.&lt;/strong&gt; No text layer, nothing to find — which also means
the redaction worked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything outside the page content.&lt;/strong&gt; A name taken off the visible page can
still be sitting in the document metadata, an attachment, a form field, or an
earlier revision of the file. Different problem, different checks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you would rather not do it by hand
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://tamperlens.com/redaction-check" rel="noopener noreferrer"&gt;Tamperlens&lt;/a&gt; because I got tired of running the &lt;code&gt;qpdf&lt;/code&gt;/&lt;code&gt;pdftotext&lt;/code&gt; dance on documents people sent me and wondering whether I had missed one. Drop a PDF in and it walks each page's content stream tracking the transformation matrix, fill colour, alpha and paint order, computes boxes for the text runs, and reports the runs that ended up under&lt;br&gt;
something opaque — with the recovered text, because if a checker can read it so can anyone who received the document. Nothing is stored: files are parsed in memory and discarded with the response.&lt;/p&gt;

&lt;p&gt;There is a fictional sample on the page that opens automatically, so you can see&lt;br&gt;
the output before deciding whether to trust it with anything of your own. It&lt;br&gt;
contains a shaded table band with text on it as well as two real covers, so you&lt;br&gt;
can watch the discrimination work in the same report.&lt;/p&gt;

&lt;p&gt;The most useful four minutes you can spend with it: take any document, draw a&lt;br&gt;
black rectangle over a line in whatever editor you have, export it, and check&lt;br&gt;
that. Watching it recover the words from a file whose history you personally know&lt;br&gt;
is worth more than this post.&lt;/p&gt;

</description>
      <category>security</category>
      <category>pdf</category>
      <category>privacy</category>
      <category>showdev</category>
    </item>
    <item>
      <title>What a forged bank statement looks like at the byte level</title>
      <dc:creator>Haruo</dc:creator>
      <pubDate>Mon, 27 Jul 2026 17:49:36 +0000</pubDate>
      <link>https://dev.to/haruodev/what-a-forged-bank-statement-looks-like-at-the-byte-level-16ji</link>
      <guid>https://dev.to/haruodev/what-a-forged-bank-statement-looks-like-at-the-byte-level-16ji</guid>
      <description>&lt;p&gt;I spent a chunk of my career in KYC/AML and document verification, mostly looking at things people had uploaded hoping nobody would look too closely — so this is the file format I have the most opinions about and the least affection for.&lt;/p&gt;

&lt;p&gt;Here is the thing that took me embarrassingly long to internalise: &lt;strong&gt;a PDF is not a picture of a document.&lt;/strong&gt; It is an append-only container with a linked list of revisions in it, and it keeps receipts about its own history whether or not anyone wanted it to. Most people editing a PDF have no idea that the thing they edited is still in the file.&lt;/p&gt;

&lt;p&gt;Let me walk a constructed example. Imagine a bank statement, generated server-side, two pages, one credit line that says the account holder received rather less than they would like a lender to believe. They download it, drop it into a free browser-based PDF editor, white out the number, type a bigger one, hit download. Two minutes of work.&lt;/p&gt;

&lt;p&gt;Here is what that leaves behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Count the ends of the file
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'%%EOF'&lt;/span&gt; statement.pdf
2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A PDF ends with &lt;code&gt;%%EOF&lt;/code&gt;. Ours contains two of them, and the second one is not a typo.&lt;/p&gt;

&lt;p&gt;PDF supports &lt;strong&gt;incremental update&lt;/strong&gt;: a conforming writer, instead of rewriting the file, may append new objects, append a new cross-reference section covering only what changed, append a new trailer, and append a new &lt;code&gt;startxref&lt;/code&gt; / &lt;code&gt;%%EOF&lt;/code&gt;. The original bytes are left exactly where they were. A document saved three times can physically contain all three states, and you can render any of them by truncating the file at the right offset.&lt;/p&gt;

&lt;p&gt;This is a feature, not a bug — it is how digital signatures manage to preserve the bytes they signed. Which also means the count alone is weak evidence. Signing a document is an incremental update. So is filling in a form field, or adding an annotation. Linearised ("fast web view") files legitimately have two &lt;code&gt;%%EOF&lt;/code&gt; markers from a single save, because the first-page cross-reference section sits near the front of the file with its own trailer.&lt;/p&gt;

&lt;p&gt;So: interesting, not conclusive. Keep going.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-abo&lt;/span&gt; &lt;span class="s1"&gt;'startxref'&lt;/span&gt; statement.pdf
118201:startxref
183640:startxref
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Walk the /Prev chain
&lt;/h2&gt;

&lt;p&gt;Every appended cross-reference section's trailer carries &lt;code&gt;/Prev&lt;/code&gt;: the byte offset of the previous section. That is a backwards linked list. Enter it at the &lt;em&gt;last&lt;/em&gt; &lt;code&gt;startxref&lt;/code&gt; in the file and walk until a trailer has no &lt;code&gt;/Prev&lt;/code&gt;; that terminal section is the original document.&lt;/p&gt;

&lt;p&gt;Our tail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;trailer
&amp;lt;&amp;lt; /Size 41 /Root 1 0 R /Info 9 0 R
   /Prev 118201 /ID [&amp;lt;8f2c9a1b...&amp;gt; &amp;lt;41de77c0...&amp;gt;] &amp;gt;&amp;gt;
startxref
183640
%%EOF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now read the appended cross-reference section that &lt;code&gt;183640&lt;/code&gt; points at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;xref
0 1
0000000000 65535 f
12 1
0000119004 00000 n
trailer
&amp;lt;&amp;lt; ... /Prev 118201 ... &amp;gt;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It lists exactly one live object: &lt;strong&gt;12&lt;/strong&gt;. And object 12 already existed in revision 1.&lt;/p&gt;

&lt;p&gt;That is the whole finding. Object resolution in PDF always uses the newest section that mentions an object number, so whatever object 12 is now, it has replaced what object 12 used to be. Check what kind of object it is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;mutool show statement.pdf 12
&amp;lt;&amp;lt; /Length 2841 /Filter /FlateDecode &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A content stream. On a page. That means what the document &lt;em&gt;renders&lt;/em&gt; changed after it was generated — and revision 1 is still sitting in the file at offset 118201, so I can extract both and diff them.&lt;/p&gt;

&lt;p&gt;This is the distinction I would put on a wall if I were running a document-review team: "the file has two revisions" is a medium-strength observation with a dozen innocent explanations. "A later revision replaces an object that already existed and that carries page or content-stream data" is a strong one, because there is no version of that sentence which is compatible with "the bank generated this and nobody touched it."&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The /ID pair, which the spec maintains for you
&lt;/h2&gt;

&lt;p&gt;Look again at that trailer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/ID [&amp;lt;8f2c9a1b...&amp;gt; &amp;lt;41de77c0...&amp;gt;]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two byte strings. The specification gives them different jobs: the first is a permanent identifier established when the document is created and must never change; the second is rewritten every time the file is written again. Two different values is the format's own record that this file was saved after it was created.&lt;/p&gt;

&lt;p&gt;The original trailer, at offset 118201, has &lt;code&gt;/ID [&amp;lt;8f2c9a1b...&amp;gt; &amp;lt;8f2c9a1b...&amp;gt;]&lt;/code&gt; — matching, as a freshly created file should. Watching element two drift while element one holds still across revisions is a satisfying little confirmation that you have read the chain correctly.&lt;/p&gt;

&lt;p&gt;Its weakness matters as much as its strength: a tool that rewrites the &lt;em&gt;whole&lt;/em&gt; file can set both elements to the same fresh value and erase the trace entirely. So divergence is informative; identity proves nothing. Almost every PDF signal is one-directional like this, which is why any honest tool in this space sells signals rather than verdicts.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Two metadata stores that stopped agreeing
&lt;/h2&gt;

&lt;p&gt;Modern PDFs describe themselves twice: the trailer's &lt;code&gt;/Info&lt;/code&gt; dictionary, and an XMP packet hanging off the document catalog's &lt;code&gt;/Metadata&lt;/code&gt; key. XMP is RDF/XML and is conventionally stored &lt;em&gt;unfiltered&lt;/em&gt;, which is why &lt;code&gt;strings&lt;/code&gt; finds it when it finds nothing else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;$ strings statement.pdf | grep -A6 'x:xmpmeta'
&lt;span class="nt"&gt;&amp;lt;pdf:Producer&amp;gt;&lt;/span&gt;iText 7.2.5&lt;span class="nt"&gt;&amp;lt;/pdf:Producer&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;xmp:CreateDate&amp;gt;&lt;/span&gt;2026-01-04T10:02:00+01:00&lt;span class="nt"&gt;&amp;lt;/xmp:CreateDate&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;xmp:ModifyDate&amp;gt;&lt;/span&gt;2026-01-04T10:02:00+01:00&lt;span class="nt"&gt;&amp;lt;/xmp:ModifyDate&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the Info dictionary in the newest revision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/Producer (iLovePDF)
/Creator (iText 7.2.5)
/CreationDate (D:20260104100200+01'00')
/ModDate (D:20260106184100+01'00')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in the format keeps those two in sync. Whether they agree is purely a property of the tools that touched the file, and plenty of editors rewrite the dictionary while leaving the XML alone because setting a dictionary value is much less work than rewriting XML.&lt;/p&gt;

&lt;p&gt;Note precisely what happened here. The XMP packet still says the file was made by iText and never modified. The Info dictionary says iLovePDF and carries a &lt;code&gt;/ModDate&lt;/code&gt; two days later. &lt;strong&gt;The divergence is the only reason the editor is visible at all.&lt;/strong&gt; If you were only reading XMP — which is the modern store, and the one a lot of tooling prefers — you would see a clean file.&lt;/p&gt;

&lt;p&gt;Multi-stage publishing pipelines produce plain divergence legitimately all the time, so a bare mismatch is a question rather than an answer. The narrow, strong version is this one: the two stores disagree about the tool, and only one of them names something whose purpose is editing.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The tell that survives metadata cleanup
&lt;/h2&gt;

&lt;p&gt;Suppose our forger reads a post like this one and strips the metadata. Then this is still there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;strings statement.pdf | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'/BaseFont *[^ /]*'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
/BaseFont /ABCDEF+Helvetica
/BaseFont /QWERTY+Helvetica
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Embedded fonts are usually subsetted — only the glyphs the document actually uses — and the name gets a six-uppercase-letter prefix and a plus sign. The letters are arbitrary. What matters is that a generator writing a document in one pass collects every glyph it needs for a typeface into &lt;strong&gt;one&lt;/strong&gt; subset with &lt;strong&gt;one&lt;/strong&gt; tag.&lt;/p&gt;

&lt;p&gt;Two tags for one typeface mean glyphs of that typeface were embedded on two separate occasions. That is the trace left when text is added to, or replaced in, an existing PDF by a different tool — and because it lives in the page resources rather than in metadata, it survives metadata stripping &lt;em&gt;and&lt;/em&gt; it survives a whole-file rewrite that flattens the revision chain.&lt;/p&gt;

&lt;p&gt;It is also the signal I have seen misread most often, so: merging two PDFs that both use Helvetica produces exactly this pattern, innocently. Someone combining two monthly statements into one upload will trip it. Never read it alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. What a ByteRange gap actually proves
&lt;/h2&gt;

&lt;p&gt;If the statement is digitally signed — routine in parts of the EU and LATAM — there is one more check, and it is the strongest structural finding available in the format because it is &lt;em&gt;arithmetic&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A signature dictionary's &lt;code&gt;/ByteRange&lt;/code&gt; is an array of offset/length pairs naming the bytes that were hashed, normally covering everything except the hole where the signature itself sits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/ByteRange [0 8420 42840 15680]

covered through 42840 + 15680 = 58520
file length                   = 61204
                                ------
bytes outside the signature    =  2684
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those 2,684 bytes were appended after signing and are not protected by that signature. No certificate needed, no trust store, no crypto: you compare two integers. And it holds &lt;em&gt;regardless of whether the signature validates&lt;/em&gt;, which is the part that catches people out. A viewer can correctly report a valid signature while displaying content the signature never covered — that is what "signed, with subsequent changes" means, and users click straight past it.&lt;/p&gt;

&lt;p&gt;The honest caveat, because this one gets oversold: bytes outside the range are not automatically illegitimate. Multi-signature workflows produce them by construction, and PDF has a whole mechanism (DocMDP permissions) for declaring which subsequent changes the signer allowed. Reporting a coverage gap is not the same as deciding the change was forbidden.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable part
&lt;/h2&gt;

&lt;p&gt;None of these signals is robust on its own, and I would distrust anyone who tells you otherwise.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Metadata cleanup is one line of code and kills signals 3 and 4.&lt;/li&gt;
&lt;li&gt;A whole-file rewrite kills the revision chain and can erase the &lt;code&gt;/ID&lt;/code&gt; divergence — but destroys any signature and leaves conspicuously fresh, coherent metadata.&lt;/li&gt;
&lt;li&gt;Flattening the page to a raster image kills the font tell — and produces a document whose own shape (a full-page scan where a machine-generated statement should be vector text) is the finding.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The evasions are close to mutually exclusive. That is the actual reason a &lt;em&gt;set&lt;/em&gt; of independent signals is worth having: not because any one of them is hard to defeat, but because defeating one tends to light up another.&lt;/p&gt;

&lt;p&gt;It is also why I think the only defensible framing is &lt;strong&gt;signals, not verdicts&lt;/strong&gt;. "This file contains two revisions, a later revision replaced a page content stream, and the Info dictionary names an online PDF editor that the XMP packet does not" is specific, true of the bytes, and something a human can act on. "Our system detected fraud" is neither of those things, and it is the sentence that ends up in a complaint.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to skip the hex editor
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://tamperlens.com/" rel="noopener noreferrer"&gt;Tamperlens&lt;/a&gt; because nothing self-serve existed between "read the bytes yourself" and "request a demo". It is a raw-byte parser — deliberately not built on a high-level PDF library, since those normalise away exactly the evidence described here — that runs eleven signal families and hands back JSON.&lt;/p&gt;

&lt;p&gt;There is a free checker on the front page: drag a PDF in, get the report, nothing is stored. The most useful thing you can do with it takes about four minutes — put a statement you know is genuine through it, then run the same file through a free online PDF editor and put &lt;em&gt;that&lt;/em&gt; through. Watching which signals light up on a file whose history you personally know is worth more than any blog post, including this one.&lt;/p&gt;

&lt;p&gt;Every signal family, with its benign causes documented at the same length as its malicious ones, is in the &lt;a href="https://tamperlens.com/pdf-fraud-signals.html" rel="noopener noreferrer"&gt;field guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Happy to argue about any of this in the comments — particularly if you have a case where one of these signals fired on something entirely innocent, because those are the interesting ones.&lt;/p&gt;

</description>
      <category>security</category>
      <category>pdf</category>
      <category>fintech</category>
      <category>node</category>
    </item>
  </channel>
</rss>
