chk_defaced 0.4.0

Detect tampered documents before they reach an LLM: font defacement (the extracted text differs from the glyphs actually drawn) and invisible or cloaked text, in PDF, DOCX and HTML. Deterministic, no LLM, no network.
Documentation
//! PDF backend: walks the object graph with `lopdf`, inspects font dictionaries, `ToUnicode` maps,
//! `/Encoding /Differences` and embedded font programs (`FontFile2/3`). Deterministic coherence
//! checks (PUA, modified cmap vs registry, forged identity, multiple subsets = variant B).

use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::sync::LazyLock;

use anyhow::{Context, Result};
use lopdf::{Dictionary, Document, Object};
use regex::Regex;

use crate::finding::{Category, Finding, Report, Severity};
use crate::registry::FontRegistry;
use crate::{font, scan};

// ToUnicode CMap regexes — compiled once (were re-compiled per embedded font).
static BFCHAR_BLK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)beginbfchar(.*?)endbfchar").unwrap());
static BFRANGE_BLK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)beginbfrange(.*?)endbfrange").unwrap());
static PAIR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>").unwrap());
static TRIPLE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>").unwrap());
static ARR: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"(?s)<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*\[([^\]]*)\]").unwrap());
static TOK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<([0-9A-Fa-f]+)>").unwrap());

fn deref<'a>(doc: &'a Document, o: &'a Object) -> Option<&'a Object> {
    match o {
        Object::Reference(id) => doc.get_object(*id).ok(),
        x => Some(x),
    }
}
fn dget<'a>(doc: &'a Document, d: &'a Dictionary, k: &[u8]) -> Option<&'a Object> {
    d.get(k).ok().and_then(|o| deref(doc, o))
}
fn as_dict(o: &Object) -> Option<&Dictionary> {
    match o {
        Object::Dictionary(d) => Some(d),
        Object::Stream(s) => Some(&s.dict),
        _ => None,
    }
}
fn name_of(o: &Object) -> Option<String> {
    match o {
        Object::Name(b) => Some(String::from_utf8_lossy(b).into_owned()),
        _ => None,
    }
}

fn font_descriptor<'a>(doc: &'a Document, fontdict: &'a Dictionary) -> Option<&'a Dictionary> {
    if let Some(fd) = dget(doc, fontdict, b"FontDescriptor").and_then(as_dict) {
        return Some(fd);
    }
    // Type0 → DescendantFonts → FontDescriptor
    if let Some(Object::Array(arr)) = dget(doc, fontdict, b"DescendantFonts") {
        if let Some(first) = arr.first().and_then(|o| deref(doc, o)).and_then(as_dict) {
            return dget(doc, first, b"FontDescriptor").and_then(as_dict);
        }
    }
    None
}

fn embedded_font_bytes(doc: &Document, fontdict: &Dictionary) -> Option<Vec<u8>> {
    let fd = font_descriptor(doc, fontdict)?;
    for key in [b"FontFile2".as_ref(), b"FontFile3".as_ref(), b"FontFile".as_ref()] {
        if let Some(Object::Stream(s)) = dget(doc, fd, key) {
            if let Ok(bytes) = s.decompressed_content() {
                return Some(bytes);
            }
        }
    }
    None
}

/// A ToUnicode destination is "unreadable" if it is in the Private Use Area or is an invisible /
/// zero-width codepoint (BOM, ZWSP/ZWNJ/ZWJ, word joiner, soft hyphen): in both cases the extracted
/// text is garbled while the glyphs render normally.
fn is_unreadable_cp(cp: u32) -> bool {
    font::is_pua(cp) || matches!(cp, 0xFEFF | 0x00AD | 0x200B..=0x200D | 0x2060..=0x2064)
}

/// Count how many UTF-16BE units of a hex destination string are "unreadable" (PUA or invisible).
fn pua_units(hex: &str) -> usize {
    let mut n = 0;
    for chunk in hex.as_bytes().chunks(4) {
        if chunk.len() == 4 {
            if let Ok(cp) = u32::from_str_radix(std::str::from_utf8(chunk).unwrap_or(""), 16) {
                if is_unreadable_cp(cp) {
                    n += 1;
                }
            }
        }
    }
    n
}

/// Count `ToUnicode` Unicode destinations that fall in the PUA. Handles both `bfchar` (`<src> <dst>`)
/// and `bfrange` (`<lo> <hi> <dst>` or `<lo> <hi> [<d0> <d1> …]`) blocks — the destination is the
/// trustworthy token (sources are the font's internal codes).
fn tounicode_pua_count(doc: &Document, fontdict: &Dictionary) -> usize {
    let Some(Object::Stream(s)) = dget(doc, fontdict, b"ToUnicode") else {
        return 0;
    };
    let Ok(content) = s.decompressed_content() else {
        return 0;
    };
    let text = String::from_utf8_lossy(&content);
    let mut pua = 0usize;

    // bfchar: <src> <dst> → the destination is the 2nd token.
    for blk in BFCHAR_BLK.captures_iter(&text) {
        for c in PAIR.captures_iter(&blk[1]) {
            pua += pua_units(&c[2]);
        }
    }

    // bfrange: <lo> <hi> <dst-start> → the destination is the 3rd token (the range maps consecutively,
    // so if dst-start is PUA the whole range is). Also handle the array form `[<d0> <d1> …]`.
    for blk in BFRANGE_BLK.captures_iter(&text) {
        let body = &blk[1];
        for c in TRIPLE.captures_iter(body) {
            pua += pua_units(&c[3]);
        }
        // Array destinations: each entry is a destination codepoint.
        for c in ARR.captures_iter(body) {
            for t in TOK.captures_iter(&c[3]) {
                pua += pua_units(&t[1]);
            }
        }
    }

    pua
}

/// Math/symbol font families that legitimately map glyphs (delimiters, operators, dingbats) to the PUA,
/// because those glyphs have no standard Unicode — so a high PUA-in-ToUnicode count is benign, not a
/// garble attack. Covers Computer Modern / AMS math (CMEX, CMSY, CMMI, MSAM, MSBM) and symbol fonts.
fn is_math_or_symbol_font(name: &str) -> bool {
    let n = name.to_ascii_lowercase();
    ["cmex", "cmsy", "cmmi", "msam", "msbm", "stmary", "wasy", "symbol", "wingding", "webding", "dingbat"]
        .iter()
        .any(|s| n.contains(s))
}

/// Extract the subset tag from a BaseFont like `ABCDEF+Arial` → ("ABCDEF", "Arial").
fn subset_parts(base: &str) -> (Option<&str>, &str) {
    if base.len() > 7 && base.as_bytes().get(6) == Some(&b'+') {
        (Some(&base[..6]), &base[7..])
    } else {
        (None, base)
    }
}

pub fn scan(path: &Path, registry: Option<&FontRegistry>) -> Result<Report> {
    let doc = Document::load(path).with_context(|| format!("opening PDF {}", path.display()))?;
    scan_doc(&doc, &path.display().to_string(), registry)
}

/// Scan an **already-loaded** document.
///
/// Split out of [`fn@scan`] so a caller that already holds the `lopdf::Document`
/// pays for a single parse. That caller has to be on the same lopdf as this
/// crate for the type to be the same one, which is why this arrives together
/// with the bump to 0.44.
pub fn scan_doc(doc: &Document, label: &str, registry: Option<&FontRegistry>) -> Result<Report> {
    let mut report = Report::new(label, "pdf");
    let meta = crate::metadata::pdf_metadata(doc);
    if !meta.is_empty() {
        report.metadata = Some(meta);
    }
    // BTree e non Hash: questa mappa viene **iterata** per emettere i finding `PDF.MANY_SUBSETS`, e
    // Rust semina l'hasher a caso a ogni processo. Con una HashMap cinque esecuzioni dello stesso
    // binario sullo stesso documento producevano cinque ordini diversi: nessun verdetto cambiava,
    // ma confrontare due release con un diff diventava rumoroso, ed e' il modo in cui una
    // regressione passa inosservata. Ordinare per famiglia costa nulla qui.
    let mut subset_families: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
    let mut examined = 0usize;

    for obj in doc.objects.values() {
        let Some(d) = as_dict(obj) else { continue };
        if dget(doc, d, b"Type").and_then(name_of).as_deref() != Some("Font") {
            continue;
        }
        let base = dget(doc, d, b"BaseFont").and_then(name_of).unwrap_or_else(|| "(no BaseFont)".into());
        let loc = format!("font {base}");
        examined += 1;

        let (tag, family) = subset_parts(&base);
        if let Some(tag) = tag {
            subset_families.entry(family.to_string()).or_default().insert(tag.to_string());
        }

        // ToUnicode → PUA / invisible codepoints (variant A1/A2: extracted text garbled).
        // Threshold: legitimate fonts (e.g. Computer Modern / LaTeX) may map a *handful* of glyphs that
        // have no standard Unicode into the PUA — benign noise. A deliberate obfuscation garbles many
        // (the test corpus shows 1-2 for clean PDFs vs 20-150+ for attacks), so we require >= 4.
        const GARBLED_MIN: usize = 4;
        let garbled = tounicode_pua_count(doc, d);
        if garbled >= GARBLED_MIN && !is_math_or_symbol_font(family) {
            report.push(Finding::new(
                "PDF.TOUNICODE_GARBLED",
                Severity::High,
                Category::FontIntegrity,
                &loc,
                format!("{garbled} ToUnicode mapping(s) to the Private Use Area or invisible/zero-width codepoints: the extracted text will be garbled while the glyphs render normally"),
                0.85,
            ));
        }

        // /Encoding /Differences: custom encoding (potential code redirect).
        if let Some(enc) = dget(doc, d, b"Encoding").and_then(as_dict) {
            if enc.get(b"Differences").is_ok() {
                report.push(Finding::new(
                    "PDF.CUSTOM_DIFFERENCES",
                    Severity::Info,
                    Category::Structural,
                    &loc,
                    "encoding with /Differences: custom code remapping (legitimate, but it is the vector for glyph↔codepoint redirects)",
                    0.3,
                ));
            }
        }

        // Embedded font program → coherence (registry/PUA).
        if let Some(bytes) = embedded_font_bytes(doc, d) {
            for parsed in font::parse_data(&bytes) {
                scan::judge_embedded_font(&parsed, &loc, registry, &mut report.findings);
            }
        }
    }

    // Variant B: one family split across many subset/font objects.
    for (family, tags) in &subset_families {
        if tags.len() >= 5 {
            report.push(Finding::new(
                "PDF.MANY_SUBSETS",
                Severity::Medium,
                Category::Structural,
                format!("family {family}"),
                format!("{} distinct subsets of the same family: possible per-page/per-run dynamic font subsetting (variant B)", tags.len()),
                0.6,
            ));
        }
    }

    // Deterministic semantic-replacement (variant A3) check on the embedded outlines: an outline that
    // draws one letter but is reached from a different extracted character. No OCR, no rendering.
    // Reuses the already-loaded `doc` (no second/third PDF parse).
    {
        let outline = crate::pdf_glyph::pdf_outline_scan_doc(doc);
        let mut subs = outline.substitutions;
        // Gate on the substitution map, not on findings: a suppressed uniform-shift artifact returns an
        // Info note with NO substitutions, and must not set the Unconfirmed verdict or run the registry
        // check — the document is not defaced.
        if !subs.is_empty() {
            // Collision found: unconfirmed until verified.
            report.verdict = Some(crate::finding::Verdict::Unconfirmed);
            // Step 1 — deterministic canonical check (registry): if a registry-known font's glyph draws
            // a different canonical letter, that confirms the tamper and gives the correct direction.
            if let Some(reg) = registry {
                {
                    let csubs = crate::pdf_glyph::pdf_canonical_substitutions_doc(doc, reg);
                    if !csubs.is_empty() {
                        report.verdict = Some(crate::finding::Verdict::Confirmed);
                        let list = csubs.iter().take(6).map(|(e, d)| format!("'{e}'→'{d}'")).collect::<Vec<_>>().join(", ");
                        report.push(Finding::new(
                            "PDF.CANONICAL_TAMPER_CONFIRMED",
                            Severity::High,
                            Category::FontIntegrity,
                            "embedded font",
                            format!("registry outline check: {} embedded glyph(s) draw a different letter than the extracted character ({list})", csubs.len()),
                            0.95,
                        ));
                        subs = csubs; // correct direction overrides the frequency heuristic
                    }
                }
            }
        }
        if !subs.is_empty() {
            // Affected sentences per page (extracted vs presumed-rendered), so each carries the page it
            // was found on — extract page-by-page rather than the whole document at once.
            let mut phrases = Vec::new();
            for pg in doc.get_pages().keys().copied() {
                if phrases.len() >= 100 {
                    break;
                }
                if let Ok(raw) = doc.extract_text(&[pg]) {
                    phrases.extend(crate::finding::phrase_diffs_paged(&raw, &subs, Some(pg)));
                }
            }
            phrases.truncate(100);
            report.phrases = phrases;
        }
        report.findings.extend(outline.findings);
    }

    // Invisible / cloaked text (prompt-injection vector): white-on-white, sub-visible size, invisible
    // render mode, off-page — orthogonal to font defacement. Reuses the already-loaded `doc`.
    report.findings.extend(crate::visibility::pdf_visibility_scan(doc));

    report.fonts_examined = examined;
    Ok(report)
}

#[cfg(test)]
mod ordering_tests {
    /// L'ordine dei finding dev'essere **riproducibile**: `subset_families` viene iterata per
    /// emettere `PDF.MANY_SUBSETS`, e con una `HashMap` Rust semina l'hasher a caso a ogni processo.
    /// Il contenuto era identico ma la riga si spostava, e un rumore che si muove a ogni corsa
    /// costringe a separare a mano il cambiamento vero dal riordino quando si confrontano due
    /// release con un diff — che è il modo in cui una regressione passa inosservata.
    ///
    /// Qui si verifica la proprietà alla fonte: la struttura ordinata restituisce sempre la stessa
    /// sequenza, a prescindere dall'ordine di inserimento.
    #[test]
    fn le_famiglie_di_subset_escono_in_ordine_stabile() {
        use std::collections::{BTreeMap, BTreeSet};
        let inserimenti = [
            vec![("Calibri", "AAAAAA"), ("Arial", "BBBBBB"), ("Times", "CCCCCC")],
            vec![("Times", "CCCCCC"), ("Calibri", "AAAAAA"), ("Arial", "BBBBBB")],
            vec![("Arial", "BBBBBB"), ("Times", "CCCCCC"), ("Calibri", "AAAAAA")],
        ];
        let mut sequenze = Vec::new();
        for ordine in &inserimenti {
            let mut m: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
            for (fam, tag) in ordine {
                m.entry(fam.to_string()).or_default().insert(tag.to_string());
            }
            sequenze.push(m.keys().cloned().collect::<Vec<_>>());
        }
        assert!(
            sequenze.windows(2).all(|w| w[0] == w[1]),
            "l'ordine di emissione dipende dall'inserimento: {sequenze:?}"
        );
        assert_eq!(sequenze[0], ["Arial", "Calibri", "Times"]);
    }
}