use std::collections::BTreeMap;
use rto_graph::{Finding, FindingsLayer, Severity};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Correspondence {
pub advisory: String,
pub aliases: Vec<String>,
pub package: String,
pub version: String,
pub reports: Vec<Report>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report {
pub analyzer: String,
pub key: String,
pub rule: String,
pub severity: Severity,
}
impl Correspondence {
#[must_use]
pub fn confirmed_by(&self) -> usize {
let mut analyzers: Vec<&str> = self.reports.iter().map(|r| r.analyzer.as_str()).collect();
analyzers.sort_unstable();
analyzers.dedup();
analyzers.len()
}
#[must_use]
pub fn analyzers(&self) -> Vec<&str> {
let mut analyzers: Vec<&str> = self.reports.iter().map(|r| r.analyzer.as_str()).collect();
analyzers.sort_unstable();
analyzers.dedup();
analyzers
}
#[must_use]
pub fn keys(&self) -> Vec<&str> {
self.reports.iter().map(|r| r.key.as_str()).collect()
}
}
#[must_use]
pub fn cross_reference(layers: &[FindingsLayer]) -> Vec<Correspondence> {
let candidates: Vec<Candidate<'_>> = layers
.iter()
.flat_map(|layer| {
layer
.findings
.iter()
.filter_map(|finding| Candidate::of(&layer.run.analyzer, finding))
})
.collect();
let mut buckets: BTreeMap<(&str, &str), Vec<&Candidate<'_>>> = BTreeMap::new();
for candidate in &candidates {
buckets
.entry((candidate.package, candidate.version))
.or_default()
.push(candidate);
}
let mut out = Vec::new();
for ((package, version), members) in buckets {
for group in group_by_shared_identifier(&members) {
out.push(assemble(package, version, &group));
}
}
out.sort_by(|a, b| {
(&a.package, &a.version, &a.advisory).cmp(&(&b.package, &b.version, &b.advisory))
});
out
}
fn group_by_shared_identifier<'a>(members: &[&'a Candidate<'a>]) -> Vec<Vec<&'a Candidate<'a>>> {
let mut parent: Vec<usize> = (0..members.len()).collect();
for (i, a) in members.iter().enumerate() {
for (j, b) in members.iter().enumerate().skip(i + 1) {
if a.shares_identifier(b) {
union(&mut parent, i, j);
}
}
}
let mut groups: BTreeMap<usize, Vec<&Candidate<'_>>> = BTreeMap::new();
for (i, member) in members.iter().enumerate() {
groups.entry(find(&mut parent, i)).or_default().push(member);
}
groups.into_values().collect()
}
fn find(parent: &mut [usize], mut node: usize) -> usize {
while parent[node] != node {
parent[node] = parent[parent[node]];
node = parent[node];
}
node
}
fn union(parent: &mut [usize], a: usize, b: usize) {
let (a, b) = (find(parent, a), find(parent, b));
if a != b {
parent[b.max(a)] = b.min(a);
}
}
fn assemble(package: &str, version: &str, group: &[&Candidate<'_>]) -> Correspondence {
let mut aliases: Vec<String> = group
.iter()
.flat_map(|c| c.identifiers.iter())
.map(String::clone)
.collect();
aliases.sort();
aliases.dedup();
let mut reports: Vec<Report> = group
.iter()
.map(|c| Report {
analyzer: c.analyzer.to_owned(),
key: c.key.clone(),
rule: c.rule.to_owned(),
severity: c.severity.clone(),
})
.collect();
reports.sort_by(|a, b| (&a.analyzer, &a.key).cmp(&(&b.analyzer, &b.key)));
Correspondence {
advisory: canonical(&reports),
aliases,
package: package.to_owned(),
version: version.to_owned(),
reports,
}
}
fn canonical(reports: &[Report]) -> String {
let mut fired: Vec<&str> = reports.iter().map(|r| r.rule.as_str()).collect();
fired.sort_unstable();
fired.dedup();
fired
.iter()
.find(|id| id.starts_with("RUSTSEC-"))
.or_else(|| fired.first())
.map(|id| (*id).to_owned())
.unwrap_or_default()
}
struct Candidate<'a> {
analyzer: &'a str,
key: String,
rule: &'a str,
severity: Severity,
package: &'a str,
version: &'a str,
identifiers: Vec<String>,
}
impl<'a> Candidate<'a> {
fn of(analyzer: &'a str, finding: &'a Finding) -> Option<Self> {
let package = finding.meta.get("package")?.as_str()?;
let version = finding.meta.get("version")?.as_str()?;
if package.is_empty() || version.is_empty() {
return None;
}
let mut identifiers = vec![finding.rule.clone()];
for field in ["aliases", "related", "ids"] {
if let Some(values) = finding.meta.get(field).and_then(|v| v.as_array()) {
identifiers.extend(values.iter().filter_map(|v| v.as_str()).map(str::to_owned));
}
}
identifiers.retain(|id| !id.trim().is_empty());
identifiers.sort();
identifiers.dedup();
Some(Self {
analyzer,
key: finding.key.render(),
rule: &finding.rule,
severity: finding.severity.clone(),
package,
version,
identifiers,
})
}
fn shares_identifier(&self, other: &Self) -> bool {
self.identifiers
.iter()
.any(|id| other.identifiers.binary_search(id).is_ok())
}
}
#[cfg(test)]
mod tests {
use super::{Correspondence, cross_reference};
use rto_graph::{
AnalysisRun, CommandPolicy, EnvironmentPolicy, Finding, FindingKey, FindingsLayer,
Isolation, NetworkPolicy, RunnerKind, Severity, SourceIdentity, WorktreeAccess,
};
fn run(analyzer: &str) -> AnalysisRun {
AnalysisRun {
layer: format!("security:{analyzer}:ab12cd34"),
analyzer: analyzer.to_owned(),
analyzer_version: "1.0.0".to_owned(),
runner: RunnerKind::Ingested,
isolation: Isolation::Ingested,
image_digest: None,
rules_digest: None,
advisory_db: None,
command_policy: CommandPolicy {
network: NetworkPolicy::Deny,
worktree: WorktreeAccess::ReadOnly,
environment: EnvironmentPolicy::Scrubbed,
},
source: SourceIdentity::default(),
started_at: "2026-08-16T09:00:00Z".to_owned(),
ended_at: "2026-08-16T09:00:01Z".to_owned(),
exit_status: 1,
report_digest: "0".repeat(64),
}
}
fn finding(analyzer: &str, rule: &str, meta: serde_json::Value) -> Finding {
Finding {
key: FindingKey::new(analyzer, &[rule.to_owned()]).expect("key"),
rule: rule.to_owned(),
severity: Severity::High,
title: format!("{rule} is a problem"),
message: String::new(),
path: None,
span: None,
meta,
}
}
fn layer(analyzer: &str, findings: Vec<Finding>) -> FindingsLayer {
FindingsLayer {
run: run(analyzer),
findings,
}
}
#[test]
fn the_same_advisory_from_two_analyzers_is_one_confirmed_correspondence() {
let layers = vec![
layer(
"cargo-audit",
vec![finding(
"cargo-audit",
"RUSTSEC-2020-0071",
serde_json::json!({
"package": "time", "version": "0.2.22",
"aliases": ["CVE-2020-26235"], "related": []
}),
)],
),
layer(
"osv-scanner",
vec![finding(
"osv-scanner",
"GHSA-wcg3-cvx6-7396",
serde_json::json!({
"package": "time", "version": "0.2.22",
"aliases": ["CVE-2020-26235", "GHSA-wcg3-cvx6-7396", "RUSTSEC-2020-0071"],
"ids": ["GHSA-wcg3-cvx6-7396", "RUSTSEC-2020-0071"]
}),
)],
),
];
let crossref = cross_reference(&layers);
assert_eq!(crossref.len(), 1, "one advisory, not two problems");
let one = &crossref[0];
assert_eq!(one.confirmed_by(), 2);
assert_eq!(one.analyzers(), vec!["cargo-audit", "osv-scanner"]);
assert_eq!(one.advisory, "RUSTSEC-2020-0071");
assert_eq!(one.keys().len(), 2);
assert!(one.keys().iter().any(|k| k.contains("cargo-audit")));
assert!(one.keys().iter().any(|k| k.contains("osv-scanner")));
let rules: Vec<&str> = one.reports.iter().map(|r| r.rule.as_str()).collect();
assert!(rules.contains(&"RUSTSEC-2020-0071"));
assert!(rules.contains(&"GHSA-wcg3-cvx6-7396"));
}
#[test]
fn two_findings_join_through_a_shared_cve_neither_names_directly() {
let layers = vec![
layer(
"cargo-audit",
vec![finding(
"cargo-audit",
"RUSTSEC-2021-0001",
serde_json::json!({
"package": "widget", "version": "1.0.0",
"aliases": [], "related": ["CVE-2021-9999"]
}),
)],
),
layer(
"osv-scanner",
vec![finding(
"osv-scanner",
"GHSA-aaaa-bbbb-cccc",
serde_json::json!({
"package": "widget", "version": "1.0.0",
"aliases": ["CVE-2021-9999"]
}),
)],
),
];
let crossref = cross_reference(&layers);
assert_eq!(crossref.len(), 1);
assert_eq!(crossref[0].confirmed_by(), 2);
}
#[test]
fn a_shared_identifier_on_different_packages_does_not_merge() {
let layers = vec![layer(
"osv-scanner",
vec![
finding(
"osv-scanner",
"GHSA-1",
serde_json::json!({
"package": "alpha", "version": "1.0.0", "aliases": ["CVE-2026-1"]
}),
),
finding(
"osv-scanner",
"GHSA-2",
serde_json::json!({
"package": "beta", "version": "1.0.0", "aliases": ["CVE-2026-1"]
}),
),
],
)];
let crossref = cross_reference(&layers);
assert_eq!(crossref.len(), 2, "different packages stay different rows");
}
#[test]
fn the_same_advisory_at_two_versions_does_not_merge() {
let layers = vec![layer(
"osv-scanner",
vec![
finding(
"osv-scanner",
"GHSA-1",
serde_json::json!({
"package": "lodash", "version": "4.17.15", "aliases": ["CVE-2020-8203"]
}),
),
finding(
"osv-scanner",
"GHSA-1b",
serde_json::json!({
"package": "lodash", "version": "4.17.20", "aliases": ["CVE-2020-8203"]
}),
),
],
)];
let crossref = cross_reference(&layers);
assert_eq!(crossref.len(), 2, "each pinned version is its own fix");
assert_eq!(crossref[0].version, "4.17.15");
assert_eq!(crossref[1].version, "4.17.20");
}
#[test]
fn an_advisory_only_one_analyzer_reports_is_a_normal_single_source_row() {
let layers = vec![
layer(
"cargo-audit",
vec![finding(
"cargo-audit",
"yanked",
serde_json::json!({"package": "half-baked", "version": "0.3.1"}),
)],
),
layer(
"osv-scanner",
vec![finding(
"osv-scanner",
"GHSA-new",
serde_json::json!({"package": "fresh", "version": "1.0.0"}),
)],
),
];
let crossref = cross_reference(&layers);
assert_eq!(crossref.len(), 2);
assert!(crossref.iter().all(|c| c.confirmed_by() == 1));
assert_eq!(crossref[0].package, "fresh");
assert_eq!(crossref[0].analyzers(), vec!["osv-scanner"]);
assert_eq!(crossref[1].package, "half-baked");
assert_eq!(crossref[1].analyzers(), vec!["cargo-audit"]);
}
#[test]
fn no_finding_is_lost_or_double_counted_by_the_join() {
let layers = vec![
layer(
"cargo-audit",
vec![
finding(
"cargo-audit",
"RUSTSEC-2020-0071",
serde_json::json!({
"package": "time", "version": "0.2.22", "aliases": ["CVE-2020-26235"]
}),
),
finding(
"cargo-audit",
"yanked",
serde_json::json!({"package": "half-baked", "version": "0.3.1"}),
),
],
),
layer(
"osv-scanner",
vec![finding(
"osv-scanner",
"RUSTSEC-2020-0071",
serde_json::json!({
"package": "time", "version": "0.2.22", "aliases": ["CVE-2020-26235"]
}),
)],
),
];
let total: usize = layers.iter().map(|l| l.findings.len()).sum();
let crossref = cross_reference(&layers);
let reported: usize = crossref.iter().map(|c| c.reports.len()).sum();
assert_eq!(reported, total, "every finding appears exactly once");
assert_eq!(total, 3);
assert_eq!(crossref.len(), 2, "…across two advisories");
}
#[test]
fn sast_findings_are_not_cross_referenced() {
let layers = vec![layer(
"semgrep",
vec![finding(
"semgrep",
"roteiro.python.eval-of-input",
serde_json::json!({"engine": "python"}),
)],
)];
assert!(cross_reference(&layers).is_empty());
}
#[test]
fn nothing_ingested_cross_references_to_nothing() {
assert!(cross_reference(&[]).is_empty());
}
#[test]
fn the_order_is_stable_and_does_not_depend_on_layer_order() {
let a = layer(
"cargo-audit",
vec![finding(
"cargo-audit",
"R-1",
serde_json::json!({"package": "zeta", "version": "1.0.0"}),
)],
);
let b = layer(
"osv-scanner",
vec![finding(
"osv-scanner",
"G-1",
serde_json::json!({"package": "alpha", "version": "1.0.0"}),
)],
);
let forwards = cross_reference(&[a.clone(), b.clone()]);
let backwards = cross_reference(&[b, a]);
assert_eq!(forwards, backwards);
let packages: Vec<&str> = forwards.iter().map(|c| c.package.as_str()).collect();
assert_eq!(packages, vec!["alpha", "zeta"]);
}
#[test]
fn an_advisory_always_has_a_name() {
let layers = vec![layer(
"osv-scanner",
vec![finding(
"osv-scanner",
"OSV-1",
serde_json::json!({"package": "x", "version": "1.0.0"}),
)],
)];
let crossref: Vec<Correspondence> = cross_reference(&layers);
assert_eq!(crossref[0].advisory, "OSV-1");
}
}