use std::collections::{HashMap, HashSet};
use std::io::Read;
use std::path::Path;
use anyhow::{Context, Result};
use regex::Regex;
use crate::finding::{Category, Finding, Severity};
fn deobfuscate(odttf: &[u8], guid: &str, reversed: bool) -> Vec<u8> {
let hexs: String = guid.chars().filter(|c| c.is_ascii_hexdigit()).collect();
if hexs.len() != 32 {
return odttf.to_vec();
}
let mut key = [0u8; 16];
for i in 0..16 {
key[i] = u8::from_str_radix(&hexs[i * 2..i * 2 + 2], 16).unwrap_or(0);
}
let mut out = odttf.to_vec();
let n = out.len().min(32);
for i in 0..n {
let kb = if reversed { key[15 - (i % 16)] } else { key[i % 16] };
out[i] ^= kb;
}
out
}
struct OutlineSig {
h: u64,
empty: bool,
}
impl OutlineSig {
fn new() -> Self {
Self { h: 0xcbf29ce484222325, empty: true }
}
fn mix(&mut self, v: i64) {
self.empty = false;
self.h ^= v as u64;
self.h = self.h.wrapping_mul(0x100000001b3);
}
}
impl ttf_parser::OutlineBuilder for OutlineSig {
fn move_to(&mut self, x: f32, y: f32) {
self.mix(1);
self.mix(x as i64);
self.mix(y as i64);
}
fn line_to(&mut self, x: f32, y: f32) {
self.mix(2);
self.mix(x as i64);
self.mix(y as i64);
}
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
self.mix(3);
self.mix(x1 as i64);
self.mix(y1 as i64);
self.mix(x as i64);
self.mix(y as i64);
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
self.mix(4);
for v in [x1, y1, x2, y2, x, y] {
self.mix(v as i64);
}
}
fn close(&mut self) {
self.mix(5);
}
}
fn letter(cp: u32) -> Option<char> {
use unicode_script::{Script, UnicodeScript};
let c = char::from_u32(cp)?;
if c.is_alphabetic() && c.script() == Script::Latin {
c.to_lowercase().next()
} else {
None
}
}
fn skel(c: char) -> String {
unicode_security::confusable_detection::skeleton(&c.to_string()).collect()
}
fn legitimately_identical(a: char, b: char) -> bool {
use unicode_normalization::UnicodeNormalization;
use unicode_script::UnicodeScript;
if a == b {
return true;
}
if a.script() != b.script() {
return true;
}
if a.to_string().nfkd().eq(b.to_string().nfkd()) {
return true;
}
if let (Some(x), Some(y)) = (deunicode::deunicode_char(a), deunicode::deunicode_char(b)) {
if !x.is_empty() && x == y {
return true;
}
}
let mut p = [a, b];
p.sort_unstable();
if matches!((p[0], p[1]), ('i', 'l')) {
return true;
}
skel(a) == skel(b)
}
#[derive(Default)]
struct Tally {
by_letter: HashMap<char, usize>,
}
fn document_letters(zip: &mut zip::ZipArchive<std::fs::File>, names: &[String]) -> HashSet<char> {
let re = Regex::new(r"(?s)<w:t[ >](.*?)</w:t>").unwrap();
let mut set = HashSet::new();
for name in names {
let textpart = name == "word/document.xml"
|| (name.starts_with("word/header") && name.ends_with(".xml"))
|| (name.starts_with("word/footer") && name.ends_with(".xml"))
|| name == "word/footnotes.xml"
|| name == "word/endnotes.xml";
if !textpart {
continue;
}
let mut s = String::new();
if zip.by_name(name).map(|mut zf| zf.read_to_string(&mut s)).is_err() {
continue;
}
for cap in re.captures_iter(&s) {
for c in cap[1].chars() {
if c.is_alphabetic() {
if let Some(l) = c.to_lowercase().next() {
set.insert(l);
}
}
}
}
}
set
}
pub fn docx_outline_scan(path: &Path) -> Result<Vec<Finding>> {
let file = std::fs::File::open(path).with_context(|| format!("opening DOCX {}", path.display()))?;
let mut zip = zip::ZipArchive::new(file).context("DOCX is not a valid ZIP")?;
let names: Vec<String> = zip.file_names().map(|s| s.to_string()).collect();
let doc_letters = document_letters(&mut zip, &names);
let mut keys: Vec<String> = Vec::new();
if let Ok(mut zf) = zip.by_name("word/fontTable.xml") {
let mut s = String::new();
if zf.read_to_string(&mut s).is_ok() {
let re = Regex::new(r#"w:fontKey="(\{[0-9A-Fa-f-]+\})""#).unwrap();
for cap in re.captures_iter(&s) {
keys.push(cap[1].to_string());
}
}
}
let mut sigs: HashMap<u64, Tally> = HashMap::new();
let odttf: Vec<String> =
names.iter().filter(|n| n.to_ascii_lowercase().ends_with(".odttf")).cloned().collect();
for name in &odttf {
let mut raw = Vec::new();
if zip.by_name(name).map(|mut zf| zf.read_to_end(&mut raw)).is_err() {
continue;
}
let mut parsed = false;
'keys: for guid in &keys {
for reversed in [true, false] {
let de = deobfuscate(&raw, guid, reversed);
if let Ok(face) = ttf_parser::Face::parse(&de, 0) {
tally_face(&face, &doc_letters, &mut sigs);
parsed = true;
break 'keys;
}
}
}
let _ = parsed;
}
let mut lies: HashMap<(char, char), usize> = HashMap::new();
for tally in sigs.values() {
if tally.by_letter.len() < 2 {
continue;
}
let mut letters: Vec<(char, usize)> = tally.by_letter.iter().map(|(c, n)| (*c, *n)).collect();
letters.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
let (truth, _) = letters[0];
for (lie, n) in letters.iter().skip(1) {
if legitimately_identical(truth, *lie) {
continue;
}
*lies.entry((truth, *lie)).or_default() += *n;
}
}
let mut findings: Vec<Finding> = lies
.into_iter()
.map(|((truth, lie), n)| {
Finding::new(
"DOCX.GLYPH_SEMANTIC_REPLACEMENT",
Severity::High,
Category::FontIntegrity,
"embedded font",
format!(
"a glyph that draws '{truth}' is mapped from the extracted character '{lie}' ({n}×): the embedded font's cmap lies (semantic replacement / variant A3)"
),
0.9,
)
})
.collect();
findings.sort_by(|a, b| a.message.cmp(&b.message));
Ok(findings)
}
pub fn docx_font_claims(path: &Path) -> Result<Vec<crate::FontClaims>> {
use std::collections::HashSet;
let file = std::fs::File::open(path).with_context(|| format!("opening DOCX {}", path.display()))?;
let mut zip = zip::ZipArchive::new(file).context("DOCX is not a valid ZIP")?;
let names: Vec<String> = zip.file_names().map(|s| s.to_string()).collect();
let doc_letters = document_letters(&mut zip, &names);
let mut keys: Vec<String> = Vec::new();
if let Ok(mut zf) = zip.by_name("word/fontTable.xml") {
let mut s = String::new();
if zf.read_to_string(&mut s).is_ok() {
let re = Regex::new(r#"w:fontKey="(\{[0-9A-Fa-f-]+\})""#).unwrap();
for cap in re.captures_iter(&s) {
keys.push(cap[1].to_string());
}
}
}
let odttf: Vec<String> =
names.iter().filter(|n| n.to_ascii_lowercase().ends_with(".odttf")).cloned().collect();
let mut out: Vec<crate::FontClaims> = Vec::new();
for name in &odttf {
let mut raw = Vec::new();
if zip.by_name(name).map(|mut zf| zf.read_to_end(&mut raw)).is_err() {
continue;
}
'keys: for guid in &keys {
for reversed in [true, false] {
let de = deobfuscate(&raw, guid, reversed);
let Ok(face) = ttf_parser::Face::parse(&de, 0) else { continue };
let Some(cmap) = face.tables().cmap else { break 'keys };
let mut claims: HashSet<(char, u16)> = HashSet::new();
for sub in cmap.subtables {
if !sub.is_unicode() {
continue;
}
sub.codepoints(|cp| {
let Some(l) = letter(cp) else { return };
if !doc_letters.contains(&l) {
return;
}
if let (Some(c), Some(gid)) = (char::from_u32(cp), sub.glyph_index(cp)) {
claims.insert((c, gid.0));
}
});
}
if !claims.is_empty() {
out.push((de, claims.into_iter().collect()));
}
break 'keys;
}
}
}
Ok(out)
}
fn tally_face(face: &ttf_parser::Face, doc_letters: &HashSet<char>, sigs: &mut HashMap<u64, Tally>) {
let Some(cmap) = face.tables().cmap else { return };
for sub in cmap.subtables {
if !sub.is_unicode() {
continue;
}
sub.codepoints(|cp| {
let Some(letter) = letter(cp) else { return };
if !doc_letters.contains(&letter) {
return;
}
if let Some(gid) = sub.glyph_index(cp) {
let mut b = OutlineSig::new();
if face.outline_glyph(gid, &mut b).is_some() && !b.empty {
*sigs.entry(b.h).or_default().by_letter.entry(letter).or_default() += 1;
}
}
});
}
}