1use std::collections::HashMap;
4use std::sync::Mutex;
5
6use anyhow::Result;
7use fnprint_db::{Corpus, Db};
8use fnprint_emu::{Config, MicroExec};
9use fnprint_loader::{Func, FuncSource};
10use fnprint_sig::Fingerprint;
11use rayon::prelude::*;
12
13pub const MIN_COMPLEXITY: u32 = 4;
15pub const SAME_THRESH: f64 = 0.88;
17pub const LOW_COVERAGE_ADVISORY: f32 = 0.15;
26const SEEDS: [u64; 4] = [0, 0x9e3779b9, 0x1234_5678, 0xdead_beef];
28
29static EMU_LOCK: Mutex<()> = Mutex::new(());
38
39#[derive(serde::Serialize, serde::Deserialize)]
40pub struct IndexedFunc {
41 pub name: Option<String>,
42 pub entry: u64,
43 pub source: FuncSource,
44 pub fp: Fingerprint,
45 #[serde(default = "default_coverage")]
49 pub coverage: f32,
50}
51
52fn default_coverage() -> f32 {
57 1.0
58}
59
60pub use fnprint_trace::EffectTrace;
63pub use fnprint_sig::SIG_LEN;
66
67pub fn warm_pool() {
71 let _: u64 = (0..256u64).into_par_iter().sum();
72}
73
74pub fn source_str(s: FuncSource) -> &'static str {
75 match s {
76 FuncSource::Symtab => "symtab",
77 FuncSource::DynSym => "dynsym",
78 FuncSource::EhFrame => "eh_frame",
79 }
80}
81
82pub fn index_bytes(bytes: &[u8], cfg: Config) -> Result<Vec<IndexedFunc>> {
84 let loaded = fnprint_loader::load(bytes)?;
85 let image = &loaded.image;
86
87 let mut symbols: HashMap<u64, String> = HashMap::new();
89 for f in &loaded.funcs {
90 if let Some(n) = &f.name {
91 symbols.insert(f.entry, n.clone());
92 }
93 }
94
95 let out: Vec<IndexedFunc> = loaded
96 .funcs
97 .par_iter()
98 .filter(|f| f.size > 0 && image.code_at(f.entry, 1).is_some())
99 .map(|f: &Func| {
100 let traces = {
105 let _g = EMU_LOCK.lock().unwrap_or_else(|e| e.into_inner());
106 let ex = MicroExec::new(cfg.clone());
107 ex.run_explore(image, f, &symbols, &SEEDS)
110 };
111 let coverage = traces.iter().map(|t| t.coverage).fold(0.0f32, f32::max);
115 IndexedFunc {
116 name: f.name.clone(),
117 entry: f.entry,
118 source: f.source,
119 fp: Fingerprint::from_traces(&traces),
120 coverage,
121 }
122 })
123 .collect();
124
125 Ok(out)
126}
127
128pub fn index_to_db(bytes: &[u8], binary: &str, db: &Db, cfg: Config) -> Result<usize> {
129 let funcs = index_bytes(bytes, cfg)?;
130 for f in &funcs {
131 db.insert(
132 binary,
133 f.name.as_deref(),
134 f.entry,
135 source_str(f.source),
136 &f.fp,
137 )?;
138 }
139 Ok(funcs.len())
140}
141
142pub struct Changed {
145 pub name: String,
146 pub similarity: f64,
147}
148
149#[derive(Default)]
150pub struct MatchReport {
151 pub same: usize,
152 pub changed: Vec<Changed>,
153 pub only_a: Vec<String>,
154 pub only_b: Vec<String>,
155 pub compared: usize,
156 pub low_signal: usize,
158}
159
160pub fn match_by_name(a: &[IndexedFunc], b: &[IndexedFunc]) -> MatchReport {
163 let mut bmap: HashMap<&str, &IndexedFunc> = HashMap::new();
164 for f in b {
165 if let Some(n) = &f.name {
166 bmap.insert(n.as_str(), f);
167 }
168 }
169 let mut amap: HashMap<&str, &IndexedFunc> = HashMap::new();
170 for f in a {
171 if let Some(n) = &f.name {
172 amap.insert(n.as_str(), f);
173 }
174 }
175
176 let mut rep = MatchReport::default();
177 for (name, fa) in &amap {
178 match bmap.get(name) {
179 Some(fb) => {
180 rep.compared += 1;
181 if fa.fp.complexity < MIN_COMPLEXITY || fb.fp.complexity < MIN_COMPLEXITY {
184 rep.low_signal += 1;
185 continue;
186 }
187 let sim = fa.fp.similarity(&fb.fp);
188 if sim >= SAME_THRESH {
189 rep.same += 1;
190 } else {
191 rep.changed.push(Changed {
192 name: name.to_string(),
193 similarity: sim,
194 });
195 }
196 }
197 None => rep.only_a.push(name.to_string()),
198 }
199 }
200 for name in bmap.keys() {
201 if !amap.contains_key(name) {
202 rep.only_b.push(name.to_string());
203 }
204 }
205 rep.changed
206 .sort_by(|x, y| x.similarity.total_cmp(&y.similarity));
207 rep.only_a.sort();
208 rep.only_b.sort();
209 rep
210}
211
212pub struct Named {
215 pub entry: u64,
216 pub guess: String,
217 pub from_binary: String,
218 pub similarity: f64,
219}
220
221fn best_in_corpus<C: Corpus>(
225 fp: &Fingerprint,
226 db: &C,
227 all: &[fnprint_db::FuncRec],
228) -> Result<Option<(f64, String, String)>> {
229 let cands = db.candidates(fp)?;
230 let pool: &[fnprint_db::FuncRec] = if cands.is_empty() { all } else { &cands };
231 let mut best: Option<(f64, String, String)> = None;
232 for c in pool {
233 if c.fp.complexity < MIN_COMPLEXITY {
234 continue;
235 }
236 let cname = match &c.name {
237 Some(n) => n,
238 None => continue,
239 };
240 let sim = fp.similarity(&c.fp);
241 let better = match best.as_ref() {
246 None => true,
247 Some((s, bn, bb)) => {
248 sim > *s
249 || (sim == *s
250 && (cname.as_str(), c.binary.as_str()) < (bn.as_str(), bb.as_str()))
251 }
252 };
253 if better {
254 best = Some((sim, cname.clone(), c.binary.clone()));
255 }
256 }
257 Ok(best)
258}
259
260pub fn query_corpus<C: Corpus>(
263 target: &[IndexedFunc],
264 corpus: &C,
265 threshold: f64,
266) -> Result<Vec<Named>> {
267 let named = corpus.all()?; let mut out = Vec::new();
269 for f in target {
270 if f.fp.complexity < MIN_COMPLEXITY || f.fp.shingles == 0 {
271 continue;
272 }
273 if let Some((sim, name, bin)) = best_in_corpus(&f.fp, corpus, &named)? {
274 if sim >= threshold {
275 out.push(Named {
276 entry: f.entry,
277 guess: name,
278 from_binary: bin,
279 similarity: sim,
280 });
281 }
282 }
283 }
284 out.sort_by(|a, b| b.similarity.total_cmp(&a.similarity));
285 Ok(out)
286}
287
288#[derive(Clone, Copy, PartialEq, Eq, Debug)]
291pub enum Verdict {
292 Vulnerable,
294 Patched,
296 Inconclusive,
298}
299
300pub struct TriageHit {
301 pub entry: u64,
302 pub verdict: Verdict,
303 pub vuln_sim: f64,
304 pub vuln_name: String,
305 pub patched_sim: f64,
306 pub patched_name: String,
307 pub coverage: f32,
312}
313
314impl TriageHit {
315 pub fn margin(&self) -> f64 {
318 self.vuln_sim - self.patched_sim
319 }
320}
321
322fn verdict_order(v: Verdict) -> u8 {
323 match v {
325 Verdict::Vulnerable => 0,
326 Verdict::Inconclusive => 1,
327 Verdict::Patched => 2,
328 }
329}
330
331pub fn triage<C: Corpus>(
340 target: &[IndexedFunc],
341 vuln: &C,
342 patched: &C,
343 min_sim: f64,
344 margin: f64,
345) -> Result<Vec<TriageHit>> {
346 let vuln_all = vuln.all()?;
347 let patched_all = patched.all()?;
348 let mut out = Vec::new();
349 for f in target {
350 if f.fp.complexity < MIN_COMPLEXITY || f.fp.shingles == 0 {
351 continue;
352 }
353 let (vuln_sim, vuln_name) = best_in_corpus(&f.fp, vuln, &vuln_all)?
354 .map(|(s, n, _)| (s, n))
355 .unwrap_or((0.0, String::new()));
356 let (patched_sim, patched_name) = best_in_corpus(&f.fp, patched, &patched_all)?
357 .map(|(s, n, _)| (s, n))
358 .unwrap_or((0.0, String::new()));
359
360 let top = vuln_sim.max(patched_sim);
361 let verdict = if top < min_sim {
362 Verdict::Inconclusive
363 } else if vuln_sim - patched_sim >= margin {
364 Verdict::Vulnerable
365 } else if patched_sim - vuln_sim >= margin {
366 Verdict::Patched
367 } else {
368 Verdict::Inconclusive
369 };
370 out.push(TriageHit {
371 entry: f.entry,
372 verdict,
373 vuln_sim,
374 vuln_name,
375 patched_sim,
376 patched_name,
377 coverage: f.coverage,
378 });
379 }
380 out.sort_by(|a, b| {
381 verdict_order(a.verdict)
382 .cmp(&verdict_order(b.verdict))
383 .then(b.vuln_sim.total_cmp(&a.vuln_sim))
384 });
385 Ok(out)
386}
387
388pub struct EvalResult {
391 pub scored: usize,
393 pub rank1: usize,
395 pub rr_sum: f64,
397 pub tp: usize,
399 pub fp: usize,
400 pub fn_: usize,
402 pub ranks: Vec<usize>,
405 pub abstained: usize,
408}
409
410impl EvalResult {
411 pub fn rank1_acc(&self) -> f64 {
412 if self.scored == 0 {
413 0.0
414 } else {
415 self.rank1 as f64 / self.scored as f64
416 }
417 }
418 pub fn mrr(&self) -> f64 {
419 if self.scored == 0 {
420 0.0
421 } else {
422 self.rr_sum / self.scored as f64
423 }
424 }
425 pub fn precision(&self) -> f64 {
426 let d = self.tp + self.fp;
427 if d == 0 {
428 0.0
429 } else {
430 self.tp as f64 / d as f64
431 }
432 }
433 pub fn recall(&self) -> f64 {
434 let d = self.tp + self.fn_;
435 if d == 0 {
436 0.0
437 } else {
438 self.tp as f64 / d as f64
439 }
440 }
441 pub fn recall_at(&self, k: usize) -> f64 {
444 if self.scored == 0 {
445 return 0.0;
446 }
447 let hits = self.ranks.iter().filter(|&&r| r <= k).count();
448 hits as f64 / self.scored as f64
449 }
450 pub fn abstain_rate(&self) -> f64 {
453 if self.scored == 0 {
454 0.0
455 } else {
456 self.abstained as f64 / self.scored as f64
457 }
458 }
459}
460
461pub fn eval(a: &[IndexedFunc], b: &[IndexedFunc]) -> EvalResult {
464 let bsig: Vec<&IndexedFunc> = b
465 .iter()
466 .filter(|f| f.fp.complexity >= MIN_COMPLEXITY && f.fp.shingles > 0 && f.name.is_some())
467 .collect();
468
469 let mut res = EvalResult {
470 scored: 0,
471 rank1: 0,
472 rr_sum: 0.0,
473 tp: 0,
474 fp: 0,
475 fn_: 0,
476 ranks: Vec::new(),
477 abstained: 0,
478 };
479
480 for fa in a {
481 if fa.fp.complexity < MIN_COMPLEXITY || fa.fp.shingles == 0 {
482 continue;
483 }
484 let aname = match &fa.name {
485 Some(n) => n.as_str(),
486 None => continue,
487 };
488 if !bsig.iter().any(|f| f.name.as_deref() == Some(aname)) {
490 continue;
491 }
492 let mut scored: Vec<(f64, &str)> = bsig
495 .iter()
496 .filter_map(|f| f.name.as_deref().map(|n| (fa.fp.similarity(&f.fp), n)))
497 .collect();
498 scored.sort_by(|x, y| y.0.total_cmp(&x.0));
499 let Some(&(top_sim, top_name)) = scored.first() else {
500 continue; };
502 res.scored += 1;
503
504 if top_name == aname {
505 res.rank1 += 1;
506 }
507 if let Some(pos) = scored.iter().position(|(_, n)| *n == aname) {
508 res.rr_sum += 1.0 / (pos as f64 + 1.0);
509 res.ranks.push(pos + 1); }
511
512 let predicted_same = top_sim >= SAME_THRESH;
514 if !predicted_same {
515 res.abstained += 1; }
517 let correct = top_name == aname;
518 match (predicted_same, correct) {
519 (true, true) => res.tp += 1,
520 (true, false) => res.fp += 1,
521 (false, true) => res.fn_ += 1,
522 (false, false) => {}
523 }
524 }
525 res
526}
527
528pub fn dump_traces(
531 bytes: &[u8],
532 name: &str,
533 cfg: Config,
534) -> Result<Vec<fnprint_trace::EffectTrace>> {
535 let loaded = fnprint_loader::load(bytes)?;
536 let image = &loaded.image;
537 let mut symbols: HashMap<u64, String> = HashMap::new();
538 for f in &loaded.funcs {
539 if let Some(n) = &f.name {
540 symbols.insert(f.entry, n.clone());
541 }
542 }
543 let f = loaded
544 .funcs
545 .iter()
546 .find(|f| f.name.as_deref() == Some(name))
547 .ok_or_else(|| anyhow::anyhow!("no function named {name}"))?;
548 let ex = MicroExec::new(cfg);
549 Ok(ex.run_explore(image, f, &symbols, &SEEDS))
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555
556 fn ifunc(name: &str, sig_seed: u64, complexity: u32) -> IndexedFunc {
560 IndexedFunc {
561 name: Some(name.to_string()),
562 entry: 0,
563 source: FuncSource::Symtab,
564 fp: fnprint_sig::Fingerprint {
565 sig: (0..fnprint_sig::SIG_LEN as u64)
566 .map(|i| i.wrapping_mul(sig_seed))
567 .collect(),
568 shingles: 20,
569 complexity,
570 capped: false,
571 },
572 coverage: 1.0,
573 }
574 }
575
576 #[test]
577 fn identical_indexes_report_no_changes() {
578 let a = vec![ifunc("foo", 3, 10), ifunc("bar", 7, 10)];
579 let b = vec![ifunc("foo", 3, 10), ifunc("bar", 7, 10)];
580 let rep = match_by_name(&a, &b);
581 assert_eq!(rep.changed.len(), 0);
582 assert_eq!(rep.same, 2);
583 }
584
585 #[test]
586 fn changed_behavior_is_flagged() {
587 let a = vec![ifunc("foo", 3, 10)];
588 let b = vec![ifunc("foo", 999, 10)]; let rep = match_by_name(&a, &b);
590 assert_eq!(rep.changed.len(), 1);
591 }
592
593 #[test]
594 fn low_signal_not_called_changed() {
595 let a = vec![ifunc("foo", 3, 2)];
597 let b = vec![ifunc("foo", 999, 2)];
598 let rep = match_by_name(&a, &b);
599 assert_eq!(rep.changed.len(), 0);
600 assert_eq!(rep.low_signal, 1);
601 }
602
603 #[test]
604 fn triage_leans_to_matching_side() {
605 let vuln = Db::open_memory().unwrap();
609 vuln.insert("v1", Some("f"), 0x1000, "symtab", &ifunc("f", 3, 10).fp)
610 .unwrap();
611 let patched = Db::open_memory().unwrap();
612 patched
613 .insert("v2", Some("f"), 0x1000, "symtab", &ifunc("f", 999, 10).fp)
614 .unwrap();
615
616 let looks_vuln = triage(&[ifunc("x", 3, 10)], &vuln, &patched, 0.5, 0.1).unwrap();
617 assert_eq!(looks_vuln[0].verdict, Verdict::Vulnerable);
618 assert!(looks_vuln[0].margin() > 0.0);
619
620 let looks_patched = triage(&[ifunc("x", 999, 10)], &vuln, &patched, 0.5, 0.1).unwrap();
621 assert_eq!(looks_patched[0].verdict, Verdict::Patched);
622 }
623
624 #[test]
625 fn low_coverage_is_advisory_not_a_gate() {
626 let vuln = Db::open_memory().unwrap();
631 vuln.insert("v1", Some("f"), 0x1000, "symtab", &ifunc("f", 3, 10).fp)
632 .unwrap();
633 let patched = Db::open_memory().unwrap();
634 patched
635 .insert("v2", Some("f"), 0x1000, "symtab", &ifunc("f", 999, 10).fp)
636 .unwrap();
637
638 let mut t = ifunc("x", 3, 10);
639 t.coverage = 0.0;
640 let hits = triage(&[t], &vuln, &patched, 0.5, 0.1).unwrap();
641 assert_eq!(hits[0].verdict, Verdict::Vulnerable);
642 assert_eq!(
643 hits[0].coverage, 0.0,
644 "coverage carried through for display"
645 );
646 }
647
648 #[test]
649 fn triage_abstains_when_nothing_close() {
650 let vuln = Db::open_memory().unwrap();
652 vuln.insert("v1", Some("f"), 0x1000, "symtab", &ifunc("f", 3, 10).fp)
653 .unwrap();
654 let patched = Db::open_memory().unwrap();
655 patched
656 .insert("v2", Some("f"), 0x1000, "symtab", &ifunc("f", 999, 10).fp)
657 .unwrap();
658
659 let hits = triage(&[ifunc("x", 55555, 10)], &vuln, &patched, 0.9, 0.1).unwrap();
660 assert_eq!(hits[0].verdict, Verdict::Inconclusive);
661 }
662}