Un PDF oscurato può fallire in più modi del classico «qualcuno ci ha disegnato sopra un rettangolo nero». Il rettangolo può far parte della pagina oppure essere un'annotazione sovrapposta, i segni di redazione possono essere aggiunti e mai applicati, la redazione può essere applicata e poi salvata in modo incrementale, lasciando la vecchia pagina dentro il file, e intanto il nome dell'autore può restare nei metadati. A schermo questi casi sono tutti identici.
Per capire quali lasciano davvero trapelare i dati ho creato sette versioni dello stesso modulo di una pagina e le ho controllate ciascuna con tre estrattori di testo (PyMuPDF, pypdf e pdfminer.six), con una scansione di tutti gli stream del file e guardando i metadati. Una delle sette è il file salvato dal PDF Editor dell'app desktop di osFoundry dopo Find text and redact, Apply e Sanitize. Gli script sono in fondo; tutto è stato eseguito con Python 3.12, PyMuPDF 2.1, pypdf 6.21.4 e pdfminer.six 20260107 su un Apple M1.
Il modulo di prova
Il modulo è un'autorizzazione all'accredito diretto generata con PyMuPDF (dati di esempio: nome Jordan Ellis, numero di conto 4471 2093 5568, telefono (503) 555-0142). Il numero di conto compare due volte, il nome anche, e il campo autore nelle informazioni del documento contiene il nome. Il testo usa il font Times integrato, quindi ogni riga nel content stream è un semplice TJ con i byte scritti come stringa esadecimale:
BT
1 0 0 1 236 326 Tm
/tiro 12.5 Tf [<3434373120323039332035353638>]TJ
ET
34 34 37 31 20 32 30 39 33 ... è 4471 2093 ... in ASCII. Tenetelo a mente per la prossima sezione.
Un rettangolo disegnato non cambia nulla nel testo
La versione «oscurata» che fanno quasi tutti disegna rettangoli pieni sopra i due numeri di conto. Nel content stream si tratta di due nuovi oggetti aggiunti dopo tutto il resto:
q
232 322 89.25 17 re
h
0 0 0 RG 0 0 0 rg B
Q
Il TJ di prima resta intatto. Il rettangolo viene disegnato dopo, quindi copre i glifi a schermo e in stampa, ma ogni estrattore legge gli operatori di testo, non i pixel.
grep non è un test
Il controllo più ovvio è cercare il numero nel file:
grep -c "4471 2093 5568" form.pdf # 0, for all seven files, including the original
Non trova nulla nemmeno nell'originale non oscurato, per due motivi. I content stream sono compressi con Flate, quindi i byte su disco non sono testo. E anche dopo la decompressione il numero è memorizzato come stringa esadecimale <3434373120...>, non come 4471 2093 5568. Una scansione degli stream deve decomprimere ogni stream e cercare entrambe le forme, ed è quello che fa il controllo più sotto. Con i font CID (la maggior parte dei PDF in giapponese, cinese e coreano) anche questo non basta, perché la stringa contiene ID di glifi che si riconducono ai caratteri solo tramite la tabella ToUnicode del font. Un vero estrattore di testo è l'unico controllo che funziona ovunque; la scansione degli stream è una seconda linea di difesa per gli avanzi che gli estrattori non leggono.
Sette versioni, tre estrattori
Ogni versione, controllata cercando il numero di conto. «Stream» conta gli stream presenti in qualsiasi punto dei byte del file, attivi o orfani, che dopo la decompressione lo contengono ancora:
| versione | PyMuPDF | pypdf | pdfminer.six | stream | autore nei metadati |
|---|---|---|---|---|---|
| originale | trovato | trovato | trovato | 2 | Jordan Ellis |
| rettangolo disegnato nella pagina | trovato | trovato | trovato | 2 | Jordan Ellis |
| rettangolo come annotazione quadrata | trovato | trovato | trovato | 2 | Jordan Ellis |
| segni di redazione aggiunti, non applicati | trovato | trovato | trovato | 2 | Jordan Ellis |
PyMuPDF apply_redactions, salvataggio completo |
– | – | – | 0 | Jordan Ellis |
PyMuPDF apply_redactions, saveIncr()
|
– | – | – | 2 | Jordan Ellis |
| osFoundry PDF Editor, Apply + Sanitize + Save | – | – | – | 0 | (vuoto) |
Per qualsiasi lettore le prime quattro sono lo stesso file. I tre estrattori hanno dato lo stesso risultato su ogni riga, quindi per questa domanda non importa quale si usa, purché se ne usi uno.
La quarta riga è quella che frega chi gli strumenti di redazione li conosce. La redazione di un PDF avviene in due passaggi: un'annotazione Redact che segna un'area e un passaggio di applicazione che rimuove ciò che c'è sotto. Un file salvato tra i due mostra contorni rossi (o rettangoli neri, a seconda del visualizzatore) e contiene ancora tutto.
La trappola del salvataggio incrementale
La sesta riga supera tutti e tre gli estrattori e perde comunque dati. saveIncr(), come il normale Salva di molti programmi PDF desktop, non riscrive il file. Aggiunge gli oggetti modificati e una nuova tabella dei riferimenti incrociati dopo il vecchio %%EOF, e i lettori seguono la tabella più recente. I vecchi content stream sono ancora lì, solo che nessuno li referenzia più.
Per recuperarli basta una riga, perché la prima revisione del file finisce al primo %%EOF:
raw = open("pymupdf-incremental.pdf", "rb").read()
first = raw[: raw.index(b"%%EOF") + 5]
print("4471 2093 5568" in pymupdf.open(stream=first, filetype="pdf")[0].get_text()) # True
Il file incrementale pesa 11.533 byte; i suoi primi 7.163 byte sono il modulo originale, intatto. Contare i marcatori %%EOF è un controllo economico: l'originale e i salvataggi completi ne hanno uno, il salvataggio incrementale due.
Cosa scrive invece una rimozione vera
Il file salvato dall'osFoundry PDF Editor non ha il numero di conto in nessuno stream, e il resto del testo non si è spostato. Questa è la stessa riga di prima, dopo Apply:
q
BT
1 0 0 1 236 326 Tm
/tiro 12.5 Tf
[-6500] TJ
ET
Q
La stringa è sparita e nell'array TJ resta solo un numero. In un array TJ un numero è uno spostamento orizzontale in millesimi della dimensione del font, sottratto dalla posizione, quindi -6500 sposta la penna di 6,5 × 12,5 = 81,25 punti verso destra senza disegnare nulla. È esattamente la larghezza di 4471 2093 5568 in Times-Roman:
| testo rimosso | dimensione font | larghezza in Times-Roman | valore in TJ
|
|---|---|---|---|
| Jordan Ellis | 12,5 | 59,375 pt | -4750 |
| (503) 555-0142 | 12,5 | 78,11 pt | -6249 |
| 4471 2093 5568 | 12,5 | 81,25 pt | -6500 |
| Jordan Ellis (firma) | 15 | 71,25 pt | -4750 |
Mantenere l'avanzamento significa che tutto ciò che segue il tratto rimosso sulla stessa riga resta dov'era. Controllando parola per parola, tutte le 63 parole rimaste hanno la stessa posizione dell'originale (0 spostate), e la pagina è passata da 75 a 63 parole. I rettangoli neri sono uno stream separato alla fine, semplici rettangoli pieni (236 323.5 81.25 12.5 re f) dimensionati sul testo rimosso, senza niente sotto.
I metadati sono un passaggio a parte
Il salvataggio completo di PyMuPDF ha rimosso il testo ma ha lasciato author = Jordan Ellis nelle informazioni del documento. La redazione lavora sul contenuto della pagina, e il dizionario info non è contenuto della pagina. Per svuotarlo bastano due chiamate:
doc.set_metadata({})
doc.del_xml_metadata()
doc.save(out, garbage=4, deflate=True)
Su questo modulo il risultato è che tutti i campi info sono vuoti e non c'è nessun Jordan in tutti i byte del file. Il passaggio Sanitize document dell'editor fa lo stesso, e in più rimuove allegati, commenti, campi modulo, livelli nascosti, segnalibri e script; il file che ha salvato non aveva alcun metadato.
Un controllo da tenere
Tutto quanto visto sopra, in una sola funzione che restituisce un elenco di problemi ed esce con un codice diverso da zero se ce ne sono:
import re, sys, zlib
import pymupdf
from pypdf import PdfReader
from pdfminer.high_level import extract_text
def problems(path, secrets):
raw = open(path, 'rb').read()
doc = pymupdf.open(path)
flat = lambda t: re.sub(r'\s+', ' ', t.replace('\xa0', ' '))
texts = {
'pymupdf': flat(''.join(p.get_text() for p in doc)),
'pypdf': flat(''.join(p.extract_text() for p in PdfReader(path).pages)),
'pdfminer': flat(extract_text(path)),
}
out = []
for s in secrets:
out += [f'{name}: text contains {s!r}' for name, t in texts.items() if s in t]
for p in doc:
for a in p.annots():
if s in (a.info.get('content') or ''):
out.append(f'annotation on page {p.number + 1} contains {s!r}')
out += [f'metadata {k} = {v!r}' for k, v in doc.metadata.items() if v and s in v]
if raw.count(b'%%EOF') > 1:
out.append(f'{raw.count(b"%%EOF")} revisions: earlier versions are still in the file')
if any(p.first_annot and p.first_annot.type[1] == 'Redact' for p in doc):
out.append('redaction annotations were never applied')
for m in re.finditer(rb'stream\r?\n(.*?)\r?\nendstream', raw, re.S):
try:
body = zlib.decompress(m.group(1))
except zlib.error:
body = m.group(1)
for s in secrets:
if s.encode() in body or s.encode().hex().encode() in body.lower():
out.append(f'stream at byte {m.start()} still holds {s!r}')
return out
if __name__ == '__main__':
found = problems(sys.argv[1], sys.argv[2:])
print('\n'.join(found) or 'clean')
sys.exit(1 if found else 0)
uvx --with pymupdf --with pypdf --with pdfminer.six python assert_redacted.py out.pdf \
"4471 2093 5568" "Jordan Ellis" "(503) 555-0142"
Sul file incrementale stampa l'autore, 2 revisions: earlier versions are still in the file e cinque stream che contengono ancora un dato sensibile. Sul file con i rettangoli disegnati stampa 9 risultati degli estrattori, l'autore e cinque stream. Sul file dell'osFoundry PDF Editor stampa clean.
Ha dei limiti che vale la pena dire. Trova solo le stringhe che gli passate, quindi un valore scritto in modo diverso (4471-2093-5568) va aggiunto a parte. Legge il testo, quindi un dato sensibile che fa parte di un'immagine richiede prima l'OCR. E la scansione degli stream non vede il testo nei font CID, che però gli estrattori trovano.
Il PDF Editor che ha prodotto il file pulito gira in locale dentro l'app desktop, in linea con il design privato e local-first di osFoundry, quindi il modulo non lascia il computer per essere oscurato. L'app è gratuita e si scarica dalla pagina di download di osFoundry.

Top comments (0)