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

nordisk/gunnar

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