before-you-send 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- before_you_send-0.2.0/.github/workflows/ci.yml +28 -0
- before_you_send-0.2.0/.github/workflows/release.yml +74 -0
- before_you_send-0.2.0/.gitignore +15 -0
- before_you_send-0.2.0/LICENSE +21 -0
- before_you_send-0.2.0/PKG-INFO +382 -0
- before_you_send-0.2.0/README.md +338 -0
- before_you_send-0.2.0/before_you_send/__init__.py +22 -0
- before_you_send-0.2.0/before_you_send/annotations.py +60 -0
- before_you_send-0.2.0/before_you_send/checks/__init__.py +60 -0
- before_you_send-0.2.0/before_you_send/checks/history.py +157 -0
- before_you_send-0.2.0/before_you_send/checks/metadata.py +237 -0
- before_you_send-0.2.0/before_you_send/checks/payload.py +218 -0
- before_you_send-0.2.0/before_you_send/checks/protection.py +106 -0
- before_you_send-0.2.0/before_you_send/checks/visibility.py +463 -0
- before_you_send-0.2.0/before_you_send/cli.py +108 -0
- before_you_send-0.2.0/before_you_send/composite.py +165 -0
- before_you_send-0.2.0/before_you_send/content.py +983 -0
- before_you_send-0.2.0/before_you_send/document.py +129 -0
- before_you_send-0.2.0/before_you_send/findings.py +237 -0
- before_you_send-0.2.0/before_you_send/fontmetrics.py +257 -0
- before_you_send-0.2.0/before_you_send/report.py +191 -0
- before_you_send-0.2.0/before_you_send/run.py +112 -0
- before_you_send-0.2.0/examples/make_examples.py +96 -0
- before_you_send-0.2.0/pyproject.toml +50 -0
- before_you_send-0.2.0/run4.log +1 -0
- before_you_send-0.2.0/tests/conftest.py +356 -0
- before_you_send-0.2.0/tests/pdfbuild.py +339 -0
- before_you_send-0.2.0/tests/test_checks.py +164 -0
- before_you_send-0.2.0/tests/test_cli.py +145 -0
- before_you_send-0.2.0/tests/test_corpus_defects.py +607 -0
- before_you_send-0.2.0/tests/test_regressions.py +413 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
test:
|
|
14
|
+
runs-on: ${{ matrix.os }}
|
|
15
|
+
strategy:
|
|
16
|
+
fail-fast: false
|
|
17
|
+
matrix:
|
|
18
|
+
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
19
|
+
python-version: ["3.9", "3.11", "3.13"]
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
- uses: actions/setup-python@v5
|
|
23
|
+
with:
|
|
24
|
+
python-version: ${{ matrix.python-version }}
|
|
25
|
+
- run: python -m pip install --upgrade pip
|
|
26
|
+
- run: pip install -e ".[dev]"
|
|
27
|
+
- run: ruff check .
|
|
28
|
+
- run: pytest -q
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Publishes a tagged version to PyPI.
|
|
2
|
+
#
|
|
3
|
+
# There is no password and no API token anywhere in this repository, on anyone's
|
|
4
|
+
# machine, or in GitHub's secrets. Publishing uses PyPI's trusted publishing: PyPI
|
|
5
|
+
# is told once that this specific workflow, in this specific repository, may publish
|
|
6
|
+
# this specific project, and GitHub proves the job's identity at run time. Nothing
|
|
7
|
+
# is stored, so nothing can leak, and a stolen token cannot exist because there is
|
|
8
|
+
# no token.
|
|
9
|
+
#
|
|
10
|
+
# It runs only on a version tag, and only after the full test suite passes on the
|
|
11
|
+
# exact commit being published. A release that cannot pass its own tests does not
|
|
12
|
+
# reach anybody.
|
|
13
|
+
|
|
14
|
+
name: Release
|
|
15
|
+
|
|
16
|
+
on:
|
|
17
|
+
push:
|
|
18
|
+
tags: ["v*"]
|
|
19
|
+
|
|
20
|
+
permissions:
|
|
21
|
+
contents: read
|
|
22
|
+
|
|
23
|
+
jobs:
|
|
24
|
+
test:
|
|
25
|
+
name: Test before publishing anything
|
|
26
|
+
runs-on: ${{ matrix.os }}
|
|
27
|
+
strategy:
|
|
28
|
+
fail-fast: true
|
|
29
|
+
matrix:
|
|
30
|
+
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
31
|
+
python-version: ["3.9", "3.13"]
|
|
32
|
+
steps:
|
|
33
|
+
- uses: actions/checkout@v4
|
|
34
|
+
- uses: actions/setup-python@v5
|
|
35
|
+
with:
|
|
36
|
+
python-version: ${{ matrix.python-version }}
|
|
37
|
+
- run: python -m pip install --upgrade pip
|
|
38
|
+
- run: pip install -e ".[dev]"
|
|
39
|
+
- run: ruff check .
|
|
40
|
+
- run: pytest -q
|
|
41
|
+
|
|
42
|
+
build:
|
|
43
|
+
name: Build the distribution
|
|
44
|
+
needs: test
|
|
45
|
+
runs-on: ubuntu-latest
|
|
46
|
+
steps:
|
|
47
|
+
- uses: actions/checkout@v4
|
|
48
|
+
- uses: actions/setup-python@v5
|
|
49
|
+
with:
|
|
50
|
+
python-version: "3.13"
|
|
51
|
+
- run: python -m pip install --upgrade pip build
|
|
52
|
+
- run: python -m build
|
|
53
|
+
- name: Check the built metadata renders
|
|
54
|
+
run: |
|
|
55
|
+
pip install twine
|
|
56
|
+
twine check dist/*
|
|
57
|
+
- uses: actions/upload-artifact@v4
|
|
58
|
+
with:
|
|
59
|
+
name: distribution
|
|
60
|
+
path: dist/
|
|
61
|
+
|
|
62
|
+
publish:
|
|
63
|
+
name: Publish to PyPI
|
|
64
|
+
needs: build
|
|
65
|
+
runs-on: ubuntu-latest
|
|
66
|
+
environment: pypi
|
|
67
|
+
permissions:
|
|
68
|
+
id-token: write # the only permission trusted publishing needs
|
|
69
|
+
steps:
|
|
70
|
+
- uses: actions/download-artifact@v4
|
|
71
|
+
with:
|
|
72
|
+
name: distribution
|
|
73
|
+
path: dist/
|
|
74
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*.egg-info/
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
.venv/
|
|
7
|
+
venv/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
.coverage
|
|
11
|
+
htmlcov/
|
|
12
|
+
.DS_Store
|
|
13
|
+
# Example documents are generated by examples/make_examples.py and never
|
|
14
|
+
# committed, so this repository carries no PDF of any kind.
|
|
15
|
+
*.pdf
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Waiga Arya
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: before-you-send
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Reads a PDF and reports what is still inside it that you may not mean to send. Runs entirely on your machine.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Waiga/before-you-send
|
|
6
|
+
Project-URL: Issues, https://github.com/Waiga/before-you-send/issues
|
|
7
|
+
Author: Waiga Arya
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 Waiga Arya
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Keywords: leak,metadata,pdf,privacy,pypdf,redaction,review
|
|
31
|
+
Classifier: Development Status :: 4 - Beta
|
|
32
|
+
Classifier: Environment :: Console
|
|
33
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
34
|
+
Classifier: Intended Audience :: Legal Industry
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Topic :: Utilities
|
|
38
|
+
Requires-Python: >=3.9
|
|
39
|
+
Requires-Dist: pypdf>=5.1
|
|
40
|
+
Provides-Extra: dev
|
|
41
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
42
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
# Before You Send
|
|
46
|
+
|
|
47
|
+
Reads a PDF and tells you what is still inside it that you may not mean to send.
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
$ before-you-send letter.pdf
|
|
51
|
+
|
|
52
|
+
Before You Send — letter.pdf
|
|
53
|
+
========================================================================
|
|
54
|
+
Read 1 page(s). 10 finding(s): 5 high, 4 medium, 1 low.
|
|
55
|
+
1 place(s) could not be seen into.
|
|
56
|
+
|
|
57
|
+
HIGH
|
|
58
|
+
------------------------------------------------------------------------
|
|
59
|
+
HIGH page 1 (72, 657)-(257, 669) [covered_text]
|
|
60
|
+
34 characters of text have an opaque shape painted over them,
|
|
61
|
+
covering 100% of the run.
|
|
62
|
+
|
|
63
|
+
HIGH document file structure [earlier_versions_retained]
|
|
64
|
+
The file contains 1 earlier version(s) of itself, kept in full
|
|
65
|
+
alongside the current one.
|
|
66
|
+
|
|
67
|
+
HIGH document attachments [embedded_files]
|
|
68
|
+
1 whole file(s) are attached inside this document.
|
|
69
|
+
|
|
70
|
+
HIGH page 1 (72, 627)-(277, 639) [invisible_text]
|
|
71
|
+
40 characters are set to render mode 3, which draws nothing on
|
|
72
|
+
the page.
|
|
73
|
+
|
|
74
|
+
COULD NOT SEE
|
|
75
|
+
------------------------------------------------------------------------
|
|
76
|
+
- page 1 (70, 554)-(290, 570)
|
|
77
|
+
an image was painted over 1 run(s) of text. Whether the image
|
|
78
|
+
hides that text, or is simply drawn across it, cannot be decided
|
|
79
|
+
without looking at the picture, which this tool does not do.
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
A black box drawn over a name does not remove the name. The characters are still
|
|
83
|
+
in the file, and anyone can select them, copy them, or pull them out in one
|
|
84
|
+
command. The same is true of a page you deleted and saved, a comment you thought
|
|
85
|
+
nobody would open, and the spreadsheet somebody attached to the document six
|
|
86
|
+
versions ago.
|
|
87
|
+
|
|
88
|
+
None of this is exotic. It is the ordinary result of treating a PDF as a picture
|
|
89
|
+
of a document when it is actually a container.
|
|
90
|
+
|
|
91
|
+
This tool does not tell you a file is safe to send. It tells you what it found,
|
|
92
|
+
where it found it, and — separately, and always — where it could not see.
|
|
93
|
+
|
|
94
|
+
One fact is reported once. Something painted in the same place on every page is a
|
|
95
|
+
header, a footer or a watermark, and printing it once per page buries the finding on
|
|
96
|
+
page 137 that actually matters. Every run also states how many pages are mostly
|
|
97
|
+
picture, findings or none, because a document flattened into images comes back with
|
|
98
|
+
nothing found and is not empty.
|
|
99
|
+
|
|
100
|
+
## Install
|
|
101
|
+
|
|
102
|
+
Python 3.9 or newer. The only dependency is `pypdf`.
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
pip install before-you-send
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Or from source:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
git clone https://github.com/Waiga/before-you-send
|
|
112
|
+
cd before-you-send
|
|
113
|
+
pip install -e .
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Use
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
before-you-send letter.pdf # where things are, not what they say
|
|
120
|
+
before-you-send letter.pdf --verbose # add why each finding matters
|
|
121
|
+
before-you-send letter.pdf --show-content # include what was actually found
|
|
122
|
+
before-you-send letter.pdf --format json # for scripts
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Exit codes, for a pipeline: `0` nothing at or above the threshold, `1` something
|
|
126
|
+
found, `2` the file could not be read. The threshold is `--fail-on high|medium|low|never`
|
|
127
|
+
and defaults to `medium`.
|
|
128
|
+
|
|
129
|
+
Try it on the examples, which the repository generates rather than stores:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
python examples/make_examples.py
|
|
133
|
+
before-you-send examples/leaky-letter.pdf --verbose
|
|
134
|
+
before-you-send examples/careful-letter.pdf
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## What it checks
|
|
138
|
+
|
|
139
|
+
| Check | Level | What it means |
|
|
140
|
+
|---|---|---|
|
|
141
|
+
| `covered_text` | high | Something opaque was painted over text *after* the text. The text was never removed. |
|
|
142
|
+
| `invisible_text` | high | Text set to a render mode that draws nothing. Extracts normally. |
|
|
143
|
+
| `text_matching_background` | high | Text the same colour as the page or the shape behind it. |
|
|
144
|
+
| `unapplied_redaction_marks` | high | Passages marked for redaction where the redaction was never applied. |
|
|
145
|
+
| `embedded_files` | high | Whole files carried inside the document. |
|
|
146
|
+
| `active_content` | high | Scripts, launch actions, or automatic form submissions. |
|
|
147
|
+
| `form_field_values` | high / medium | Form fields still holding what somebody typed. High when the field is hidden. |
|
|
148
|
+
| `earlier_versions_retained` | high / medium / low | Previous versions of the file kept inside it. Low when a signature explains it. |
|
|
149
|
+
| `text_clipped_away` | high | Text excluded by a clipping path, so none of it is drawn. |
|
|
150
|
+
| `text_too_small_to_read` | high | Text scaled to effectively zero size. |
|
|
151
|
+
| `text_outside_page` | medium | Text parked entirely outside the visible page. |
|
|
152
|
+
| `hidden_layers` | medium | Layers switched off. The content is still there. |
|
|
153
|
+
| `annotation_authors` | medium | Comments and markup, and the names attached to them. |
|
|
154
|
+
| `document_author` | medium | A named author in the document properties. |
|
|
155
|
+
| `build_path_in_metadata` | medium | A filesystem path left in the properties, naming a user or a client folder. |
|
|
156
|
+
| `xmp_metadata` | medium / low | A second author record, which editing tools often forget to update. |
|
|
157
|
+
| `descriptive_metadata` | low | Title, subject or keywords — frequently the original filename. |
|
|
158
|
+
| `encryption_without_a_password` | low | Restrictions the file asks for but cannot enforce. |
|
|
159
|
+
| `scanned_text_layer` | low | Invisible text under a page-sized image: the searchable layer of a scan, reported so you know it extracts. |
|
|
160
|
+
|
|
161
|
+
## What it does not check
|
|
162
|
+
|
|
163
|
+
Stated in the output of every run, not just here.
|
|
164
|
+
|
|
165
|
+
**Anything inside a picture.** Images are not examined. Text in a screenshot, a
|
|
166
|
+
scanned page, or a chart saved as an image is invisible to this tool. A document
|
|
167
|
+
flattened into pictures will look empty here and will not be.
|
|
168
|
+
|
|
169
|
+
**Whether what it found is actually a secret.** It reports that something is
|
|
170
|
+
present and not visible. Whether that matters is a judgement about the content,
|
|
171
|
+
and the tool does not read for meaning.
|
|
172
|
+
|
|
173
|
+
**Whether the visible text should be visible.** Content plainly on the page is
|
|
174
|
+
never a finding, however confidential. This looks only for what a sender does not
|
|
175
|
+
know is there.
|
|
176
|
+
|
|
177
|
+
**Runs of one or two characters.** Too short to carry a name, a number of
|
|
178
|
+
consequence, or a word, and on real documents almost always a plot marker, a table
|
|
179
|
+
rule or a mathematical glyph. Measured over 450 published PDFs they were 57% of
|
|
180
|
+
every covered-text finding and 100% of every "too small to read" one, and all of
|
|
181
|
+
them were wrong. The count of runs passed over is printed in every report, because a
|
|
182
|
+
threshold nobody is told about is just an undocumented bug.
|
|
183
|
+
|
|
184
|
+
**What text says, when a font gives no way to know.** A composite font addresses
|
|
185
|
+
glyphs by number. Where it carries no map from those numbers to characters, runs in
|
|
186
|
+
it are located and measured exactly and their text is not guessed at. Composite fonts
|
|
187
|
+
using an encoding other than Identity keep having their widths estimated, for the
|
|
188
|
+
same reason: a width read against the wrong glyph is worse than an admitted estimate.
|
|
189
|
+
|
|
190
|
+
When an image is painted over text, the tool says so as a **blind spot** — a
|
|
191
|
+
located place it can prove something is drawn at and cannot see under. The same
|
|
192
|
+
goes for a shape whose colour the file names indirectly, through a pattern or a
|
|
193
|
+
spot colour, where whether it conceals anything cannot be decided from the
|
|
194
|
+
drawing instructions at all. Blind spots are printed separately from findings and
|
|
195
|
+
are never counted as findings, because a report that says "no problems" about a
|
|
196
|
+
page it could not read is worse than no report.
|
|
197
|
+
|
|
198
|
+
## How it tells a redaction from a design choice
|
|
199
|
+
|
|
200
|
+
This is the one thing worth explaining, because it is where a tool like this
|
|
201
|
+
usually becomes useless.
|
|
202
|
+
|
|
203
|
+
A black box over black text, and white heading text on a black bar, are the same
|
|
204
|
+
overlap. Geometry cannot separate them. What separates them is the order the two
|
|
205
|
+
things were painted, which the file records:
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
text, then box -> the box was put there to hide the text reported
|
|
209
|
+
box, then text -> the box is a background the text sits on not reported
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Order alone is not enough, because a shape is not painted everywhere its path
|
|
213
|
+
reaches. Three things bound it, and all three had to be modelled before this was
|
|
214
|
+
usable on real documents:
|
|
215
|
+
|
|
216
|
+
- **a clipping path**, which trims everything drawn after it. Without it, every
|
|
217
|
+
chart from matplotlib or a browser's print-to-PDF reports its own caption as a
|
|
218
|
+
covered secret.
|
|
219
|
+
- **a form's bounding box**, a hard limit on what that form draws. Without it, a
|
|
220
|
+
small logo stamp whose artwork is larger than its box appears to cover the page.
|
|
221
|
+
- **blending and soft masks**, which let what is underneath show through. A
|
|
222
|
+
flattened highlighter mark is an opaque yellow rectangle drawn over text, and
|
|
223
|
+
reading only its alpha value reports every highlight in a document.
|
|
224
|
+
|
|
225
|
+
The order is a fact from the file. The verdict is not purely a fact: it is gated
|
|
226
|
+
by a coverage threshold, an opacity threshold, a colour tolerance, and — for any
|
|
227
|
+
font that does not declare its character widths, which includes Helvetica and
|
|
228
|
+
Times — an estimate of how wide a line of text really is. Where that estimate is
|
|
229
|
+
load-bearing the report says "about", and it is listed under what was not checked.
|
|
230
|
+
|
|
231
|
+
The test suite holds every innocent twin as a matched pair against the case it
|
|
232
|
+
resembles: an outlined box that covers nothing, a see-through highlight, a
|
|
233
|
+
multiply-blended highlighter, a panel clipping the end of a line, a clipped
|
|
234
|
+
chart, a bounded logo stamp, a spot-colour brand bar, a white caption on a
|
|
235
|
+
photograph, a scanned page's searchable text layer, text bleeding off an edge,
|
|
236
|
+
and a signature that explains an extra revision. Each must stay silent, and a run
|
|
237
|
+
that loses one of them fails.
|
|
238
|
+
|
|
239
|
+
## Against real documents
|
|
240
|
+
|
|
241
|
+
The suite passes, and that was never the question. A tool like this can be green on
|
|
242
|
+
every test it wrote for itself and still be useless on the first real file it meets,
|
|
243
|
+
so it was pointed at 931 published PDFs it had nothing to do with: the US Federal
|
|
244
|
+
Register, arXiv, gov.uk, the World Health Organization, US court filings, and
|
|
245
|
+
scanned FOIA releases from the FBI's reading room. Six producers, which matters more
|
|
246
|
+
than six sources — a Word document, a LaTeX paper and an InDesign report fail in
|
|
247
|
+
different ways.
|
|
248
|
+
|
|
249
|
+
It found thirteen classes of defect. The first pass produced **4,280 findings across
|
|
250
|
+
450 documents, 3,126 of them HIGH**, and almost none of them worth reading.
|
|
251
|
+
|
|
252
|
+
| | before | after |
|
|
253
|
+
|---|---|---|
|
|
254
|
+
| findings, same 450 documents | 4,280 | 1,703 |
|
|
255
|
+
| of which HIGH | 3,126 | 954 |
|
|
256
|
+
| median per document | 6 | 3 |
|
|
257
|
+
| worst document | 604 | 177 |
|
|
258
|
+
|
|
259
|
+
Across the full 931, the median document now reports 3 findings and the 90th
|
|
260
|
+
percentile reports 4. Nothing crashed, timed out, or came back unreadable.
|
|
261
|
+
|
|
262
|
+
Four are worth naming, because none of them could have been found any other way:
|
|
263
|
+
|
|
264
|
+
**The check most likely to hide a real leak never ran.** `earlier_versions_retained`
|
|
265
|
+
began by asking the parsed trailer for `/Prev`. A parser only surfaces that key for a
|
|
266
|
+
classic cross-reference table, and every modern PDF — Word, Acrobat, InDesign,
|
|
267
|
+
Chrome, every linearized government file — uses a cross-reference stream instead, so
|
|
268
|
+
the check returned on its first line and never reached the byte walk written for
|
|
269
|
+
exactly this question. Measured over the 887 documents collected at that point, it
|
|
270
|
+
was silent on **256 of them**. Most of those are
|
|
271
|
+
linearization, which it knows how to excuse; 34 are retained earlier versions with no
|
|
272
|
+
benign explanation, and 5 are serious. Every fixture in the suite used a classic xref
|
|
273
|
+
table, so no test could have seen it.
|
|
274
|
+
|
|
275
|
+
**A border was being read as a block.** One 175-page government table produced
|
|
276
|
+
**8,638** covered-text findings, 68% of every such finding in the corpus. Rendered,
|
|
277
|
+
the page is an ordinary Word table: white cells, black gridlines, entirely readable.
|
|
278
|
+
A word processor draws a cell edge as an outer outline and an inner one in a single
|
|
279
|
+
path; measured as one rectangle, a hollow frame becomes a solid block of ink over
|
|
280
|
+
everything inside it. That document now reports 4 findings, all true.
|
|
281
|
+
|
|
282
|
+
**Composite fonts were being guessed at.** A Type0 font addresses glyphs by number,
|
|
283
|
+
two bytes at a time, and keeps its widths on a descendant font. Read as though the
|
|
284
|
+
bytes were characters, a run measures about twice as wide as it is — and that width
|
|
285
|
+
is the denominator of the coverage fraction that decides whether a passage was
|
|
286
|
+
redacted. Twice too wide halves the coverage, drops it under the threshold, and the
|
|
287
|
+
finding never appears. On the Word and InDesign slice, documents relying on estimated
|
|
288
|
+
widths fell from 78% to 47%.
|
|
289
|
+
|
|
290
|
+
**One fact was being reported once per page.** The Federal Register prints a
|
|
291
|
+
typesetter's control line and an operator's account name in white in the margin of
|
|
292
|
+
every page. Both are real, and one of them names a person. Reported per page they
|
|
293
|
+
came to 62 HIGH findings on a 31-page notice and 470 on the longest document in the
|
|
294
|
+
corpus, which is the same as reporting nothing: a genuine single-page leak could not
|
|
295
|
+
have been found in that. It now reports 3, and the two HIGH ones are true.
|
|
296
|
+
|
|
297
|
+
### What that does and does not establish
|
|
298
|
+
|
|
299
|
+
It establishes that the tool survives real-world PDFs, and it measures how often it
|
|
300
|
+
cries wolf. Every number above is a false-positive number.
|
|
301
|
+
|
|
302
|
+
It is much weaker evidence about the failure that actually hurts somebody, which is
|
|
303
|
+
the one where a document *is* leaking and the report says nothing. Ordinary published
|
|
304
|
+
documents are overwhelmingly documents where nobody tried to hide anything, so they
|
|
305
|
+
exercise that path barely at all.
|
|
306
|
+
|
|
307
|
+
So the corpus was also run through an independent check for it: every page extracted
|
|
308
|
+
with `pdftotext` and separately rendered and read with OCR, on the theory that text
|
|
309
|
+
which extracts but is not on the rendered page is text somebody cannot see. Across
|
|
310
|
+
450 documents that turned up two candidates, and both were OCR failing on dense
|
|
311
|
+
numeric tables rather than the tool missing anything. That is real evidence and it is
|
|
312
|
+
not proof. It is one independent check, on a population where concealment is rare.
|
|
313
|
+
|
|
314
|
+
Concretely: this has not been validated against a corpus of documents where people
|
|
315
|
+
actually attempted redaction and got it wrong. If you have one, that is the most
|
|
316
|
+
useful thing you could point this at.
|
|
317
|
+
|
|
318
|
+
## Privacy
|
|
319
|
+
|
|
320
|
+
The file is read on your machine and nothing is sent anywhere. There is no
|
|
321
|
+
account, no API key, and no network call in the tool at all.
|
|
322
|
+
|
|
323
|
+
The report withholds what it found by default. You get `page 1 (72, 657)-(258, 669)`
|
|
324
|
+
and a character count, not the account number underneath. That is deliberate: the
|
|
325
|
+
report of a document you are worried about is itself the thing most likely to be
|
|
326
|
+
pasted into a chat window. Use `--show-content` when you actually want to see it.
|
|
327
|
+
|
|
328
|
+
Control characters in anything recovered from the document are stripped before
|
|
329
|
+
printing, so a hostile file cannot use its own title to repaint your terminal.
|
|
330
|
+
|
|
331
|
+
The report header echoes the path you gave it, which may itself name a client or
|
|
332
|
+
a matter. Worth knowing before pasting one.
|
|
333
|
+
|
|
334
|
+
It never writes to the file it is reading, and it has no repair mode. A tool that
|
|
335
|
+
silently strips something you needed is a data-loss tool wearing a safety label.
|
|
336
|
+
|
|
337
|
+
## How this compares
|
|
338
|
+
|
|
339
|
+
The detection here is not new. Several of these problems have been known for as
|
|
340
|
+
long as the format has existed, and there is good software for parts of it.
|
|
341
|
+
|
|
342
|
+
For **metadata specifically**, [ExifTool](https://exiftool.org/) reads and writes
|
|
343
|
+
it comprehensively, and [mat2](https://0xacab.org/jvoisin/mat2) removes it across
|
|
344
|
+
many formats. Both are mature, free, and better at that one job than this is.
|
|
345
|
+
|
|
346
|
+
For **covered and invisible text**, the tools that exist are mostly web services
|
|
347
|
+
you upload the document to — which is the wrong shape for a file you are worried
|
|
348
|
+
about — or paid consistency-checking add-ins sold to firms rather than people.
|
|
349
|
+
[pdfalyzer](https://pypi.org/project/pdfalyzer/) is free and local but aimed at
|
|
350
|
+
malware forensics, and it is GPL-licensed.
|
|
351
|
+
|
|
352
|
+
What this puts together in one place: local only, permissively licensed, one
|
|
353
|
+
dependency, content withheld by default, an exit code for CI, and a report that
|
|
354
|
+
separates what it found from what it could not see.
|
|
355
|
+
|
|
356
|
+
If a check here is wrong, or a document is misreported, that is the most useful
|
|
357
|
+
issue you can open.
|
|
358
|
+
|
|
359
|
+
## Development
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
pip install -e ".[dev]"
|
|
363
|
+
pytest -q
|
|
364
|
+
ruff check .
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
Every test fixture is built from literal bytes in `tests/pdfbuild.py`. No PDF is
|
|
368
|
+
committed to this repository, and none of the examples came from a real document.
|
|
369
|
+
|
|
370
|
+
`tests/test_regressions.py` holds one test per defect found by deliberately
|
|
371
|
+
attacking the tool after the first suite was already passing. Most of those
|
|
372
|
+
defects were false positives on entirely ordinary documents, which is the failure
|
|
373
|
+
worth guarding hardest against.
|
|
374
|
+
|
|
375
|
+
`tests/test_corpus_defects.py` holds one test per defect found afterwards, by
|
|
376
|
+
running the finished tool over 931 real published PDFs it had never seen. Every one
|
|
377
|
+
of them names the document shape that produced it, and every one was checked to fail
|
|
378
|
+
without its fix — a test that passes either way is not a test.
|
|
379
|
+
|
|
380
|
+
## Licence
|
|
381
|
+
|
|
382
|
+
MIT.
|