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///
135/// # This does **not** suppress single-source rows
136///
137/// Nothing here counts analyzers. One dependency analyzer with N advisories yields
138/// N correspondences, each reading `confirmed_by: 1` — which is a true description
139/// of what it saw and the wrong thing to *report*, because a table in which every
140/// row says "confirmed by 1" is noise dressed as information. The suppression is
141/// [`across_analyzers`], and a caller that renders a cross-reference section wants
142/// that one. Said here rather than only there because taking this function for the
143/// reporting view is a mistake that has actually been made (PR #468 review):
144/// `security list --json` had the guard inline and the model-facing surface, added
145/// later, documented it and did not have it.
146#[must_use]
147pub fn cross_reference(layers: &[FindingsLayer]) -> Vec<Correspondence> {
148 let candidates: Vec<Candidate<'_>> = layers
149 .iter()
150 .flat_map(|layer| {
151 layer
152 .findings
153 .iter()
154 .filter_map(|finding| Candidate::of(&layer.run.analyzer, finding))
155 })
156 .collect();
157
158 // Bucket by package and version first. Identifier intersection alone
159 // over-merges, because one CVE is regularly assigned to several packages.
160 let mut buckets: BTreeMap<(&str, &str), Vec<&Candidate<'_>>> = BTreeMap::new();
161 for candidate in &candidates {
162 buckets
163 .entry((candidate.package, candidate.version))
164 .or_default()
165 .push(candidate);
166 }
167
168 let mut out = Vec::new();
169 for ((package, version), members) in buckets {
170 for group in group_by_shared_identifier(&members) {
171 out.push(assemble(package, version, &group));
172 }
173 }
174 out.sort_by(|a, b| {
175 (&a.package, &a.version, &a.advisory).cmp(&(&b.package, &b.version, &b.advisory))
176 });
177 out
178}
179
180/// The **reporting** view of [`cross_reference`]: the same correspondences, or
181/// empty when fewer than two analyzers contributed any of them.
182///
183/// ADR-0018 v1.1 keeps agreement between independent sources as evidence, and
184/// below two sources there is no agreement to have either way — so the section is
185/// suppressed rather than rendered with every row reading `confirmed_by: 1`.
186///
187/// # The count is of analyzers *in the correspondences*, not of layers
188///
189/// Deliberately, and the difference is load-bearing. A repository with a `semgrep`
190/// layer and a `cargo-audit` layer has two live layers and **one** analyzer on the
191/// dependency axis: `semgrep` produces no `meta.package`, so it takes no part in
192/// the join (see [`cross_reference`]) and cannot corroborate a dependency advisory.
193/// Counting layers would show a table of single-source rows and call it
194/// cross-referenced.
195///
196/// # It hides a view, never a finding
197///
198/// Every finding stays in its own layer under its own key and every total still
199/// counts it. This decides whether a *section* is rendered, and nothing else — the
200/// same guarantee [`cross_reference`] makes one level down.
201#[must_use]
202pub fn across_analyzers(layers: &[FindingsLayer]) -> Vec<Correspondence> {
203 let correspondences = cross_reference(layers);
204 let mut analyzers: Vec<&str> = correspondences
205 .iter()
206 .flat_map(Correspondence::analyzers)
207 .collect();
208 analyzers.sort_unstable();
209 analyzers.dedup();
210 if analyzers.len() < 2 {
211 return Vec::new();
212 }
213 correspondences
214}
215
216/// Partition one package's findings into groups whose identifier sets overlap,
217/// transitively.
218///
219/// Transitive closure is what makes the join work in the direction it has to:
220/// `cargo-audit` may name an advisory `RUSTSEC-x` with alias `CVE-y`, and
221/// `osv-scanner` may name it `GHSA-z` with alias `CVE-y`. Neither shares an id
222/// with the other directly; both share one with the CVE.
223fn group_by_shared_identifier<'a>(members: &[&'a Candidate<'a>]) -> Vec<Vec<&'a Candidate<'a>>> {
224 let mut parent: Vec<usize> = (0..members.len()).collect();
225 for (i, a) in members.iter().enumerate() {
226 for (j, b) in members.iter().enumerate().skip(i + 1) {
227 if a.shares_identifier(b) {
228 union(&mut parent, i, j);
229 }
230 }
231 }
232 let mut groups: BTreeMap<usize, Vec<&Candidate<'_>>> = BTreeMap::new();
233 for (i, member) in members.iter().enumerate() {
234 groups.entry(find(&mut parent, i)).or_default().push(member);
235 }
236 groups.into_values().collect()
237}
238
239fn find(parent: &mut [usize], mut node: usize) -> usize {
240 while parent[node] != node {
241 parent[node] = parent[parent[node]];
242 node = parent[node];
243 }
244 node
245}
246
247fn union(parent: &mut [usize], a: usize, b: usize) {
248 let (a, b) = (find(parent, a), find(parent, b));
249 if a != b {
250 parent[b.max(a)] = b.min(a);
251 }
252}
253
254/// Build the reported view of one group.
255fn assemble(package: &str, version: &str, group: &[&Candidate<'_>]) -> Correspondence {
256 let mut aliases: Vec<String> = group
257 .iter()
258 .flat_map(|c| c.identifiers.iter())
259 .map(String::clone)
260 .collect();
261 aliases.sort();
262 aliases.dedup();
263
264 let mut reports: Vec<Report> = group
265 .iter()
266 .map(|c| Report {
267 analyzer: c.analyzer.to_owned(),
268 key: c.key.clone(),
269 rule: c.rule.to_owned(),
270 severity: c.severity.clone(),
271 })
272 .collect();
273 reports.sort_by(|a, b| (&a.analyzer, &a.key).cmp(&(&b.analyzer, &b.key)));
274
275 Correspondence {
276 advisory: canonical(&reports),
277 aliases,
278 package: package.to_owned(),
279 version: version.to_owned(),
280 reports,
281 }
282}
283
284/// The identifier to name an advisory by: the RUSTSEC id an analyzer actually
285/// fired, otherwise the lowest id an analyzer fired.
286///
287/// **Only ids that were fired, never merely aliased**, and that restriction was
288/// put here by real fixture data rather than by taste. `cargo-audit` reports
289/// `chrono 0.4.19` as `RUSTSEC-2020-0159` and lists `RUSTSEC-2020-0071` under
290/// `related`; that alias sorts *first*, so naming the group from the alias set
291/// would label chrono's advisory with the id of the unrelated `time` one. An id
292/// no analyzer fired is not a name for what they found.
293///
294/// Preferring RUSTSEC is not favouritism towards Rust — it is that ADR-0018
295/// names it as *the* join key, and that where both analyzers report a Rust
296/// advisory it is the one identifier both of them publish.
297fn canonical(reports: &[Report]) -> String {
298 let mut fired: Vec<&str> = reports.iter().map(|r| r.rule.as_str()).collect();
299 fired.sort_unstable();
300 fired.dedup();
301 fired
302 .iter()
303 .find(|id| id.starts_with("RUSTSEC-"))
304 .or_else(|| fired.first())
305 .map(|id| (*id).to_owned())
306 .unwrap_or_default()
307}
308
309/// A dependency finding, reduced to what the join needs.
310struct Candidate<'a> {
311 analyzer: &'a str,
312 key: String,
313 rule: &'a str,
314 severity: Severity,
315 package: &'a str,
316 version: &'a str,
317 /// Every identifier this finding publishes for its advisory, including the
318 /// rule id itself.
319 identifiers: Vec<String>,
320}
321
322impl<'a> Candidate<'a> {
323 /// A candidate, or `None` if the finding is not on the dependency axis.
324 fn of(analyzer: &'a str, finding: &'a Finding) -> Option<Self> {
325 let package = finding.meta.get("package")?.as_str()?;
326 let version = finding.meta.get("version")?.as_str()?;
327 if package.is_empty() || version.is_empty() {
328 return None;
329 }
330 let mut identifiers = vec![finding.rule.clone()];
331 // `aliases` is what both adapters call the set; `related` is where
332 // `cargo-audit` puts a CVE that RustSec did not list as an alias, and
333 // `ids` is `osv-scanner`'s group membership. All three are identifiers
334 // an upstream published, so all three join.
335 for field in ["aliases", "related", "ids"] {
336 if let Some(values) = finding.meta.get(field).and_then(|v| v.as_array()) {
337 identifiers.extend(values.iter().filter_map(|v| v.as_str()).map(str::to_owned));
338 }
339 }
340 identifiers.retain(|id| !id.trim().is_empty());
341 identifiers.sort();
342 identifiers.dedup();
343 Some(Self {
344 analyzer,
345 key: finding.key.render(),
346 rule: &finding.rule,
347 severity: finding.severity.clone(),
348 package,
349 version,
350 identifiers,
351 })
352 }
353
354 /// Whether two findings name at least one identifier in common.
355 fn shares_identifier(&self, other: &Self) -> bool {
356 self.identifiers
357 .iter()
358 .any(|id| other.identifiers.binary_search(id).is_ok())
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::{Correspondence, across_analyzers, cross_reference};
365 use rto_graph::{
366 AnalysisRun, CommandPolicy, EnvironmentPolicy, Finding, FindingKey, FindingsLayer,
367 Isolation, NetworkPolicy, RunnerKind, Severity, SourceIdentity, WorktreeAccess,
368 };
369
370 fn run(analyzer: &str) -> AnalysisRun {
371 AnalysisRun {
372 layer: format!("security:{analyzer}:ab12cd34"),
373 analyzer: analyzer.to_owned(),
374 analyzer_version: "1.0.0".to_owned(),
375 runner: RunnerKind::Ingested,
376 isolation: Isolation::Ingested,
377 image_digest: None,
378 rules_digest: None,
379 advisory_db: None,
380 command_policy: CommandPolicy {
381 network: NetworkPolicy::Deny,
382 worktree: WorktreeAccess::ReadOnly,
383 environment: EnvironmentPolicy::Scrubbed,
384 },
385 source: SourceIdentity::default(),
386 started_at: "2026-08-16T09:00:00Z".to_owned(),
387 ended_at: "2026-08-16T09:00:01Z".to_owned(),
388 exit_status: 1,
389 report_digest: "0".repeat(64),
390 }
391 }
392
393 fn finding(analyzer: &str, rule: &str, meta: serde_json::Value) -> Finding {
394 Finding {
395 key: FindingKey::new(analyzer, &[rule.to_owned()]).expect("key"),
396 rule: rule.to_owned(),
397 severity: Severity::High,
398 title: format!("{rule} is a problem"),
399 message: String::new(),
400 path: None,
401 span: None,
402 meta,
403 }
404 }
405
406 fn layer(analyzer: &str, findings: Vec<Finding>) -> FindingsLayer {
407 FindingsLayer {
408 run: run(analyzer),
409 findings,
410 }
411 }
412
413 /// A layer built from **real** linter output, to check the join against what
414 /// the adapter actually emits rather than against a hand-made stand-in.
415 fn lint_layer() -> FindingsLayer {
416 use crate::adapter::NativeContext;
417 use crate::adapter::clippy::Clippy;
418
419 let source = SourceIdentity::default();
420 let ctx = NativeContext {
421 started_at: "2026-08-18T09:00:00Z".to_owned(),
422 ended_at: "2026-08-18T09:01:00Z".to_owned(),
423 analyzer_version: Some("0.1.94".to_owned()),
424 exit_status: 0,
425 source: &source,
426 rules_digest: None,
427 advisory_db: None,
428 worktree: None,
429 snippets: &crate::snippet::NoSnippets,
430 };
431 let native = concat!(
432 r#"{"reason":"compiler-message","message":{"message":"unused import: `time`","#,
433 r#""code":{"code":"unused_imports"},"level":"warning","spans":[{"#,
434 r#""file_name":"src/lib.rs","byte_start":0,"byte_end":11,"line_start":1,"#,
435 r#""line_end":1,"column_start":1,"is_primary":true}]}}"#,
436 "\n",
437 r#"{"reason":"build-finished","success":true}"#
438 );
439 let (report, _) = Clippy::parse(native.as_bytes(), &ctx).expect("parse");
440 let findings = report
441 .findings
442 .iter()
443 .map(|f| Finding {
444 key: FindingKey::new("clippy", &f.identity).expect("key"),
445 rule: f.rule.clone(),
446 severity: f.severity.clone(),
447 title: f.title.clone(),
448 message: f.message.clone(),
449 path: f.path.clone(),
450 span: f.span,
451 meta: f.meta.clone(),
452 })
453 .collect();
454 layer("clippy", findings)
455 }
456
457 /// Requirement of ADR-0020 condition 5: a lint never enters the
458 /// cross-analyzer join. The join's correctness rests on both upstreams
459 /// publishing identifiers, and **nobody publishes lint names** — they are
460 /// release notes. `roteiro lint` stores nothing, so no such layer can exist
461 /// today; this checks the join would refuse one anyway, because the guard
462 /// that matters is the absent `package`/`version` pair in the adapter's
463 /// `meta` rather than the current absence of a caller.
464 #[test]
465 fn a_lint_finding_cannot_enter_the_dependency_join() {
466 assert!(
467 cross_reference(&[lint_layer()]).is_empty(),
468 "a linter is not on the dependency axis at all"
469 );
470
471 // …and it does not attach itself to a real advisory about a package it
472 // happens to mention in a message, either.
473 let advisory = layer(
474 "cargo-audit",
475 vec![finding(
476 "cargo-audit",
477 "RUSTSEC-2020-0071",
478 serde_json::json!({
479 "package": "time", "version": "0.2.22",
480 "aliases": ["CVE-2020-26235"], "related": []
481 }),
482 )],
483 );
484 let joined = cross_reference(&[lint_layer(), advisory]);
485 assert_eq!(joined.len(), 1, "only the advisory takes part");
486 assert_eq!(joined[0].analyzers(), vec!["cargo-audit"]);
487 for correspondence in &joined {
488 for report in &correspondence.reports {
489 assert_ne!(report.analyzer, "clippy");
490 }
491 }
492 }
493
494 /// The headline case: the same Rust advisory from both analyzers, named by
495 /// different ids, joined on the RUSTSEC id both of them publish. One
496 /// advisory, confirmed twice — and both keys still addressable.
497 #[test]
498 fn the_same_advisory_from_two_analyzers_is_one_confirmed_correspondence() {
499 let layers = vec![
500 layer(
501 "cargo-audit",
502 vec![finding(
503 "cargo-audit",
504 "RUSTSEC-2020-0071",
505 serde_json::json!({
506 "package": "time", "version": "0.2.22",
507 "aliases": ["CVE-2020-26235"], "related": []
508 }),
509 )],
510 ),
511 layer(
512 "osv-scanner",
513 vec![finding(
514 "osv-scanner",
515 "GHSA-wcg3-cvx6-7396",
516 serde_json::json!({
517 "package": "time", "version": "0.2.22",
518 "aliases": ["CVE-2020-26235", "GHSA-wcg3-cvx6-7396", "RUSTSEC-2020-0071"],
519 "ids": ["GHSA-wcg3-cvx6-7396", "RUSTSEC-2020-0071"]
520 }),
521 )],
522 ),
523 ];
524
525 let crossref = cross_reference(&layers);
526 assert_eq!(crossref.len(), 1, "one advisory, not two problems");
527 let one = &crossref[0];
528 assert_eq!(one.confirmed_by(), 2);
529 assert_eq!(one.analyzers(), vec!["cargo-audit", "osv-scanner"]);
530 // Named by the id ADR-0018 calls the join key.
531 assert_eq!(one.advisory, "RUSTSEC-2020-0071");
532 // Both keys survive: neither analyzer's finding is superseded here.
533 assert_eq!(one.keys().len(), 2);
534 assert!(one.keys().iter().any(|k| k.contains("cargo-audit")));
535 assert!(one.keys().iter().any(|k| k.contains("osv-scanner")));
536 // Each analyzer's own rule id is preserved, not rewritten to the
537 // canonical one.
538 let rules: Vec<&str> = one.reports.iter().map(|r| r.rule.as_str()).collect();
539 assert!(rules.contains(&"RUSTSEC-2020-0071"));
540 assert!(rules.contains(&"GHSA-wcg3-cvx6-7396"));
541 }
542
543 /// The transitive case, which is the one that actually happens: neither side
544 /// names an id the other names directly, and both name the same CVE.
545 #[test]
546 fn two_findings_join_through_a_shared_cve_neither_names_directly() {
547 let layers = vec![
548 layer(
549 "cargo-audit",
550 vec![finding(
551 "cargo-audit",
552 "RUSTSEC-2021-0001",
553 serde_json::json!({
554 "package": "widget", "version": "1.0.0",
555 "aliases": [], "related": ["CVE-2021-9999"]
556 }),
557 )],
558 ),
559 layer(
560 "osv-scanner",
561 vec![finding(
562 "osv-scanner",
563 "GHSA-aaaa-bbbb-cccc",
564 serde_json::json!({
565 "package": "widget", "version": "1.0.0",
566 "aliases": ["CVE-2021-9999"]
567 }),
568 )],
569 ),
570 ];
571 let crossref = cross_reference(&layers);
572 assert_eq!(crossref.len(), 1);
573 assert_eq!(crossref[0].confirmed_by(), 2);
574 }
575
576 /// The failure this join must not have. One CVE is regularly assigned to
577 /// several packages; joining on the identifier alone would fuse advisories
578 /// about different packages into one row.
579 #[test]
580 fn a_shared_identifier_on_different_packages_does_not_merge() {
581 let layers = vec![layer(
582 "osv-scanner",
583 vec![
584 finding(
585 "osv-scanner",
586 "GHSA-1",
587 serde_json::json!({
588 "package": "alpha", "version": "1.0.0", "aliases": ["CVE-2026-1"]
589 }),
590 ),
591 finding(
592 "osv-scanner",
593 "GHSA-2",
594 serde_json::json!({
595 "package": "beta", "version": "1.0.0", "aliases": ["CVE-2026-1"]
596 }),
597 ),
598 ],
599 )];
600 let crossref = cross_reference(&layers);
601 assert_eq!(crossref.len(), 2, "different packages stay different rows");
602 }
603
604 /// The same package at two versions is two advisories to fix, not one.
605 ///
606 /// The two findings deliberately share an advisory id: without that, they
607 /// would stay apart because nothing joins them, and this test would pass
608 /// whether or not the version were part of the bucket. A monorepo pinning
609 /// one library at two versions is the real case, and each pin is its own fix.
610 #[test]
611 fn the_same_advisory_at_two_versions_does_not_merge() {
612 let layers = vec![layer(
613 "osv-scanner",
614 vec![
615 finding(
616 "osv-scanner",
617 "GHSA-1",
618 serde_json::json!({
619 "package": "lodash", "version": "4.17.15", "aliases": ["CVE-2020-8203"]
620 }),
621 ),
622 finding(
623 "osv-scanner",
624 "GHSA-1b",
625 serde_json::json!({
626 "package": "lodash", "version": "4.17.20", "aliases": ["CVE-2020-8203"]
627 }),
628 ),
629 ],
630 )];
631 let crossref = cross_reference(&layers);
632 assert_eq!(crossref.len(), 2, "each pinned version is its own fix");
633 assert_eq!(crossref[0].version, "4.17.15");
634 assert_eq!(crossref[1].version, "4.17.20");
635 }
636
637 /// "Present in one, absent in the other" is a real state, not a defect: the
638 /// two analyzers pin their databases independently, and `yanked` is not an
639 /// advisory kind OSV can ever carry.
640 #[test]
641 fn an_advisory_only_one_analyzer_reports_is_a_normal_single_source_row() {
642 let layers = vec![
643 layer(
644 "cargo-audit",
645 vec![finding(
646 "cargo-audit",
647 "yanked",
648 serde_json::json!({"package": "half-baked", "version": "0.3.1"}),
649 )],
650 ),
651 layer(
652 "osv-scanner",
653 vec![finding(
654 "osv-scanner",
655 "GHSA-new",
656 serde_json::json!({"package": "fresh", "version": "1.0.0"}),
657 )],
658 ),
659 ];
660 let crossref = cross_reference(&layers);
661 assert_eq!(crossref.len(), 2);
662 assert!(crossref.iter().all(|c| c.confirmed_by() == 1));
663 // Ordered by package: `fresh` before `half-baked`.
664 assert_eq!(crossref[0].package, "fresh");
665 assert_eq!(crossref[0].analyzers(), vec!["osv-scanner"]);
666 assert_eq!(crossref[1].package, "half-baked");
667 assert_eq!(crossref[1].analyzers(), vec!["cargo-audit"]);
668 }
669
670 /// The invariant ADR-0018 states in as many words: a cross-reference must
671 /// never be a count that silently halves. Every finding is still accounted
672 /// for after the join.
673 #[test]
674 fn no_finding_is_lost_or_double_counted_by_the_join() {
675 let layers = vec![
676 layer(
677 "cargo-audit",
678 vec![
679 finding(
680 "cargo-audit",
681 "RUSTSEC-2020-0071",
682 serde_json::json!({
683 "package": "time", "version": "0.2.22", "aliases": ["CVE-2020-26235"]
684 }),
685 ),
686 finding(
687 "cargo-audit",
688 "yanked",
689 serde_json::json!({"package": "half-baked", "version": "0.3.1"}),
690 ),
691 ],
692 ),
693 layer(
694 "osv-scanner",
695 vec![finding(
696 "osv-scanner",
697 "RUSTSEC-2020-0071",
698 serde_json::json!({
699 "package": "time", "version": "0.2.22", "aliases": ["CVE-2020-26235"]
700 }),
701 )],
702 ),
703 ];
704 let total: usize = layers.iter().map(|l| l.findings.len()).sum();
705 let crossref = cross_reference(&layers);
706 let reported: usize = crossref.iter().map(|c| c.reports.len()).sum();
707 assert_eq!(reported, total, "every finding appears exactly once");
708 assert_eq!(total, 3);
709 assert_eq!(crossref.len(), 2, "…across two advisories");
710 }
711
712 /// A SAST finding is not on the dependency axis, so there is nothing for a
713 /// dependency scanner to agree with and it does not take part.
714 #[test]
715 fn sast_findings_are_not_cross_referenced() {
716 let layers = vec![layer(
717 "semgrep",
718 vec![finding(
719 "semgrep",
720 "roteiro.python.eval-of-input",
721 serde_json::json!({"engine": "python"}),
722 )],
723 )];
724 assert!(cross_reference(&layers).is_empty());
725 }
726
727 #[test]
728 fn nothing_ingested_cross_references_to_nothing() {
729 assert!(cross_reference(&[]).is_empty());
730 }
731
732 /// A stable order, so two renderings of the same store are identical.
733 #[test]
734 fn the_order_is_stable_and_does_not_depend_on_layer_order() {
735 let a = layer(
736 "cargo-audit",
737 vec![finding(
738 "cargo-audit",
739 "R-1",
740 serde_json::json!({"package": "zeta", "version": "1.0.0"}),
741 )],
742 );
743 let b = layer(
744 "osv-scanner",
745 vec![finding(
746 "osv-scanner",
747 "G-1",
748 serde_json::json!({"package": "alpha", "version": "1.0.0"}),
749 )],
750 );
751 let forwards = cross_reference(&[a.clone(), b.clone()]);
752 let backwards = cross_reference(&[b, a]);
753 assert_eq!(forwards, backwards);
754 let packages: Vec<&str> = forwards.iter().map(|c| c.package.as_str()).collect();
755 assert_eq!(packages, vec!["alpha", "zeta"]);
756 }
757
758 /// A correspondence with no identifiers at all still names itself, rather
759 /// than rendering as a blank row.
760 #[test]
761 fn an_advisory_always_has_a_name() {
762 let layers = vec![layer(
763 "osv-scanner",
764 vec![finding(
765 "osv-scanner",
766 "OSV-1",
767 serde_json::json!({"package": "x", "version": "1.0.0"}),
768 )],
769 )];
770 let crossref: Vec<Correspondence> = cross_reference(&layers);
771 assert_eq!(crossref[0].advisory, "OSV-1");
772 }
773 /// The suppression, at the level it lives, and stated as the difference between
774 /// the two functions rather than as a property of either alone.
775 ///
776 /// This is the pairing that matters: the same input, one row out of the raw join
777 /// and nothing out of the reporting view. A future reader deciding whether the
778 /// guard is load-bearing can read it off this test instead of guessing — which
779 /// is what went wrong in PR #468, where a caller took the raw join for the
780 /// reporting view and documented the guard it did not have.
781 #[test]
782 fn one_analyzer_joins_but_is_not_reported() {
783 let layers = vec![layer(
784 "cargo-audit",
785 vec![finding(
786 "cargo-audit",
787 "RUSTSEC-2020-0071",
788 serde_json::json!({ "package": "time", "version": "0.1.44" }),
789 )],
790 )];
791
792 let raw = cross_reference(&layers);
793 assert_eq!(raw.len(), 1, "the join describes what it saw");
794 assert_eq!(raw[0].confirmed_by(), 1);
795
796 assert!(
797 across_analyzers(&layers).is_empty(),
798 "one source carries no signal about agreement either way, so the \
799 section is not rendered"
800 );
801 }
802
803 /// A second *layer* is not a second dependency analyzer.
804 ///
805 /// `semgrep` produces no `meta.package`, so it takes no part in the join and
806 /// cannot corroborate a dependency advisory. Counting layers rather than the
807 /// analyzers actually in the correspondences would render a table of
808 /// single-source rows and call it cross-referenced.
809 #[test]
810 fn a_sast_layer_does_not_make_a_second_dependency_analyzer() {
811 let layers = vec![
812 layer(
813 "cargo-audit",
814 vec![finding(
815 "cargo-audit",
816 "RUSTSEC-2020-0071",
817 serde_json::json!({ "package": "time", "version": "0.1.44" }),
818 )],
819 ),
820 layer(
821 "semgrep",
822 vec![finding(
823 "semgrep",
824 "rules.taint",
825 // No package/version: not on the dependency axis at all.
826 serde_json::json!({ "cwe": "CWE-89" }),
827 )],
828 ),
829 ];
830 assert_eq!(layers.len(), 2, "two live layers");
831 assert!(
832 across_analyzers(&layers).is_empty(),
833 "still one analyzer on the dependency axis"
834 );
835 }
836
837 /// Two dependency analyzers agreeing is what the section exists for, and the
838 /// guard must pass it through untouched — including any single-source rows
839 /// alongside it, which are real advisories about a repository that does have two.
840 #[test]
841 fn two_dependency_analyzers_are_reported_including_single_source_rows() {
842 let layers = vec![
843 layer(
844 "cargo-audit",
845 vec![
846 finding(
847 "cargo-audit",
848 "RUSTSEC-2020-0071",
849 serde_json::json!({ "package": "time", "version": "0.1.44" }),
850 ),
851 finding(
852 "cargo-audit",
853 "RUSTSEC-2099-0001",
854 serde_json::json!({ "package": "yanked-only", "version": "1.0.0" }),
855 ),
856 ],
857 ),
858 layer(
859 "osv-scanner",
860 vec![finding(
861 "osv-scanner",
862 "GHSA-wcg3-cvx6-7396",
863 serde_json::json!({
864 "package": "time",
865 "version": "0.1.44",
866 "aliases": ["RUSTSEC-2020-0071"]
867 }),
868 )],
869 ),
870 ];
871 let reported = across_analyzers(&layers);
872 assert_eq!(reported.len(), 2, "{reported:?}");
873 let corroborated: Vec<&Correspondence> =
874 reported.iter().filter(|c| c.confirmed_by() == 2).collect();
875 assert_eq!(corroborated.len(), 1, "the agreed advisory: {reported:?}");
876 assert_eq!(corroborated[0].package, "time");
877 // The single-source row survives: `yanked` is a kind OSV cannot carry, so
878 // dropping it would hide a real finding behind a rule about agreement.
879 assert!(
880 reported.iter().any(|c| c.package == "yanked-only"),
881 "{reported:?}"
882 );
883 }
884}