Dev.to Security 🔐 Cybersecurity 👁 0 📖 3 min read

Python से कैसे जाँचें कि PDF में redact किया गया टेक्स्ट सच में हट गया है

redact की गई PDF सिर्फ़ "किसी ने काला बॉक्स बना दिया" वाले तरीके से ही फ़ेल नहीं होती। बॉक्स पेज का हिस्सा हो सकता है या उसके ऊपर रखा एक annotation। redaction मार्क जोड़े जा सकते हैं और कभी apply नहीं होते। redaction app

Python से कैसे जाँचें कि PDF में redact किया गया टेक्स्ट सच में हट गया है

redact की गई PDF सिर्फ़ "किसी ने काला बॉक्स बना दिया" वाले तरीके से ही फ़ेल नहीं होती। बॉक्स पेज का हिस्सा हो सकता है या उसके ऊपर रखा एक annotation। redaction मार्क जोड़े जा सकते हैं और कभी apply नहीं होते। redaction apply होकर भी incremental save हो सकता है, जिससे पुराना पेज फ़ाइल में ही पड़ा रहता है। और लेखक का नाम पूरे समय मेटाडेटा में बैठा रह सकता है। स्क्रीन पर ये सब एक जैसे दिखते हैं।

कौन से सच में लीक करते हैं, यह देखने के लिए मैंने एक ही एक-पेज फ़ॉर्म के सात वर्शन बनाए और हर एक को तीन टेक्स्ट एक्सट्रैक्टर (PyMuPDF, pypdf और pdfminer.six), फ़ाइल की हर स्ट्रीम के स्कैन और मेटाडेटा से जाँचा। सात में से एक वह फ़ाइल है जिसे osFoundry डेस्कटॉप ऐप के PDF Editor ने Find text and redact, Apply और Sanitize के बाद सेव किया। स्क्रिप्ट्स आख़िर में हैं; सब कुछ Apple M1 पर Python 3.12, PyMuPDF 2.1, pypdf 6.21.4 और pdfminer.six 20260107 के साथ चलाया गया।

टेस्ट फ़ॉर्म

फ़ॉर्म एक direct-deposit authorization है जिसे PyMuPDF से बनाया गया (उदाहरण डेटा: नाम Jordan Ellis, अकाउंट नंबर 4471 2093 5568, फ़ोन (503) 555-0142)। अकाउंट नंबर दो बार और नाम दो बार आता है, और document info में author फ़ील्ड में वही नाम है। टेक्स्ट built-in Times फ़ॉन्ट में है, इसलिए content stream में हर लाइन एक सादा TJ है जिसमें बाइट्स hex string के रूप में लिखे हैं:

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

34 34 37 31 20 32 30 39 33 ... ASCII में 4471 2093 ... है। अगले सेक्शन के लिए यह बात याद रखें।

बनाया गया बॉक्स टेक्स्ट में कुछ नहीं बदलता

ज़्यादातर लोग जो "redacted" वर्शन बनाते हैं, उसमें दोनों अकाउंट नंबरों के ऊपर भरे हुए आयत (rectangle) बना दिए जाते हैं। content stream में ये बाकी सब के बाद जोड़े गए दो नए ऑब्जेक्ट हैं:

q
232 322 89.25 17 re
h
0 0 0 RG 0 0 0 rg B
Q

ऊपर वाला TJ जस का तस है। आयत बाद में पेंट होता है, इसलिए स्क्रीन और प्रिंट में ग्लिफ़ ढक जाते हैं, लेकिन हर एक्सट्रैक्टर टेक्स्ट ऑपरेटर पढ़ता है, पिक्सल नहीं।

grep कोई टेस्ट नहीं है

सबसे आसान जाँच यह लगती है कि फ़ाइल में नंबर सर्च कर लिया जाए:

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

यह बिना redact किए original पर भी कुछ नहीं लौटाता, और इसकी दो वजहें हैं। content streams Flate से compressed हैं, इसलिए डिस्क पर रखे बाइट्स टेक्स्ट नहीं हैं। और decompress करने के बाद भी नंबर 4471 2093 5568 के रूप में नहीं, बल्कि hex string <3434373120...> के रूप में रखा होता है। स्ट्रीम स्कैन को हर स्ट्रीम inflate करनी होगी और दोनों रूप ढूँढने होंगे, और नीचे वाला चेकर यही करता है। CID फ़ॉन्ट्स (ज़्यादातर जापानी, चीनी और कोरियाई PDF) में यह भी काम नहीं करता, क्योंकि string में glyph ID होते हैं जो फ़ॉन्ट की ToUnicode टेबल से ही वापस अक्षरों में बदलते हैं। हर जगह काम करने वाली इकलौती जाँच एक असली टेक्स्ट एक्सट्रैक्टर है; स्ट्रीम स्कैन उन बचे-खुचे हिस्सों के लिए दूसरी लाइन है जिन्हें एक्सट्रैक्टर नहीं पढ़ते।

सात वर्शन, तीन एक्सट्रैक्टर

हर वर्शन को अकाउंट नंबर के लिए जाँचा गया। "स्ट्रीम्स" उन स्ट्रीम्स की गिनती है जो फ़ाइल के बाइट्स में कहीं भी हैं, चालू हों या orphaned, और inflate करने के बाद भी नंबर रखती हैं:

वर्शन PyMuPDF pypdf pdfminer.six स्ट्रीम्स मेटाडेटा में author
original मिला मिला मिला 2 Jordan Ellis
पेज में बनाया गया बॉक्स मिला मिला मिला 2 Jordan Ellis
square annotation के रूप में बॉक्स मिला मिला मिला 2 Jordan Ellis
redaction मार्क जोड़े, apply नहीं किए मिला मिला मिला 2 Jordan Ellis
PyMuPDF apply_redactions, फ़ुल सेव – – – 0 Jordan Ellis
PyMuPDF apply_redactions, saveIncr() – – – 2 Jordan Ellis
osFoundry PDF Editor, Apply + Sanitize + Save – – – 0 (खाली)

किसी भी रीडर के लिए पहले चार एक ही फ़ाइल हैं। तीनों एक्सट्रैक्टर हर लाइन पर एकमत रहे, इसलिए इस सवाल के लिए फ़र्क नहीं पड़ता कि आप कौन सा इस्तेमाल करते हैं, बस कोई एक इस्तेमाल करें।

चौथी लाइन उन लोगों को फँसाती है जो redaction टूल्स के बारे में जानते हैं। PDF redaction दो स्टेप में होता है: एक Redact annotation जो जगह को मार्क करता है, और एक apply स्टेप जो उसके नीचे की चीज़ हटाता है। दोनों के बीच सेव की गई फ़ाइल में लाल आउटलाइन (या viewer के हिसाब से काले बॉक्स) दिखते हैं, और फिर भी उसमें सब कुछ मौजूद रहता है।

incremental save का जाल

छठी लाइन तीनों एक्सट्रैक्टर पास कर लेती है और फिर भी लीक करती है। saveIncr(), कई डेस्कटॉप PDF टूल्स के सादे Save की तरह, फ़ाइल को दोबारा नहीं लिखता। यह बदले हुए ऑब्जेक्ट्स और एक नई cross-reference टेबल को पुराने %%EOF के बाद जोड़ देता है, और रीडर सबसे नई टेबल को फ़ॉलो करते हैं। पुरानी content streams अब भी वहीं हैं, बस अब उन्हें कोई refer नहीं करता।

उन्हें वापस निकालने में एक लाइन लगती है, क्योंकि फ़ाइल का पहला revision पहले %%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

incremental फ़ाइल 11,533 बाइट्स की है; उसके पहले 7,163 बाइट्स पूरा का पूरा original फ़ॉर्म हैं। %%EOF मार्कर गिनना एक सस्ती जाँच है: original और फ़ुल सेव में एक है, incremental सेव में दो।

असली removal इसकी जगह क्या लिखता है

osFoundry PDF Editor से सेव की गई फ़ाइल की किसी भी स्ट्रीम में अकाउंट नंबर नहीं है, और बाकी टेक्स्ट अपनी जगह से नहीं हिला। Apply के बाद यह वही लाइन है:

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

string चली गई है और TJ array में सिर्फ़ एक संख्या बची है। TJ array में संख्या फ़ॉन्ट साइज़ के हज़ारवें हिस्सों में एक horizontal adjustment होती है जो पोज़ीशन से घटाई जाती है, इसलिए -6500 बिना कुछ बनाए पेन को 6.5 × 12.5 = 81.25 पॉइंट दाईं ओर ले जाता है। Times-Roman में 4471 2093 5568 की चौड़ाई ठीक इतनी ही है:

हटाया गया टेक्स्ट फ़ॉन्ट साइज़ Times-Roman में चौड़ाई 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 (हस्ताक्षर) 15 71.25 pt -4750

advance बनाए रखने का मतलब है कि उसी लाइन पर हटाए गए हिस्से के बाद जो भी है, वह अपनी जगह पर रहता है। शब्द-दर-शब्द जाँचने पर बचे हुए सभी 63 शब्दों की पोज़ीशन original जैसी ही है (0 हिले), और पेज 75 शब्दों से 63 पर आ गया। काले बॉक्स आख़िर में एक अलग स्ट्रीम हैं, सादे भरे हुए आयत (236 323.5 81.25 12.5 re f) जिनका साइज़ हटाए गए टेक्स्ट जितना है, और उनके नीचे कुछ नहीं है।

Apply के बाद PDF Editor में फ़ॉर्म, जहाँ पाँचों मार्क थे वहाँ काले बॉक्स

मेटाडेटा एक अलग स्टेप है

PyMuPDF के फ़ुल सेव ने टेक्स्ट हटा दिया, लेकिन document info में author = Jordan Ellis छोड़ दिया। redaction पेज के कंटेंट पर काम करता है, और info dictionary पेज का कंटेंट नहीं है। इसे साफ़ करने के लिए दो कॉल चाहिए:

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

इस फ़ॉर्म पर इससे info के सभी फ़ील्ड खाली हो जाते हैं और फ़ाइल के बाइट्स में कहीं भी Jordan नहीं बचता। एडिटर का Sanitize document स्टेप यही करता है, साथ में attachments, comments, form fields, hidden layers, bookmarks और scripts भी हटाता है, और उसकी सेव की गई फ़ाइल में कोई मेटाडेटा था ही नहीं।

एक चेकर जिसे आप रख सकते हैं

ऊपर की हर चीज़ एक फ़ंक्शन में, जो समस्याओं की लिस्ट लौटाता है और कोई समस्या हो तो non-zero के साथ exit करता है:

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"

incremental फ़ाइल पर यह author, 2 revisions: earlier versions are still in the file और पाँच ऐसी स्ट्रीम्स प्रिंट करता है जिनमें अब भी कोई secret है। बनाए गए बॉक्स वाली फ़ाइल पर यह 9 एक्सट्रैक्टर हिट, author और पाँच स्ट्रीम्स प्रिंट करता है। osFoundry PDF Editor वाली फ़ाइल पर यह clean प्रिंट करता है।

इसकी कुछ सीमाएँ बताने लायक हैं। यह सिर्फ़ वही strings ढूँढता है जो आप देते हैं, इसलिए अलग तरह से लिखी वैल्यू (4471-2093-5568) के लिए अलग एंट्री चाहिए। यह टेक्स्ट पढ़ता है, इसलिए अगर secret किसी इमेज का हिस्सा है तो पहले OCR करना होगा। और स्ट्रीम स्कैन CID-फ़ॉन्ट वाला टेक्स्ट नहीं पकड़ता, जिसे एक्सट्रैक्टर फिर भी पकड़ लेते हैं।

जिस PDF Editor ने साफ़ फ़ाइल बनाई, वह osFoundry के private, local-first डिज़ाइन के मुताबिक डेस्कटॉप ऐप के अंदर लोकल चलता है, इसलिए redact करने के लिए फ़ॉर्म को मशीन से बाहर नहीं जाना पड़ता। ऐप osFoundry डाउनलोड पेज से मुफ़्त है।

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.