xtask/tests/osv_triage.rs
//! **The two triage lists cannot drift: every `osv-scanner.toml` ignore is a `deny.toml` ignore.**
//!
//! Two tools read this repository's locks for known vulnerabilities:
//!
//! * `cargo deny` (the 0-known-CVE gate, `cargo xtask cve`) reads the root `deny.toml`;
//! * OSV-Scanner reads an `osv-scanner.toml` in the directory of each `Cargo.lock`, and the
//! OpenSSF Scorecard's Vulnerabilities check runs OSV-Scanner over the whole tree.
//!
//! A triage written into only one of them is a decision the other tool does not know about.
//! So the rule (lane OSSF-NEXT, 2026-10-30):
//!
//! 1. every `[[IgnoredVulns]]` id in a non-fixture `osv-scanner.toml` is also in the root
//! `deny.toml` `[advisories] ignore`, with a reason that carries its triage date
//! (`Triaged YYYY-MM-DD`);
//! 2. every such entry has its own reason and an `ignoreUntil` date, so the mute ends by itself
//! and the next look is forced;
//! 3. a whole-package mute (`[[PackageOverrides]]`) exists only beside a deliberate test
//! fixture under `xtask/tests/fixtures/`, and says so in its reason;
//! 4. an `osv-scanner.toml` stands only beside a `Cargo.lock` (anywhere else OSV-Scanner never
//! reads it, and a file nobody reads is a triage nobody made).
//!
//! The files are small and of one fixed shape, so this test reads them line by line rather
//! than adding a TOML parser to xtask's graph.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("xtask has a parent")
.to_path_buf()
}
const FIXTURES: &str = "xtask/tests/fixtures/";
/// The `ignore = [ { id = "…", reason = "…" }, … ]` entries of a `deny.toml`, id → reason.
fn deny_ignores(text: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
for line in text.lines().map(str::trim) {
if line.starts_with('#') || !line.starts_with('{') {
continue;
}
let id = field(line, "id");
let reason = field(line, "reason");
if let (Some(id), Some(reason)) = (id, reason) {
out.insert(id, reason);
}
}
out
}
/// `key = "value"` out of one line (inline table or plain key line).
fn field(line: &str, key: &str) -> Option<String> {
let mut rest = line;
while let Some(at) = rest.find(key) {
let before_ok = at == 0 || !rest.as_bytes()[at - 1].is_ascii_alphanumeric();
let after = rest[at + key.len()..].trim_start();
if before_ok {
if let Some(after) = after.strip_prefix('=') {
let after = after.trim_start();
if let Some(after) = after.strip_prefix('"') {
return after.find('"').map(|end| after[..end].to_string());
}
}
}
rest = &rest[at + key.len()..];
}
None
}
#[derive(Debug, Default)]
struct OsvFile {
/// (id, reason, ignoreUntil) per `[[IgnoredVulns]]`.
ignored: Vec<(String, Option<String>, Option<String>)>,
/// reason per `[[PackageOverrides]]`.
overrides: Vec<Option<String>>,
}
fn osv_file(text: &str) -> OsvFile {
let mut f = OsvFile::default();
let mut section = "";
for line in text.lines().map(str::trim) {
if line.is_empty() || line.starts_with('#') {
continue;
}
if line == "[[IgnoredVulns]]" {
section = "ignored";
f.ignored.push((String::new(), None, None));
continue;
}
if line == "[[PackageOverrides]]" {
section = "override";
f.overrides.push(None);
continue;
}
if line.starts_with('[') {
continue;
}
let key = line.split('=').next().unwrap_or("").trim();
match (section, key) {
("ignored", "id") => {
f.ignored.last_mut().unwrap().0 = field(line, "id").unwrap_or_default()
}
("ignored", "reason") => f.ignored.last_mut().unwrap().1 = field(line, "reason"),
("ignored", "ignoreUntil") => {
f.ignored.last_mut().unwrap().2 = line
.split('=')
.nth(1)
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
("override", "reason") => *f.overrides.last_mut().unwrap() = field(line, "reason"),
_ => {}
}
}
f
}
fn osv_files(dir: &Path, root: &Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
let name = e.file_name().to_string_lossy().to_string();
if p.is_dir() {
if name == "target" || name == ".git" || name.starts_with(".iso-out") {
continue;
}
osv_files(&p, root, out);
} else if name == "osv-scanner.toml" {
out.push(
p.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/"),
);
}
}
}
fn has_date(reason: &str) -> bool {
reason.match_indices("Triaged 20").any(|(at, _)| {
let d = &reason[at + "Triaged ".len()..];
d.len() >= 10
&& d.as_bytes()[4] == b'-'
&& d.as_bytes()[7] == b'-'
&& d[..4]
.bytes()
.chain(d[5..7].bytes())
.chain(d[8..10].bytes())
.all(|b| b.is_ascii_digit())
})
}
#[test]
fn every_osv_scanner_ignore_is_a_dated_deny_toml_ignore() {
let root = root();
let deny = deny_ignores(&std::fs::read_to_string(root.join("deny.toml")).expect("deny.toml"));
let mut files = Vec::new();
osv_files(&root, &root, &mut files);
files.sort();
let mut wrong = Vec::new();
for rel in &files {
let dir = rel.trim_end_matches("osv-scanner.toml");
if !root.join(dir).join("Cargo.lock").is_file() {
wrong.push(format!(
"{rel}: no Cargo.lock beside it, so OSV-Scanner never reads it"
));
}
let f = osv_file(&std::fs::read_to_string(root.join(rel)).unwrap());
let fixture = rel.starts_with(FIXTURES);
for (id, reason, until) in &f.ignored {
if id.is_empty() {
wrong.push(format!("{rel}: an [[IgnoredVulns]] entry without an id"));
continue;
}
if reason.as_deref().unwrap_or("").len() < 40 {
wrong.push(format!("{rel}: {id} has no reason of its own"));
}
if until.is_none() {
wrong.push(format!(
"{rel}: {id} has no ignoreUntil, so the mute never ends"
));
}
if fixture {
continue;
}
match deny.get(id) {
None => wrong.push(format!(
"{rel}: {id} is ignored for OSV but not in deny.toml"
)),
Some(r) if !has_date(r) => wrong.push(format!(
"{rel}: {id}'s deny.toml reason carries no `Triaged YYYY-MM-DD`"
)),
Some(_) => {}
}
}
for reason in &f.overrides {
if !fixture {
wrong.push(format!(
"{rel}: a whole-package [[PackageOverrides]] mute outside the fixtures"
));
} else if !reason
.as_deref()
.unwrap_or("")
.contains("deliberate test fixture")
{
wrong.push(format!(
"{rel}: a fixture's [[PackageOverrides]] must say `deliberate test fixture`"
));
}
}
}
assert!(
files.iter().any(|f| f == "osv-scanner.toml"),
"the root lock carries triaged advisories, so the root needs its osv-scanner.toml; found {files:?}"
);
assert!(
wrong.is_empty(),
"the OSV triage and deny.toml disagree:\n {}",
wrong.join("\n ")
);
}
/// The checker is not the identity: a file that mutes an id deny.toml never triaged is caught,
/// and so is an undated deny reason and a blanket override outside the fixtures.
#[test]
fn the_checker_sees_an_untriaged_mute() {
let deny = deny_ignores(
r#"ignore = [
{ id = "RUSTSEC-1-1", reason = "something long enough to count as a reason. Triaged 2026-10-30." },
{ id = "RUSTSEC-2-2", reason = "no date in this one" },
]"#,
);
assert_eq!(deny.len(), 2);
assert!(has_date(&deny["RUSTSEC-1-1"]));
assert!(!has_date(&deny["RUSTSEC-2-2"]));
let f = osv_file(
"[[IgnoredVulns]]\nid = \"RUSTSEC-3-3\"\nignoreUntil = 2027-01-31\nreason = \"x\"\n\n[[PackageOverrides]]\necosystem = \"crates.io\"\n",
);
assert_eq!(f.ignored.len(), 1);
assert_eq!(f.ignored[0].0, "RUSTSEC-3-3");
assert_eq!(f.ignored[0].2.as_deref(), Some("2027-01-31"));
assert!(!deny.contains_key(&f.ignored[0].0));
assert_eq!(f.overrides, vec![None]);
}