src/registry.rs
//! Registry **yank / deprecation** feed import (feature `scan`).
//!
//! A published package version that the registry itself has **pulled back** —
//! *yanked* on crates.io, *deprecated* on npm — is a supply-chain signal with no
//! CVE: the maintainer (or the registry) is telling you *do not depend on this
//! version*. Reasons range from a broken release to an actively-malicious one that
//! was scrubbed after publish. OSV/NVD do not carry it, so this module imports the
//! signal offline (the same airgap sealed-znippy path as [`crate::feeds`]) and
//! produces [`Advisory`]s that land in the SAME `cve_advisories` warehouse table —
//! no second seam (L5): a yank is coords + an affected version + a severity + a
//! stable id, which is exactly what an [`Advisory`] is.
//!
//! - **crates.io yanks** — the crates.io API `GET /api/v1/crates/{crate}` returns a
//! `versions[]` array, each `{ num, yanked, crate }`. Every `yanked: true` version
//! becomes a `MODGUNN-YANK-cargo-*` [`Advisory`] (ecosystem `cargo`), exact-version
//! keyed. ([`parse_crates_yanks`])
//! - **npm deprecations** — the npm registry `GET /{pkg}` packument returns a
//! `versions{}` map, each version object optionally carrying a `deprecated` string
//! (the `npm deprecate` message). Every deprecated version becomes a
//! `MODGUNN-DEPRECATED-npm-*` [`Advisory`] (ecosystem `npm`). ([`parse_npm_deprecations`])
//!
//! **Anti-silent-empty:** a document that lacks the `versions` field is a malformed
//! feed — [`parse_crates_yanks`] / [`parse_npm_deprecations`] return an `Err` (the
//! `versions` field is non-optional, so serde rejects it), which `cmd_import` counts
//! and names on stderr. A well-formed document whose versions are simply *none
//! yanked / none deprecated* legitimately yields an empty `Vec` — that is a real
//! zero, not a truncated fetch, and the two must not be confused.
//!
//! Both signals ride at `Severity::Low`: a yank/deprecation is "stop depending on
//! this", not a scored vulnerability, so it must be VISIBLE in the ledger without
//! escalating a verdict to refuse on its own. The [`crate::scan`] verdict + the
//! Mímir yank-cascade join (`.nornir/baddie-detection-design.md`) are what escalate
//! it, by combining the yank with release-lineage reachability.
use serde::Deserialize;
use crate::core::Severity;
use crate::scan::Advisory;
/// One entry of the crates.io `versions[]` array. `crate` (the owning crate name)
/// is carried on every version object, so the package coord is recoverable even
/// from a bare version list; `yanked` defaults to `false` for forward-compat with
/// versions that omit it.
#[derive(Debug, Deserialize)]
struct CratesVersion {
num: String,
#[serde(default)]
yanked: bool,
/// The owning crate name (crates.io stamps it on each version object).
#[serde(rename = "crate", default)]
crate_name: String,
}
/// A crates.io `GET /api/v1/crates/{crate}` response. `versions` is **non-optional**
/// so a truncated/wrong document (no `versions`) is a parse error, never a silent
/// empty. The optional top-level `crate` block supplies the name when a version
/// object omits it.
#[derive(Debug, Deserialize)]
struct CratesResponse {
versions: Vec<CratesVersion>,
#[serde(rename = "crate", default)]
krate: Option<CrateMeta>,
}
#[derive(Debug, Deserialize)]
struct CrateMeta {
#[serde(default)]
name: String,
}
/// Parse a crates.io crate document into one `MODGUNN-YANK-cargo-*` [`Advisory`] per
/// **yanked** version. A well-formed document with no yanked versions yields an
/// empty `Vec` (a real zero); a document without a `versions` array is an `Err`.
pub fn parse_crates_yanks(json: &str) -> Result<Vec<Advisory>, serde_json::Error> {
let resp: CratesResponse = serde_json::from_str(json)?;
let top_name = resp.krate.as_ref().map(|c| c.name.as_str()).unwrap_or("");
// The clean (non-yanked) versions — the candidate upgrade targets for the
// baddie-detection yank-cascade remediation (b3).
let clean: Vec<&str> = resp
.versions
.iter()
.filter(|v| !v.yanked)
.map(|v| v.num.as_str())
.collect();
let mut out = Vec::new();
for v in &resp.versions {
if !v.yanked {
continue;
}
// Prefer the version's own `crate` field; fall back to the top-level name.
let pkg = if v.crate_name.is_empty() {
top_name
} else {
v.crate_name.as_str()
};
if pkg.is_empty() {
// A yanked version we cannot attribute to a crate is useless (and would
// land a coordless row); skip it rather than emit a phantom package.
continue;
}
out.push(
Advisory::basic(
format!("MODGUNN-YANK-cargo-{pkg}-{}", v.num),
"cargo",
pkg,
vec![v.num.clone()],
Severity::Low,
yank_summary(
"crates.io",
pkg,
&v.num,
earliest_clean_successor("cargo", &clean, &v.num),
),
)
.with_source("crates.io"),
);
}
Ok(out)
}
/// The earliest **clean** version strictly greater than `after`, under `ecosystem`'s
/// version ordering — the actionable "upgrade to" target for a yanked/deprecated
/// version (the baddie-detection **b3 yank-cascade remediation**: given a yanked
/// pin, which release supersedes it). `None` when nothing clean supersedes it (the
/// whole tail is yanked, or `after` is already the latest) — an honest "no fix
/// forward" the lens can surface distinctly. Pure: reuses [`crate::versions::compare`],
/// so per-ecosystem prerelease/epoch rules apply (semver / PEP 440 / …).
pub fn earliest_clean_successor<'a>(
ecosystem: &str,
clean: &[&'a str],
after: &str,
) -> Option<&'a str> {
clean
.iter()
.copied()
.filter(|c| crate::versions::compare(ecosystem, c, after) == std::cmp::Ordering::Greater)
.min_by(|a, b| crate::versions::compare(ecosystem, a, b))
}
/// Build a yank/deprecation summary, appending the remediation when a clean
/// successor exists (`; upgrade to X`) or noting its absence — so the actionable
/// fix rides the round-tripping `summary` column for smid's Mímir lens.
fn yank_summary(source: &str, pkg: &str, version: &str, successor: Option<&str>) -> String {
match successor {
Some(s) => format!("yanked {source} version {version} of {pkg}; upgrade to {s}"),
None => format!("yanked {source} version {version} of {pkg} (no clean successor)"),
}
}
/// A single npm packument version object. `deprecated` is a free `Value` because npm
/// historically stores the deprecation *message* (a string) but tolerates a bare
/// `true`; [`deprecation_reason`] coerces both.
#[derive(Debug, Deserialize)]
struct NpmVersion {
#[serde(default)]
deprecated: Option<serde_json::Value>,
}
/// An npm `GET /{pkg}` packument. `versions` is **non-optional** (anti-silent-empty:
/// a document without it is a parse error, not a fake pass). `name` supplies the
/// package coord.
#[derive(Debug, Deserialize)]
struct NpmPackument {
#[serde(default)]
name: String,
versions: std::collections::HashMap<String, NpmVersion>,
}
/// Coerce npm's `deprecated` value into an actual deprecation reason. A non-empty
/// string is the message; `true` is a bare deprecation with no message; a `false`
/// / empty string / absent value is **not** deprecated (`None`).
fn deprecation_reason(v: &Option<serde_json::Value>) -> Option<String> {
match v {
Some(serde_json::Value::String(s)) if !s.trim().is_empty() => Some(s.trim().to_string()),
Some(serde_json::Value::Bool(true)) => Some(String::new()),
_ => None,
}
}
/// Parse an npm packument into one `MODGUNN-DEPRECATED-npm-*` [`Advisory`] per
/// **deprecated** version. A well-formed packument with no deprecated versions
/// yields an empty `Vec`; a document without a `versions` map is an `Err`.
pub fn parse_npm_deprecations(json: &str) -> Result<Vec<Advisory>, serde_json::Error> {
let pkg: NpmPackument = serde_json::from_str(json)?;
if pkg.name.is_empty() {
// A packument with no top-level name cannot key its versions to a package —
// treat it as malformed so the row is never coordless.
return Err(serde::de::Error::custom("npm packument missing `name`"));
}
let mut out = Vec::new();
// The clean (non-deprecated) versions — the b3 upgrade-target candidates.
let clean: Vec<&str> = pkg
.versions
.iter()
.filter(|(_, m)| deprecation_reason(&m.deprecated).is_none())
.map(|(v, _)| v.as_str())
.collect();
// Sort by version key so the emitted advisory order is deterministic (HashMap
// iteration is not) — matters for stable warehouse generations + test assertions.
let mut versions: Vec<(&String, &NpmVersion)> = pkg.versions.iter().collect();
versions.sort_by(|a, b| a.0.cmp(b.0));
for (ver, meta) in versions {
let Some(reason) = deprecation_reason(&meta.deprecated) else {
continue;
};
let base = if reason.is_empty() {
format!("deprecated npm version {ver} of {}", pkg.name)
} else {
format!("deprecated npm version {ver} of {}: {reason}", pkg.name)
};
let summary = match earliest_clean_successor("npm", &clean, ver) {
Some(s) => format!("{base}; upgrade to {s}"),
None => base,
};
out.push(
Advisory::basic(
format!("MODGUNN-DEPRECATED-npm-{}-{ver}", pkg.name),
"npm",
&pkg.name,
vec![ver.clone()],
Severity::Low,
summary,
)
.with_source("npm"),
);
}
Ok(out)
}
/// A single PyPI release-file record (one wheel/sdist). PEP 592 yanks operate at
/// the **file** level; `yanked` defaults to `false`.
#[derive(Debug, Deserialize)]
struct PypiFile {
#[serde(default)]
yanked: bool,
#[serde(default)]
yanked_reason: Option<String>,
}
/// A PyPI `GET /pypi/{project}/json` response. `releases` (version → files) is
/// **non-optional** (anti-silent-empty). `info.name` supplies the package coord.
#[derive(Debug, Deserialize)]
struct PypiProject {
info: PypiInfo,
releases: std::collections::HashMap<String, Vec<PypiFile>>,
}
#[derive(Debug, Deserialize)]
struct PypiInfo {
#[serde(default)]
name: String,
}
/// Parse a PyPI project document into one `MODGUNN-YANK-pypi-*` [`Advisory`] per
/// **yanked release** (PEP 592). A release is treated as yanked when it has files
/// and **every** file is yanked — matching PyPI's release-level yank (a *partial*
/// file yank leaves the version installable, so it is not flagged). The reason is
/// the first non-empty `yanked_reason`. A well-formed project with no fully-yanked
/// release yields an empty `Vec`; a document without a `releases` map is an `Err`.
pub fn parse_pypi_yanks(json: &str) -> Result<Vec<Advisory>, serde_json::Error> {
let proj: PypiProject = serde_json::from_str(json)?;
if proj.info.name.is_empty() {
return Err(serde::de::Error::custom("PyPI project missing `info.name`"));
}
let mut versions: Vec<(&String, &Vec<PypiFile>)> = proj.releases.iter().collect();
versions.sort_by(|a, b| a.0.cmp(b.0)); // deterministic emit order (HashMap is not)
// The clean (still-installable) releases — a release with files, not all yanked —
// are the upgrade-target candidates for the b3 remediation.
let clean: Vec<&str> = proj
.releases
.iter()
.filter(|(_, files)| !files.is_empty() && !files.iter().all(|f| f.yanked))
.map(|(v, _)| v.as_str())
.collect();
let mut out = Vec::new();
for (ver, files) in versions {
// A release with no files at all is not "yanked" — it is simply absent.
if files.is_empty() || !files.iter().all(|f| f.yanked) {
continue;
}
let reason = files
.iter()
.find_map(|f| {
f.yanked_reason
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
})
.unwrap_or("");
let base = if reason.is_empty() {
format!("yanked PyPI release {ver} of {}", proj.info.name)
} else {
format!("yanked PyPI release {ver} of {}: {reason}", proj.info.name)
};
let summary = match earliest_clean_successor("pypi", &clean, ver) {
Some(s) => format!("{base}; upgrade to {s}"),
None => base,
};
out.push(
Advisory::basic(
format!("MODGUNN-YANK-pypi-{}-{ver}", proj.info.name),
"pypi",
&proj.info.name,
vec![ver.clone()],
Severity::Low,
summary,
)
.with_source("pypi"),
);
}
Ok(out)
}
/// Parse a Go module's **`go.mod`** into one `MODGUNN-YANK-go-*` [`Advisory`] per
/// **`retract`ed** version. Go has no central yank registry — a module withdraws
/// its own releases with `retract` directives in its `go.mod`, so this is the only
/// Go yank signal. Handles the single form (`retract v1.2.3`), the inline range
/// (`retract [v1.0.0, v1.2.0]`, inclusive both ends), and the block form
/// (`retract ( … )`); `// rationale` comments and blank lines are ignored. Returns
/// an empty `Vec` for a `go.mod` with no retractions (a real zero); a `go.mod` with
/// no `module` line yields an empty `Vec` (nothing to attribute), never a panic.
pub fn parse_go_retractions(gomod: &str) -> Vec<Advisory> {
// The module path (needed for coords) — `module <path>` at the top.
let module = gomod.lines().map(str::trim).find_map(|l| {
l.strip_prefix("module ")
.map(|m| m.trim().trim_matches('"'))
});
let Some(module) = module.filter(|m| !m.is_empty()) else {
return Vec::new();
};
let mut out = Vec::new();
let mut in_block = false;
for raw in gomod.lines() {
// Drop an end-of-line `// comment`, then trim.
let line = raw.split("//").next().unwrap_or(raw).trim();
if line.is_empty() {
continue;
}
if in_block {
if line == ")" {
in_block = false;
continue;
}
push_retraction(module, line, &mut out);
continue;
}
if let Some(rest) = line.strip_prefix("retract") {
let rest = rest.trim();
if rest == "(" {
in_block = true;
} else if !rest.is_empty() {
push_retraction(module, rest, &mut out);
}
}
}
out
}
/// Turn one retraction spec — a single `vX.Y.Z` or an inclusive range
/// `[vLow, vHigh]` — into an [`Advisory`] for `module`, appended to `out`.
fn push_retraction(module: &str, spec: &str, out: &mut Vec<Advisory>) {
use crate::versions::RangeEvent;
let spec = spec.trim();
if let Some(inner) = spec.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
// `[low, high]` — an inclusive interval of retracted versions.
let mut it = inner.split(',');
let (Some(low), Some(high)) = (it.next(), it.next()) else {
return;
};
let (low, high) = (low.trim(), high.trim());
if low.is_empty() || high.is_empty() {
return;
}
out.push(
Advisory::basic(
format!("MODGUNN-YANK-go-{module}-{low}-{high}"),
"go",
module,
Vec::new(),
Severity::Low,
format!("retracted go module range {low}..={high} of {module}"),
)
.with_source("go")
.with_ranges(vec![
RangeEvent::Introduced(low.into()),
RangeEvent::LastAffected(high.into()),
]),
);
} else {
let version = spec.trim_matches('"');
if version.is_empty() {
return;
}
out.push(
Advisory::basic(
format!("MODGUNN-YANK-go-{module}-{version}"),
"go",
module,
vec![version.to_string()],
Severity::Low,
format!("retracted go module version {version} of {module}"),
)
.with_source("go"),
);
}
}
// ── ecosyste.ms package-status import ────────────────────────────────────────
//
// packages.ecosyste.ms aggregates registry metadata across ALL ecosystems with a
// uniform, no-auth JSON API. Its per-package `status` (`removed` / `unpublished` /
// `deprecated`) is a PACKAGE-level "stop depending on this" signal the per-registry
// yank parsers above don't capture — a whole package pulled from the registry
// (often a malicious-package takedown) or maintainer-deprecated. Unlike a version
// yank it is not version-scoped, so it lands as an ALL-VERSIONS advisory (empty
// affected set ⇒ `matches` flags every version — correct: the whole package is
// gone). Low severity, same as the yanks: visible in the ledger, not a refuse on
// its own; the lens escalates via the yank-cascade join.
/// The ecosyste.ms `registry` sub-object (carries the ecosystem when the top-level
/// field is absent).
#[derive(Debug, Default, Deserialize)]
struct EcosystemsRegistry {
#[serde(default)]
ecosystem: String,
#[serde(default)]
name: String,
}
/// A packages.ecosyste.ms package document (only the fields modgunn keys on;
/// tolerant of the rest). `status` is optional — `None`/`"active"` means nothing to
/// flag (a real zero, not a malformed feed).
#[derive(Debug, Deserialize)]
struct EcosystemsPackage {
#[serde(default)]
name: String,
#[serde(default)]
ecosystem: String,
#[serde(default)]
registry: EcosystemsRegistry,
#[serde(default)]
status: Option<String>,
}
/// Map an ecosyste.ms ecosystem label to modgunn's canonical key (most already
/// align; `rubygems`→`gem`, `packagist`→`composer`).
fn ecosystems_eco(label: &str) -> Option<&'static str> {
match label.trim().to_ascii_lowercase().as_str() {
"npm" => Some("npm"),
"pypi" => Some("pypi"),
"cargo" | "crates" => Some("cargo"),
"maven" => Some("maven"),
"go" => Some("go"),
"nuget" => Some("nuget"),
"rubygems" | "gem" => Some("gem"),
"packagist" | "composer" => Some("composer"),
_ => None,
}
}
/// Parse one ecosyste.ms package document into a package-level status
/// [`Advisory`] — `Some` only when `status` is a recognized pulled/deprecated state
/// (`removed`/`unpublished`/`deprecated`), `None` for active/unknown status or a
/// package we can't key (no name / unmapped ecosystem). The ecosystem is taken from
/// the top-level `ecosystem`, else `registry.ecosystem`, else `registry.name`.
pub fn parse_ecosystems_package(json: &str) -> Result<Option<Advisory>, serde_json::Error> {
let pkg: EcosystemsPackage = serde_json::from_str(json)?;
let status = match pkg.status.as_deref().map(|s| s.trim().to_ascii_lowercase()) {
Some(s) if matches!(s.as_str(), "removed" | "unpublished" | "deprecated") => s,
_ => return Ok(None), // active / null / unknown ⇒ nothing to flag (a real zero)
};
if pkg.name.trim().is_empty() {
return Ok(None);
}
let eco_label = if !pkg.ecosystem.is_empty() {
pkg.ecosystem.as_str()
} else if !pkg.registry.ecosystem.is_empty() {
pkg.registry.ecosystem.as_str()
} else {
pkg.registry.name.as_str()
};
let Some(eco) = ecosystems_eco(eco_label) else {
return Ok(None); // an ecosystem modgunn doesn't model — can't key the join
};
let name = pkg.name.trim();
let marker = if status == "deprecated" {
"DEPRECATED"
} else {
"REMOVED"
};
Ok(Some(
Advisory::basic(
format!("MODGUNN-ECOSYSTEMS-{marker}-{eco}-{name}"),
eco,
name,
Vec::new(), // package-level ⇒ all versions affected
Severity::Low,
format!("package `{name}` is {status} on {eco} (ecosyste.ms package status)"),
)
.with_source("ecosyste.ms"),
))
}
// ── libraries.io project-status import ───────────────────────────────────────
//
// libraries.io is the elder cross-ecosystem package index (ecosyste.ms is its open
// successor — see above). Its per-project `status` field carries the SAME
// "stop depending on this" package-level signal: `Deprecated` / `Removed` /
// `Unmaintained` / `Hidden`, plus a free-text `deprecation_reason`. Like the
// ecosyste.ms status row it lands as an all-versions Low advisory (empty affected
// set ⇒ every version flagged — correct: the package itself is pulled/deprecated).
//
// LIVE FETCH: the libraries.io API (`GET https://libraries.io/api/:platform/:name`)
// requires a FREE registered `api_key` query param. This OFFLINE parser is
// key-free — it reads the already-downloaded project JSON, matching modgunn's
// file-in collector model (obtaining the file is out of scope).
/// A libraries.io project document (only the fields modgunn keys on; tolerant of
/// the rest). `platform` is libraries.io's ecosystem label (`NPM`/`Pypi`/`Cargo`/
/// `Go`/`Maven`/`Rubygems`/`Packagist`/`NuGet`), mapped through [`ecosystems_eco`]
/// (case-folded — the same label set ecosyste.ms uses).
#[derive(Debug, Default, Deserialize)]
struct LibrariesioProject {
#[serde(default)]
name: String,
#[serde(default)]
platform: String,
/// `Active` / `Deprecated` / `Removed` / `Unmaintained` / `Hidden` / null.
#[serde(default)]
status: Option<String>,
/// Free-text deprecation note; present ⇒ deprecated even if `status` is null.
#[serde(default)]
deprecation_reason: Option<String>,
}
/// Parse one **libraries.io** project document into a package-level status
/// [`Advisory`] — `Some` only when the project is in a pulled/deprecated/abandoned
/// state (`status` ∈ {removed, deprecated, unmaintained, hidden} OR a non-empty
/// `deprecation_reason`), `None` for an active/unknown project, an empty name, or
/// an ecosystem modgunn doesn't model (can't key the join). Mirrors
/// [`parse_ecosystems_package`] — the same all-versions Low "stop depending on
/// this" row, distinguished only by the `MODGUNN-LIBRARIESIO-*` id + source label.
pub fn parse_librariesio_project(json: &str) -> Result<Option<Advisory>, serde_json::Error> {
let proj: LibrariesioProject = serde_json::from_str(json)?;
if proj.name.trim().is_empty() {
return Ok(None);
}
let Some(eco) = ecosystems_eco(&proj.platform) else {
return Ok(None); // an ecosystem modgunn doesn't model — can't key the join
};
let reason = proj
.deprecation_reason
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let status = proj
.status
.as_deref()
.map(|s| s.trim().to_ascii_lowercase());
let (marker, state): (&str, String) = match status.as_deref() {
Some("removed") => ("REMOVED", "removed".into()),
Some("deprecated") => ("DEPRECATED", "deprecated".into()),
Some("unmaintained") => ("UNMAINTAINED", "unmaintained".into()),
Some("hidden") => ("HIDDEN", "hidden".into()),
// status is active/null/unknown — a deprecation_reason alone still flags it.
_ if reason.is_some() => ("DEPRECATED", "deprecated".into()),
_ => return Ok(None), // active / null / unknown, no reason ⇒ a real zero
};
let name = proj.name.trim();
let summary = match reason {
Some(r) => format!("package `{name}` is {state} on {eco} (libraries.io): {r}"),
None => format!("package `{name}` is {state} on {eco} (libraries.io project status)"),
};
Ok(Some(
Advisory::basic(
format!("MODGUNN-LIBRARIESIO-{marker}-{eco}-{name}"),
eco,
name,
Vec::new(), // package-level ⇒ all versions affected
Severity::Low,
summary,
)
.with_source("libraries.io"),
))
}
// ── Sonatype OSS-Index — the purl-keyed component vulnerability report ────────
//
// OSS-Index answers "what is wrong with THIS exact package version?" across every
// major ecosystem with one uniform, purl-keyed document — the same join key
// `vuln_findings` uses. Unlike the registry parsers above (which report a package's
// *status*), this is a genuine advisory feed, so its rows are real CVE advisories,
// not `MODGUNN-*` synthetic ones: when the report carries a `cve` the advisory is
// keyed by that CVE id, so `merge_by_alias` folds it into the SAME row the OSV/NVD
// import produced instead of creating a duplicate finding. Only a report entry with
// no CVE at all (an OSS-Index-only finding, identified by its Sonatype UUID) gets a
// `MODGUNN-OSSINDEX-*` id.
/// One `vulnerabilities[]` entry of an OSS-Index component report.
#[derive(Debug, Deserialize)]
struct OssIndexVuln {
#[serde(default)]
id: String,
#[serde(default)]
title: String,
#[serde(default)]
description: String,
#[serde(default)]
cve: Option<String>,
#[serde(rename = "displayName", default)]
display_name: String,
#[serde(rename = "cvssScore", default)]
cvss_score: Option<f64>,
#[serde(rename = "cvssVector", default)]
cvss_vector: String,
#[serde(default)]
cwe: String,
}
/// One component entry of an OSS-Index report. `coordinates` is a **purl**.
#[derive(Debug, Deserialize)]
struct OssIndexComponent {
#[serde(default)]
coordinates: String,
#[serde(default)]
vulnerabilities: Vec<OssIndexVuln>,
}
/// Map an OSS-Index / purl **type** to modgunn's canonical ecosystem key. purl uses
/// `golang`, `rubygems` and `cocoapods` where modgunn/OSV use `go` and `gem`; the
/// rest already align. An unmodelled type yields `None` — the row is skipped rather
/// than landing under a label nothing joins on.
fn purl_type_to_ecosystem(t: &str) -> Option<&'static str> {
match t.trim().to_ascii_lowercase().as_str() {
"npm" => Some("npm"),
"pypi" => Some("pypi"),
"cargo" | "crates" => Some("cargo"),
"maven" => Some("maven"),
"golang" | "go" => Some("go"),
"nuget" => Some("nuget"),
"gem" | "rubygems" => Some("gem"),
"composer" | "packagist" => Some("composer"),
"conan" => Some("conan"),
_ => None,
}
}
/// Parse a **Sonatype OSS-Index** component report into [`Advisory`]s — one per
/// `(component, vulnerability)` pair, keyed to the component's **exact version**
/// (the report is a per-version answer, so the affected set is that single version).
///
/// The document is the API's response shape: a bare `[ … ]` array of components, or
/// a `{ "componentReports": [ … ] }` wrapper. Each component's `coordinates` is a
/// purl, parsed by the ONE purl grammar ([`crate::sbom::parse_purl`]) rather than a
/// hand-rolled split — so a scoped npm (`pkg:npm/%40scope/name@1.0.0`) and a maven
/// `group/artifact` land on exactly the coords the join keys on.
///
/// **Id choice matters for de-duplication.** A report entry carrying a `cve` is
/// keyed by that CVE id and lists the OSS-Index UUID as an alias, so
/// [`merge_by_alias`](crate::scan::merge_by_alias) folds it into the SAME advisory
/// the OSV/NVD import already produced (range union + max severity) instead of
/// double-reporting one vulnerability. Only a CVE-less finding gets a synthetic
/// `MODGUNN-OSSINDEX-<uuid>` id. Severity comes from the numeric `cvssScore` through
/// the SHARED [`crate::osv::severity_from_base`] bucketing, falling back to the
/// `cvssVector` and finally to `Medium`.
///
/// A component with no vulnerabilities is a **real zero** (the common case — that is
/// what a clean component looks like); a document that is not JSON is an `Err`.
pub fn parse_ossindex_report(json: &str) -> Result<Vec<Advisory>, serde_json::Error> {
let doc: serde_json::Value = serde_json::from_str(json)?;
let components: Vec<OssIndexComponent> = if let Some(arr) = doc.get("componentReports") {
serde_json::from_value(arr.clone())?
} else if doc.is_array() {
serde_json::from_value(doc)?
} else {
vec![serde_json::from_value(doc)?]
};
let mut out = Vec::new();
for comp in &components {
let Some((ptype, name, version)) = crate::sbom::parse_purl(&comp.coordinates) else {
continue;
};
let Some(eco) = purl_type_to_ecosystem(&ptype) else {
continue;
};
if name.is_empty() || version.is_empty() {
// A package-level coord cannot be keyed to an affected version, and an
// all-versions advisory here would be a mass false positive (OSS-Index
// findings are version-scoped by construction).
continue;
}
for v in &comp.vulnerabilities {
let cve = v
.cve
.as_deref()
.map(str::trim)
.filter(|s| s.starts_with("CVE-"))
.or_else(|| Some(v.display_name.trim()).filter(|s| s.starts_with("CVE-")));
let (id, aliases) = match cve {
Some(c) => (
c.to_string(),
if v.id.is_empty() {
Vec::new()
} else {
vec![v.id.clone()]
},
),
None if !v.id.is_empty() => (format!("MODGUNN-OSSINDEX-{}", v.id), Vec::new()),
None => continue, // no id at all ⇒ nothing stable to key or dedup on
};
let severity = v
.cvss_score
.map(crate::osv::severity_from_base)
.or_else(|| crate::osv::severity_from_cvss_vector(&v.cvss_vector))
.unwrap_or(Severity::Medium);
let summary = if !v.title.trim().is_empty() {
v.title.trim().to_string()
} else if !v.description.trim().is_empty() {
v.description.trim().to_string()
} else {
format!("OSS-Index finding for {name}@{version}")
};
let mut adv = Advisory::basic(id, eco, &name, vec![version.clone()], severity, summary)
.with_source("oss-index");
adv.aliases = aliases;
adv.cvss_vector = v.cvss_vector.trim().to_string();
adv.cwe = if v.cwe.trim().is_empty() {
Vec::new()
} else {
vec![v.cwe.trim().to_string()]
};
out.push(adv);
}
}
Ok(out)
}
// ── socket.dev supply-chain alerts (SCAFFOLD — PENDING paid API key) ──────────
//
// Socket scores a package's *behaviour* rather than its CVEs: install scripts,
// obfuscated code, telemetry/network at install, shell access, and — the killer
// signal — `malware`. Each finding is an `alert` with a `type` + `severity`
// + `category`. This is a supply-chain-risk signal the CVE feeds do not carry, so
// it lands as a per-package-version [`Advisory`] (worst-alert severity; any
// malware/critical alert ⇒ Critical), keyed on the purl `type`→ecosystem the join
// already uses.
//
// ⚠ PENDING — live ingestion needs a **PAID** Socket API key
// (`GET https://api.socket.dev/v0/...`, HTTP Basic). This parser is written to
// Socket's PUBLIC alert schema (docs.socket.dev) but the exact response ENVELOPE
// is NOT yet validated against a live paid response, so treat the wiring as a
// scaffold: the model + parse + tests are real (empty-is-RED holds), but the
// field-for-field fidelity to the live feed is unverified. See feeds-design.md §D.
/// One Socket `alert` (a single supply-chain finding on a package version).
#[derive(Debug, Default, Deserialize)]
struct SocketAlert {
#[serde(default, rename = "type")]
kind: String,
#[serde(default)]
severity: String,
#[serde(default)]
category: String,
}
/// A Socket package-issues document (`{type, name, version, alerts:[…]}`); only the
/// fields modgunn keys on. Tolerant of a single object OR a batch array (below).
#[derive(Debug, Default, Deserialize)]
struct SocketPackage {
#[serde(default, rename = "type")]
purl_type: String,
#[serde(default)]
name: String,
#[serde(default)]
version: String,
#[serde(default)]
alerts: Vec<SocketAlert>,
}
/// Map a Socket alert `severity` label to a modgunn [`Severity`].
fn socket_severity(label: &str) -> Severity {
match label.trim().to_ascii_lowercase().as_str() {
"critical" => Severity::Critical,
"high" => Severity::High,
"middle" | "medium" => Severity::Medium,
_ => Severity::Low,
}
}
/// Parse a **socket.dev** package-issues document (single object or a batch array)
/// into per-package-version supply-chain [`Advisory`]s. A package with no alerts
/// yields nothing (a real zero — Socket found nothing). Severity is the worst alert
/// present, and ANY `malware` alert (by type or category) escalates to `Critical`
/// regardless of the reported label. A malformed document is an `Err`.
///
/// ⚠ SCAFFOLD / PENDING — see the module note: the live Socket API is paid, so the
/// response envelope is unverified against a real feed.
pub fn parse_socket_alerts(json: &str) -> Result<Vec<Advisory>, serde_json::Error> {
let doc: serde_json::Value = serde_json::from_str(json)?;
// Accept a bare array (batch), a `{ "packages": [ … ] }` envelope, or one object.
let packages: Vec<SocketPackage> = if let Some(arr) = doc.as_array() {
serde_json::from_value(serde_json::Value::Array(arr.clone()))?
} else if let Some(arr) = doc.get("packages").and_then(|v| v.as_array()) {
serde_json::from_value(serde_json::Value::Array(arr.clone()))?
} else {
vec![serde_json::from_value(doc)?]
};
let mut out = Vec::new();
for pkg in &packages {
if pkg.alerts.is_empty() || pkg.name.trim().is_empty() {
continue; // no finding, or nothing to key on — a real zero
}
let Some(eco) = purl_type_to_ecosystem(&pkg.purl_type) else {
continue; // an ecosystem modgunn doesn't model — can't key the join
};
let is_malware = pkg.alerts.iter().any(|a| {
a.kind.eq_ignore_ascii_case("malware") || a.category.eq_ignore_ascii_case("malware")
});
let severity = if is_malware {
Severity::Critical
} else {
pkg.alerts
.iter()
.map(|a| socket_severity(&a.severity))
.max()
.unwrap_or(Severity::Low)
};
let name = pkg.name.trim();
let version = pkg.version.trim();
let mut kinds: Vec<&str> = pkg
.alerts
.iter()
.map(|a| a.kind.trim())
.filter(|s| !s.is_empty())
.collect();
kinds.sort_unstable();
kinds.dedup();
let affected = if version.is_empty() {
Vec::new()
} else {
vec![version.to_string()]
};
let vpart = if version.is_empty() {
"*".to_string()
} else {
version.to_string()
};
out.push(
Advisory::basic(
format!("MODGUNN-SOCKET-{eco}-{name}-{vpart}"),
eco,
name,
affected,
severity,
format!(
"socket.dev supply-chain alerts on `{name}`{}: {}",
if version.is_empty() {
String::new()
} else {
format!("@{version}")
},
kinds.join(", ")
),
)
.with_source("socket.dev"),
);
}
Ok(out)
}
// ── GUAC CertifyVuln ingest (SCAFFOLD — PENDING a running GUAC instance) ──────
//
// GUAC (Graph for Understanding Artifact Composition) aggregates SBOMs, attestations
// and vuln claims into one graph exposed over GraphQL. Its `CertifyVuln` edges say
// "this package version is certified vulnerable to this vulnerability" — a package↔
// vuln join modgunn can ingest as advisories, keyed BY the vulnerability id (so a
// `cve`/`ghsa`/`osv` id folds into the row OSV/NVD already produced via
// `merge_by_alias`). GUAC's package model is the "package trie": type → namespaces →
// names → versions.
//
// ⚠ PENDING — ingestion needs a **running GUAC GraphQL endpoint** with data loaded
// (`POST http://localhost:8080/query`); there is no offline corpus to point at here.
// This parser targets GUAC's documented `CertifyVuln` GraphQL response shape, with
// real tests, but is a scaffold until validated against a live GUAC. `novuln` (GUAC's
// "certified NOT vulnerable" marker) and empty ids are skipped. See feeds-design.md §D.
#[derive(Debug, Default, Deserialize)]
struct GuacResponse {
#[serde(default)]
data: GuacData,
}
#[derive(Debug, Default, Deserialize)]
struct GuacData {
#[serde(default, rename = "CertifyVuln")]
certify_vuln: Vec<GuacCertifyVuln>,
}
#[derive(Debug, Default, Deserialize)]
struct GuacCertifyVuln {
#[serde(default)]
package: GuacPackage,
#[serde(default)]
vulnerability: GuacVulnerability,
}
#[derive(Debug, Default, Deserialize)]
struct GuacPackage {
#[serde(default, rename = "type")]
purl_type: String,
#[serde(default)]
namespaces: Vec<GuacNamespace>,
}
#[derive(Debug, Default, Deserialize)]
struct GuacNamespace {
#[serde(default)]
namespace: String,
#[serde(default)]
names: Vec<GuacName>,
}
#[derive(Debug, Default, Deserialize)]
struct GuacName {
#[serde(default)]
name: String,
#[serde(default)]
versions: Vec<GuacVersion>,
}
#[derive(Debug, Default, Deserialize)]
struct GuacVersion {
#[serde(default)]
version: String,
}
#[derive(Debug, Default, Deserialize)]
struct GuacVulnerability {
/// `cve` / `ghsa` / `osv` / `novuln`.
#[serde(default, rename = "type")]
vuln_type: String,
#[serde(default, rename = "vulnerabilityIDs")]
vulnerability_ids: Vec<GuacVulnId>,
}
#[derive(Debug, Default, Deserialize)]
struct GuacVulnId {
#[serde(default, rename = "vulnerabilityID")]
vulnerability_id: String,
}
/// Parse a **GUAC** `CertifyVuln` GraphQL response into [`Advisory`]s — one per
/// (package version × vulnerability id). The advisory is keyed BY the vuln id
/// (upper-cased for CVE/GHSA so `merge_by_alias` folds it into an existing row); a
/// `novuln` certification or an empty id is skipped (GUAC's "certified NOT
/// vulnerable" is a real zero, not a finding). Package coords come out of the trie:
/// `type`→ecosystem, `namespace/name` (namespace prefixed when present — maven
/// `group/artifact`, npm `@scope/name`, go module path), per version. A malformed
/// document is an `Err`.
///
/// ⚠ SCAFFOLD / PENDING — see the module note: needs a running GUAC instance; the
/// GraphQL schema is unvalidated against a live endpoint here.
pub fn parse_guac_certify_vuln(json: &str) -> Result<Vec<Advisory>, serde_json::Error> {
let resp: GuacResponse = serde_json::from_str(json)?;
let mut out = Vec::new();
for cv in &resp.data.certify_vuln {
// GUAC uses `novuln` to record "certified NOT vulnerable" — not a finding.
if cv.vulnerability.vuln_type.eq_ignore_ascii_case("novuln") {
continue;
}
let Some(eco) = purl_type_to_ecosystem(&cv.package.purl_type) else {
continue; // an ecosystem modgunn doesn't model — can't key the join
};
// Normalize each id: GUAC lower-cases them; CVE/GHSA canonicalize to upper.
let ids: Vec<String> = cv
.vulnerability
.vulnerability_ids
.iter()
.filter_map(|v| {
let id = v.vulnerability_id.trim();
if id.is_empty() {
return None;
}
let up = id.to_ascii_uppercase();
Some(if up.starts_with("CVE-") || up.starts_with("GHSA-") {
up
} else {
id.to_string()
})
})
.collect();
if ids.is_empty() {
continue;
}
for ns in &cv.package.namespaces {
for nm in &ns.names {
if nm.name.trim().is_empty() {
continue;
}
let full = if ns.namespace.trim().is_empty() {
nm.name.trim().to_string()
} else {
format!("{}/{}", ns.namespace.trim(), nm.name.trim())
};
for ver in &nm.versions {
let version = ver.version.trim();
let affected = if version.is_empty() {
Vec::new()
} else {
vec![version.to_string()]
};
for id in &ids {
let summary = format!(
"GUAC CertifyVuln: `{full}`{} certified vulnerable to {id}",
if version.is_empty() {
String::new()
} else {
format!("@{version}")
}
);
out.push(
Advisory::basic(
id,
eco,
&full,
affected.clone(),
Severity::Medium,
summary,
)
.with_aliases([id.clone()])
.with_source("guac"),
);
}
}
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn librariesio_flags_pulled_states_and_ignores_active() {
// Deprecated by explicit status.
let a = parse_librariesio_project(
r#"{ "name": "left-pad", "platform": "NPM", "status": "Deprecated" }"#,
)
.unwrap()
.expect("deprecated ⇒ an advisory");
assert_eq!(
(a.ecosystem.as_str(), a.package.as_str()),
("npm", "left-pad")
);
assert_eq!(a.severity, Severity::Low);
assert!(a.id.starts_with("MODGUNN-LIBRARIESIO-DEPRECATED-npm-"));
assert!(
a.affected_versions.is_empty(),
"package-level ⇒ all versions"
);
assert_eq!(a.source, "libraries.io");
// Removed maps its own marker; platform label is case-folded (Pypi→pypi).
let r = parse_librariesio_project(
r#"{ "name": "evilpkg", "platform": "Pypi", "status": "Removed" }"#,
)
.unwrap()
.unwrap();
assert!(r.id.contains("REMOVED-pypi"));
// deprecation_reason alone (status null) still flags, and is carried through.
let dr = parse_librariesio_project(
r#"{ "name": "oldlib", "platform": "Cargo", "status": null, "deprecation_reason": "use newlib" }"#,
)
.unwrap()
.unwrap();
assert!(dr.summary.contains("use newlib"));
assert_eq!(dr.ecosystem, "cargo");
// Active / unmapped ecosystem / no name ⇒ a real None, never a phantom row.
assert!(parse_librariesio_project(
r#"{ "name": "react", "platform": "NPM", "status": "Active" }"#
)
.unwrap()
.is_none());
assert!(
parse_librariesio_project(
r#"{ "name": "x", "platform": "CocoaPods", "status": "Removed" }"#
)
.unwrap()
.is_none(),
"unmodelled ecosystem"
);
assert!(parse_librariesio_project(
r#"{ "name": "", "platform": "NPM", "status": "Removed" }"#
)
.unwrap()
.is_none());
assert!(
parse_librariesio_project("garbage").is_err(),
"malformed ⇒ Err"
);
}
#[test]
fn socket_alerts_worst_severity_and_malware_escalation() {
// A batch: a malware package (escalates to Critical regardless of label) and a
// behaviour-flagged package (worst-of the alert severities).
let json = r#"[
{ "type": "npm", "name": "event-stream", "version": "3.3.6",
"alerts": [ { "type": "malware", "severity": "high", "category": "supplyChainRisk" },
{ "type": "installScript", "severity": "low" } ] },
{ "type": "pypi", "name": "requests", "version": "2.31.0",
"alerts": [ { "type": "telemetry", "severity": "middle" },
{ "type": "networkAccess", "severity": "low" } ] },
{ "type": "npm", "name": "clean-pkg", "version": "1.0.0", "alerts": [] },
{ "type": "cocoapods", "name": "unmodelled", "version": "1.0", "alerts": [ { "type": "x", "severity": "high" } ] }
]"#;
let advs = parse_socket_alerts(json).unwrap();
assert_eq!(
advs.len(),
2,
"clean (no alerts) and unmodelled-ecosystem packages skipped"
);
let es = advs.iter().find(|a| a.package == "event-stream").unwrap();
assert_eq!(
es.severity,
Severity::Critical,
"any malware alert ⇒ Critical"
);
assert!(es.summary.contains("malware"));
assert_eq!(es.source, "socket.dev");
assert_eq!(es.affected_versions, vec!["3.3.6".to_string()]);
let req = advs.iter().find(|a| a.package == "requests").unwrap();
assert_eq!(
req.severity,
Severity::Medium,
"worst of middle+low alerts = Medium"
);
// A single object (not a batch) parses too; malformed is an Err.
let one = parse_socket_alerts(r#"{ "type": "npm", "name": "a", "version": "1.0.0", "alerts": [ { "type": "shellAccess", "severity": "critical" } ] }"#).unwrap();
assert_eq!(one.len(), 1);
assert!(parse_socket_alerts("not json").is_err());
}
#[test]
fn guac_certify_vuln_keys_by_vuln_id_and_skips_novuln() {
// GUAC's package trie: type → namespaces → names → versions, plus a vuln with
// an id list. Keying by the CVE id (canonicalized upper) is what lets
// merge_by_alias fold it into the OSV/NVD row instead of double-reporting.
let json = r#"{ "data": { "CertifyVuln": [
{ "package": { "type": "golang",
"namespaces": [ { "namespace": "github.com/foo", "names": [
{ "name": "bar", "versions": [ { "version": "v1.2.3" } ] } ] } ] },
"vulnerability": { "type": "cve", "vulnerabilityIDs": [ { "vulnerabilityID": "cve-2021-44228" } ] } },
{ "package": { "type": "pypi", "namespaces": [ { "namespace": "", "names": [
{ "name": "safe", "versions": [ { "version": "1.0" } ] } ] } ] },
"vulnerability": { "type": "novuln", "vulnerabilityIDs": [] } }
] } }"#;
let advs = parse_guac_certify_vuln(json).unwrap();
assert_eq!(
advs.len(),
1,
"the novuln (certified NOT vulnerable) row adds nothing"
);
let a = &advs[0];
assert_eq!(
a.id, "CVE-2021-44228",
"id canonicalized upper, keyed by the vuln"
);
assert_eq!(a.ecosystem, "go");
assert_eq!(
a.package, "github.com/foo/bar",
"namespace prefixed onto the name"
);
assert_eq!(a.affected_versions, vec!["v1.2.3".to_string()]);
assert!(
a.aliases.contains(&"CVE-2021-44228".to_string()),
"carries the id as an alias for the fold"
);
assert_eq!(a.source, "guac");
assert!(parse_guac_certify_vuln("{").is_err(), "malformed ⇒ Err");
assert!(
parse_guac_certify_vuln(r#"{ "data": { "CertifyVuln": [] } }"#)
.unwrap()
.is_empty(),
"empty graph ⇒ real empty"
);
}
/// OSS-Index is purl-keyed, so the coords must come out of the ONE purl grammar —
/// a scoped npm and a maven `group/artifact` land on exactly the coords the join
/// keys on. And the id choice is what stops it double-reporting: a CVE-carrying
/// entry is keyed BY the CVE (UUID as an alias) so `merge_by_alias` folds it into
/// the row OSV already produced. Reverting either — a hand-rolled coordinate split
/// or a `MODGUNN-OSSINDEX-*` id for a CVE-carrying finding — turns this red.
#[test]
fn ossindex_report_keys_by_cve_and_parses_purl_coords() {
use crate::core::ArtifactRef;
let json = r#"[
{ "coordinates": "pkg:npm/%40scope/name@1.2.3",
"vulnerabilities": [
{ "id": "8dd9c0c3-1111-2222-3333-444455556666",
"displayName": "CVE-2020-11022",
"title": "[CVE-2020-11022] CWE-79",
"cve": "CVE-2020-11022",
"cvssScore": 9.1, "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H",
"cwe": "CWE-79" } ] },
{ "coordinates": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
"vulnerabilities": [
{ "id": "aaaa-bbbb", "title": "Sonatype-only finding", "cvssScore": 4.3 } ] },
{ "coordinates": "pkg:golang/github.com/foo/bar@v1.2.3", "vulnerabilities": [] },
{ "coordinates": "pkg:cocoapods/Alamofire@5.0.0",
"vulnerabilities": [ { "id": "x", "title": "unmodelled ecosystem" } ] },
{ "coordinates": "pkg:npm/lodash",
"vulnerabilities": [ { "id": "y", "title": "no version to key on" } ] }
]"#;
let advs = parse_ossindex_report(json).unwrap();
assert_eq!(
advs.len(),
2,
"clean component, unmodelled ecosystem and version-less coord all skipped"
);
// ── the CVE-carrying finding ──
let npm = &advs[0];
assert_eq!(
npm.id, "CVE-2020-11022",
"keyed BY the CVE so merge_by_alias dedups it"
);
assert_eq!(
npm.aliases,
vec!["8dd9c0c3-1111-2222-3333-444455556666".to_string()]
);
assert_eq!(
(npm.ecosystem.as_str(), npm.package.as_str()),
("npm", "@scope/name")
);
assert_eq!(
npm.severity,
Severity::Critical,
"cvssScore 9.1 buckets through the SHARED thresholds"
);
assert_eq!(npm.source, "oss-index");
assert_eq!(npm.cwe, vec!["CWE-79".to_string()]);
assert!(npm.matches(&ArtifactRef {
ecosystem: "npm".into(),
name: "@scope/name".into(),
version: "1.2.3".into(),
blob_sha256: String::new(),
}));
assert!(
!npm.matches(&ArtifactRef {
ecosystem: "npm".into(),
name: "@scope/name".into(),
version: "1.2.4".into(),
blob_sha256: String::new(),
}),
"version-scoped: a different version is NOT flagged"
);
// ── the CVE-less (Sonatype-only) finding ──
let mvn = &advs[1];
assert_eq!(
mvn.id, "MODGUNN-OSSINDEX-aaaa-bbbb",
"only a CVE-less finding gets a synthetic id"
);
assert_eq!(
(mvn.ecosystem.as_str(), mvn.package.as_str()),
("maven", "org.apache.logging.log4j:log4j-core"),
"maven purl group/artifact folds to the group:artifact coord OSV uses"
);
assert_eq!(mvn.severity, Severity::Medium);
// De-duplication actually fires: the OSS-Index row merges into the OSV row
// for the same CVE rather than double-reporting it.
let osv_row = Advisory::basic(
"GHSA-jfh8",
"npm",
"@scope/name",
vec!["1.2.3".into()],
Severity::Low,
"x",
)
.with_aliases(["CVE-2020-11022"]);
let merged = crate::scan::merge_by_alias(vec![osv_row, npm.clone()]);
assert_eq!(
merged.len(),
1,
"one vulnerability, one row — not two findings for the same CVE"
);
assert_eq!(
merged[0].severity,
Severity::Critical,
"max severity wins the merge"
);
// The two container shapes and the failure edge.
assert_eq!(
parse_ossindex_report(&format!(r#"{{ "componentReports": {json} }}"#))
.unwrap()
.len(),
2
);
assert!(
parse_ossindex_report("<html>403</html>").is_err(),
"a non-JSON document is an error"
);
// A clean report is a REAL empty — that is what an unaffected component is.
assert!(parse_ossindex_report(
r#"[{"coordinates":"pkg:npm/a@1.0.0","vulnerabilities":[]}]"#
)
.unwrap()
.is_empty());
}
#[test]
fn ecosystems_status_becomes_all_versions_advisory() {
use crate::core::ArtifactRef;
// A removed npm package → an all-versions Low advisory that flags ANY version.
let removed = r#"{ "name": "evil-pkg", "ecosystem": "npm", "status": "removed" }"#;
let a = parse_ecosystems_package(removed)
.unwrap()
.expect("removed ⇒ advisory");
assert_eq!(a.id, "MODGUNN-ECOSYSTEMS-REMOVED-npm-evil-pkg");
assert_eq!(a.ecosystem, "npm");
assert_eq!(a.severity, Severity::Low);
assert!(
a.affected_versions.is_empty() && a.ranges.is_empty(),
"package-level ⇒ all versions"
);
assert!(
a.matches(&ArtifactRef {
ecosystem: "npm".into(),
name: "evil-pkg".into(),
version: "9.9.9".into(),
blob_sha256: String::new()
}),
"flags any version of the removed package"
);
// Deprecated → the DEPRECATED marker; ecosystem read from registry.ecosystem.
let dep = r#"{ "name": "old", "registry": { "ecosystem": "pypi", "name": "pypi.org" }, "status": "deprecated" }"#;
assert_eq!(
parse_ecosystems_package(dep).unwrap().unwrap().id,
"MODGUNN-ECOSYSTEMS-DEPRECATED-pypi-old"
);
// Active / null status ⇒ no advisory (a real zero, not a malformed feed).
assert!(parse_ecosystems_package(
r#"{ "name": "fine", "ecosystem": "npm", "status": "active" }"#
)
.unwrap()
.is_none());
assert!(
parse_ecosystems_package(r#"{ "name": "fine", "ecosystem": "npm" }"#)
.unwrap()
.is_none()
);
// An ecosystem modgunn doesn't model ⇒ skipped (can't key the join).
assert!(parse_ecosystems_package(
r#"{ "name": "x", "ecosystem": "cocoapods", "status": "removed" }"#
)
.unwrap()
.is_none());
}
#[test]
fn crates_yanks_only_yanked_versions_become_advisories() {
let json = r#"{
"crate": { "name": "openssl-src" },
"versions": [
{ "num": "300.1.0", "yanked": false, "crate": "openssl-src" },
{ "num": "300.0.9", "yanked": true, "crate": "openssl-src" },
{ "num": "300.0.8", "yanked": true, "crate": "openssl-src" }
]
}"#;
let advs = parse_crates_yanks(json).unwrap();
assert_eq!(advs.len(), 2, "only the two yanked versions produce rows");
assert!(advs.iter().all(|a| a.ecosystem == "cargo"));
assert!(advs.iter().all(|a| a.package == "openssl-src"));
assert!(advs.iter().all(|a| a.severity == Severity::Low));
assert!(advs.iter().all(|a| a.source == "crates.io"));
// Exact-version keyed so it joins to an sbom_components version row.
let ids: Vec<_> = advs.iter().map(|a| a.id.as_str()).collect();
assert!(ids.contains(&"MODGUNN-YANK-cargo-openssl-src-300.0.9"));
assert!(ids.contains(&"MODGUNN-YANK-cargo-openssl-src-300.0.8"));
let yanked = advs.iter().find(|a| a.id.ends_with("300.0.9")).unwrap();
assert_eq!(yanked.affected_versions, vec!["300.0.9".to_string()]);
}
#[test]
fn earliest_clean_successor_picks_nearest_greater_non_yanked() {
// semver (cargo): the nearest clean version ABOVE the yanked one, skipping a
// yanked candidate and ignoring older ones.
let clean = ["1.0.0", "1.2.0", "1.5.0", "0.9.0"];
assert_eq!(
earliest_clean_successor("cargo", &clean, "1.1.0"),
Some("1.2.0")
);
assert_eq!(
earliest_clean_successor("cargo", &clean, "1.2.0"),
Some("1.5.0"),
"strictly greater"
);
assert_eq!(
earliest_clean_successor("cargo", &clean, "1.5.0"),
None,
"already the latest clean"
);
assert_eq!(
earliest_clean_successor("cargo", &clean, "2.0.0"),
None,
"nothing supersedes it"
);
// pep440 ordering: a prerelease successor is ranked correctly.
let py = ["2.0.0", "2.0.0rc1", "2.1.0"];
assert_eq!(
earliest_clean_successor("pypi", &py, "1.9.0"),
Some("2.0.0rc1")
);
}
#[test]
fn crates_yank_summary_carries_the_upgrade_target() {
let json = r#"{ "crate": { "name": "leftpad-rs" }, "versions": [
{ "num": "1.2.0", "yanked": false, "crate": "leftpad-rs" },
{ "num": "1.1.0", "yanked": true, "crate": "leftpad-rs" },
{ "num": "1.0.0", "yanked": true, "crate": "leftpad-rs" } ] }"#;
let advs = parse_crates_yanks(json).unwrap();
let a11 = advs.iter().find(|a| a.id.ends_with("1.1.0")).unwrap();
assert!(
a11.summary.contains("upgrade to 1.2.0"),
"b3 remediation: nearest clean successor"
);
let a10 = advs.iter().find(|a| a.id.ends_with("1.0.0")).unwrap();
assert!(
a10.summary.contains("upgrade to 1.2.0"),
"1.0.0's successor skips the yanked 1.1.0"
);
}
#[test]
fn crates_yank_with_no_clean_successor_says_so() {
// Every version yanked ⇒ no clean successor exists; the advisory must say so
// (an honest "no fix forward"), not fabricate a yanked upgrade target.
let json = r#"{ "crate": { "name": "doomed" }, "versions": [
{ "num": "1.0.0", "yanked": true, "crate": "doomed" },
{ "num": "1.1.0", "yanked": true, "crate": "doomed" } ] }"#;
let advs = parse_crates_yanks(json).unwrap();
let a10 = advs.iter().find(|a| a.id.ends_with("1.0.0")).unwrap();
assert!(
a10.summary.contains("no clean successor"),
"no clean version supersedes it"
);
assert!(
!a10.summary.contains("upgrade to"),
"must not point at another yanked version"
);
}
#[test]
fn crates_falls_back_to_top_level_name_when_version_omits_crate() {
let json = r#"{
"crate": { "name": "toplevel-only" },
"versions": [ { "num": "1.0.0", "yanked": true } ]
}"#;
let advs = parse_crates_yanks(json).unwrap();
assert_eq!(advs.len(), 1);
assert_eq!(advs[0].package, "toplevel-only");
}
#[test]
fn crates_well_formed_but_no_yanks_is_a_real_empty_not_an_error() {
// Anti-silent-empty's other edge: a valid doc where nothing is yanked is a
// legitimate zero, NOT a parse failure. (The error case is a missing
// `versions` field — see `crates_missing_versions_is_an_error`.)
let json = r#"{ "crate": { "name": "clean" }, "versions": [
{ "num": "1.0.0", "yanked": false, "crate": "clean" } ] }"#;
let advs = parse_crates_yanks(json).unwrap();
assert!(advs.is_empty());
}
#[test]
fn crates_missing_versions_is_an_error_not_a_silent_empty() {
// Red-when-reverted: if `versions` were made Option and defaulted, a
// truncated feed would parse to an empty Vec and report a fake PASS. The
// non-optional field forces a hard error the importer counts.
let json = r#"{ "crate": { "name": "truncated" } }"#;
assert!(
parse_crates_yanks(json).is_err(),
"a doc with no versions[] must ERROR"
);
}
#[test]
fn npm_deprecations_only_deprecated_versions_become_advisories() {
let json = r#"{
"name": "left-pad",
"versions": {
"1.3.0": { "version": "1.3.0" },
"1.2.0": { "version": "1.2.0", "deprecated": "use String.prototype.padStart" },
"1.1.0": { "version": "1.1.0", "deprecated": "" }
}
}"#;
let advs = parse_npm_deprecations(json).unwrap();
assert_eq!(
advs.len(),
1,
"empty-string deprecated is NOT a deprecation"
);
assert_eq!(advs[0].ecosystem, "npm");
assert_eq!(advs[0].package, "left-pad");
assert_eq!(advs[0].severity, Severity::Low);
assert_eq!(advs[0].source, "npm");
assert_eq!(advs[0].id, "MODGUNN-DEPRECATED-npm-left-pad-1.2.0");
assert_eq!(advs[0].affected_versions, vec!["1.2.0".to_string()]);
assert!(advs[0].summary.contains("use String.prototype.padStart"));
}
#[test]
fn npm_deprecation_summary_carries_upgrade_target() {
let json = r#"{ "name": "old-lib", "versions": {
"1.0.0": { "deprecated": "buggy" },
"1.1.0": { "deprecated": "still buggy" },
"2.0.0": { "version": "2.0.0" } } }"#;
let advs = parse_npm_deprecations(json).unwrap();
// 1.0.0's nearest clean successor is 2.0.0 (1.1.0 is also deprecated).
let a10 = advs.iter().find(|a| a.id.ends_with("1.0.0")).unwrap();
assert!(
a10.summary.contains("upgrade to 2.0.0"),
"skips the deprecated 1.1.0"
);
}
#[test]
fn npm_deprecated_true_is_a_reasonless_deprecation() {
let json = r#"{ "name": "old-pkg", "versions": {
"2.0.0": { "deprecated": true },
"2.1.0": { "deprecated": false } } }"#;
let advs = parse_npm_deprecations(json).unwrap();
assert_eq!(advs.len(), 1, "true deprecates; false does not");
assert_eq!(advs[0].affected_versions, vec!["2.0.0".to_string()]);
}
#[test]
fn npm_output_is_deterministic_by_version() {
// HashMap iteration order is not stable; the parser must sort so the
// warehouse generation + these assertions are reproducible.
let json = r#"{ "name": "multi", "versions": {
"1.2.0": { "deprecated": "b" },
"1.10.0": { "deprecated": "c" },
"1.1.0": { "deprecated": "a" } } }"#;
let advs = parse_npm_deprecations(json).unwrap();
let vers: Vec<_> = advs
.iter()
.flat_map(|a| a.affected_versions.clone())
.collect();
assert_eq!(
vers,
vec!["1.1.0", "1.10.0", "1.2.0"],
"sorted string order"
);
}
#[test]
fn npm_missing_versions_is_an_error_not_a_silent_empty() {
let json = r#"{ "name": "truncated" }"#;
assert!(
parse_npm_deprecations(json).is_err(),
"a packument with no versions map must ERROR"
);
}
#[test]
fn npm_missing_name_is_rejected_so_no_coordless_row() {
let json = r#"{ "versions": { "1.0.0": { "deprecated": "x" } } }"#;
assert!(parse_npm_deprecations(json).is_err());
}
#[test]
fn pypi_yanks_flags_fully_yanked_releases_only() {
let json = r#"{
"info": { "name": "ctx" },
"releases": {
"0.1.2": [ { "filename": "ctx-0.1.2.tar.gz", "yanked": true, "yanked_reason": "malicious" },
{ "filename": "ctx-0.1.2-py3.whl", "yanked": true, "yanked_reason": null } ],
"0.1.1": [ { "filename": "ctx-0.1.1.tar.gz", "yanked": false } ]
}
}"#;
let advs = parse_pypi_yanks(json).unwrap();
assert_eq!(advs.len(), 1, "only the fully-yanked release is flagged");
assert_eq!(advs[0].ecosystem, "pypi");
assert_eq!(advs[0].package, "ctx");
assert_eq!(advs[0].severity, Severity::Low);
assert_eq!(advs[0].source, "pypi");
assert_eq!(advs[0].id, "MODGUNN-YANK-pypi-ctx-0.1.2");
assert_eq!(advs[0].affected_versions, vec!["0.1.2".to_string()]);
assert!(
advs[0].summary.contains("malicious"),
"carries the yank reason"
);
}
#[test]
fn pypi_partial_file_yank_is_not_a_release_yank() {
// PEP 592: a version with a still-installable (un-yanked) file is NOT yanked
// as a release — flagging it would be a false positive.
let json = r#"{ "info": { "name": "half" }, "releases": {
"1.0": [ { "yanked": true }, { "yanked": false } ] } }"#;
let advs = parse_pypi_yanks(json).unwrap();
assert!(advs.is_empty());
}
#[test]
fn pypi_release_with_no_files_is_not_yanked() {
let json = r#"{ "info": { "name": "empty-rel" }, "releases": { "1.0": [] } }"#;
let advs = parse_pypi_yanks(json).unwrap();
assert!(
advs.is_empty(),
"an empty release list is absent, not yanked"
);
}
#[test]
fn pypi_missing_releases_is_an_error_not_a_silent_empty() {
let json = r#"{ "info": { "name": "truncated" } }"#;
assert!(
parse_pypi_yanks(json).is_err(),
"a doc with no releases map must ERROR"
);
}
#[test]
fn go_retractions_single_range_and_block() {
let gomod = "\
module github.com/acme/widget
go 1.21
require github.com/x/y v1.0.0
retract v1.4.0 // published by mistake
retract [v1.1.0, v1.2.0]
retract (
v1.3.0 // security
[v1.5.0, v1.6.0]
)
";
let advs = parse_go_retractions(gomod);
assert_eq!(advs.len(), 4);
assert!(advs
.iter()
.all(|a| a.ecosystem == "go" && a.package == "github.com/acme/widget"));
assert!(advs
.iter()
.all(|a| a.severity == Severity::Low && a.source == "go"));
// single v1.4.0 → exact-version
let single = advs.iter().find(|a| a.id.ends_with("-v1.4.0")).unwrap();
assert_eq!(single.affected_versions, vec!["v1.4.0".to_string()]);
assert!(single.ranges.is_empty());
// range [v1.1.0, v1.2.0] → an inclusive range, matches the endpoints + inside
let art = |v: &str| crate::core::ArtifactRef {
ecosystem: "go".into(),
name: "github.com/acme/widget".into(),
version: v.into(),
blob_sha256: String::new(),
};
let ranged = advs
.iter()
.find(|a| a.id.ends_with("-v1.1.0-v1.2.0"))
.unwrap();
assert!(ranged.matches(&art("v1.1.0")), "low endpoint inclusive");
assert!(ranged.matches(&art("v1.1.5")), "inside the range");
assert!(ranged.matches(&art("v1.2.0")), "high endpoint inclusive");
assert!(!ranged.matches(&art("v1.2.1")), "just above is clean");
assert!(!ranged.matches(&art("v1.0.0")), "just below is clean");
}
#[test]
fn go_retractions_none_and_no_module_are_empty() {
assert!(
parse_go_retractions("module x\n\ngo 1.21\n").is_empty(),
"no retract → empty"
);
assert!(
parse_go_retractions("retract v1.0.0\n").is_empty(),
"no module line → empty (nothing to key)"
);
}
#[test]
fn pypi_output_is_deterministic_by_version() {
let json = r#"{ "info": { "name": "multi" }, "releases": {
"1.2": [ { "yanked": true } ],
"1.10": [ { "yanked": true } ],
"1.1": [ { "yanked": true } ] } }"#;
let advs = parse_pypi_yanks(json).unwrap();
let vers: Vec<_> = advs
.iter()
.flat_map(|a| a.affected_versions.clone())
.collect();
assert_eq!(vers, vec!["1.1", "1.10", "1.2"], "sorted string order");
}
}