Skip to main content

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}