DEV Community

Cover image for Comment vérifier avec Python que le texte caviardé a vraiment disparu d'un PDF

Comment vérifier avec Python que le texte caviardé a vraiment disparu d'un PDF

Un PDF caviardé peut échouer de bien plus de façons que « quelqu'un a dessiné un rectangle noir ». Le rectangle peut faire partie de la page ou être une annotation posée dessus, les marques de caviardage peuvent être ajoutées sans jamais être appliquées, le caviardage peut être appliqué puis enregistré de façon incrémentale, si bien que l'ancienne page reste dans le fichier, et le nom de l'auteur peut rester dans les métadonnées depuis le début. À l'écran, tous ces cas sont identiques.

Pour voir lesquels laissent vraiment fuiter des données, j'ai généré sept versions du même formulaire d'une page et j'ai vérifié chacune avec trois extracteurs de texte (PyMuPDF, pypdf et pdfminer.six), un scan de tous les streams du fichier et les métadonnées. L'une des sept est le fichier enregistré par le PDF Editor de l'application de bureau osFoundry après Find text and redact, Apply et Sanitize. Les scripts sont à la fin ; tout a tourné avec Python 3.12, PyMuPDF 2.1, pypdf 6.21.4 et pdfminer.six 20260107 sur un Apple M1.

Le formulaire de test

Le formulaire est une autorisation de virement direct (direct deposit) générée avec PyMuPDF (données d'exemple : nom Jordan Ellis, numéro de compte 4471 2093 5568, téléphone (503) 555-0142). Le numéro de compte apparaît deux fois, le nom aussi, et le champ auteur des informations du document contient le nom. Le texte utilise la police Times intégrée, donc chaque ligne du content stream est un simple TJ dont les octets sont écrits sous forme de chaîne hexadécimale :

BT
1 0 0 1 236 326 Tm
/tiro 12.5 Tf [<3434373120323039332035353638>]TJ
ET
Enter fullscreen mode Exit fullscreen mode

34 34 37 31 20 32 30 39 33 ... correspond à 4471 2093 ... en ASCII. Gardez-le en tête pour la section suivante.

Un rectangle dessiné ne change rien au texte

La version « caviardée » que fait la plupart des gens dessine des rectangles pleins par-dessus les deux numéros de compte. Dans le content stream, ce sont deux nouveaux objets ajoutés après tout le reste :

q
232 322 89.25 17 re
h
0 0 0 RG 0 0 0 rg B
Q
Enter fullscreen mode Exit fullscreen mode

Le TJ ci-dessus n'est pas modifié. Le rectangle est peint après, il masque donc les glyphes à l'écran et à l'impression, mais tous les extracteurs lisent les opérateurs de texte, pas les pixels.

grep n'est pas un test

La vérification évidente consiste à chercher le numéro dans le fichier :

grep -c "4471 2093 5568" form.pdf    # 0, for all seven files, including the original
Enter fullscreen mode Exit fullscreen mode

Cela ne renvoie rien, même pour l'original non caviardé, pour deux raisons. Les content streams sont compressés en Flate, donc les octets sur le disque ne sont pas du texte. Et même après décompression, le numéro est stocké sous la forme de la chaîne hexadécimale <3434373120...>, pas sous la forme 4471 2093 5568. Un scan des streams doit décompresser chaque stream et chercher les deux écritures, ce que fait le vérificateur plus bas. Avec les polices CID (la plupart des PDF en japonais, chinois et coréen), même cela échoue, parce que la chaîne contient des identifiants de glyphes qui ne se ramènent à des caractères qu'à travers la table ToUnicode de la police. Un vrai extracteur de texte est la seule vérification qui marche partout ; le scan des streams est une deuxième ligne de défense pour les restes que les extracteurs ne lisent pas.

Sept versions, trois extracteurs

Chaque version, vérifiée pour le numéro de compte. « Streams » compte les streams situés n'importe où dans les octets du fichier, actifs ou orphelins, qui le contiennent encore après décompression :

version PyMuPDF pypdf pdfminer.six streams auteur dans les métadonnées
original trouvé trouvé trouvé 2 Jordan Ellis
rectangle dessiné dans la page trouvé trouvé trouvé 2 Jordan Ellis
rectangle en annotation carrée trouvé trouvé trouvé 2 Jordan Ellis
marques de caviardage ajoutées, non appliquées trouvé trouvé trouvé 2 Jordan Ellis
PyMuPDF apply_redactions, enregistrement complet – – – 0 Jordan Ellis
PyMuPDF apply_redactions, saveIncr() – – – 2 Jordan Ellis
osFoundry PDF Editor, Apply + Sanitize + Save – – – 0 (vide)

Pour n'importe quel lecteur, les quatre premières sont le même fichier. Les trois extracteurs sont d'accord sur toutes les lignes, donc pour cette question peu importe lequel vous utilisez, du moment que vous en utilisez un.

La quatrième ligne est celle qui piège les gens qui connaissent déjà les outils de caviardage. Caviarder un PDF se fait en deux étapes : une annotation Redact qui marque une zone, puis une étape d'application qui supprime ce qu'il y a dessous. Un fichier enregistré entre les deux affiche des contours rouges (ou des rectangles noirs, selon le lecteur) et contient toujours tout.

Le piège de l'enregistrement incrémental

La sixième ligne passe les trois extracteurs et fuit quand même. saveIncr(), comme le simple Enregistrer de nombreux logiciels PDF de bureau, ne réécrit pas le fichier. Il ajoute les objets modifiés et une nouvelle table de références croisées après l'ancien %%EOF, et les lecteurs suivent la table la plus récente. Les anciens content streams sont toujours là, simplement plus référencés.

Les récupérer tient en une ligne, car la première révision du fichier se termine au premier %%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
Enter fullscreen mode Exit fullscreen mode

Le fichier incrémental fait 11 533 octets ; ses 7 163 premiers octets sont le formulaire d'origine, intact. Compter les marqueurs %%EOF est une vérification peu coûteuse : l'original et les enregistrements complets en ont un, l'enregistrement incrémental en a deux.

Ce qu'une vraie suppression écrit à la place

Le fichier enregistré par le PDF Editor d'osFoundry ne contient le numéro de compte dans aucun stream, et le reste du texte n'a pas bougé. Voici la même ligne qu'avant, après Apply :

q
BT
1 0 0 1 236 326 Tm
/tiro 12.5 Tf
[-6500] TJ
ET
Q
Enter fullscreen mode Exit fullscreen mode

La chaîne a disparu, et il ne reste qu'un nombre dans le tableau du TJ. Dans un tableau TJ, un nombre est un ajustement horizontal en millièmes de la taille de police, soustrait de la position : -6500 déplace donc le curseur de 6,5 × 12,5 = 81,25 points vers la droite sans rien dessiner. C'est exactement la largeur de 4471 2093 5568 en Times-Roman :

texte supprimé taille de police largeur en Times-Roman valeur dans 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 (signature) 15 71,25 pt -4750

Conserver l'avance fait que tout ce qui suit le passage supprimé sur la même ligne reste à sa place. Vérifiés mot par mot, les 63 mots restants ont la même position que dans l'original (0 déplacé), et la page est passée de 75 mots à 63. Les rectangles noirs sont un stream séparé à la fin, de simples rectangles pleins (236 323.5 81.25 12.5 re f) à la taille du texte supprimé, sans rien dessous.

Le formulaire dans le PDF Editor après Apply, avec des rectangles noirs à la place des cinq marques

Les métadonnées sont une étape à part

L'enregistrement complet de PyMuPDF a supprimé le texte et laissé author = Jordan Ellis dans les informations du document. Le caviardage agit sur le contenu de la page, et le dictionnaire d'informations n'en fait pas partie. Le vider prend deux appels :

doc.set_metadata({})
doc.del_xml_metadata()
doc.save(out, garbage=4, deflate=True)
Enter fullscreen mode Exit fullscreen mode

Sur ce formulaire, cela laisse tous les champs d'information vides et aucun Jordan nulle part dans les octets du fichier. L'étape Sanitize document de l'éditeur fait la même chose et retire en plus les pièces jointes, commentaires, champs de formulaire, calques masqués, signets et scripts ; le fichier qu'il a enregistré n'avait aucune métadonnée.

Un vérificateur à garder sous la main

Tout ce qui précède, réuni dans une fonction qui renvoie une liste de problèmes et quitte avec un code non nul s'il y en a :

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)
Enter fullscreen mode Exit fullscreen mode
uvx --with pymupdf --with pypdf --with pdfminer.six python assert_redacted.py out.pdf \
    "4471 2093 5568" "Jordan Ellis" "(503) 555-0142"
Enter fullscreen mode Exit fullscreen mode

Sur le fichier incrémental, il affiche l'auteur, 2 revisions: earlier versions are still in the file et cinq streams qui contiennent encore une donnée secrète. Sur le fichier au rectangle dessiné, il affiche 9 résultats des extracteurs, l'auteur et cinq streams. Sur le fichier du PDF Editor d'osFoundry, il affiche clean.

Il a des limites qu'il vaut mieux connaître. Il ne trouve que les chaînes que vous lui donnez, donc une valeur écrite autrement (4471-2093-5568) doit avoir sa propre entrée. Il lit du texte, donc une donnée secrète qui fait partie d'une image demande d'abord un OCR. Et le scan des streams rate le texte en polices CID, que les extracteurs détectent quand même.

Le PDF Editor qui a produit le fichier propre tourne en local dans l'application de bureau, conformément à la conception privée et local-first d'osFoundry : le formulaire ne quitte pas la machine pour être caviardé. L'application est gratuite sur la page de téléchargement d'osFoundry.

Top comments (0)