1use 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
12pub const MIN_COMPLEXITY: u32 = 4;
14pub const SAME_THRESH: f64 = 0.88;
16const 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
27pub use fnprint_trace::EffectTrace;
30
31pub 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
46pub fn index_bytes(bytes: &[u8], cfg: Config) -> Result<Vec<IndexedFunc>> {
48 let loaded = fnprint_loader::load(bytes)?;
49 let image = &loaded.image;
50
51 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 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
94pub 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 pub low_signal: usize,
110}
111
112pub 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 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
164pub struct Named {
167 pub entry: u64,
168 pub guess: String,
169 pub from_binary: String,
170 pub similarity: f64,
171}
172
173fn 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
200pub fn query_corpus(target: &[IndexedFunc], corpus: &Db, threshold: f64) -> Result<Vec<Named>> {
203 let named = corpus.all()?; 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
227pub enum Verdict {
228 Vulnerable,
230 Patched,
232 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 pub fn margin(&self) -> f64 {
249 self.vuln_sim - self.patched_sim
250 }
251}
252
253fn verdict_order(v: Verdict) -> u8 {
254 match v {
256 Verdict::Vulnerable => 0,
257 Verdict::Inconclusive => 1,
258 Verdict::Patched => 2,
259 }
260}
261
262pub 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
318pub struct EvalResult {
321 pub scored: usize,
323 pub rank1: usize,
325 pub rr_sum: f64,
327 pub tp: usize,
329 pub fp: usize,
330 pub fn_: usize,
332 pub ranks: Vec<usize>,
335 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 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 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
391pub 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 if !bsig.iter().any(|f| f.name.as_deref() == Some(aname)) {
420 continue;
421 }
422 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; };
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); }
441
442 let predicted_same = top_sim >= SAME_THRESH;
444 if !predicted_same {
445 res.abstained += 1; }
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
458pub 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 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)]; 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 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 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 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}