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-advisory check, `cargo deny --no-default-features check
//! advisories` in each workspace) 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 (Gunnar's and Holger's since lane OSSF-NEXT, 2026-10-30; monetize's since lane
//! MON-OSSF, 2026-10-31):
//!
//! 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]);
}
/// The `version`s of package `name` in a `Cargo.lock` (the lock's fixed shape).
fn lock_versions(lock: &str, name: &str) -> Vec<String> {
let mut out = Vec::new();
let mut lines = lock.lines();
while let Some(l) = lines.next() {
if l.trim() == format!("name = \"{name}\"") {
if let Some(v) = lines.next().and_then(|v| field(v, "version")) {
out.push(v);
}
}
}
out
}
fn semver(v: &str) -> (u64, u64, u64) {
let core = v.split(['-', '+']).next().unwrap_or("");
let mut it = core.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
(it.next().unwrap_or(0), it.next().unwrap_or(0), it.next().unwrap_or(0))
}
/// **A fixed advisory stays fixed** (MON-OSSF, 2026-10-31): the semver-compatible fixes the
/// lane moved every lock to, as floors, so a lock regenerated from an older one, or a sibling
/// that pins the old release, is red here by name instead of a new Scorecard finding.
///
/// | package | floor | advisories |
/// |---|---|---|
/// | wasmtime | 49.0.5 | RUSTSEC-2026-0422, RUSTSEC-2026-0423, RUSTSEC-2026-0424 (the guest host, edda guest-host) |
/// | pageant | 0.2.5 | GHSA-662x-8wvj-2276 (under russh, nornir-git) |
#[test]
fn the_fixed_advisories_stay_fixed_in_every_lock() {
const FLOORS: &[(&str, &str, &str)] = &[
("wasmtime", "49.0.5", "RUSTSEC-2026-0422/0423/0424"),
("pageant", "0.2.5", "GHSA-662x-8wvj-2276"),
];
let root = root();
let mut locks = Vec::new();
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
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('.') {
walk(&p, out);
}
} else if name == "Cargo.lock" {
out.push(p);
}
}
}
walk(&root, &mut locks);
assert!(locks.len() >= 6, "the walk found the locks: {locks:?}");
let mut wrong = Vec::new();
for lock in &locks {
let text = std::fs::read_to_string(lock).unwrap();
for (name, floor, ids) in FLOORS {
for v in lock_versions(&text, name) {
if semver(&v) < semver(floor) {
wrong.push(format!("{}: {name} {v} is under {floor} ({ids})", lock.strip_prefix(&root).unwrap().display()));
}
}
}
}
assert!(wrong.is_empty(), "a lock carries a release with a fixed advisory:\n {}", wrong.join("\n "));
}