src/scan.rs
//! Engine A — artifact security **verdict** (multi-ecosystem CVE / license /
//! provenance). Lean; holger's policy gate + static-holger quarantine consume the
//! [`crate::core::Verdict`].
//!
//! Extracted + generalized from `nornir/src/security.rs` (cargo-only, network OSV
//! today) into a **multi-ecosystem, offline-evaluable verdict core**: given an
//! [`Artifact`] (identity + declared license + provenance) plus a [`Policy`] and an
//! [`AdvisoryDb`], fold the three checks into one [`Decision`].
//!
//! ## The decision table
//! Each check emits zero or more [`Finding`]s; the gate [`Decision`] is the strongest
//! severity across all findings (see [`decide`]):
//!
//! | finding severity | decision |
//! |-------------------------|-----------------|
//! | `Critical` / `High` | [`Decision::Block`] (deny) |
//! | `Medium` / `Low` | [`Decision::Warn`] |
//! | `Info` / none | [`Decision::Pass`] (allow) |
//!
//! The three checks map their policy outcome onto a severity, so the table above is
//! the single source of truth:
//! - **Known vulnerability** — every [`Advisory`] matching the artifact becomes a
//! finding at the advisory's own severity (a `Critical`/`High` CVE denies; a
//! `Low`/`Medium` one warns).
//! - **License policy** — a denied license → `High` (deny); a license that is not on
//! a non-empty allow-list → `Medium` (warn, "unreviewed"); an unknown/unstated
//! license → the severity that realizes [`LicensePolicy::on_unknown`].
//! - **Provenance** — a missing signature → the severity realizing
//! [`ProvenancePolicy`] (`Require` denies, `Warn` warns, `Ignore` is silent).
use crate::core::{ArtifactRef, Decision, Finding, Severity, Verdict};
use std::borrow::Cow;
/// Canonical **ecosystem** label for the advisory↔component equi-join. `pip` — the
/// label the `manifests` PyPI parser emits — and `pypi` — the label
/// [`crate::osv::parse_osv`] normalizes OSV's `PyPI` to — name the SAME registry.
/// Left unequal, `matches` rejects on the ecosystem guard BEFORE it ever compares
/// names, so every PyPI advisory silently misses every pip-manifest-sourced
/// component (a systemic false negative). Folding them at the join keeps both
/// producers free to spell it either way. Every other label passes through verbatim.
pub fn canonical_eco(eco: &str) -> &str {
match eco {
"pip" => "pypi",
other => other,
}
}
/// Canonicalize a package **name** for the equi-join key.
///
/// PyPI project names compare per **PEP 503**: case-insensitive, with any run of
/// `-`, `_` or `.` folded to a single `-` (`re.sub(r"[-_.]+", "-", name).lower()`).
/// The advisory DBs (OSV / PyPA) store that normalized form (`zope-interface`,
/// `django`); a manifest / SBOM can carry the author's spelling (`zope.interface`,
/// `Django`). An exact compare then MISSES the CVE (false negative). Normalizing
/// **both** sides for `pypi`/`pip` closes it. **NuGet** package IDs are likewise
/// case-INSENSITIVE (but their `.` separators ARE significant — `Foo.Bar` ≠
/// `FooBar`), so those fold on case only. Every other ecosystem keeps its name
/// verbatim (case-sensitive) — collapsing separators elsewhere would corrupt real
/// keys (a Maven `group.artifact`, an npm scope) and manufacture false positives.
pub fn canonical_pkg<'a>(ecosystem: &str, name: &'a str) -> Cow<'a, str> {
match canonical_eco(ecosystem) {
"pypi" => {
let mut out = String::with_capacity(name.len());
let mut prev_sep = false;
for ch in name.chars() {
if matches!(ch, '-' | '_' | '.') {
if !prev_sep {
out.push('-');
}
prev_sep = true;
} else {
out.push(ch.to_ascii_lowercase());
prev_sep = false;
}
}
Cow::Owned(out)
}
// NuGet IDs are case-insensitive-unique (two IDs can't differ by case only),
// so lowercasing both sides can never manufacture a false positive; it only
// fixes a latent FN when an advisory and a manifest disagree on casing.
"nuget" if name.bytes().any(|b| b.is_ascii_uppercase()) => {
Cow::Owned(name.to_ascii_lowercase())
}
_ => Cow::Borrowed(name),
}
}
/// Provenance / signature presence on an artifact — the "who are you?" Móðguðr
/// demands at the bridge. A real verdict cares whether the bytes are *attested*,
/// not just what they declare.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Provenance {
/// True when the artifact carries a verified signature / attestation.
pub signed: bool,
/// Optional label of the signer / attestation source (for display).
pub signer: Option<String>,
}
impl Provenance {
/// A signed artifact with a named signer.
pub fn signed_by(signer: impl Into<String>) -> Self {
Self {
signed: true,
signer: Some(signer.into()),
}
}
/// An unsigned / unattested artifact.
pub fn unsigned() -> Self {
Self {
signed: false,
signer: None,
}
}
}
/// The thing Engine A scans: content-addressed identity ([`ArtifactRef`]) plus the
/// scannable metadata a verdict needs — the declared license and the provenance.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Artifact {
/// Identity the verdict is keyed by (ecosystem, name, version, blob hash).
pub artifact: ArtifactRef,
/// SPDX-ish license expression as declared by the ecosystem manifest.
/// `None` ⇒ unknown / unstated (handled by [`LicensePolicy::on_unknown`]).
pub license: Option<String>,
/// Signature / attestation presence.
pub provenance: Provenance,
}
impl Artifact {
/// Build from an [`ArtifactRef`] with a declared license and provenance.
pub fn new(artifact: ArtifactRef, license: Option<String>, provenance: Provenance) -> Self {
Self {
artifact,
license,
provenance,
}
}
}
/// A known-vulnerability advisory, ecosystem-scoped. Multi-ecosystem by design:
/// matches by `(ecosystem, package)` then version. Version matching is **exact**
/// against [`Advisory::affected_versions`] (an empty list ⇒ *all* versions of the
/// package are affected) — kept offline + ecosystem-agnostic rather than tying the
/// verdict core to one ecosystem's range grammar (cargo semver, Maven ranges, npm
/// semver, PEP 440, …). A range-resolving front-end can expand ranges into this
/// exact form before handing the db to the scanner.
#[derive(Debug, Clone, PartialEq)]
pub struct Advisory {
/// Stable id surfaced in the verdict reason (e.g. `RUSTSEC-2024-0003`, a CVE).
pub id: String,
pub ecosystem: String,
pub package: String,
/// Exact affected versions. Empty ⇒ every version of the package is affected.
pub affected_versions: Vec<String>,
pub severity: Severity,
pub summary: String,
/// **EPSS** — FIRST's Exploit-Prediction-Scoring-System probability (`0.0..=1.0`)
/// that this advisory is exploited in the next 30 days; `None` ⇒ unknown. Drives
/// [`Policy::epss_block_threshold`] escalation (the field has converged on as the
/// exploit-likelihood signal alongside CVSS severity).
pub epss: Option<f64>,
/// **KEV** — present in CISA's Known-Exploited-Vulnerabilities catalog (confirmed
/// exploited in the wild). When [`Policy::kev_blocks`] (default on), a KEV hit
/// escalates to `Critical` regardless of base severity.
pub kev: bool,
/// **Affected ranges** — the OSV `affected[].ranges` source of truth, as a
/// **disjunction**: one inner event list per OSV range object, and the artifact
/// is affected if ANY of them covers its version
/// ([`crate::versions::affected_by_any`]). They must stay separate — a range
/// walk is a monotone on/off sweep, so concatenating two ranges' events cancels
/// them against each other instead of unioning them.
///
/// When non-empty, [`matches`](Advisory::matches) tests range-native, so a
/// range-only advisory matches WITHOUT pre-expanding to exact versions against a
/// registry index. Empty ⇒ fall back to [`affected_versions`](Advisory::affected_versions).
pub ranges: Vec<Vec<crate::versions::RangeEvent>>,
/// **Aliases** — every other id this advisory is known by (CVE ↔ GHSA ↔ RUSTSEC).
/// The dedup/merge key when folding the same vuln across feeds (see
/// [`AdvisoryDb::merge_by_alias`]).
pub aliases: Vec<String>,
/// **Withdrawn** — OSV `withdrawn` set ⇒ the advisory was retracted; it never
/// matches (filtered out of the verdict).
pub withdrawn: bool,
/// **Source feed** the advisory was imported from (`osv|ghsa|nvd|debian|…`) —
/// provenance for the warehouse row + per-source policy toggles. `""` = unset.
pub source: String,
/// **CVSS vector string** (e.g. `CVSS:3.1/AV:N/AC:L/...`), typically from NVD —
/// the precise score basis behind `severity`. `""` = none.
pub cvss_vector: String,
/// **CWE ids** (e.g. `CWE-502`) — the weakness classification, typically from
/// NVD. Empty ⇒ unclassified.
pub cwe: Vec<String>,
}
impl Advisory {
/// A minimal advisory with no EPSS/KEV enrichment — the common case for an
/// offline RUSTSEC/OSV import carrying only `(coords, severity)`.
pub fn basic(
id: impl Into<String>,
ecosystem: impl Into<String>,
package: impl Into<String>,
affected_versions: Vec<String>,
severity: Severity,
summary: impl Into<String>,
) -> Self {
Self {
id: id.into(),
ecosystem: ecosystem.into(),
package: package.into(),
affected_versions,
severity,
summary: summary.into(),
epss: None,
kev: false,
ranges: Vec::new(),
aliases: Vec::new(),
withdrawn: false,
source: String::new(),
cvss_vector: String::new(),
cwe: Vec::new(),
}
}
/// Builder: set **one** affected range from its event list (the common
/// hand-built case). Use [`with_range_sets`](Advisory::with_range_sets) for an
/// advisory that carries several range objects.
pub fn with_ranges(mut self, ranges: Vec<crate::versions::RangeEvent>) -> Self {
self.ranges = if ranges.is_empty() {
Vec::new()
} else {
vec![ranges]
};
self
}
/// Builder: set the full range disjunction — one event list per OSV range
/// object. Empty inner lists are dropped.
pub fn with_range_sets(mut self, ranges: Vec<Vec<crate::versions::RangeEvent>>) -> Self {
self.ranges = ranges.into_iter().filter(|r| !r.is_empty()).collect();
self
}
/// Builder: set the alias ids (the dedup key across feeds).
pub fn with_aliases<I, S>(mut self, aliases: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.aliases = aliases.into_iter().map(Into::into).collect();
self
}
/// Builder: set the source feed label.
pub fn with_source(mut self, source: impl Into<String>) -> Self {
self.source = source.into();
self
}
/// Does this advisory affect `artifact`'s ecosystem/name/version?
///
/// A withdrawn advisory never matches. Otherwise the ecosystem + package must
/// match, then the version is affected when ANY of:
/// - it is listed in `affected_versions` (exact); or
/// - `ranges` is non-empty and [`crate::versions::affected_by_any`] says ANY of
/// the range objects covers it (range-native); or
/// - both `affected_versions` and `ranges` are empty (⇒ every version is affected).
pub fn matches(&self, artifact: &ArtifactRef) -> bool {
if self.withdrawn {
return false;
}
// Canonicalize both sides of the join: fold pip↔pypi and apply PEP 503 to
// PyPI names, so a `pypi` advisory keyed `django` / `zope-interface` matches
// a component spelled `Django` / `zope.interface` (or labelled `pip`).
// Non-PyPI ecosystems compare verbatim (case-sensitive), unchanged.
if canonical_eco(&self.ecosystem) != canonical_eco(&artifact.ecosystem)
|| canonical_pkg(&self.ecosystem, &self.package)
!= canonical_pkg(&artifact.ecosystem, &artifact.name)
{
return false;
}
let exact_hit = !self.affected_versions.is_empty()
&& self
.affected_versions
.iter()
.any(|v| v == &artifact.version);
let range_hit = !self.ranges.is_empty()
&& crate::versions::affected_by_any(&self.ecosystem, &self.ranges, &artifact.version);
let all_versions = self.affected_versions.is_empty() && self.ranges.is_empty();
exact_hit || range_hit || all_versions
}
}
/// An offline advisory set with a revision id — the `advisory_db_rev` part of the
/// verdict cache key, so a consumer invalidates a cached verdict when the db moves.
#[derive(Debug, Clone, Default)]
pub struct AdvisoryDb {
pub rev: String,
pub advisories: Vec<Advisory>,
}
impl AdvisoryDb {
pub fn new(rev: impl Into<String>, advisories: Vec<Advisory>) -> Self {
Self {
rev: rev.into(),
advisories,
}
}
/// The advisories affecting `artifact`.
pub fn query<'a>(&'a self, artifact: &ArtifactRef) -> Vec<&'a Advisory> {
self.advisories
.iter()
.filter(|adv| adv.matches(artifact))
.collect()
}
}
/// Fold advisories that describe the **same** vulnerability for the same
/// `(ecosystem, package)` — i.e. that share any id/alias (CVE ↔ GHSA ↔ RUSTSEC) —
/// into one row, unioning ranges/affected-versions/aliases (an all-versions row —
/// empty ranges AND empty affected-versions — absorbs any ranged sibling, since
/// that is the union), taking the max
/// severity, OR-ing `kev`, the max known `epss`, and treating it as withdrawn only
/// if *every* source withdrew it. This is the dedup the ingest pipeline applies
/// when the same CVE arrives from OSV + GHSA + NVD. Order-independent.
pub fn merge_by_alias(advisories: Vec<Advisory>) -> Vec<Advisory> {
let id_set = |a: &Advisory| -> std::collections::HashSet<String> {
std::iter::once(a.id.clone())
.chain(a.aliases.iter().cloned())
.collect()
};
let mut groups: Vec<(std::collections::HashSet<String>, Advisory)> = Vec::new();
'next: for adv in advisories {
let ids = id_set(&adv);
for (g_ids, g) in groups.iter_mut() {
if g.ecosystem == adv.ecosystem
&& g.package == adv.package
&& ids.iter().any(|i| g_ids.contains(i))
{
// Merge `adv` into the existing group.
g.severity = g.severity.max(adv.severity);
g.kev |= adv.kev;
g.epss = match (g.epss, adv.epss) {
(Some(x), Some(y)) => Some(x.max(y)),
(a, b) => a.or(b),
};
g.withdrawn = g.withdrawn && adv.withdrawn;
// "Every version of this package is affected" is encoded as empty
// affected_versions AND empty ranges (see `matches`). Its union with
// ANY window is still every version, so the sentinel has to win —
// merging a window into it would NARROW the merged row and turn a
// matching package into a false PASS.
let all_versions =
|a: &Advisory| a.affected_versions.is_empty() && a.ranges.is_empty();
if all_versions(g) || all_versions(&adv) {
g.affected_versions.clear();
g.ranges.clear();
} else {
for v in adv.affected_versions {
if !g.affected_versions.contains(&v) {
g.affected_versions.push(v);
}
}
// Append each range OBJECT whole — never splice one range's
// events into another's. `matches` ORs the objects, which is the
// union; concatenating the events would instead let the second
// feed's `Fixed` cancel the first feed's wider window.
for r in adv.ranges {
if !g.ranges.contains(&r) {
g.ranges.push(r);
}
}
}
if g.source.is_empty() {
g.source = adv.source;
}
if g.cvss_vector.is_empty() {
g.cvss_vector = adv.cvss_vector;
}
for c in adv.cwe {
if !g.cwe.contains(&c) {
g.cwe.push(c);
}
}
g_ids.extend(ids);
// Keep every known id (except the surviving primary) as an alias.
g.aliases = g_ids.iter().filter(|i| **i != g.id).cloned().collect();
g.aliases.sort();
continue 'next;
}
}
groups.push((ids, adv));
}
groups.into_iter().map(|(_, a)| a).collect()
}
/// How a declared license maps to a gate [`Decision`].
#[derive(Debug, Clone, Default)]
pub struct LicensePolicy {
/// Licenses explicitly reviewed + allowed. A **non-empty** allow-list makes any
/// license outside it `Warn` ("unreviewed"); an empty allow-list allows anything
/// not in [`LicensePolicy::deny`].
pub allow: Vec<String>,
/// Licenses that must deny (e.g. strong copyleft for a proprietary serve).
pub deny: Vec<String>,
/// What to do when the artifact declares no license at all.
pub on_unknown: Decision,
}
impl LicensePolicy {
/// An allow-list policy: only these licenses pass clean; unknown ⇒ `Warn`.
pub fn allowlist<I, S>(allow: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
allow: allow.into_iter().map(Into::into).collect(),
deny: Vec::new(),
on_unknown: Decision::Warn,
}
}
}
/// A parsed SPDX license expression: a single id, or an AND/OR of sub-expressions.
/// `WITH <exception>` is folded onto the base id (the exception does not change the
/// base license's allow/deny status for the gate).
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum SpdxExpr {
Id(String),
And(Vec<SpdxExpr>),
Or(Vec<SpdxExpr>),
}
/// Which predicate [`spdx_eval`] computes over an id set.
#[derive(Clone, Copy)]
pub(crate) enum SpdxMode {
/// Denied: an `AND` is denied if ANY operand is (all licenses apply), an `OR`
/// only if ALL are (a clean disjunct can be chosen).
Denied,
/// Allowed: an `AND` needs ALL operands allowed, an `OR` needs ANY.
Allowed,
}
/// Evaluate a parsed SPDX expression against `set` (case-insensitive id membership)
/// under `mode`. The AND/OR combinators flip between the two modes precisely so a
/// denied license inside an `AND` denies the whole, while an allowed license inside
/// an `OR` allows the whole.
pub(crate) fn spdx_eval(expr: &SpdxExpr, set: &[String], mode: SpdxMode) -> bool {
let member = |id: &str| set.iter().any(|s| s.eq_ignore_ascii_case(id));
match expr {
SpdxExpr::Id(id) => member(id),
SpdxExpr::And(v) => match mode {
SpdxMode::Denied => v.iter().any(|e| spdx_eval(e, set, mode)),
SpdxMode::Allowed => v.iter().all(|e| spdx_eval(e, set, mode)),
},
SpdxExpr::Or(v) => match mode {
SpdxMode::Denied => v.iter().all(|e| spdx_eval(e, set, mode)),
SpdxMode::Allowed => v.iter().any(|e| spdx_eval(e, set, mode)),
},
}
}
/// Collect the leaf license ids of a parsed SPDX expression (the `Id` atoms of the
/// AND/OR tree), in left-to-right order. The `crate::spdx` license-list linter walks
/// these to classify each declared id as known / deprecated / unknown — reusing the
/// single expression parser rather than a second tokenizer.
pub(crate) fn spdx_leaf_ids(expr: &SpdxExpr) -> Vec<String> {
let mut out = Vec::new();
fn walk(e: &SpdxExpr, out: &mut Vec<String>) {
match e {
SpdxExpr::Id(id) => out.push(id.clone()),
SpdxExpr::And(v) | SpdxExpr::Or(v) => v.iter().for_each(|c| walk(c, out)),
}
}
walk(expr, &mut out);
out
}
/// Parse an SPDX license expression. Best-effort + total: a malformed/plain string
/// (no operators) becomes a single [`SpdxExpr::Id`], so the single-license gate
/// behaviour is preserved and nothing ever panics. Operator precedence follows SPDX:
/// `WITH` > `AND` > `OR`; parentheses override. Operators are matched
/// case-insensitively.
pub(crate) fn spdx_parse(expr: &str) -> SpdxExpr {
let toks = spdx_tokens(expr);
// DoS guard: the license string is attacker-controlled (a scanned project's
// manifest), and the recursive-descent parser recurses once per paren nesting
// level — a `(((…)))` bomb would overflow the stack (SIGABRT), violating this
// module's never-panics contract. Cap the nesting depth; beyond it, fall back to
// the whole-string atom (the documented behaviour for anything unparseable). A
// real SPDX expression nests 2–3 deep, never 64.
const MAX_DEPTH: i32 = 64;
let (mut depth, mut max_depth) = (0i32, 0i32);
for t in &toks {
if t == "(" {
depth += 1;
max_depth = max_depth.max(depth);
} else if t == ")" {
depth -= 1;
}
}
if max_depth > MAX_DEPTH {
return SpdxExpr::Id(expr.trim().to_string());
}
let mut pos = 0;
let ast = spdx_parse_or(&toks, &mut pos);
// If we didn't consume everything (stray tokens / unbalanced parens), fall back
// to the whole string as one atom — never worse than the pre-expression match.
if pos == toks.len() && !matches!(&ast, SpdxExpr::Id(s) if s.is_empty()) {
ast
} else {
SpdxExpr::Id(expr.trim().to_string())
}
}
fn spdx_tokens(expr: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
for ch in expr.chars() {
match ch {
'(' | ')' => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
out.push(ch.to_string());
}
c if c.is_whitespace() => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
c => cur.push(c),
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
fn is_kw(tok: &str, kw: &str) -> bool {
tok.eq_ignore_ascii_case(kw)
}
fn spdx_parse_or(toks: &[String], pos: &mut usize) -> SpdxExpr {
let mut terms = vec![spdx_parse_and(toks, pos)];
while *pos < toks.len() && is_kw(&toks[*pos], "OR") {
*pos += 1;
terms.push(spdx_parse_and(toks, pos));
}
if terms.len() == 1 {
terms.pop().unwrap()
} else {
SpdxExpr::Or(terms)
}
}
fn spdx_parse_and(toks: &[String], pos: &mut usize) -> SpdxExpr {
let mut factors = vec![spdx_parse_factor(toks, pos)];
while *pos < toks.len() && is_kw(&toks[*pos], "AND") {
*pos += 1;
factors.push(spdx_parse_factor(toks, pos));
}
if factors.len() == 1 {
factors.pop().unwrap()
} else {
SpdxExpr::And(factors)
}
}
fn spdx_parse_factor(toks: &[String], pos: &mut usize) -> SpdxExpr {
if *pos >= toks.len() {
return SpdxExpr::Id(String::new());
}
if toks[*pos] == "(" {
*pos += 1;
let inner = spdx_parse_or(toks, pos);
if *pos < toks.len() && toks[*pos] == ")" {
*pos += 1;
}
return inner;
}
// A license id, optionally `id WITH exception` — the base id carries the gate
// identity; skip the exception operand.
let id = toks[*pos].clone();
*pos += 1;
if *pos + 1 < toks.len() && is_kw(&toks[*pos], "WITH") {
*pos += 2; // consume WITH + the exception id
}
SpdxExpr::Id(id)
}
/// Provenance requirement: what a missing signature costs.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ProvenancePolicy {
/// Don't care about signatures.
#[default]
Ignore,
/// Missing signature ⇒ `Warn`.
Warn,
/// Missing signature ⇒ deny.
Require,
}
/// Engine A policy: license rules + provenance requirement + exploit-intelligence
/// thresholds + a version label (the `policy_version` part of the verdict cache key).
#[derive(Debug, Clone)]
pub struct Policy {
pub version: String,
pub license: LicensePolicy,
pub provenance: ProvenancePolicy,
/// **KEV escalation** — a matched advisory in CISA's Known-Exploited catalog
/// ([`Advisory::kev`]) is bumped to `Critical` (⇒ `Block`). On by default: a
/// confirmed-exploited vuln is the strongest signal the field recognizes.
pub kev_blocks: bool,
/// **EPSS escalation** — when set, a matched advisory whose [`Advisory::epss`]
/// ≥ this probability is bumped to at least `High` (⇒ `Block`). `None` ⇒ EPSS
/// is informational only (recorded in the finding, never changes the decision).
pub epss_block_threshold: Option<f64>,
/// **VEX `not_affected`** — advisory ids the consumer has asserted do not
/// apply to this artifact (e.g. the vulnerable path is unreachable). A matched
/// advisory whose id is listed is **suppressed** (no finding). This is the
/// VEX-suppression the field uses to kill unreachable-CVE noise.
pub vex_not_affected: Vec<String>,
}
impl Default for Policy {
fn default() -> Self {
Self {
version: String::new(),
license: LicensePolicy::default(),
provenance: ProvenancePolicy::default(),
// Security-sane defaults: KEV always escalates; EPSS informational
// until a threshold is set; no suppressions.
kev_blocks: true,
epss_block_threshold: None,
vex_not_affected: Vec::new(),
}
}
}
impl Policy {
/// The license check → zero or one [`Finding`].
///
/// Shared by [`OsvScanner`] (Engine A's convenience per-artifact path) and the
/// swappable [`crate::engine`] engines, so the license → severity mapping has a
/// single source of truth regardless of which matching strategy found the vulns.
pub(crate) fn license_finding(&self, artifact: &Artifact) -> Option<Finding> {
let lp = &self.license;
match &artifact.license {
// A declared license is an SPDX **expression** ("MIT OR Apache-2.0",
// "GPL-3.0 AND MIT"), not always a single id. Evaluating the expression
// (see [`spdx_parse`]/[`spdx_eval`]) fixes two bugs a whole-string match
// has: a DENY fail-open — "MIT AND GPL-3.0" is not byte-equal to
// "GPL-3.0", so a denied license hidden in an AND slips the gate — and a
// false-positive Warn on a dual-licensed component whose allowed disjunct
// is fine. A bare single id parses to one atom, so the (case-insensitive)
// single-license behaviour is unchanged.
Some(lic) if spdx_eval(&spdx_parse(lic), &lp.deny, SpdxMode::Denied) => Some(Finding {
severity: Severity::High,
id: format!("MODGUNN-LICENSE-DENIED:{lic}"),
summary: format!("license `{lic}` is denied by policy {}", self.version),
}),
Some(lic)
if !lp.allow.is_empty()
&& !spdx_eval(&spdx_parse(lic), &lp.allow, SpdxMode::Allowed) =>
{
Some(Finding {
severity: Severity::Medium,
id: format!("MODGUNN-LICENSE-UNREVIEWED:{lic}"),
summary: format!("license `{lic}` is not on the policy allow-list"),
})
}
Some(_) => None, // allowed (explicitly, or by an empty allow-list)
None => severity_for(lp.on_unknown).map(|severity| Finding {
severity,
id: "MODGUNN-LICENSE-UNKNOWN".to_string(),
summary: "artifact declares no license".to_string(),
}),
}
}
/// One matched advisory → zero or one [`Finding`], applying VEX suppression
/// and KEV/EPSS escalation. Returns `None` when VEX marks it `not_affected`.
pub(crate) fn vuln_finding(&self, adv: &Advisory) -> Option<Finding> {
// VEX: the consumer asserted this advisory does not apply (e.g. the
// vulnerable path is unreachable) → suppress it entirely.
if self.vex_not_affected.iter().any(|id| id == &adv.id) {
return None;
}
let mut severity = adv.severity;
let mut tags = String::new();
// KEV: confirmed exploited in the wild ⇒ escalate to Critical.
if adv.kev && self.kev_blocks {
severity = severity.max(Severity::Critical);
tags.push_str(" [KEV]");
}
// EPSS: high exploit probability ⇒ escalate to at least High (Block).
if let Some(threshold) = self.epss_block_threshold {
if adv.epss.is_some_and(|e| e >= threshold) {
severity = severity.max(Severity::High);
}
}
if let Some(e) = adv.epss {
tags.push_str(&format!(" EPSS={e:.2}"));
}
Some(Finding {
severity,
id: adv.id.clone(),
summary: format!("{}{tags}", adv.summary),
})
}
/// The provenance check → zero or one [`Finding`].
pub(crate) fn provenance_finding(&self, artifact: &Artifact) -> Option<Finding> {
if artifact.provenance.signed {
return None;
}
let decision = match self.provenance {
ProvenancePolicy::Ignore => Decision::Pass,
ProvenancePolicy::Warn => Decision::Warn,
ProvenancePolicy::Require => Decision::Block,
};
severity_for(decision).map(|severity| Finding {
severity,
id: "MODGUNN-PROVENANCE-MISSING".to_string(),
summary: "artifact has no signature / attestation".to_string(),
})
}
/// Fold an artifact + its already-matched advisories into a cacheable
/// [`Verdict`], applying the three checks (vuln / license / provenance) and the
/// module's decision table. This is the engine-agnostic verdict assembly: the
/// ONLY thing Engine A and Engine B do differently is *find* `matched` — once the
/// matching advisories are in hand, the verdict is identical. `advisory_db_rev`
/// is the cache-key rev of the db the matches came from.
pub fn assemble(
&self,
advisory_db_rev: &str,
artifact: &Artifact,
matched: &[&Advisory],
) -> Verdict {
let mut findings = Vec::new();
// 1. Known-vulnerability match — every matching advisory, escalated by
// exploit intelligence (KEV/EPSS) and filtered by VEX suppression.
for adv in matched {
if let Some(f) = self.vuln_finding(adv) {
findings.push(f);
}
}
// 2. License policy.
if let Some(f) = self.license_finding(artifact) {
findings.push(f);
}
// 3. Provenance / signature presence.
if let Some(f) = self.provenance_finding(artifact) {
findings.push(f);
}
let decision = decide(&findings);
// Introspection marker — the engine-agnostic verdict-assembly hot path
// reports which branch it took (allow=Pass/Warn → GREEN, deny=Block → RED)
// so `nornir test` SEES the policy evaluation itself, not only the
// `OsvScanner` wrapper (Engine B's `verdict()` path routes through here too,
// so both engines light the same surface). Feature-gated: a compiled-out
// `#[inline]` no-op with no nornir dep in the lean `scan` build.
#[cfg(feature = "testmatrix")]
crate::functional_status(
"modgunn/verdict-assembly",
"policy_decision",
!matches!(decision, Decision::Block),
&format!(
"{} {} → {:?} ({} finding(s) over {} matched advisor{})",
artifact.artifact.name,
artifact.artifact.version,
decision,
findings.len(),
matched.len(),
if matched.len() == 1 { "y" } else { "ies" },
),
);
Verdict {
artifact: artifact.artifact.clone(),
decision,
findings,
policy_version: self.version.clone(),
advisory_db_rev: advisory_db_rev.to_string(),
}
}
}
/// The gate [`Decision`] implied by a finding's severity (the table in the module
/// docs). `Critical`/`High` deny; `Medium`/`Low` warn; `Info`/none pass.
pub fn decision_for(severity: Severity) -> Decision {
match severity {
Severity::Critical | Severity::High => Decision::Block,
Severity::Medium | Severity::Low => Decision::Warn,
Severity::Info => Decision::Pass,
}
}
/// The overall [`Decision`] for a set of findings: the strongest implied decision
/// (the max severity drives it). No findings ⇒ [`Decision::Pass`].
pub fn decide(findings: &[Finding]) -> Decision {
findings
.iter()
.map(|f| decision_for(f.severity))
.max()
.unwrap_or(Decision::Pass)
}
/// The severity that *realizes* a target decision when a check needs to emit a
/// finding to push the verdict to that decision (`Block`→`High`, `Warn`→`Medium`,
/// `Pass`→no finding). Inverse-compatible with [`decision_for`].
fn severity_for(decision: Decision) -> Option<Severity> {
match decision {
Decision::Block => Some(Severity::High),
Decision::Warn => Some(Severity::Medium),
Decision::Pass => None,
}
}
// `Decision`'s derived `Ord` follows declaration order Pass < Warn < Block, so
// `.max()` above picks the strictest decision. Pin that with a test rather than a
// comment alone.
/// A pluggable scanner that turns an [`Artifact`] into a cacheable [`Verdict`].
pub trait ScanEngine {
fn scan(&self, artifact: &Artifact) -> Verdict;
}
/// The default multi-ecosystem scanner: known-vuln match (offline [`AdvisoryDb`],
/// OSV-shaped) + license policy + provenance check, folded into a [`Verdict`] via
/// the module's decision table. Named for the OSV advisory shape it consumes.
#[derive(Debug, Default, Clone)]
pub struct OsvScanner {
pub policy: Policy,
pub advisories: AdvisoryDb,
}
impl OsvScanner {
pub fn new(policy: Policy, advisories: AdvisoryDb) -> Self {
Self { policy, advisories }
}
}
impl ScanEngine for OsvScanner {
fn scan(&self, artifact: &Artifact) -> Verdict {
// Engine A's per-artifact path: linear [`AdvisoryDb::query`] to find the
// matching advisories, then the shared engine-agnostic verdict assembly.
let matched = self.advisories.query(&artifact.artifact);
let verdict = self
.policy
.assemble(&self.advisories.rev, artifact, &matched);
// Introspection marker — Engine A reports each verdict to the test matrix.
#[cfg(feature = "testmatrix")]
crate::functional_status(
"modgunn/engine-a-osv-scanner",
"scan_verdict",
!matches!(verdict.decision, Decision::Block),
&format!(
"{} {} → {:?} ({} finding(s))",
artifact.artifact.name,
artifact.artifact.version,
verdict.decision,
verdict.findings.len()
),
);
verdict
}
}
#[cfg(test)]
mod tests {
use super::*;
fn aref(ecosystem: &str, name: &str, version: &str) -> ArtifactRef {
ArtifactRef {
ecosystem: ecosystem.into(),
name: name.into(),
version: version.into(),
blob_sha256: format!("{name}-{version}-sha"),
}
}
/// PyPI equi-join canonicalization (PEP 503 + pip↔pypi fold). A `pypi` advisory
/// keyed on the normalized name the OSV/PyPA DBs ship must still match a
/// component that carries the author's spelling or the `pip` manifest label —
/// otherwise every PyPI CVE is a silent false negative on real projects. RED if
/// the join reverts to exact `ecosystem`/`name` string equality.
#[test]
fn pypi_join_is_pep503_and_pip_pypi_folded() {
// Case-fold: advisory `django`, component `Django`.
let a = Advisory::basic(
"CVE-DJ",
"pypi",
"django",
vec!["4.2.1".into()],
Severity::High,
"x",
);
assert!(
a.matches(&aref("pypi", "Django", "4.2.1")),
"PyPI names are case-insensitive"
);
// Separator collapse: advisory `zope-interface`, component `zope.interface`.
let b = Advisory::basic(
"CVE-ZI",
"pypi",
"zope-interface",
vec!["6.0".into()],
Severity::Low,
"x",
);
assert!(
b.matches(&aref("pypi", "zope.interface", "6.0")),
"PEP 503 folds . / _ / - runs"
);
assert!(
b.matches(&aref("pypi", "Zope__Interface", "6.0")),
"case + underscore run together"
);
// Ecosystem fold: the pip manifest parser labels components `pip`, OSV uses `pypi`.
assert!(
a.matches(&aref("pip", "django", "4.2.1")),
"pip and pypi are the same registry"
);
assert!(
a.matches(&aref("pip", "Django", "4.2.1")),
"fold + case together"
);
// Negative — separator collapse must NOT bleed across ecosystems: a cargo
// advisory keeps case-sensitive, separator-significant exact matching, so no
// false positive is manufactured for non-PyPI names.
let c = Advisory::basic(
"CVE-OSSL",
"cargo",
"OpenSSL",
vec!["1.0.0".into()],
Severity::High,
"x",
);
assert!(
!c.matches(&aref("cargo", "openssl", "1.0.0")),
"cargo stays case-sensitive (no over-normalize)"
);
assert!(
c.matches(&aref("cargo", "OpenSSL", "1.0.0")),
"exact cargo name still matches"
);
}
/// NuGet IDs are case-insensitive but separator-significant: a casing mismatch
/// between advisory and manifest must still match, while a `.`-vs-nothing
/// difference must NOT (those are genuinely different packages).
#[test]
fn nuget_join_is_case_insensitive_but_separator_significant() {
let a = Advisory::basic(
"CVE-NJ",
"nuget",
"Newtonsoft.Json",
vec!["13.0.1".into()],
Severity::High,
"x",
);
assert!(
a.matches(&aref("nuget", "newtonsoft.json", "13.0.1")),
"NuGet IDs are case-insensitive"
);
assert!(
a.matches(&aref("nuget", "Newtonsoft.Json", "13.0.1")),
"exact casing still matches"
);
assert!(
!a.matches(&aref("nuget", "NewtonsoftJson", "13.0.1")),
"the '.' is significant — different pkg"
);
}
// ── P0: range-native matching + withdrawn + alias dedup ──────────────────
/// An advisory with OSV ranges matches by `affected_by` (range-native) — no
/// pre-expansion to exact versions needed: `[1.0.0, 1.2.0)` hits 1.0.0/1.1.x,
/// misses the fixed 1.2.0 and anything below the introduced bound.
#[test]
fn range_native_match_uses_affected_by() {
use crate::versions::RangeEvent::{Fixed, Introduced};
let adv = Advisory::basic("CVE-R", "cargo", "widget", vec![], Severity::High, "ranged")
.with_ranges(vec![Introduced("1.0.0".into()), Fixed("1.2.0".into())]);
assert!(
adv.matches(&aref("cargo", "widget", "1.0.0")),
"introduced bound is inclusive"
);
assert!(
adv.matches(&aref("cargo", "widget", "1.1.9")),
"inside the range"
);
assert!(
!adv.matches(&aref("cargo", "widget", "1.2.0")),
"fixed bound is exclusive"
);
assert!(
!adv.matches(&aref("cargo", "widget", "0.9.0")),
"below introduced"
);
// A range advisory must NOT match the all-versions fallback for a different pkg.
assert!(
!adv.matches(&aref("cargo", "other", "1.1.0")),
"different package"
);
}
/// A withdrawn advisory never matches, even one that would otherwise hit every
/// version (empty affected_versions + empty ranges).
#[test]
fn withdrawn_advisory_never_matches() {
let mut adv = Advisory::basic(
"GHSA-W",
"npm",
"leftpad",
vec![],
Severity::Low,
"retracted",
);
assert!(
adv.matches(&aref("npm", "leftpad", "1.0.0")),
"all-versions hit before withdrawal"
);
adv.withdrawn = true;
assert!(
!adv.matches(&aref("npm", "leftpad", "1.0.0")),
"withdrawn ⇒ never matches"
);
}
/// The same vulnerability arriving as a CVE row and a GHSA row (sharing an
/// alias) for the same package folds into one advisory: union of ranges +
/// aliases, max severity, OR-ed kev.
#[test]
fn merge_by_alias_folds_same_vuln() {
use crate::versions::RangeEvent::Introduced;
let cve = Advisory::basic(
"CVE-1",
"cargo",
"openssl",
vec![],
Severity::Medium,
"from NVD",
)
.with_aliases(["GHSA-xxxx"])
.with_ranges(vec![Introduced("0.1.0".into())]);
let mut ghsa = Advisory::basic(
"GHSA-xxxx",
"cargo",
"openssl",
vec!["0.2.0".into()],
Severity::High,
"from GHSA",
);
ghsa.kev = true;
// An unrelated advisory for a different package must survive untouched.
let other = Advisory::basic("CVE-2", "npm", "lodash", vec![], Severity::Low, "unrelated");
let merged = merge_by_alias(vec![cve, ghsa, other]);
assert_eq!(
merged.len(),
2,
"the two openssl rows fold to one; lodash stays"
);
let os = merged.iter().find(|a| a.package == "openssl").unwrap();
assert_eq!(os.severity, Severity::High, "max severity wins");
assert!(os.kev, "kev OR-ed in");
assert!(os.aliases.iter().any(|a| a == "GHSA-xxxx") || os.id == "GHSA-xxxx");
assert!(
os.affected_versions.contains(&"0.2.0".to_string()),
"affected versions unioned"
);
assert!(!os.ranges.is_empty(), "ranges unioned");
}
/// Two feeds describing the SAME CVE with different windows must union, not
/// cancel. OSV says `[0, 2.0.0)`, the aliasing GHSA says `[1.5.0, 1.9.0)`;
/// 1.9.5 is inside the OSV window, so the merged row must still match it.
/// Splicing both feeds' events into one list makes the GHSA `Fixed(1.9.0)`
/// switch the walk off and reports the vulnerable 1.9.5 as CLEAN.
#[test]
fn merge_by_alias_unions_windows_instead_of_cancelling() {
use crate::versions::RangeEvent::{Fixed, Introduced};
let osv = Advisory::basic("CVE-X", "cargo", "foo", vec![], Severity::High, "osv")
.with_ranges(vec![Introduced("0".into()), Fixed("2.0.0".into())]);
let ghsa = Advisory::basic("GHSA-Y", "cargo", "foo", vec![], Severity::High, "ghsa")
.with_aliases(["CVE-X"])
.with_ranges(vec![Introduced("1.5.0".into()), Fixed("1.9.0".into())]);
for order in [vec![osv.clone(), ghsa.clone()], vec![ghsa, osv]] {
let merged = merge_by_alias(order);
assert_eq!(merged.len(), 1, "aliased rows fold");
let m = &merged[0];
assert!(
m.matches(&aref("cargo", "foo", "1.9.5")),
"1.9.5 is inside [0, 2.0.0)"
);
assert!(
m.matches(&aref("cargo", "foo", "1.6.0")),
"inside both windows"
);
assert!(
!m.matches(&aref("cargo", "foo", "2.0.0")),
"above both windows"
);
}
}
/// An all-versions row (empty affected_versions AND empty ranges) merged with an
/// aliased ranged sibling must stay all-versions — the union of "every version"
/// with a window is every version. Absorbing the window instead NARROWS the row
/// and turns a previously-matching package into a false PASS.
#[test]
fn merge_by_alias_keeps_all_versions_sentinel() {
use crate::versions::RangeEvent::{Fixed, Introduced};
let all = Advisory::basic(
"GHSA-A",
"npm",
"foo",
vec![],
Severity::Critical,
"malicious",
);
let ranged = Advisory::basic("CVE-B", "npm", "foo", vec![], Severity::High, "ranged")
.with_aliases(["GHSA-A"])
.with_ranges(vec![Introduced("1.0.0".into()), Fixed("1.2.0".into())]);
assert!(
all.matches(&aref("npm", "foo", "3.0.0")),
"all-versions matches before the merge"
);
for order in [vec![all.clone(), ranged.clone()], vec![ranged, all]] {
let merged = merge_by_alias(order);
assert_eq!(merged.len(), 1, "aliased rows fold");
assert!(
merged[0].matches(&aref("npm", "foo", "3.0.0")),
"all-versions must survive the merge"
);
assert!(
merged[0].matches(&aref("npm", "foo", "1.1.0")),
"and still cover the window"
);
}
}
/// A policy that allows MIT/Apache-2.0, denies GPL-3.0, warns on unknown
/// license, and requires provenance. The advisory db carries one High-severity
/// vuln for `openssl@0.1.0` (cargo) and one Low-severity one for `left-pad`.
fn scanner() -> OsvScanner {
let policy = Policy {
version: "policy-v1".into(),
license: LicensePolicy {
allow: vec!["MIT".into(), "Apache-2.0".into()],
deny: vec!["GPL-3.0".into()],
on_unknown: Decision::Warn,
},
provenance: ProvenancePolicy::Require,
..Default::default()
};
let advisories = AdvisoryDb::new(
"db-rev-42",
vec![
Advisory::basic(
"RUSTSEC-2024-0001",
"cargo",
"openssl",
vec!["0.1.0".into()],
Severity::High,
"use-after-free in openssl",
),
Advisory::basic(
"CVE-2099-LEFTPAD",
"npm",
"left-pad",
vec![], // all versions
Severity::Low,
"left-pad disappeared",
),
],
);
OsvScanner::new(policy, advisories)
}
/// Clean dependency: allowed license, signed, no vuln ⇒ Pass, no findings.
#[test]
fn clean_dependency_passes() {
let art = Artifact::new(
aref("cargo", "serde", "1.0.0"),
Some("MIT".into()),
Provenance::signed_by("nordisk-ci"),
);
let v = scanner().scan(&art);
assert_eq!(v.decision, Decision::Pass);
assert!(v.findings.is_empty(), "a clean dep has no findings");
// Cache key carries the policy + db revs.
assert_eq!(v.policy_version, "policy-v1");
assert_eq!(v.advisory_db_rev, "db-rev-42");
assert_eq!(
v.cache_key(),
(
"serde-1.0.0-sha".into(),
"policy-v1".into(),
"db-rev-42".into()
)
);
}
/// Known High-severity vuln ⇒ Block, and the advisory id + summary are in the
/// reason. (Signed + allowed license, so the vuln is the *only* reason.)
#[test]
fn known_high_vuln_blocks_with_advisory_in_reason() {
let art = Artifact::new(
aref("cargo", "openssl", "0.1.0"),
Some("Apache-2.0".into()),
Provenance::signed_by("nordisk-ci"),
);
let v = scanner().scan(&art);
assert_eq!(v.decision, Decision::Block, "a High vuln denies");
assert_eq!(v.findings.len(), 1, "only the vuln finding");
let f = &v.findings[0];
assert_eq!(f.id, "RUSTSEC-2024-0001", "the advisory id is the reason");
assert_eq!(f.severity, Severity::High);
assert!(f.summary.contains("use-after-free"));
}
/// A different version of the same package is NOT affected by an exact-version
/// advisory ⇒ Pass.
#[test]
fn unaffected_version_is_not_flagged() {
let art = Artifact::new(
aref("cargo", "openssl", "0.2.0"),
Some("Apache-2.0".into()),
Provenance::signed_by("nordisk-ci"),
);
let v = scanner().scan(&art);
assert_eq!(v.decision, Decision::Pass);
assert!(v.findings.is_empty());
}
/// An all-versions advisory in a different ecosystem only matches that
/// ecosystem — a cargo `left-pad` is clean; the npm one is flagged (Low ⇒ Warn).
#[test]
fn advisory_is_ecosystem_scoped() {
let cargo = Artifact::new(
aref("cargo", "left-pad", "1.0.0"),
Some("MIT".into()),
Provenance::signed_by("ci"),
);
assert_eq!(
scanner().scan(&cargo).decision,
Decision::Pass,
"no cargo left-pad advisory"
);
let npm = Artifact::new(
aref("npm", "left-pad", "1.0.0"),
Some("MIT".into()),
Provenance::signed_by("ci"),
);
let v = scanner().scan(&npm);
assert_eq!(v.decision, Decision::Warn, "a Low npm advisory warns");
assert_eq!(v.findings[0].id, "CVE-2099-LEFTPAD");
}
/// A denied license ⇒ Block, with the license named in the reason.
#[test]
fn denied_license_blocks() {
let art = Artifact::new(
aref("cargo", "copyleftcrate", "1.0.0"),
Some("GPL-3.0".into()),
Provenance::signed_by("ci"),
);
let v = scanner().scan(&art);
assert_eq!(v.decision, Decision::Block);
assert_eq!(v.findings.len(), 1);
assert!(v.findings[0].id.contains("LICENSE-DENIED"));
assert!(v.findings[0].summary.contains("GPL-3.0"));
}
/// RED-when-reverted: the DENY gate matches SPDX ids case-insensitively — a
/// manifest author must not defeat a hard deny by re-casing the license.
/// `gpl-3.0` against a `GPL-3.0` deny list still Blocks (was a fail-open to Warn).
#[test]
fn denied_license_is_case_insensitive() {
let art = Artifact::new(
aref("cargo", "sneaky", "1.0.0"),
Some("gpl-3.0".into()),
Provenance::signed_by("ci"),
);
let v = scanner().scan(&art);
assert_eq!(
v.decision,
Decision::Block,
"a re-cased denied license must still Block"
);
assert!(
v.findings[0].id.contains("LICENSE-DENIED"),
"not the softer UNREVIEWED arm"
);
}
/// RED-when-reverted: SPDX expression evaluation. The deny gate must not fail
/// open on an `AND` that hides a denied license, and must not warn an `OR` whose
/// allowed disjunct is fine.
#[test]
fn spdx_expression_deny_and_allow() {
let deny = vec!["GPL-3.0".to_string()];
let allow = vec!["MIT".to_string(), "Apache-2.0".to_string()];
// AND: a denied license anywhere in the conjunction denies the whole (the
// fail-open a whole-string match had — "MIT AND GPL-3.0" != "GPL-3.0").
assert!(spdx_eval(
&spdx_parse("MIT AND GPL-3.0"),
&deny,
SpdxMode::Denied
));
assert!(spdx_eval(
&spdx_parse("GPL-3.0 AND MIT"),
&deny,
SpdxMode::Denied
));
// OR: can pick the clean disjunct → NOT denied.
assert!(!spdx_eval(
&spdx_parse("MIT OR GPL-3.0"),
&deny,
SpdxMode::Denied
));
// Allowed: an OR is allowed if ANY disjunct is (no spurious "unreviewed").
assert!(spdx_eval(
&spdx_parse("MIT OR Apache-2.0"),
&allow,
SpdxMode::Allowed
));
assert!(spdx_eval(
&spdx_parse("MIT OR SomethingUnknown"),
&allow,
SpdxMode::Allowed
));
// Allowed: an AND needs ALL operands allowed.
assert!(spdx_eval(
&spdx_parse("MIT AND Apache-2.0"),
&allow,
SpdxMode::Allowed
));
assert!(!spdx_eval(
&spdx_parse("MIT AND BSD-3-Clause"),
&allow,
SpdxMode::Allowed
));
// Parens + precedence, and WITH-exception folds onto the base id.
assert!(spdx_eval(
&spdx_parse("(MIT OR Apache-2.0) AND MIT"),
&allow,
SpdxMode::Allowed
));
assert!(spdx_eval(
&spdx_parse("Apache-2.0 WITH LLVM-exception"),
&allow,
SpdxMode::Allowed
));
// Single id (backward compat) + case-insensitive.
assert!(spdx_eval(&spdx_parse("gpl-3.0"), &deny, SpdxMode::Denied));
assert_eq!(spdx_parse("MIT"), SpdxExpr::Id("MIT".into()));
}
/// RED-when-reverted: a deeply-nested-parens license (attacker-controlled from a
/// scanned manifest) must NOT overflow the parser's stack — it falls back to the
/// whole-string atom. Without the depth guard this aborts the process (SIGABRT).
#[test]
fn spdx_deeply_nested_parens_does_not_overflow() {
let bomb = format!("{}MIT{}", "(".repeat(5000), ")".repeat(5000));
// Must return (not abort); beyond the depth cap it's the whole-string atom.
let expr = spdx_parse(&bomb);
assert!(
matches!(expr, SpdxExpr::Id(_)),
"past the depth cap ⇒ whole-string fallback"
);
// A normally-shaped nested expression still parses to structure (not fallback).
assert!(matches!(
spdx_parse("(MIT OR Apache-2.0) AND BSD-3-Clause"),
SpdxExpr::And(_)
));
}
/// End-to-end: a component licensed `MIT AND GPL-3.0` (GPL-3.0 denied) must Block,
/// not slip to Warn — the fail-open the whole-string match had.
#[test]
fn spdx_and_expression_hitting_deny_blocks() {
let art = Artifact::new(
aref("cargo", "dual", "1.0.0"),
Some("MIT AND GPL-3.0".into()),
Provenance::signed_by("ci"),
);
let v = scanner().scan(&art);
assert_eq!(
v.decision,
Decision::Block,
"a denied license in an AND must Block"
);
assert!(v.findings.iter().any(|f| f.id.contains("LICENSE-DENIED")));
}
/// End-to-end: a dual-licensed `MIT OR Apache-2.0` (both allowed) must Pass, not
/// warn as unreviewed (the false positive the whole-string match had).
#[test]
fn spdx_or_expression_with_allowed_disjunct_passes() {
let art = Artifact::new(
aref("cargo", "dual2", "1.0.0"),
Some("MIT OR Apache-2.0".into()),
Provenance::signed_by("ci"),
);
let v = scanner().scan(&art);
assert!(
!v.findings.iter().any(|f| f.id.contains("LICENSE")),
"an allowed disjunct ⇒ no license finding"
);
}
/// The symmetric allow-list case-insensitivity: an allowed license spelled in a
/// different case is allowed (Pass), not warned as unreviewed.
#[test]
fn allowed_license_is_case_insensitive() {
// `scanner()`'s allow-list carries "MIT"; a lowercased `mit` must still pass.
let art = Artifact::new(
aref("cargo", "fine", "1.0.0"),
Some("mit".into()),
Provenance::signed_by("ci"),
);
let v = scanner().scan(&art);
assert!(
!v.findings.iter().any(|f| f.id.contains("LICENSE")),
"a case-variant of an allowed license is not a license finding"
);
}
/// A license that is neither allowed nor denied, against a non-empty allow-list,
/// is "unreviewed" ⇒ Warn.
#[test]
fn unreviewed_license_warns() {
let art = Artifact::new(
aref("cargo", "oddlicense", "1.0.0"),
Some("BSL-1.1".into()),
Provenance::signed_by("ci"),
);
let v = scanner().scan(&art);
assert_eq!(v.decision, Decision::Warn);
assert!(v.findings[0].id.contains("LICENSE-UNREVIEWED"));
assert_eq!(v.findings[0].severity, Severity::Medium);
}
/// Unknown/unstated license follows `on_unknown` (Warn here).
#[test]
fn unknown_license_follows_policy() {
let art = Artifact::new(
aref("cargo", "nolicense", "1.0.0"),
None,
Provenance::signed_by("ci"),
);
let v = scanner().scan(&art);
assert_eq!(v.decision, Decision::Warn);
assert_eq!(v.findings[0].id, "MODGUNN-LICENSE-UNKNOWN");
}
/// Provenance required + missing signature ⇒ Block (deny), even with a clean
/// license and no vuln.
#[test]
fn missing_provenance_when_required_blocks() {
let art = Artifact::new(
aref("cargo", "serde", "1.0.0"),
Some("MIT".into()),
Provenance::unsigned(),
);
let v = scanner().scan(&art);
assert_eq!(v.decision, Decision::Block, "Require + unsigned denies");
assert_eq!(v.findings.len(), 1);
assert_eq!(v.findings[0].id, "MODGUNN-PROVENANCE-MISSING");
}
/// Provenance only `Warn`: a missing signature warns rather than blocks.
#[test]
fn missing_provenance_when_warn_only_warns() {
let mut s = scanner();
s.policy.provenance = ProvenancePolicy::Warn;
let art = Artifact::new(
aref("cargo", "serde", "1.0.0"),
Some("MIT".into()),
Provenance::unsigned(),
);
let v = s.scan(&art);
assert_eq!(v.decision, Decision::Warn);
assert_eq!(v.findings[0].severity, Severity::Medium);
}
/// Provenance `Ignore`: a missing signature is silent (no finding).
#[test]
fn missing_provenance_when_ignored_is_silent() {
let mut s = scanner();
s.policy.provenance = ProvenancePolicy::Ignore;
let art = Artifact::new(
aref("cargo", "serde", "1.0.0"),
Some("MIT".into()),
Provenance::unsigned(),
);
let v = s.scan(&art);
assert_eq!(v.decision, Decision::Pass);
assert!(v.findings.is_empty());
}
/// The decision is the STRONGEST across checks: a Low vuln (Warn) plus an
/// unsigned-when-required artifact (Block) ⇒ Block, and BOTH findings are kept.
#[test]
fn strongest_decision_wins_and_all_findings_kept() {
let art = Artifact::new(
aref("npm", "left-pad", "1.0.0"), // Low vuln ⇒ Warn
Some("BSL-1.1".into()), // unreviewed ⇒ Warn
Provenance::unsigned(), // required+missing ⇒ Block
);
let v = scanner().scan(&art);
assert_eq!(
v.decision,
Decision::Block,
"the Block from provenance wins"
);
assert_eq!(
v.findings.len(),
3,
"vuln + license + provenance findings all retained"
);
let ids: Vec<&str> = v.findings.iter().map(|f| f.id.as_str()).collect();
assert!(ids.contains(&"CVE-2099-LEFTPAD"));
assert!(ids.iter().any(|id| id.contains("LICENSE-UNREVIEWED")));
assert!(ids.contains(&"MODGUNN-PROVENANCE-MISSING"));
}
/// A low-severity advisory marked KEV (confirmed exploited) escalates to
/// Critical ⇒ Block, and the reason is tagged `[KEV]`. (Default `kev_blocks`.)
#[test]
fn kev_escalates_low_vuln_to_block() {
let mut adv = Advisory::basic(
"CVE-KEV-1",
"npm",
"kevpkg",
vec![],
Severity::Low,
"exploited",
);
adv.kev = true;
let s = OsvScanner::new(
Policy {
version: "p".into(),
..Default::default()
},
AdvisoryDb::new("db", vec![adv]),
);
let art = Artifact::new(
aref("npm", "kevpkg", "1.0.0"),
Some("MIT".into()),
Provenance::signed_by("ci"),
);
let v = s.scan(&art);
assert_eq!(
v.decision,
Decision::Block,
"a KEV hit blocks even at base Low"
);
assert_eq!(v.findings[0].severity, Severity::Critical);
assert!(v.findings[0].summary.contains("[KEV]"));
}
/// EPSS escalation: a Low vuln with EPSS ≥ threshold is bumped to High ⇒ Block;
/// below threshold it stays Warn but the probability is recorded in the reason.
#[test]
fn epss_threshold_escalates_and_is_recorded() {
let mk = |epss: f64| {
let mut a = Advisory::basic(
"CVE-EPSS-1",
"npm",
"epsspkg",
vec![],
Severity::Low,
"maybe-exploited",
);
a.epss = Some(epss);
OsvScanner::new(
Policy {
version: "p".into(),
epss_block_threshold: Some(0.5),
..Default::default()
},
AdvisoryDb::new("db", vec![a]),
)
};
let art = Artifact::new(
aref("npm", "epsspkg", "1.0.0"),
Some("MIT".into()),
Provenance::signed_by("ci"),
);
let hot = mk(0.97).scan(&art);
assert_eq!(
hot.decision,
Decision::Block,
"EPSS 0.97 ≥ 0.5 ⇒ High ⇒ Block"
);
assert!(hot.findings[0].summary.contains("EPSS=0.97"));
let cold = mk(0.10).scan(&art);
assert_eq!(
cold.decision,
Decision::Warn,
"EPSS 0.10 < 0.5 ⇒ stays Low ⇒ Warn"
);
assert!(cold.findings[0].summary.contains("EPSS=0.10"));
}
/// VEX `not_affected`: a matched advisory whose id is suppressed yields NO
/// finding (the unreachable-CVE-noise killer), so an otherwise-clean dep passes.
#[test]
fn vex_not_affected_suppresses_the_finding() {
let s = OsvScanner::new(
Policy {
version: "p".into(),
vex_not_affected: vec!["RUSTSEC-2024-0001".into()],
..Default::default()
},
AdvisoryDb::new(
"db",
vec![Advisory::basic(
"RUSTSEC-2024-0001",
"cargo",
"openssl",
vec!["0.1.0".into()],
Severity::High,
"use-after-free",
)],
),
);
let art = Artifact::new(
aref("cargo", "openssl", "0.1.0"),
Some("MIT".into()),
Provenance::signed_by("ci"),
);
let v = s.scan(&art);
assert_eq!(
v.decision,
Decision::Pass,
"VEX not_affected suppresses the vuln"
);
assert!(
v.findings.is_empty(),
"no finding for a VEX-suppressed advisory"
);
}
/// Pin the decision table + that `Decision`'s `Ord` ranks Pass < Warn < Block.
#[test]
fn decision_table_is_severity_ordered() {
assert_eq!(decision_for(Severity::Critical), Decision::Block);
assert_eq!(decision_for(Severity::High), Decision::Block);
assert_eq!(decision_for(Severity::Medium), Decision::Warn);
assert_eq!(decision_for(Severity::Low), Decision::Warn);
assert_eq!(decision_for(Severity::Info), Decision::Pass);
assert!(Decision::Block > Decision::Warn && Decision::Warn > Decision::Pass);
assert_eq!(decide(&[]), Decision::Pass);
}
}