rto_exec/crossref.rs
1//! Cross-referencing dependency findings across analyzers.
2//!
3//! `cargo-audit` and `osv-scanner` both read `Cargo.lock`, and OSV.dev ingests
4//! the `RustSec` database — so the same Rust advisory arrives twice, under
5//! `finding:cargo-audit:…` and `finding:osv-scanner:…`. [ADR-0018] v1.1 decides
6//! what to do about it: **keep both findings and cross-reference them at the
7//! reporting layer**. This module is that reporting layer's join.
8//!
9//! [ADR-0018]: https://github.com/OffeneDatenmodellierung/Roteiro/blob/main/docs/adr/0018-analyzer-coverage-matrix.md
10//!
11//! # Nothing here filters, merges, or renumbers
12//!
13//! A [`Correspondence`] is a *view over* findings, not a replacement for them.
14//! Every finding stays in its own layer, keyed as its own analyzer named it, and
15//! every layer is still replaced wholesale per analyzer. The count of findings is
16//! unchanged by anything in this file, which is the specific failure ADR-0018
17//! names: "never a merged super-finding, and never a count that silently halves".
18//! A duplicate pair reads as *one advisory confirmed by two analyzers*, with
19//! both [`Correspondence::keys`] still addressable — so a reader who fixes the
20//! advisory watches both disappear.
21//!
22//! # The join needs no invention
23//!
24//! Both upstreams publish the identifiers already. OSV keys a `RustSec`-derived
25//! record by *the RUSTSEC id itself* (`RUSTSEC-2020-0071` resolves, carrying
26//! `aliases: ["CVE-2020-26235", "GHSA-wcg3-cvx6-7396"]`), and `cargo-audit`'s
27//! adapter stores the advisory's `aliases` and `related` verbatim in `meta`. So
28//! two findings correspond when their **identifier sets intersect** — the
29//! RUSTSEC id where both name it, any shared CVE or GHSA id otherwise. That is a
30//! deterministic join over published identifiers: no similarity matching, no
31//! heuristic, and nothing that needs a confidence score.
32//!
33//! # Why the package must match too
34//!
35//! Identifier intersection alone over-merges. A single CVE is regularly assigned
36//! to several packages, and joining on it alone would fuse advisories about
37//! different crates into one row. Correspondence therefore also requires the
38//! same package **at the same version**, which both adapters record in
39//! `meta.package` and `meta.version`. A finding without those — every SAST
40//! finding — is not on the dependency axis and does not take part at all.
41//!
42//! # "Present in one, absent in the other" is a real state
43//!
44//! The two analyzers pin their databases independently and are prefetched at
45//! different times, so they will legitimately disagree for a window, and there
46//! are advisory kinds only one of them can ever carry (`cargo-audit` learns
47//! *yanked* from the registry index, which is not an advisory and is not in OSV
48//! at all). A [`Correspondence`] reported by one analyzer is therefore a normal
49//! result, not a defect: [`Correspondence::confirmed_by`] answers *how many* said
50//! so, and the caller renders that rather than treating a single source as a
51//! discrepancy.
52//!
53//! @rto:0012
54//! @rto:0018
55
56use std::collections::BTreeMap;
57
58use rto_graph::{Finding, FindingsLayer, Severity};
59
60/// One advisory, and every finding that reported it.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Correspondence {
63 /// The advisory's canonical identifier: the RUSTSEC id where any member
64 /// names one — that is the id ADR-0018 calls the join key and the one a Rust
65 /// developer recognises — and otherwise the lowest identifier in the set.
66 pub advisory: String,
67 /// Every identifier this advisory is published under, across all members.
68 pub aliases: Vec<String>,
69 /// The package it is about.
70 pub package: String,
71 /// The version of that package that was resolved.
72 pub version: String,
73 /// One entry per reporting finding, ordered by analyzer then key.
74 pub reports: Vec<Report>,
75}
76
77/// One analyzer's report of an advisory.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Report {
80 /// The analyzer that reported it.
81 pub analyzer: String,
82 /// The finding's rendered [`rto_graph::FindingKey`] — still addressable, and
83 /// still owned by its own layer.
84 pub key: String,
85 /// The rule or advisory id *this* analyzer fired, which is not always the
86 /// canonical one: `osv-scanner` may name an advisory by its GHSA id where
87 /// `cargo-audit` names it by its RUSTSEC id.
88 pub rule: String,
89 /// The severity that analyzer assigned.
90 pub severity: Severity,
91}
92
93impl Correspondence {
94 /// How many distinct analyzers reported this advisory.
95 ///
96 /// Two or more is agreement between independent sources, which ADR-0018
97 /// keeps as evidence rather than tidying away. One is a normal state, not a
98 /// discrepancy — see the module docs.
99 #[must_use]
100 pub fn confirmed_by(&self) -> usize {
101 let mut analyzers: Vec<&str> = self.reports.iter().map(|r| r.analyzer.as_str()).collect();
102 analyzers.sort_unstable();
103 analyzers.dedup();
104 analyzers.len()
105 }
106
107 /// The distinct analyzers that reported it, sorted.
108 #[must_use]
109 pub fn analyzers(&self) -> Vec<&str> {
110 let mut analyzers: Vec<&str> = self.reports.iter().map(|r| r.analyzer.as_str()).collect();
111 analyzers.sort_unstable();
112 analyzers.dedup();
113 analyzers
114 }
115
116 /// Every finding key that reported it. Both halves of a duplicate pair stay
117 /// addressable; neither is superseded by this view.
118 #[must_use]
119 pub fn keys(&self) -> Vec<&str> {
120 self.reports.iter().map(|r| r.key.as_str()).collect()
121 }
122}
123
124/// Cross-reference the dependency findings across `layers`.
125///
126/// Returns one [`Correspondence`] per advisory-and-package, ordered by package,
127/// then version, then advisory — a stable order, so two runs over the same store
128/// render identically. Findings that are not on the dependency axis (no
129/// `meta.package`) are absent, because there is nothing about a SAST finding for
130/// a dependency scanner to agree with.
131///
132/// The findings themselves are neither modified nor consumed: this borrows them
133/// and describes what it saw.
134#[must_use]
135pub fn cross_reference(layers: &[FindingsLayer]) -> Vec<Correspondence> {
136 let candidates: Vec<Candidate<'_>> = layers
137 .iter()
138 .flat_map(|layer| {
139 layer
140 .findings
141 .iter()
142 .filter_map(|finding| Candidate::of(&layer.run.analyzer, finding))
143 })
144 .collect();
145
146 // Bucket by package and version first. Identifier intersection alone
147 // over-merges, because one CVE is regularly assigned to several packages.
148 let mut buckets: BTreeMap<(&str, &str), Vec<&Candidate<'_>>> = BTreeMap::new();
149 for candidate in &candidates {
150 buckets
151 .entry((candidate.package, candidate.version))
152 .or_default()
153 .push(candidate);
154 }
155
156 let mut out = Vec::new();
157 for ((package, version), members) in buckets {
158 for group in group_by_shared_identifier(&members) {
159 out.push(assemble(package, version, &group));
160 }
161 }
162 out.sort_by(|a, b| {
163 (&a.package, &a.version, &a.advisory).cmp(&(&b.package, &b.version, &b.advisory))
164 });
165 out
166}
167
168/// Partition one package's findings into groups whose identifier sets overlap,
169/// transitively.
170///
171/// Transitive closure is what makes the join work in the direction it has to:
172/// `cargo-audit` may name an advisory `RUSTSEC-x` with alias `CVE-y`, and
173/// `osv-scanner` may name it `GHSA-z` with alias `CVE-y`. Neither shares an id
174/// with the other directly; both share one with the CVE.
175fn group_by_shared_identifier<'a>(members: &[&'a Candidate<'a>]) -> Vec<Vec<&'a Candidate<'a>>> {
176 let mut parent: Vec<usize> = (0..members.len()).collect();
177 for (i, a) in members.iter().enumerate() {
178 for (j, b) in members.iter().enumerate().skip(i + 1) {
179 if a.shares_identifier(b) {
180 union(&mut parent, i, j);
181 }
182 }
183 }
184 let mut groups: BTreeMap<usize, Vec<&Candidate<'_>>> = BTreeMap::new();
185 for (i, member) in members.iter().enumerate() {
186 groups.entry(find(&mut parent, i)).or_default().push(member);
187 }
188 groups.into_values().collect()
189}
190
191fn find(parent: &mut [usize], mut node: usize) -> usize {
192 while parent[node] != node {
193 parent[node] = parent[parent[node]];
194 node = parent[node];
195 }
196 node
197}
198
199fn union(parent: &mut [usize], a: usize, b: usize) {
200 let (a, b) = (find(parent, a), find(parent, b));
201 if a != b {
202 parent[b.max(a)] = b.min(a);
203 }
204}
205
206/// Build the reported view of one group.
207fn assemble(package: &str, version: &str, group: &[&Candidate<'_>]) -> Correspondence {
208 let mut aliases: Vec<String> = group
209 .iter()
210 .flat_map(|c| c.identifiers.iter())
211 .map(String::clone)
212 .collect();
213 aliases.sort();
214 aliases.dedup();
215
216 let mut reports: Vec<Report> = group
217 .iter()
218 .map(|c| Report {
219 analyzer: c.analyzer.to_owned(),
220 key: c.key.clone(),
221 rule: c.rule.to_owned(),
222 severity: c.severity.clone(),
223 })
224 .collect();
225 reports.sort_by(|a, b| (&a.analyzer, &a.key).cmp(&(&b.analyzer, &b.key)));
226
227 Correspondence {
228 advisory: canonical(&reports),
229 aliases,
230 package: package.to_owned(),
231 version: version.to_owned(),
232 reports,
233 }
234}
235
236/// The identifier to name an advisory by: the RUSTSEC id an analyzer actually
237/// fired, otherwise the lowest id an analyzer fired.
238///
239/// **Only ids that were fired, never merely aliased**, and that restriction was
240/// put here by real fixture data rather than by taste. `cargo-audit` reports
241/// `chrono 0.4.19` as `RUSTSEC-2020-0159` and lists `RUSTSEC-2020-0071` under
242/// `related`; that alias sorts *first*, so naming the group from the alias set
243/// would label chrono's advisory with the id of the unrelated `time` one. An id
244/// no analyzer fired is not a name for what they found.
245///
246/// Preferring RUSTSEC is not favouritism towards Rust — it is that ADR-0018
247/// names it as *the* join key, and that where both analyzers report a Rust
248/// advisory it is the one identifier both of them publish.
249fn canonical(reports: &[Report]) -> String {
250 let mut fired: Vec<&str> = reports.iter().map(|r| r.rule.as_str()).collect();
251 fired.sort_unstable();
252 fired.dedup();
253 fired
254 .iter()
255 .find(|id| id.starts_with("RUSTSEC-"))
256 .or_else(|| fired.first())
257 .map(|id| (*id).to_owned())
258 .unwrap_or_default()
259}
260
261/// A dependency finding, reduced to what the join needs.
262struct Candidate<'a> {
263 analyzer: &'a str,
264 key: String,
265 rule: &'a str,
266 severity: Severity,
267 package: &'a str,
268 version: &'a str,
269 /// Every identifier this finding publishes for its advisory, including the
270 /// rule id itself.
271 identifiers: Vec<String>,
272}
273
274impl<'a> Candidate<'a> {
275 /// A candidate, or `None` if the finding is not on the dependency axis.
276 fn of(analyzer: &'a str, finding: &'a Finding) -> Option<Self> {
277 let package = finding.meta.get("package")?.as_str()?;
278 let version = finding.meta.get("version")?.as_str()?;
279 if package.is_empty() || version.is_empty() {
280 return None;
281 }
282 let mut identifiers = vec![finding.rule.clone()];
283 // `aliases` is what both adapters call the set; `related` is where
284 // `cargo-audit` puts a CVE that RustSec did not list as an alias, and
285 // `ids` is `osv-scanner`'s group membership. All three are identifiers
286 // an upstream published, so all three join.
287 for field in ["aliases", "related", "ids"] {
288 if let Some(values) = finding.meta.get(field).and_then(|v| v.as_array()) {
289 identifiers.extend(values.iter().filter_map(|v| v.as_str()).map(str::to_owned));
290 }
291 }
292 identifiers.retain(|id| !id.trim().is_empty());
293 identifiers.sort();
294 identifiers.dedup();
295 Some(Self {
296 analyzer,
297 key: finding.key.render(),
298 rule: &finding.rule,
299 severity: finding.severity.clone(),
300 package,
301 version,
302 identifiers,
303 })
304 }
305
306 /// Whether two findings name at least one identifier in common.
307 fn shares_identifier(&self, other: &Self) -> bool {
308 self.identifiers
309 .iter()
310 .any(|id| other.identifiers.binary_search(id).is_ok())
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::{Correspondence, cross_reference};
317 use rto_graph::{
318 AnalysisRun, CommandPolicy, EnvironmentPolicy, Finding, FindingKey, FindingsLayer,
319 Isolation, NetworkPolicy, RunnerKind, Severity, SourceIdentity, WorktreeAccess,
320 };
321
322 fn run(analyzer: &str) -> AnalysisRun {
323 AnalysisRun {
324 layer: format!("security:{analyzer}:ab12cd34"),
325 analyzer: analyzer.to_owned(),
326 analyzer_version: "1.0.0".to_owned(),
327 runner: RunnerKind::Ingested,
328 isolation: Isolation::Ingested,
329 image_digest: None,
330 rules_digest: None,
331 advisory_db: None,
332 command_policy: CommandPolicy {
333 network: NetworkPolicy::Deny,
334 worktree: WorktreeAccess::ReadOnly,
335 environment: EnvironmentPolicy::Scrubbed,
336 },
337 source: SourceIdentity::default(),
338 started_at: "2026-08-16T09:00:00Z".to_owned(),
339 ended_at: "2026-08-16T09:00:01Z".to_owned(),
340 exit_status: 1,
341 report_digest: "0".repeat(64),
342 }
343 }
344
345 fn finding(analyzer: &str, rule: &str, meta: serde_json::Value) -> Finding {
346 Finding {
347 key: FindingKey::new(analyzer, &[rule.to_owned()]).expect("key"),
348 rule: rule.to_owned(),
349 severity: Severity::High,
350 title: format!("{rule} is a problem"),
351 message: String::new(),
352 path: None,
353 span: None,
354 meta,
355 }
356 }
357
358 fn layer(analyzer: &str, findings: Vec<Finding>) -> FindingsLayer {
359 FindingsLayer {
360 run: run(analyzer),
361 findings,
362 }
363 }
364
365 /// The headline case: the same Rust advisory from both analyzers, named by
366 /// different ids, joined on the RUSTSEC id both of them publish. One
367 /// advisory, confirmed twice — and both keys still addressable.
368 #[test]
369 fn the_same_advisory_from_two_analyzers_is_one_confirmed_correspondence() {
370 let layers = vec![
371 layer(
372 "cargo-audit",
373 vec![finding(
374 "cargo-audit",
375 "RUSTSEC-2020-0071",
376 serde_json::json!({
377 "package": "time", "version": "0.2.22",
378 "aliases": ["CVE-2020-26235"], "related": []
379 }),
380 )],
381 ),
382 layer(
383 "osv-scanner",
384 vec![finding(
385 "osv-scanner",
386 "GHSA-wcg3-cvx6-7396",
387 serde_json::json!({
388 "package": "time", "version": "0.2.22",
389 "aliases": ["CVE-2020-26235", "GHSA-wcg3-cvx6-7396", "RUSTSEC-2020-0071"],
390 "ids": ["GHSA-wcg3-cvx6-7396", "RUSTSEC-2020-0071"]
391 }),
392 )],
393 ),
394 ];
395
396 let crossref = cross_reference(&layers);
397 assert_eq!(crossref.len(), 1, "one advisory, not two problems");
398 let one = &crossref[0];
399 assert_eq!(one.confirmed_by(), 2);
400 assert_eq!(one.analyzers(), vec!["cargo-audit", "osv-scanner"]);
401 // Named by the id ADR-0018 calls the join key.
402 assert_eq!(one.advisory, "RUSTSEC-2020-0071");
403 // Both keys survive: neither analyzer's finding is superseded here.
404 assert_eq!(one.keys().len(), 2);
405 assert!(one.keys().iter().any(|k| k.contains("cargo-audit")));
406 assert!(one.keys().iter().any(|k| k.contains("osv-scanner")));
407 // Each analyzer's own rule id is preserved, not rewritten to the
408 // canonical one.
409 let rules: Vec<&str> = one.reports.iter().map(|r| r.rule.as_str()).collect();
410 assert!(rules.contains(&"RUSTSEC-2020-0071"));
411 assert!(rules.contains(&"GHSA-wcg3-cvx6-7396"));
412 }
413
414 /// The transitive case, which is the one that actually happens: neither side
415 /// names an id the other names directly, and both name the same CVE.
416 #[test]
417 fn two_findings_join_through_a_shared_cve_neither_names_directly() {
418 let layers = vec![
419 layer(
420 "cargo-audit",
421 vec![finding(
422 "cargo-audit",
423 "RUSTSEC-2021-0001",
424 serde_json::json!({
425 "package": "widget", "version": "1.0.0",
426 "aliases": [], "related": ["CVE-2021-9999"]
427 }),
428 )],
429 ),
430 layer(
431 "osv-scanner",
432 vec![finding(
433 "osv-scanner",
434 "GHSA-aaaa-bbbb-cccc",
435 serde_json::json!({
436 "package": "widget", "version": "1.0.0",
437 "aliases": ["CVE-2021-9999"]
438 }),
439 )],
440 ),
441 ];
442 let crossref = cross_reference(&layers);
443 assert_eq!(crossref.len(), 1);
444 assert_eq!(crossref[0].confirmed_by(), 2);
445 }
446
447 /// The failure this join must not have. One CVE is regularly assigned to
448 /// several packages; joining on the identifier alone would fuse advisories
449 /// about different packages into one row.
450 #[test]
451 fn a_shared_identifier_on_different_packages_does_not_merge() {
452 let layers = vec![layer(
453 "osv-scanner",
454 vec![
455 finding(
456 "osv-scanner",
457 "GHSA-1",
458 serde_json::json!({
459 "package": "alpha", "version": "1.0.0", "aliases": ["CVE-2026-1"]
460 }),
461 ),
462 finding(
463 "osv-scanner",
464 "GHSA-2",
465 serde_json::json!({
466 "package": "beta", "version": "1.0.0", "aliases": ["CVE-2026-1"]
467 }),
468 ),
469 ],
470 )];
471 let crossref = cross_reference(&layers);
472 assert_eq!(crossref.len(), 2, "different packages stay different rows");
473 }
474
475 /// The same package at two versions is two advisories to fix, not one.
476 ///
477 /// The two findings deliberately share an advisory id: without that, they
478 /// would stay apart because nothing joins them, and this test would pass
479 /// whether or not the version were part of the bucket. A monorepo pinning
480 /// one library at two versions is the real case, and each pin is its own fix.
481 #[test]
482 fn the_same_advisory_at_two_versions_does_not_merge() {
483 let layers = vec![layer(
484 "osv-scanner",
485 vec![
486 finding(
487 "osv-scanner",
488 "GHSA-1",
489 serde_json::json!({
490 "package": "lodash", "version": "4.17.15", "aliases": ["CVE-2020-8203"]
491 }),
492 ),
493 finding(
494 "osv-scanner",
495 "GHSA-1b",
496 serde_json::json!({
497 "package": "lodash", "version": "4.17.20", "aliases": ["CVE-2020-8203"]
498 }),
499 ),
500 ],
501 )];
502 let crossref = cross_reference(&layers);
503 assert_eq!(crossref.len(), 2, "each pinned version is its own fix");
504 assert_eq!(crossref[0].version, "4.17.15");
505 assert_eq!(crossref[1].version, "4.17.20");
506 }
507
508 /// "Present in one, absent in the other" is a real state, not a defect: the
509 /// two analyzers pin their databases independently, and `yanked` is not an
510 /// advisory kind OSV can ever carry.
511 #[test]
512 fn an_advisory_only_one_analyzer_reports_is_a_normal_single_source_row() {
513 let layers = vec![
514 layer(
515 "cargo-audit",
516 vec![finding(
517 "cargo-audit",
518 "yanked",
519 serde_json::json!({"package": "half-baked", "version": "0.3.1"}),
520 )],
521 ),
522 layer(
523 "osv-scanner",
524 vec![finding(
525 "osv-scanner",
526 "GHSA-new",
527 serde_json::json!({"package": "fresh", "version": "1.0.0"}),
528 )],
529 ),
530 ];
531 let crossref = cross_reference(&layers);
532 assert_eq!(crossref.len(), 2);
533 assert!(crossref.iter().all(|c| c.confirmed_by() == 1));
534 // Ordered by package: `fresh` before `half-baked`.
535 assert_eq!(crossref[0].package, "fresh");
536 assert_eq!(crossref[0].analyzers(), vec!["osv-scanner"]);
537 assert_eq!(crossref[1].package, "half-baked");
538 assert_eq!(crossref[1].analyzers(), vec!["cargo-audit"]);
539 }
540
541 /// The invariant ADR-0018 states in as many words: a cross-reference must
542 /// never be a count that silently halves. Every finding is still accounted
543 /// for after the join.
544 #[test]
545 fn no_finding_is_lost_or_double_counted_by_the_join() {
546 let layers = vec![
547 layer(
548 "cargo-audit",
549 vec![
550 finding(
551 "cargo-audit",
552 "RUSTSEC-2020-0071",
553 serde_json::json!({
554 "package": "time", "version": "0.2.22", "aliases": ["CVE-2020-26235"]
555 }),
556 ),
557 finding(
558 "cargo-audit",
559 "yanked",
560 serde_json::json!({"package": "half-baked", "version": "0.3.1"}),
561 ),
562 ],
563 ),
564 layer(
565 "osv-scanner",
566 vec![finding(
567 "osv-scanner",
568 "RUSTSEC-2020-0071",
569 serde_json::json!({
570 "package": "time", "version": "0.2.22", "aliases": ["CVE-2020-26235"]
571 }),
572 )],
573 ),
574 ];
575 let total: usize = layers.iter().map(|l| l.findings.len()).sum();
576 let crossref = cross_reference(&layers);
577 let reported: usize = crossref.iter().map(|c| c.reports.len()).sum();
578 assert_eq!(reported, total, "every finding appears exactly once");
579 assert_eq!(total, 3);
580 assert_eq!(crossref.len(), 2, "…across two advisories");
581 }
582
583 /// A SAST finding is not on the dependency axis, so there is nothing for a
584 /// dependency scanner to agree with and it does not take part.
585 #[test]
586 fn sast_findings_are_not_cross_referenced() {
587 let layers = vec![layer(
588 "semgrep",
589 vec![finding(
590 "semgrep",
591 "roteiro.python.eval-of-input",
592 serde_json::json!({"engine": "python"}),
593 )],
594 )];
595 assert!(cross_reference(&layers).is_empty());
596 }
597
598 #[test]
599 fn nothing_ingested_cross_references_to_nothing() {
600 assert!(cross_reference(&[]).is_empty());
601 }
602
603 /// A stable order, so two renderings of the same store are identical.
604 #[test]
605 fn the_order_is_stable_and_does_not_depend_on_layer_order() {
606 let a = layer(
607 "cargo-audit",
608 vec![finding(
609 "cargo-audit",
610 "R-1",
611 serde_json::json!({"package": "zeta", "version": "1.0.0"}),
612 )],
613 );
614 let b = layer(
615 "osv-scanner",
616 vec![finding(
617 "osv-scanner",
618 "G-1",
619 serde_json::json!({"package": "alpha", "version": "1.0.0"}),
620 )],
621 );
622 let forwards = cross_reference(&[a.clone(), b.clone()]);
623 let backwards = cross_reference(&[b, a]);
624 assert_eq!(forwards, backwards);
625 let packages: Vec<&str> = forwards.iter().map(|c| c.package.as_str()).collect();
626 assert_eq!(packages, vec!["alpha", "zeta"]);
627 }
628
629 /// A correspondence with no identifiers at all still names itself, rather
630 /// than rendering as a blank row.
631 #[test]
632 fn an_advisory_always_has_a_name() {
633 let layers = vec![layer(
634 "osv-scanner",
635 vec![finding(
636 "osv-scanner",
637 "OSV-1",
638 serde_json::json!({"package": "x", "version": "1.0.0"}),
639 )],
640 )];
641 let crossref: Vec<Correspondence> = cross_reference(&layers);
642 assert_eq!(crossref[0].advisory, "OSV-1");
643 }
644}