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;
30pub use fnprint_sig::SIG_LEN;
33
34pub 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
49pub fn index_bytes(bytes: &[u8], cfg: Config) -> Result<Vec<IndexedFunc>> {
51 let loaded = fnprint_loader::load(bytes)?;
52 let image = &loaded.image;
53
54 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 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
97pub 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 pub low_signal: usize,
113}
114
115pub 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 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
167pub struct Named {
170 pub entry: u64,
171 pub guess: String,
172 pub from_binary: String,
173 pub similarity: f64,
174}
175
176fn 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
203pub fn query_corpus(target: &[IndexedFunc], corpus: &Db, threshold: f64) -> Result<Vec<Named>> {
206 let named = corpus.all()?; 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
230pub enum Verdict {
231 Vulnerable,
233 Patched,
235 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 pub fn margin(&self) -> f64 {
252 self.vuln_sim - self.patched_sim
253 }
254}
255
256fn verdict_order(v: Verdict) -> u8 {
257 match v {
259 Verdict::Vulnerable => 0,
260 Verdict::Inconclusive => 1,
261 Verdict::Patched => 2,
262 }
263}
264
265pub 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
321pub struct EvalResult {
324 pub scored: usize,
326 pub rank1: usize,
328 pub rr_sum: f64,
330 pub tp: usize,
332 pub fp: usize,
333 pub fn_: usize,
335 pub ranks: Vec<usize>,
338 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 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 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
394pub 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 if !bsig.iter().any(|f| f.name.as_deref() == Some(aname)) {
423 continue;
424 }
425 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; };
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); }
444
445 let predicted_same = top_sim >= SAME_THRESH;
447 if !predicted_same {
448 res.abstained += 1; }
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
461pub 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 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)]; 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 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 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 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}