Skip to main content

fnprint_core/
lib.rs

1//! Index / match / query, built on the loader + emulator + fingerprint + db.
2
3use std::collections::HashMap;
4
5use anyhow::Result;
6use fnprint_db::Db;
7use fnprint_emu::{Config, MicroExec};
8use fnprint_loader::{Func, FuncSource};
9use fnprint_sig::Fingerprint;
10use rayon::prelude::*;
11
12/// below this we don't trust a match, thunks and tiny leaves all look alike
13pub const MIN_COMPLEXITY: u32 = 4;
14/// two prints this close are "the same function"
15pub const SAME_THRESH: f64 = 0.88;
16/// fixed seeds used per function. deterministic, so prints are reproducible.
17const SEEDS: [u64; 4] = [0, 0x9e3779b9, 0x1234_5678, 0xdead_beef];
18
19#[derive(serde::Serialize, serde::Deserialize)]
20pub struct IndexedFunc {
21    pub name: Option<String>,
22    pub entry: u64,
23    pub source: FuncSource,
24    pub fp: Fingerprint,
25}
26
27// re-exported so the cli's privsep worker can render dump output without
28// depending on the trace crate directly.
29pub use fnprint_trace::EffectTrace;
30
31/// Force the rayon global pool to spawn its worker threads now. The sandboxed
32/// worker calls this before it jails itself, so the jail can forbid clone/clone3
33/// (no thread creation after lockdown) without starving the parallel index.
34pub fn warm_pool() {
35    let _: u64 = (0..256u64).into_par_iter().sum();
36}
37
38pub fn source_str(s: FuncSource) -> &'static str {
39    match s {
40        FuncSource::Symtab => "symtab",
41        FuncSource::DynSym => "dynsym",
42        FuncSource::EhFrame => "eh_frame",
43    }
44}
45
46/// micro-execute + fingerprint every discovered function in an ELF blob.
47pub fn index_bytes(bytes: &[u8], cfg: Config) -> Result<Vec<IndexedFunc>> {
48    let loaded = fnprint_loader::load(bytes)?;
49    let image = &loaded.image;
50
51    // entry -> name, so stubbed calls can be resolved to a symbol
52    let mut symbols: HashMap<u64, String> = HashMap::new();
53    for f in &loaded.funcs {
54        if let Some(n) = &f.name {
55            symbols.insert(f.entry, n.clone());
56        }
57    }
58
59    let out: Vec<IndexedFunc> = loaded
60        .funcs
61        .par_iter()
62        .filter(|f| f.size > 0 && image.code_at(f.entry, 1).is_some())
63        .map(|f: &Func| {
64            let ex = MicroExec::new(cfg.clone());
65            // a few deterministic seeds vary the input buffers so behavior that
66            // only shows up on some inputs still makes it into the print.
67            let traces = ex.run_explore(image, f, &symbols, &SEEDS);
68            IndexedFunc {
69                name: f.name.clone(),
70                entry: f.entry,
71                source: f.source,
72                fp: Fingerprint::from_traces(&traces),
73            }
74        })
75        .collect();
76
77    Ok(out)
78}
79
80pub fn index_to_db(bytes: &[u8], binary: &str, db: &Db, cfg: Config) -> Result<usize> {
81    let funcs = index_bytes(bytes, cfg)?;
82    for f in &funcs {
83        db.insert(
84            binary,
85            f.name.as_deref(),
86            f.entry,
87            source_str(f.source),
88            &f.fp,
89        )?;
90    }
91    Ok(funcs.len())
92}
93
94// -------- match (n-day / cross-version diff) --------
95
96pub struct Changed {
97    pub name: String,
98    pub similarity: f64,
99}
100
101#[derive(Default)]
102pub struct MatchReport {
103    pub same: usize,
104    pub changed: Vec<Changed>,
105    pub only_a: Vec<String>,
106    pub only_b: Vec<String>,
107    pub compared: usize,
108    /// present in both but too little signal to judge (tiny/scalar helpers)
109    pub low_signal: usize,
110}
111
112/// align two indexes by symbol name and report which shared functions actually
113/// changed behavior. this is the "what did the vendor quietly patch" view.
114pub fn match_by_name(a: &[IndexedFunc], b: &[IndexedFunc]) -> MatchReport {
115    let mut bmap: HashMap<&str, &IndexedFunc> = HashMap::new();
116    for f in b {
117        if let Some(n) = &f.name {
118            bmap.insert(n.as_str(), f);
119        }
120    }
121    let mut amap: HashMap<&str, &IndexedFunc> = HashMap::new();
122    for f in a {
123        if let Some(n) = &f.name {
124            amap.insert(n.as_str(), f);
125        }
126    }
127
128    let mut rep = MatchReport::default();
129    for (name, fa) in &amap {
130        match bmap.get(name) {
131            Some(fb) => {
132                rep.compared += 1;
133                // don't cry wolf on thunks/scalar helpers: not enough behavior
134                // to tell "changed" from "recompiled the same".
135                if fa.fp.complexity < MIN_COMPLEXITY || fb.fp.complexity < MIN_COMPLEXITY {
136                    rep.low_signal += 1;
137                    continue;
138                }
139                let sim = fa.fp.similarity(&fb.fp);
140                if sim >= SAME_THRESH {
141                    rep.same += 1;
142                } else {
143                    rep.changed.push(Changed {
144                        name: name.to_string(),
145                        similarity: sim,
146                    });
147                }
148            }
149            None => rep.only_a.push(name.to_string()),
150        }
151    }
152    for name in bmap.keys() {
153        if !amap.contains_key(name) {
154            rep.only_b.push(name.to_string());
155        }
156    }
157    rep.changed
158        .sort_by(|x, y| x.similarity.total_cmp(&y.similarity));
159    rep.only_a.sort();
160    rep.only_b.sort();
161    rep
162}
163
164// -------- query (auto-name against a corpus) --------
165
166pub struct Named {
167    pub entry: u64,
168    pub guess: String,
169    pub from_binary: String,
170    pub similarity: f64,
171}
172
173/// best-scoring named function in a corpus for one print. narrows with the LSH
174/// bands first and falls back to the full set if no band hit. returns
175/// (similarity, name, binary). the preloaded `all` is the fallback pool.
176fn best_in_corpus(
177    fp: &Fingerprint,
178    db: &Db,
179    all: &[fnprint_db::FuncRec],
180) -> Result<Option<(f64, String, String)>> {
181    let cands = db.candidates(fp)?;
182    let pool: &[fnprint_db::FuncRec] = if cands.is_empty() { all } else { &cands };
183    let mut best: Option<(f64, String, String)> = None;
184    for c in pool {
185        if c.fp.complexity < MIN_COMPLEXITY {
186            continue;
187        }
188        let cname = match &c.name {
189            Some(n) => n,
190            None => continue,
191        };
192        let sim = fp.similarity(&c.fp);
193        if best.as_ref().map(|(s, _, _)| sim > *s).unwrap_or(true) {
194            best = Some((sim, cname.clone(), c.binary.clone()));
195        }
196    }
197    Ok(best)
198}
199
200/// for each function in the target that we can trust, pull the best-matching
201/// named function out of the corpus db. withholds tiny/low-signal functions.
202pub fn query_corpus(target: &[IndexedFunc], corpus: &Db, threshold: f64) -> Result<Vec<Named>> {
203    let named = corpus.all()?; // small corpora, fine to hold in memory
204    let mut out = Vec::new();
205    for f in target {
206        if f.fp.complexity < MIN_COMPLEXITY || f.fp.shingles == 0 {
207            continue;
208        }
209        if let Some((sim, name, bin)) = best_in_corpus(&f.fp, corpus, &named)? {
210            if sim >= threshold {
211                out.push(Named {
212                    entry: f.entry,
213                    guess: name,
214                    from_binary: bin,
215                    similarity: sim,
216                });
217            }
218        }
219    }
220    out.sort_by(|a, b| b.similarity.total_cmp(&a.similarity));
221    Ok(out)
222}
223
224// -------- triage (n-day: vulnerable vs patched, the actionable view) --------
225
226#[derive(Clone, Copy, PartialEq, Eq, Debug)]
227pub enum Verdict {
228    /// leans toward the known-vulnerable version, clear of the patched one
229    Vulnerable,
230    /// leans toward the patched version
231    Patched,
232    /// neither side is close enough, or the two are too close to separate
233    Inconclusive,
234}
235
236pub struct TriageHit {
237    pub entry: u64,
238    pub verdict: Verdict,
239    pub vuln_sim: f64,
240    pub vuln_name: String,
241    pub patched_sim: f64,
242    pub patched_name: String,
243}
244
245impl TriageHit {
246    /// how far the vulnerable side leads the patched side. negative means it
247    /// looks patched. this is the separation the reviewer actually cares about.
248    pub fn margin(&self) -> f64 {
249        self.vuln_sim - self.patched_sim
250    }
251}
252
253fn verdict_order(v: Verdict) -> u8 {
254    // vulnerable-leaning to the top of the review queue, patched to the bottom
255    match v {
256        Verdict::Vulnerable => 0,
257        Verdict::Inconclusive => 1,
258        Verdict::Patched => 2,
259    }
260}
261
262/// rank each target function against a known-vulnerable corpus and a known-patched
263/// corpus and call which side it leans to. a function close to the vulnerable
264/// version and clearly separated from the patched one is a candidate worth a
265/// human's time, which is more useful for n-day work than a single match score.
266///
267/// `min_sim`: a side has to be at least this similar to count as a real lead.
268/// `margin`: how far the two sides must separate before we commit to a verdict.
269/// the result is sorted as a review queue, strongest vulnerable lead first.
270pub fn triage(
271    target: &[IndexedFunc],
272    vuln: &Db,
273    patched: &Db,
274    min_sim: f64,
275    margin: f64,
276) -> Result<Vec<TriageHit>> {
277    let vuln_all = vuln.all()?;
278    let patched_all = patched.all()?;
279    let mut out = Vec::new();
280    for f in target {
281        if f.fp.complexity < MIN_COMPLEXITY || f.fp.shingles == 0 {
282            continue;
283        }
284        let (vuln_sim, vuln_name) = best_in_corpus(&f.fp, vuln, &vuln_all)?
285            .map(|(s, n, _)| (s, n))
286            .unwrap_or((0.0, String::new()));
287        let (patched_sim, patched_name) = best_in_corpus(&f.fp, patched, &patched_all)?
288            .map(|(s, n, _)| (s, n))
289            .unwrap_or((0.0, String::new()));
290
291        let top = vuln_sim.max(patched_sim);
292        let verdict = if top < min_sim {
293            Verdict::Inconclusive
294        } else if vuln_sim - patched_sim >= margin {
295            Verdict::Vulnerable
296        } else if patched_sim - vuln_sim >= margin {
297            Verdict::Patched
298        } else {
299            Verdict::Inconclusive
300        };
301        out.push(TriageHit {
302            entry: f.entry,
303            verdict,
304            vuln_sim,
305            vuln_name,
306            patched_sim,
307            patched_name,
308        });
309    }
310    out.sort_by(|a, b| {
311        verdict_order(a.verdict)
312            .cmp(&verdict_order(b.verdict))
313            .then(b.vuln_sim.total_cmp(&a.vuln_sim))
314    });
315    Ok(out)
316}
317
318// -------- eval (accuracy metrics against symbol-name ground truth) --------
319
320pub struct EvalResult {
321    /// functions in A that we scored (had signal and a same-named twin in B)
322    pub scored: usize,
323    /// top-1 ranked match in B is the same-named function
324    pub rank1: usize,
325    /// sum of 1/rank of the correct match, for mean reciprocal rank
326    pub rr_sum: f64,
327    /// at SAME_THRESH: predicted-same that are actually same-named
328    pub tp: usize,
329    pub fp: usize,
330    /// same-named pairs we failed to call same
331    pub fn_: usize,
332    /// 1-based rank of the correct match for each scored function. lets us ask
333    /// "does reviewing the top k candidates find it", not just the top-1 number.
334    pub ranks: Vec<usize>,
335    /// scored functions where the top-1 similarity was below SAME_THRESH, i.e.
336    /// the tool would decline to make a confident call rather than guess.
337    pub abstained: usize,
338}
339
340impl EvalResult {
341    pub fn rank1_acc(&self) -> f64 {
342        if self.scored == 0 {
343            0.0
344        } else {
345            self.rank1 as f64 / self.scored as f64
346        }
347    }
348    pub fn mrr(&self) -> f64 {
349        if self.scored == 0 {
350            0.0
351        } else {
352            self.rr_sum / self.scored as f64
353        }
354    }
355    pub fn precision(&self) -> f64 {
356        let d = self.tp + self.fp;
357        if d == 0 {
358            0.0
359        } else {
360            self.tp as f64 / d as f64
361        }
362    }
363    pub fn recall(&self) -> f64 {
364        let d = self.tp + self.fn_;
365        if d == 0 {
366            0.0
367        } else {
368            self.tp as f64 / d as f64
369        }
370    }
371    /// fraction of scored functions whose correct match lands in the top k.
372    /// recall@5 answers "if an analyst looks at 5 candidates, do they find it".
373    pub fn recall_at(&self, k: usize) -> f64 {
374        if self.scored == 0 {
375            return 0.0;
376        }
377        let hits = self.ranks.iter().filter(|&&r| r <= k).count();
378        hits as f64 / self.scored as f64
379    }
380    /// how often the tool declined a confident top-1 call. high abstention with
381    /// high precision is the honest tradeoff: quiet when it isn't sure.
382    pub fn abstain_rate(&self) -> f64 {
383        if self.scored == 0 {
384            0.0
385        } else {
386            self.abstained as f64 / self.scored as f64
387        }
388    }
389}
390
391/// rank every signal-bearing function in A against all of B, using symbol names
392/// as ground truth. this is the headline accuracy measurement.
393pub fn eval(a: &[IndexedFunc], b: &[IndexedFunc]) -> EvalResult {
394    let bsig: Vec<&IndexedFunc> = b
395        .iter()
396        .filter(|f| f.fp.complexity >= MIN_COMPLEXITY && f.fp.shingles > 0 && f.name.is_some())
397        .collect();
398
399    let mut res = EvalResult {
400        scored: 0,
401        rank1: 0,
402        rr_sum: 0.0,
403        tp: 0,
404        fp: 0,
405        fn_: 0,
406        ranks: Vec::new(),
407        abstained: 0,
408    };
409
410    for fa in a {
411        if fa.fp.complexity < MIN_COMPLEXITY || fa.fp.shingles == 0 {
412            continue;
413        }
414        let aname = match &fa.name {
415            Some(n) => n.as_str(),
416            None => continue,
417        };
418        // only score functions that actually exist in B (a fair denominator)
419        if !bsig.iter().any(|f| f.name.as_deref() == Some(aname)) {
420            continue;
421        }
422        // rank B by similarity. bsig is prefiltered to named funcs, but use
423        // filter_map + first() so a later filter change can't unwrap or panic.
424        let mut scored: Vec<(f64, &str)> = bsig
425            .iter()
426            .filter_map(|f| f.name.as_deref().map(|n| (fa.fp.similarity(&f.fp), n)))
427            .collect();
428        scored.sort_by(|x, y| y.0.total_cmp(&x.0));
429        let Some(&(top_sim, top_name)) = scored.first() else {
430            continue; // unreachable: the twin check above guarantees a named hit
431        };
432        res.scored += 1;
433
434        if top_name == aname {
435            res.rank1 += 1;
436        }
437        if let Some(pos) = scored.iter().position(|(_, n)| *n == aname) {
438            res.rr_sum += 1.0 / (pos as f64 + 1.0);
439            res.ranks.push(pos + 1); // 1-based rank for recall@k
440        }
441
442        // threshold-based precision/recall on the top-1 call
443        let predicted_same = top_sim >= SAME_THRESH;
444        if !predicted_same {
445            res.abstained += 1; // below threshold, we'd decline to call it
446        }
447        let correct = top_name == aname;
448        match (predicted_same, correct) {
449            (true, true) => res.tp += 1,
450            (true, false) => res.fp += 1,
451            (false, true) => res.fn_ += 1,
452            (false, false) => {}
453        }
454    }
455    res
456}
457
458/// debug helper: micro-execute one named function and return its effect traces
459/// (one per seed/path). used by `fnprint dump` to see what the engine records.
460pub fn dump_traces(
461    bytes: &[u8],
462    name: &str,
463    cfg: Config,
464) -> Result<Vec<fnprint_trace::EffectTrace>> {
465    let loaded = fnprint_loader::load(bytes)?;
466    let image = &loaded.image;
467    let mut symbols: HashMap<u64, String> = HashMap::new();
468    for f in &loaded.funcs {
469        if let Some(n) = &f.name {
470            symbols.insert(f.entry, n.clone());
471        }
472    }
473    let f = loaded
474        .funcs
475        .iter()
476        .find(|f| f.name.as_deref() == Some(name))
477        .ok_or_else(|| anyhow::anyhow!("no function named {name}"))?;
478    let ex = MicroExec::new(cfg);
479    Ok(ex.run_explore(image, f, &symbols, &SEEDS))
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    // a tiny position-independent ELF-less path isn't easy here, so we test the
487    // matcher/eval logic on hand-built indexes instead. loader+emu are covered
488    // in their own crates and end-to-end by the bench harness.
489    fn ifunc(name: &str, sig_seed: u64, complexity: u32) -> IndexedFunc {
490        IndexedFunc {
491            name: Some(name.to_string()),
492            entry: 0,
493            source: FuncSource::Symtab,
494            fp: fnprint_sig::Fingerprint {
495                sig: (0..fnprint_sig::SIG_LEN as u64)
496                    .map(|i| i.wrapping_mul(sig_seed))
497                    .collect(),
498                shingles: 20,
499                complexity,
500                capped: false,
501            },
502        }
503    }
504
505    #[test]
506    fn identical_indexes_report_no_changes() {
507        let a = vec![ifunc("foo", 3, 10), ifunc("bar", 7, 10)];
508        let b = vec![ifunc("foo", 3, 10), ifunc("bar", 7, 10)];
509        let rep = match_by_name(&a, &b);
510        assert_eq!(rep.changed.len(), 0);
511        assert_eq!(rep.same, 2);
512    }
513
514    #[test]
515    fn changed_behavior_is_flagged() {
516        let a = vec![ifunc("foo", 3, 10)];
517        let b = vec![ifunc("foo", 999, 10)]; // very different sig
518        let rep = match_by_name(&a, &b);
519        assert_eq!(rep.changed.len(), 1);
520    }
521
522    #[test]
523    fn low_signal_not_called_changed() {
524        // same name, low complexity on one side -> low_signal, never "changed"
525        let a = vec![ifunc("foo", 3, 2)];
526        let b = vec![ifunc("foo", 999, 2)];
527        let rep = match_by_name(&a, &b);
528        assert_eq!(rep.changed.len(), 0);
529        assert_eq!(rep.low_signal, 1);
530    }
531
532    #[test]
533    fn triage_leans_to_matching_side() {
534        // vuln corpus holds the function at seed 3, patched holds it at seed 999.
535        // a target that behaves like seed 3 must come back Vulnerable, and one
536        // like seed 999 must come back Patched.
537        let vuln = Db::open_memory().unwrap();
538        vuln.insert("v1", Some("f"), 0x1000, "symtab", &ifunc("f", 3, 10).fp)
539            .unwrap();
540        let patched = Db::open_memory().unwrap();
541        patched
542            .insert("v2", Some("f"), 0x1000, "symtab", &ifunc("f", 999, 10).fp)
543            .unwrap();
544
545        let looks_vuln = triage(&[ifunc("x", 3, 10)], &vuln, &patched, 0.5, 0.1).unwrap();
546        assert_eq!(looks_vuln[0].verdict, Verdict::Vulnerable);
547        assert!(looks_vuln[0].margin() > 0.0);
548
549        let looks_patched = triage(&[ifunc("x", 999, 10)], &vuln, &patched, 0.5, 0.1).unwrap();
550        assert_eq!(looks_patched[0].verdict, Verdict::Patched);
551    }
552
553    #[test]
554    fn triage_abstains_when_nothing_close() {
555        // target matches neither side -> below min_sim -> Inconclusive
556        let vuln = Db::open_memory().unwrap();
557        vuln.insert("v1", Some("f"), 0x1000, "symtab", &ifunc("f", 3, 10).fp)
558            .unwrap();
559        let patched = Db::open_memory().unwrap();
560        patched
561            .insert("v2", Some("f"), 0x1000, "symtab", &ifunc("f", 999, 10).fp)
562            .unwrap();
563
564        let hits = triage(&[ifunc("x", 55555, 10)], &vuln, &patched, 0.9, 0.1).unwrap();
565        assert_eq!(hits[0].verdict, Verdict::Inconclusive);
566    }
567}