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