Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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  "));
}