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;
17const SEEDS: [u64; 4] = [0, 0x9e3779b9, 0x1234_5678, 0xdead_beef];
19
20static EMU_LOCK: Mutex<()> = Mutex::new(());
29
30#[derive(serde::Serialize, serde::Deserialize)]
31pub struct IndexedFunc {
32 pub name: Option<String>,
33 pub entry: u64,
34 pub source: FuncSource,
35 pub fp: Fingerprint,
36}
37
38pub use fnprint_trace::EffectTrace;
41pub use fnprint_sig::SIG_LEN;
44
45pub fn warm_pool() {
49 let _: u64 = (0..256u64).into_par_iter().sum();
50}
51
52pub fn source_str(s: FuncSource) -> &'static str {
53 match s {
54 FuncSource::Symtab => "symtab",
55 FuncSource::DynSym => "dynsym",
56 FuncSource::EhFrame => "eh_frame",
57 }
58}
59
60pub fn index_bytes(bytes: &[u8], cfg: Config) -> Result<Vec<IndexedFunc>> {
62 let loaded = fnprint_loader::load(bytes)?;
63 let image = &loaded.image;
64
65 let mut symbols: HashMap<u64, String> = HashMap::new();
67 for f in &loaded.funcs {
68 if let Some(n) = &f.name {
69 symbols.insert(f.entry, n.clone());
70 }
71 }
72
73 let out: Vec<IndexedFunc> = loaded
74 .funcs
75 .par_iter()
76 .filter(|f| f.size > 0 && image.code_at(f.entry, 1).is_some())
77 .map(|f: &Func| {
78 let traces = {
83 let _g = EMU_LOCK.lock().unwrap_or_else(|e| e.into_inner());
84 let ex = MicroExec::new(cfg.clone());
85 ex.run_explore(image, f, &symbols, &SEEDS)
88 };
89 IndexedFunc {
90 name: f.name.clone(),
91 entry: f.entry,
92 source: f.source,
93 fp: Fingerprint::from_traces(&traces),
94 }
95 })
96 .collect();
97
98 Ok(out)
99}
100
101pub fn index_to_db(bytes: &[u8], binary: &str, db: &Db, cfg: Config) -> Result<usize> {
102 let funcs = index_bytes(bytes, cfg)?;
103 for f in &funcs {
104 db.insert(
105 binary,
106 f.name.as_deref(),
107 f.entry,
108 source_str(f.source),
109 &f.fp,
110 )?;
111 }
112 Ok(funcs.len())
113}
114
115pub struct Changed {
118 pub name: String,
119 pub similarity: f64,
120}
121
122#[derive(Default)]
123pub struct MatchReport {
124 pub same: usize,
125 pub changed: Vec<Changed>,
126 pub only_a: Vec<String>,
127 pub only_b: Vec<String>,
128 pub compared: usize,
129 pub low_signal: usize,
131}
132
133pub fn match_by_name(a: &[IndexedFunc], b: &[IndexedFunc]) -> MatchReport {
136 let mut bmap: HashMap<&str, &IndexedFunc> = HashMap::new();
137 for f in b {
138 if let Some(n) = &f.name {
139 bmap.insert(n.as_str(), f);
140 }
141 }
142 let mut amap: HashMap<&str, &IndexedFunc> = HashMap::new();
143 for f in a {
144 if let Some(n) = &f.name {
145 amap.insert(n.as_str(), f);
146 }
147 }
148
149 let mut rep = MatchReport::default();
150 for (name, fa) in &amap {
151 match bmap.get(name) {
152 Some(fb) => {
153 rep.compared += 1;
154 if fa.fp.complexity < MIN_COMPLEXITY || fb.fp.complexity < MIN_COMPLEXITY {
157 rep.low_signal += 1;
158 continue;
159 }
160 let sim = fa.fp.similarity(&fb.fp);
161 if sim >= SAME_THRESH {
162 rep.same += 1;
163 } else {
164 rep.changed.push(Changed {
165 name: name.to_string(),
166 similarity: sim,
167 });
168 }
169 }
170 None => rep.only_a.push(name.to_string()),
171 }
172 }
173 for name in bmap.keys() {
174 if !amap.contains_key(name) {
175 rep.only_b.push(name.to_string());
176 }
177 }
178 rep.changed
179 .sort_by(|x, y| x.similarity.total_cmp(&y.similarity));
180 rep.only_a.sort();
181 rep.only_b.sort();
182 rep
183}
184
185pub struct Named {
188 pub entry: u64,
189 pub guess: String,
190 pub from_binary: String,
191 pub similarity: f64,
192}
193
194fn best_in_corpus<C: Corpus>(
198 fp: &Fingerprint,
199 db: &C,
200 all: &[fnprint_db::FuncRec],
201) -> Result<Option<(f64, String, String)>> {
202 let cands = db.candidates(fp)?;
203 let pool: &[fnprint_db::FuncRec] = if cands.is_empty() { all } else { &cands };
204 let mut best: Option<(f64, String, String)> = None;
205 for c in pool {
206 if c.fp.complexity < MIN_COMPLEXITY {
207 continue;
208 }
209 let cname = match &c.name {
210 Some(n) => n,
211 None => continue,
212 };
213 let sim = fp.similarity(&c.fp);
214 if best.as_ref().map(|(s, _, _)| sim > *s).unwrap_or(true) {
215 best = Some((sim, cname.clone(), c.binary.clone()));
216 }
217 }
218 Ok(best)
219}
220
221pub fn query_corpus<C: Corpus>(
224 target: &[IndexedFunc],
225 corpus: &C,
226 threshold: f64,
227) -> Result<Vec<Named>> {
228 let named = corpus.all()?; let mut out = Vec::new();
230 for f in target {
231 if f.fp.complexity < MIN_COMPLEXITY || f.fp.shingles == 0 {
232 continue;
233 }
234 if let Some((sim, name, bin)) = best_in_corpus(&f.fp, corpus, &named)? {
235 if sim >= threshold {
236 out.push(Named {
237 entry: f.entry,
238 guess: name,
239 from_binary: bin,
240 similarity: sim,
241 });
242 }
243 }
244 }
245 out.sort_by(|a, b| b.similarity.total_cmp(&a.similarity));
246 Ok(out)
247}
248
249#[derive(Clone, Copy, PartialEq, Eq, Debug)]
252pub enum Verdict {
253 Vulnerable,
255 Patched,
257 Inconclusive,
259}
260
261pub struct TriageHit {
262 pub entry: u64,
263 pub verdict: Verdict,
264 pub vuln_sim: f64,
265 pub vuln_name: String,
266 pub patched_sim: f64,
267 pub patched_name: String,
268}
269
270impl TriageHit {
271 pub fn margin(&self) -> f64 {
274 self.vuln_sim - self.patched_sim
275 }
276}
277
278fn verdict_order(v: Verdict) -> u8 {
279 match v {
281 Verdict::Vulnerable => 0,
282 Verdict::Inconclusive => 1,
283 Verdict::Patched => 2,
284 }
285}
286
287pub fn triage<C: Corpus>(
296 target: &[IndexedFunc],
297 vuln: &C,
298 patched: &C,
299 min_sim: f64,
300 margin: f64,
301) -> Result<Vec<TriageHit>> {
302 let vuln_all = vuln.all()?;
303 let patched_all = patched.all()?;
304 let mut out = Vec::new();
305 for f in target {
306 if f.fp.complexity < MIN_COMPLEXITY || f.fp.shingles == 0 {
307 continue;
308 }
309 let (vuln_sim, vuln_name) = best_in_corpus(&f.fp, vuln, &vuln_all)?
310 .map(|(s, n, _)| (s, n))
311 .unwrap_or((0.0, String::new()));
312 let (patched_sim, patched_name) = best_in_corpus(&f.fp, patched, &patched_all)?
313 .map(|(s, n, _)| (s, n))
314 .unwrap_or((0.0, String::new()));
315
316 let top = vuln_sim.max(patched_sim);
317 let verdict = if top < min_sim {
318 Verdict::Inconclusive
319 } else if vuln_sim - patched_sim >= margin {
320 Verdict::Vulnerable
321 } else if patched_sim - vuln_sim >= margin {
322 Verdict::Patched
323 } else {
324 Verdict::Inconclusive
325 };
326 out.push(TriageHit {
327 entry: f.entry,
328 verdict,
329 vuln_sim,
330 vuln_name,
331 patched_sim,
332 patched_name,
333 });
334 }
335 out.sort_by(|a, b| {
336 verdict_order(a.verdict)
337 .cmp(&verdict_order(b.verdict))
338 .then(b.vuln_sim.total_cmp(&a.vuln_sim))
339 });
340 Ok(out)
341}
342
343pub struct EvalResult {
346 pub scored: usize,
348 pub rank1: usize,
350 pub rr_sum: f64,
352 pub tp: usize,
354 pub fp: usize,
355 pub fn_: usize,
357 pub ranks: Vec<usize>,
360 pub abstained: usize,
363}
364
365impl EvalResult {
366 pub fn rank1_acc(&self) -> f64 {
367 if self.scored == 0 {
368 0.0
369 } else {
370 self.rank1 as f64 / self.scored as f64
371 }
372 }
373 pub fn mrr(&self) -> f64 {
374 if self.scored == 0 {
375 0.0
376 } else {
377 self.rr_sum / self.scored as f64
378 }
379 }
380 pub fn precision(&self) -> f64 {
381 let d = self.tp + self.fp;
382 if d == 0 {
383 0.0
384 } else {
385 self.tp as f64 / d as f64
386 }
387 }
388 pub fn recall(&self) -> f64 {
389 let d = self.tp + self.fn_;
390 if d == 0 {
391 0.0
392 } else {
393 self.tp as f64 / d as f64
394 }
395 }
396 pub fn recall_at(&self, k: usize) -> f64 {
399 if self.scored == 0 {
400 return 0.0;
401 }
402 let hits = self.ranks.iter().filter(|&&r| r <= k).count();
403 hits as f64 / self.scored as f64
404 }
405 pub fn abstain_rate(&self) -> f64 {
408 if self.scored == 0 {
409 0.0
410 } else {
411 self.abstained as f64 / self.scored as f64
412 }
413 }
414}
415
416pub fn eval(a: &[IndexedFunc], b: &[IndexedFunc]) -> EvalResult {
419 let bsig: Vec<&IndexedFunc> = b
420 .iter()
421 .filter(|f| f.fp.complexity >= MIN_COMPLEXITY && f.fp.shingles > 0 && f.name.is_some())
422 .collect();
423
424 let mut res = EvalResult {
425 scored: 0,
426 rank1: 0,
427 rr_sum: 0.0,
428 tp: 0,
429 fp: 0,
430 fn_: 0,
431 ranks: Vec::new(),
432 abstained: 0,
433 };
434
435 for fa in a {
436 if fa.fp.complexity < MIN_COMPLEXITY || fa.fp.shingles == 0 {
437 continue;
438 }
439 let aname = match &fa.name {
440 Some(n) => n.as_str(),
441 None => continue,
442 };
443 if !bsig.iter().any(|f| f.name.as_deref() == Some(aname)) {
445 continue;
446 }
447 let mut scored: Vec<(f64, &str)> = bsig
450 .iter()
451 .filter_map(|f| f.name.as_deref().map(|n| (fa.fp.similarity(&f.fp), n)))
452 .collect();
453 scored.sort_by(|x, y| y.0.total_cmp(&x.0));
454 let Some(&(top_sim, top_name)) = scored.first() else {
455 continue; };
457 res.scored += 1;
458
459 if top_name == aname {
460 res.rank1 += 1;
461 }
462 if let Some(pos) = scored.iter().position(|(_, n)| *n == aname) {
463 res.rr_sum += 1.0 / (pos as f64 + 1.0);
464 res.ranks.push(pos + 1); }
466
467 let predicted_same = top_sim >= SAME_THRESH;
469 if !predicted_same {
470 res.abstained += 1; }
472 let correct = top_name == aname;
473 match (predicted_same, correct) {
474 (true, true) => res.tp += 1,
475 (true, false) => res.fp += 1,
476 (false, true) => res.fn_ += 1,
477 (false, false) => {}
478 }
479 }
480 res
481}
482
483pub fn dump_traces(
486 bytes: &[u8],
487 name: &str,
488 cfg: Config,
489) -> Result<Vec<fnprint_trace::EffectTrace>> {
490 let loaded = fnprint_loader::load(bytes)?;
491 let image = &loaded.image;
492 let mut symbols: HashMap<u64, String> = HashMap::new();
493 for f in &loaded.funcs {
494 if let Some(n) = &f.name {
495 symbols.insert(f.entry, n.clone());
496 }
497 }
498 let f = loaded
499 .funcs
500 .iter()
501 .find(|f| f.name.as_deref() == Some(name))
502 .ok_or_else(|| anyhow::anyhow!("no function named {name}"))?;
503 let ex = MicroExec::new(cfg);
504 Ok(ex.run_explore(image, f, &symbols, &SEEDS))
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 fn ifunc(name: &str, sig_seed: u64, complexity: u32) -> IndexedFunc {
515 IndexedFunc {
516 name: Some(name.to_string()),
517 entry: 0,
518 source: FuncSource::Symtab,
519 fp: fnprint_sig::Fingerprint {
520 sig: (0..fnprint_sig::SIG_LEN as u64)
521 .map(|i| i.wrapping_mul(sig_seed))
522 .collect(),
523 shingles: 20,
524 complexity,
525 capped: false,
526 },
527 }
528 }
529
530 #[test]
531 fn identical_indexes_report_no_changes() {
532 let a = vec![ifunc("foo", 3, 10), ifunc("bar", 7, 10)];
533 let b = vec![ifunc("foo", 3, 10), ifunc("bar", 7, 10)];
534 let rep = match_by_name(&a, &b);
535 assert_eq!(rep.changed.len(), 0);
536 assert_eq!(rep.same, 2);
537 }
538
539 #[test]
540 fn changed_behavior_is_flagged() {
541 let a = vec![ifunc("foo", 3, 10)];
542 let b = vec![ifunc("foo", 999, 10)]; let rep = match_by_name(&a, &b);
544 assert_eq!(rep.changed.len(), 1);
545 }
546
547 #[test]
548 fn low_signal_not_called_changed() {
549 let a = vec![ifunc("foo", 3, 2)];
551 let b = vec![ifunc("foo", 999, 2)];
552 let rep = match_by_name(&a, &b);
553 assert_eq!(rep.changed.len(), 0);
554 assert_eq!(rep.low_signal, 1);
555 }
556
557 #[test]
558 fn triage_leans_to_matching_side() {
559 let vuln = Db::open_memory().unwrap();
563 vuln.insert("v1", Some("f"), 0x1000, "symtab", &ifunc("f", 3, 10).fp)
564 .unwrap();
565 let patched = Db::open_memory().unwrap();
566 patched
567 .insert("v2", Some("f"), 0x1000, "symtab", &ifunc("f", 999, 10).fp)
568 .unwrap();
569
570 let looks_vuln = triage(&[ifunc("x", 3, 10)], &vuln, &patched, 0.5, 0.1).unwrap();
571 assert_eq!(looks_vuln[0].verdict, Verdict::Vulnerable);
572 assert!(looks_vuln[0].margin() > 0.0);
573
574 let looks_patched = triage(&[ifunc("x", 999, 10)], &vuln, &patched, 0.5, 0.1).unwrap();
575 assert_eq!(looks_patched[0].verdict, Verdict::Patched);
576 }
577
578 #[test]
579 fn triage_abstains_when_nothing_close() {
580 let vuln = Db::open_memory().unwrap();
582 vuln.insert("v1", Some("f"), 0x1000, "symtab", &ifunc("f", 3, 10).fp)
583 .unwrap();
584 let patched = Db::open_memory().unwrap();
585 patched
586 .insert("v2", Some("f"), 0x1000, "symtab", &ifunc("f", 999, 10).fp)
587 .unwrap();
588
589 let hits = triage(&[ifunc("x", 55555, 10)], &vuln, &patched, 0.9, 0.1).unwrap();
590 assert_eq!(hits[0].verdict, Verdict::Inconclusive);
591 }
592}