//! Exploit-intelligence + malicious-package feed import (feature `scan`).
//!
//! Engine A's [`Advisory`] carries an `epss` probability, a `kev` flag, and (via a
//! `MAL-` id + Critical severity) malicious-package signal. OSV doesn't ship those
//! — they are separate feeds. This module imports them offline (the airgap
//! sealed-znippy path) and enriches / produces [`Advisory`]s:
//!
//! - **EPSS** — FIRST's daily CSV (`cve,epss,percentile`, `#`-comment header) →
//! a CVE→probability map. ([`parse_epss_csv`])
//! - **KEV** — CISA's Known-Exploited-Vulnerabilities JSON catalog → the set of
//! confirmed-exploited CVE ids. ([`parse_kev_json`])
//! - **OpenSSF Malicious Packages** — an OSV-format feed of `MAL-*` records (the
//! no-CVE malware Socket sees) → Critical [`Advisory`]s. ([`parse_malicious`])
//! - **PoC-in-GitHub** — the community exploit-availability index (nomi-sec), a JSON
//! map keyed by CVE id → the public proof-of-concept repos for it. A CVE with a
//! public PoC is *exploitable-in-practice* (weaker evidence than KEV's
//! confirmed-in-the-wild, stronger than none). ([`parse_poc_in_github`])
//!
//! [`enrich`] folds EPSS + KEV onto a set of advisories by CVE id, so the verdict's
//! KEV/EPSS escalation (see [`crate::scan::Policy`]) operates on populated flags;
//! [`enrich_exploited`] lifts an advisory with a public PoC to at least `High` and
//! tags its summary `[public-exploit]` (schema-free, so the signal round-trips
//! through the existing `severity`/`summary` warehouse columns and the
//! baddie-detection join can rank it — the exploit/risk axis of the mission).
use std::collections::{HashMap, HashSet};
use serde::Deserialize;
use crate::core::Severity;
use crate::osv;
use crate::scan::Advisory;
/// Parse a FIRST EPSS CSV into a `CVE → probability` map. The published feed has
/// `#`-prefixed metadata lines and a `cve,epss,percentile` header; both are
/// skipped. Rows with an unparseable probability are dropped.
pub fn parse_epss_csv(csv: &str) -> HashMap<String, f64> {
let mut out = HashMap::new();
for line in csv.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut cols = line.split(',');
let (Some(cve), Some(epss)) = (cols.next(), cols.next()) else {
continue;
};
// Skip the header row.
if cve.eq_ignore_ascii_case("cve") {
continue;
}
if let Ok(p) = epss.trim().parse::<f64>() {
out.insert(cve.trim().to_string(), p);
}
}
out
}
#[derive(Debug, Deserialize)]
struct KevCatalog {
#[serde(default)]
vulnerabilities: Vec<KevEntry>,
}
#[derive(Debug, Deserialize)]
struct KevEntry {
#[serde(rename = "cveID")]
cve_id: String,
}
/// Parse a CISA KEV JSON catalog into the set of known-exploited CVE ids.
pub fn parse_kev_json(json: &str) -> Result<HashSet<String>, serde_json::Error> {
let cat: KevCatalog = serde_json::from_str(json)?;
Ok(cat.vulnerabilities.into_iter().map(|v| v.cve_id).collect())
}
/// Enrich advisories in place with EPSS probability + KEV flag. Matched by the
/// advisory id **OR any of its aliases** (a CVE id) — EPSS/KEV feeds are keyed by
/// CVE, but `merge_by_alias` keeps the first-seen record as primary, so an
/// actively-exploited CVE whose merged advisory is GHSA/RUSTSEC-primary carries
/// the CVE only in `aliases`. Iterating id + aliases (matching `enrich_with_nvd`)
/// ensures the KEV→Critical / EPSS→High escalation still fires. An advisory
/// already carrying a value is overwritten only when the feed has one (EPSS) /
/// set (KEV).
pub fn enrich(advisories: &mut [Advisory], epss: &HashMap<String, f64>, kev: &HashSet<String>) {
for a in advisories.iter_mut() {
let ids = || std::iter::once(a.id.as_str()).chain(a.aliases.iter().map(|s| s.as_str()));
if let Some(p) = ids().find_map(|i| epss.get(i)) {
a.epss = Some(*p);
}
if ids().any(|i| kev.contains(i)) {
a.kev = true;
}
}
}
/// Parse an OpenSSF **Malicious Packages** OSV document (single or batch) into
/// Critical [`Advisory`]s. These `MAL-*` records flag whole packages as malware
/// (most have no CVE), so every match is a hard `Block`-grade signal.
pub fn parse_malicious(json: &str) -> Result<Vec<Advisory>, serde_json::Error> {
let mut advs = match osv::parse_osv_batch(json) {
Ok(v) if !v.is_empty() => v,
_ => osv::parse_osv(json)?,
};
for a in &mut advs {
a.severity = Severity::Critical;
if !a.summary.to_ascii_lowercase().contains("malicious") {
a.summary = if a.summary.is_empty() {
"malicious package".to_string()
} else {
format!("malicious package: {}", a.summary)
};
}
}
Ok(advs)
}
/// Parse an **ENISA EUVD** document into the set of CVE ids the EU database marks as
/// **exploited in the wild** (a record with a non-empty `exploitedSince`). This is a
/// KEV-grade signal from a second, independent authority (CISA KEV is US; EUVD is
/// EU), so its ids fold into the same `kev` set the [`enrich`] escalation keys on.
/// EUVD's `aliases` field is a **newline-separated** string of ids (`"CVE-…\nGHSA-…"`)
/// — the CVE tokens are extracted. Tolerant of the API's container shapes: a
/// `{ "items": [...] }` page, a bare `[...]` array, or a single record object. A
/// malformed document (not JSON) is an `Err`; a well-formed doc with nothing
/// exploited is a real empty set.
pub fn parse_euvd_exploited(json: &str) -> Result<HashSet<String>, serde_json::Error> {
let doc: serde_json::Value = serde_json::from_str(json)?;
let records: &[serde_json::Value] =
if let Some(items) = doc.get("items").and_then(|v| v.as_array()) {
items
} else if let Some(arr) = doc.as_array() {
arr
} else {
std::slice::from_ref(&doc)
};
let mut out = HashSet::new();
for rec in records {
// `exploitedSince` present + non-null + non-empty ⇒ exploited in the wild.
let exploited = rec
.get("exploitedSince")
.map(|v| !v.is_null() && v.as_str().map(|s| !s.trim().is_empty()).unwrap_or(true))
.unwrap_or(false);
if !exploited {
continue;
}
// `aliases` is a newline/whitespace-separated id string; take the CVE ids.
if let Some(aliases) = rec.get("aliases").and_then(|v| v.as_str()) {
for tok in aliases.split_whitespace() {
let tok = tok.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-');
if tok.starts_with("CVE-") {
out.insert(tok.to_string());
}
}
}
}
Ok(out)
}
/// A **VulnCheck KEV** catalog (`{ "_meta": …, "data": [ … ] }`). Only the
/// per-entry `cve` list is keyed on.
#[derive(Debug, Default, Deserialize)]
struct VulnCheckKev {
#[serde(default)]
data: Vec<VulnCheckKevEntry>,
}
/// One VulnCheck KEV entry. `cve` is a **list** of CVE ids (an entry can cover
/// several); a real entry always carries at least one.
#[derive(Debug, Default, Deserialize)]
struct VulnCheckKevEntry {
#[serde(default)]
cve: Vec<String>,
}
/// Parse a **VulnCheck KEV** (`vulncheck-kev`) catalog into the set of
/// known-exploited CVE ids. VulnCheck KEV is a *superset* of CISA KEV — it adds
/// exploited CVEs CISA has not (yet) catalogued, sourced from VulnCheck's own
/// exploitation reporting (`vulncheck_reported_exploitation`) — so it is a
/// KEV-grade "exploited in the wild" signal from a third, independent authority
/// (CISA=US-gov, EUVD=EU, VulnCheck=commercial intel) and its ids fold into the
/// SAME `kev` set the [`enrich`] escalation keys on.
///
/// The document is `{ "data": [ { "cve": ["CVE-…", …], … }, … ] }`; every
/// `CVE-`-shaped id across every entry is collected (case-folded to the canonical
/// upper-case `CVE-` prefix, so a lower-case `cve-…` from the API still matches
/// the CVE-keyed advisories). A malformed document (not the `{data:[…]}` shape) is
/// an `Err`; a well-formed catalog with no entries is a real empty set (which the
/// caller treats as empty-is-RED when the feed was explicitly requested).
///
/// LIVE FETCH: the VulnCheck KEV *download* requires a free VulnCheck API token
/// (`GET https://api.vulncheck.com/v3/index/vulncheck-kev`, `Bearer` auth). This
/// OFFLINE parser is token-free — it reads the already-downloaded JSON, matching
/// modgunn's file-in collector model (obtaining the file is out of scope).
pub fn parse_vulncheck_kev(json: &str) -> Result<HashSet<String>, serde_json::Error> {
let cat: VulnCheckKev = serde_json::from_str(json)?;
let mut out = HashSet::new();
for entry in &cat.data {
for id in &entry.cve {
let id = id.trim();
if let Some(rest) = id.strip_prefix("CVE-").or_else(|| id.strip_prefix("cve-")) {
if !rest.is_empty() {
out.insert(format!("CVE-{rest}"));
}
}
}
}
Ok(out)
}
/// The stable summary marker an [`enrich_exploited`] lift prepends — so a
/// severity that was raised *because a public exploit exists* is distinguishable
/// (in the round-tripped `summary` column) from a naturally-High advisory, and the
/// baddie-detection join can rank exploit-backed findings first.
pub const PUBLIC_EXPLOIT_MARKER: &str = "[public-exploit]";
/// Parse a **PoC-in-GitHub** (nomi-sec) year document into the set of CVE ids that
/// have at least one public proof-of-concept repo. The document is a JSON object
/// keyed by CVE id whose value is the list of PoC repos; a key with an **empty**
/// list carries no PoC and is skipped, and only `CVE-`-shaped keys are taken (the
/// index is CVE-keyed, but this guards against a stray non-CVE key becoming a
/// phantom exploit id). A malformed document (not a JSON object) is an `Err`.
pub fn parse_poc_in_github(json: &str) -> Result<HashSet<String>, serde_json::Error> {
let map: HashMap<String, Vec<serde_json::Value>> = serde_json::from_str(json)?;
Ok(map
.into_iter()
.filter(|(cve, pocs)| cve.starts_with("CVE-") && !pocs.is_empty())
.map(|(cve, _)| cve)
.collect())
}
/// Parse a **Metasploit** `modules_metadata_base.json` (the framework's module
/// index) into the set of CVE ids that have a Metasploit module — a *weaponized*
/// public exploit, the same exploit-availability signal as [`parse_poc_in_github`]
/// (both feed [`enrich_exploited`]). The document is a JSON object keyed by module
/// path; each module's `references` is a list of `["TYPE", "value"]` pairs, and a
/// `["CVE", "2021-44228"]` pair yields `CVE-2021-44228`. A non-`CVE` reference (URL,
/// EDB, …) is ignored. A malformed document (not a JSON object) is an `Err`.
pub fn parse_metasploit_cves(json: &str) -> Result<HashSet<String>, serde_json::Error> {
#[derive(Deserialize)]
struct Module {
#[serde(default)]
references: Vec<Vec<String>>,
}
let modules: HashMap<String, Module> = serde_json::from_str(json)?;
let mut out = HashSet::new();
for m in modules.values() {
for r in &m.references {
// A reference is a ["TYPE", "value"] pair; take the CVE ones.
if let [kind, value] = r.as_slice() {
if kind.eq_ignore_ascii_case("CVE") && !value.is_empty() {
out.insert(format!("CVE-{value}"));
}
}
}
}
Ok(out)
}
/// Parse a **Nuclei** `cves.json` (ProjectDiscovery's template index) into the set
/// of CVE ids that have a Nuclei detection/exploitation template — an
/// exploit-availability signal, the same class as [`parse_poc_in_github`] /
/// [`parse_metasploit_cves`] (all feed [`enrich_exploited`]). The file is **NDJSON**:
/// one JSON object per line, each `{ "ID": "CVE-…", "Info": {…} }`. Blank lines and
/// non-CVE ids are skipped. Anti-silent-empty: a non-blank document from which NOT
/// ONE line parses as a JSON object (e.g. an HTML error page fetched by mistake) is
/// an `Err`, not a fake-empty set; a valid NDJSON with no CVE ids is a real empty.
pub fn parse_nuclei_cves(ndjson: &str) -> Result<HashSet<String>, serde_json::Error> {
let mut out = HashSet::new();
let mut parsed_any = false;
for line in ndjson.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let obj: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue, // tolerate a partial/garbage line (NDJSON norm)
};
parsed_any = true;
if let Some(id) = obj.get("ID").and_then(|v| v.as_str()) {
if id.starts_with("CVE-") {
out.insert(id.to_string());
}
}
}
if !ndjson.trim().is_empty() && !parsed_any {
// Not NDJSON at all — surface it rather than silently import nothing.
return Err(serde::de::Error::custom(
"nuclei cves.json: no JSON object line parsed",
));
}
Ok(out)
}
/// Parse an **Exploit-DB** `files_exploits.csv` (the offline mirror of the
/// Exploit Database index) into the set of CVE ids that have a **published,
/// downloadable exploit** — the strongest of the public-exploit signals this module
/// folds into [`enrich_exploited`] (a PoC-in-GitHub repo may be a stub; an
/// Exploit-DB entry is a working exploit file with an EDB-ID). Same shape as
/// [`parse_poc_in_github`] / [`parse_metasploit_cves`] / [`parse_nuclei_cves`]: an
/// exploit-CVE set, unioned by the importer.
///
/// The CVE ids live in the `;`-separated **`codes`** column
/// (`"CVE-2021-3156;OSVDB-12345"`); non-CVE codes (OSVDB, EDB, MSB) are ignored.
/// The column index is read from the **header row**, never hardcoded: Exploit-DB has
/// added columns over the years (`aliases`, `screenshot_url`, `source_url`), so a
/// fixed offset silently reads the wrong field on a newer mirror — a fake pass.
/// Fields are split with the shared RFC-4180 splitter ([`crate::ioc::split_csv`])
/// because the `description` column is quoted and full of commas.
///
/// Anti-silent-empty: a document whose header carries no `codes` column is not an
/// Exploit-DB index at all (wrong file / an HTML error page) — that is an `Err`, not
/// an empty set. A genuine index with no CVE-tagged rows is a real empty.
pub fn parse_exploitdb_csv(csv: &str) -> Result<HashSet<String>, serde_json::Error> {
let mut lines = csv.lines().map(str::trim).filter(|l| !l.is_empty());
let Some(header) = lines.next() else {
return Err(serde::de::Error::custom("exploit-db csv: empty document"));
};
let cols = crate::ioc::split_csv(header);
let Some(codes_at) = cols
.iter()
.position(|c| c.trim().eq_ignore_ascii_case("codes"))
else {
return Err(serde::de::Error::custom(
"exploit-db csv: no `codes` column in the header — not a files_exploits.csv",
));
};
let mut out = HashSet::new();
for line in lines {
let fields = crate::ioc::split_csv(line);
let Some(codes) = fields.get(codes_at) else {
continue;
};
for code in codes.split(';') {
let code = code.trim();
if is_cve_id(code) {
out.insert(code.to_ascii_uppercase());
}
}
}
Ok(out)
}
/// Is `s` a syntactically valid CVE id (`CVE-<4+ digits>-<digits>`)? Used to keep a
/// non-CVE code (`OSVDB-12345`) or a garbage cell out of the exploit-CVE set — a
/// bogus id would never match an advisory, but it would inflate the "we have exploit
/// intel" count into a fake pass.
fn is_cve_id(s: &str) -> bool {
let Some(rest) = s.strip_prefix("CVE-").or_else(|| s.strip_prefix("cve-")) else {
return false;
};
let Some((year, seq)) = rest.split_once('-') else {
return false;
};
year.len() >= 4
&& year.bytes().all(|b| b.is_ascii_digit())
&& !seq.is_empty()
&& seq.bytes().all(|b| b.is_ascii_digit())
}
/// Lift every advisory that has a **public exploit** (its id or any alias is in
/// `exploited`) to at least `Severity::High`, and tag its summary with
/// [`PUBLIC_EXPLOIT_MARKER`] once. Matched by id **OR alias** (same reasoning as
/// [`enrich`]: a CVE with a public PoC may be merged under a GHSA-primary row that
/// carries the CVE only in `aliases`). A public PoC is weaker evidence than KEV, so
/// it floors at `High` rather than escalating to `Critical` — it does not *lower* an
/// already-`Critical` advisory. Idempotent: re-running does not double-tag.
pub fn enrich_exploited(advisories: &mut [Advisory], exploited: &HashSet<String>) {
for a in advisories.iter_mut() {
let hit = std::iter::once(a.id.as_str())
.chain(a.aliases.iter().map(|s| s.as_str()))
.any(|i| exploited.contains(i));
if !hit {
continue;
}
a.severity = a.severity.max(Severity::High);
if !a.summary.contains(PUBLIC_EXPLOIT_MARKER) {
a.summary = if a.summary.is_empty() {
PUBLIC_EXPLOIT_MARKER.to_string()
} else {
format!("{PUBLIC_EXPLOIT_MARKER} {}", a.summary)
};
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn epss_csv_skips_comments_and_header() {
let csv = "#model_version:v2025.03.14\ncve,epss,percentile\nCVE-2021-1\
,0.97,0.99\nCVE-2021-2,0.01,0.10\nbad,notanumber,x";
let m = parse_epss_csv(csv);
assert_eq!(m.len(), 2);
assert_eq!(m["CVE-2021-1"], 0.97);
assert_eq!(m["CVE-2021-2"], 0.01);
}
#[test]
fn exploitdb_csv_reads_codes_by_header_and_honors_quoted_commas() {
// A *newer-mirror* header: `codes` sits at a DIFFERENT index than the legacy
// layout, and `description` is quoted with embedded commas. Reverting either
// the header lookup (to a fixed offset) or the RFC-4180 splitter (to
// `split(',')`) reads the wrong column and turns this red.
let csv = "id,file,description,date_published,author,type,platform,port,\
date_added,date_updated,verified,codes,tags,aliases,screenshot_url,\
source_url\n\
50011,exploits/linux/local/50011.py,\"Sudo 1.8.31 - 'Baron Samedit', \
Heap-Based Overflow\",2021-01-27,x,local,linux,,2021-01-27,2021-02-01,\
1,OSVDB-12345;CVE-2021-3156,\"tags,with,commas\",,,\n\
50012,exploits/windows/remote/50012.rb,\"PrintNightmare, RCE\",\
2021-07-01,y,remote,windows,445,2021-07-01,2021-07-02,1,\
CVE-2021-34527;CVE-2021-1675,,,,\n\
50013,exploits/multiple/dos/50013.txt,\"No CVE here\",2021-08-01,z,\
dos,multiple,,2021-08-01,2021-08-01,0,,,,,\n";
let set = parse_exploitdb_csv(csv).unwrap();
assert_eq!(
set,
["CVE-2021-3156", "CVE-2021-34527", "CVE-2021-1675"]
.into_iter()
.map(String::from)
.collect::<HashSet<_>>(),
"only the CVE codes land — OSVDB-* is dropped, an empty codes cell is a real zero"
);
// The legacy layout (no `aliases`/`screenshot_url`/`source_url`) puts `codes`
// at a different index again — header-driven lookup handles both.
let legacy = "id,file,description,date,author,platform,type,port,codes\n\
1,a.c,\"x, y\",2010-01-01,a,linux,local,,CVE-2010-0001\n";
assert!(parse_exploitdb_csv(legacy)
.unwrap()
.contains("CVE-2010-0001"));
// Anti-silent-empty: a document with no `codes` column is the WRONG file
// (or an HTML error page) — an error, never a fake-empty exploit set.
assert!(parse_exploitdb_csv("id,file,description\n1,a.c,x\n").is_err());
assert!(parse_exploitdb_csv("<html><body>404</body></html>").is_err());
assert!(parse_exploitdb_csv("").is_err());
// A genuine index whose rows carry no CVE is a REAL empty, not an error.
assert!(parse_exploitdb_csv("id,codes\n1,\n").unwrap().is_empty());
}
#[test]
fn exploitdb_cves_drive_the_public_exploit_lift() {
// End-to-end: the Exploit-DB set feeds the SAME `enrich_exploited` lift the
// other exploit sources do — a Low advisory whose CVE has a published exploit
// is floored at High and tagged.
let csv = "id,codes\n50011,CVE-2021-3156\n";
let exploited = parse_exploitdb_csv(csv).unwrap();
let mut advs = vec![
Advisory::basic(
"GHSA-xxxx",
"npm",
"sudo-js",
vec![],
Severity::Low,
"wrapper bug",
)
.with_aliases(vec!["CVE-2021-3156".to_string()]),
Advisory::basic(
"CVE-2099-QUIET",
"npm",
"calm",
vec![],
Severity::Low,
"minor",
),
];
enrich_exploited(&mut advs, &exploited);
assert_eq!(
advs[0].severity,
Severity::High,
"alias-matched Exploit-DB entry floors at High"
);
assert!(advs[0].summary.contains(PUBLIC_EXPLOIT_MARKER));
assert_eq!(
advs[1].severity,
Severity::Low,
"an unexploited advisory is untouched"
);
}
#[test]
fn kev_json_yields_cve_set() {
let json = r#"{ "title": "CISA KEV", "vulnerabilities": [
{ "cveID": "CVE-2021-44228", "vendorProject": "Apache" },
{ "cveID": "CVE-2017-0144" } ] }"#;
let set = parse_kev_json(json).unwrap();
assert!(set.contains("CVE-2021-44228"));
assert!(set.contains("CVE-2017-0144"));
assert_eq!(set.len(), 2);
}
#[test]
fn vulncheck_kev_yields_cve_set_and_folds_into_kev_escalation() {
// Real VulnCheck KEV shape: `{ "data": [ { "cve": [ … ], … } ] }`, where an
// entry's `cve` is a LIST (one entry can cover several CVEs). Keying on the
// CISA `vulnerabilities[].cveID` shape (parse_kev_json) would deserialize this
// to an empty set — a silent loss of the whole VulnCheck exploited signal.
// Red-when-reverted: point this at parse_kev_json and it yields 0.
let json = r#"{
"_meta": { "timestamp": "2026-07-29T00:00:00Z", "total_documents": 2 },
"data": [
{ "vendorProject": "Apache", "product": "Log4j2",
"cve": [ "CVE-2021-44228", "CVE-2021-45046" ],
"vulncheck_reported_exploitation": [ { "url": "https://x", "date_added": "2021-12-10" } ] },
{ "vendorProject": "Fortinet", "product": "FortiOS", "cve": [ "cve-2018-13379" ] },
{ "vendorProject": "NoCve", "product": "x", "cve": [] }
]
}"#;
let set = parse_vulncheck_kev(json).unwrap();
assert!(set.contains("CVE-2021-44228"));
assert!(set.contains("CVE-2021-45046"));
assert!(
set.contains("CVE-2018-13379"),
"lower-case cve- prefix canonicalized"
);
assert_eq!(
set.len(),
3,
"three distinct CVEs across entries; the cve-less entry adds none"
);
// It escalates an advisory exactly like CISA KEV — folded into the SAME set.
let mut advs = vec![Advisory::basic(
"GHSA-jfh8-c2jp-5v3q",
"maven",
"log4j-core",
vec![],
Severity::Low,
"rce",
)
.with_aliases(["CVE-2021-44228"])];
enrich(&mut advs, &HashMap::new(), &set);
assert!(
advs[0].kev,
"VulnCheck KEV hit (via alias) sets the kev flag"
);
}
#[test]
fn vulncheck_kev_empty_and_malformed() {
assert!(
parse_vulncheck_kev(r#"{ "data": [] }"#).unwrap().is_empty(),
"well-formed empty ⇒ real empty set"
);
assert!(
parse_vulncheck_kev("not json at all").is_err(),
"malformed ⇒ Err, never a silent empty"
);
}
#[test]
fn enrich_populates_epss_and_kev_by_id() {
let mut advs = vec![
Advisory::basic(
"CVE-2021-44228",
"maven",
"log4j-core",
vec![],
Severity::High,
"rce",
),
Advisory::basic(
"CVE-2099-QUIET",
"npm",
"calm",
vec![],
Severity::Low,
"minor",
),
];
let mut epss = HashMap::new();
epss.insert("CVE-2021-44228".to_string(), 0.97);
let mut kev = HashSet::new();
kev.insert("CVE-2021-44228".to_string());
enrich(&mut advs, &epss, &kev);
assert_eq!(advs[0].epss, Some(0.97));
assert!(advs[0].kev);
assert_eq!(advs[1].epss, None, "untouched when not in feed");
assert!(!advs[1].kev);
}
#[test]
fn enrich_matches_on_alias_not_just_primary_id() {
// Regression: an actively-exploited CVE whose merged advisory is
// GHSA-primary (CVE only in aliases) MUST still be enriched, else the
// KEV→Critical / EPSS→High escalation silently never fires.
let mut advs = vec![Advisory::basic(
"GHSA-jfh8-c2jp-5v3q",
"maven",
"log4j-core",
vec![],
Severity::High,
"rce",
)
.with_aliases(["CVE-2021-44228"])];
let mut epss = HashMap::new();
epss.insert("CVE-2021-44228".to_string(), 0.97);
let mut kev = HashSet::new();
kev.insert("CVE-2021-44228".to_string());
enrich(&mut advs, &epss, &kev);
assert_eq!(advs[0].epss, Some(0.97), "EPSS matched via alias");
assert!(advs[0].kev, "KEV matched via alias");
}
#[test]
fn euvd_exploited_extracts_cve_ids_from_exploited_records() {
// The API `{ items: [...] }` page shape; aliases are newline-separated.
let json = r#"{ "items": [
{ "id": "EUVD-2025-1", "aliases": "CVE-2025-0001\nGHSA-xxxx", "exploitedSince": "May 1, 2025" },
{ "id": "EUVD-2025-2", "aliases": "CVE-2025-0002", "exploitedSince": null },
{ "id": "EUVD-2025-3", "aliases": "CVE-2025-0003" }
], "total": 3 }"#;
let set = parse_euvd_exploited(json).unwrap();
assert!(
set.contains("CVE-2025-0001"),
"an exploited record's CVE is captured"
);
assert!(
!set.contains("CVE-2025-0002"),
"exploitedSince=null ⇒ not exploited"
);
assert!(
!set.contains("CVE-2025-0003"),
"absent exploitedSince ⇒ not exploited"
);
assert_eq!(set.len(), 1, "GHSA alias is not a CVE");
}
#[test]
fn euvd_exploited_handles_bare_array_and_single_record() {
let arr = r#"[ { "aliases": "CVE-2024-9", "exploitedSince": "2024" } ]"#;
assert!(parse_euvd_exploited(arr).unwrap().contains("CVE-2024-9"));
let single = r#"{ "aliases": "CVE-2024-8", "exploitedSince": "2024" }"#;
assert!(parse_euvd_exploited(single).unwrap().contains("CVE-2024-8"));
// A well-formed doc with nothing exploited is a real empty set, not an error.
let none = r#"{ "items": [ { "aliases": "CVE-2024-7" } ] }"#;
assert!(parse_euvd_exploited(none).unwrap().is_empty());
// Malformed ⇒ error.
assert!(parse_euvd_exploited("not json").is_err());
}
#[test]
fn poc_in_github_keys_with_pocs_become_exploit_cves() {
let json = r#"{
"CVE-2021-44228": [ { "name": "log4shell-poc", "full_name": "x/log4shell-poc" } ],
"CVE-2099-EMPTY": [],
"not-a-cve": [ { "name": "junk" } ]
}"#;
let set = parse_poc_in_github(json).unwrap();
assert!(
set.contains("CVE-2021-44228"),
"a CVE with a PoC repo is exploited"
);
assert!(
!set.contains("CVE-2099-EMPTY"),
"empty PoC list is not an exploit"
);
assert!(!set.contains("not-a-cve"), "non-CVE keys are ignored");
assert_eq!(set.len(), 1);
}
#[test]
fn poc_malformed_is_an_error_not_a_silent_empty() {
assert!(
parse_poc_in_github("[1,2,3]").is_err(),
"a non-object doc must ERROR, not yield {{}}"
);
}
#[test]
fn enrich_exploited_floors_severity_at_high_and_tags_once() {
let mut advs = vec![
Advisory::basic("CVE-A", "npm", "p", vec![], Severity::Low, "rce"),
Advisory::basic(
"CVE-B",
"npm",
"q",
vec![],
Severity::Critical,
"already crit",
),
Advisory::basic("CVE-C", "npm", "r", vec![], Severity::Medium, "untouched"),
];
let mut ex = HashSet::new();
ex.insert("CVE-A".to_string());
ex.insert("CVE-B".to_string());
enrich_exploited(&mut advs, &ex);
// Low → lifted to High + tagged.
assert_eq!(advs[0].severity, Severity::High);
assert!(advs[0].summary.starts_with(PUBLIC_EXPLOIT_MARKER));
// Critical is NOT lowered, still tagged.
assert_eq!(
advs[1].severity,
Severity::Critical,
"a public PoC never lowers Critical"
);
assert!(advs[1].summary.contains(PUBLIC_EXPLOIT_MARKER));
// Not in the set → untouched.
assert_eq!(advs[2].severity, Severity::Medium);
assert!(!advs[2].summary.contains(PUBLIC_EXPLOIT_MARKER));
// Idempotent: no double-tag.
enrich_exploited(&mut advs, &ex);
assert_eq!(advs[0].summary.matches(PUBLIC_EXPLOIT_MARKER).count(), 1);
}
#[test]
fn nuclei_ndjson_extracts_cve_ids() {
let ndjson = "\
{\"ID\":\"CVE-2021-44228\",\"Info\":{\"Name\":\"log4shell\",\"Severity\":\"critical\"},\"file_path\":\"http/cves/2021/CVE-2021-44228.yaml\"}
{\"ID\":\"CVE-2021-26855\",\"Info\":{\"Severity\":\"high\"}}
{\"ID\":\"panel-detect\",\"Info\":{}}
garbage line that is not json";
let set = parse_nuclei_cves(ndjson).unwrap();
assert!(set.contains("CVE-2021-44228"));
assert!(set.contains("CVE-2021-26855"));
assert!(!set.contains("panel-detect"), "non-CVE template id ignored");
assert_eq!(set.len(), 2, "blank + garbage lines tolerated");
}
#[test]
fn nuclei_non_ndjson_document_errors() {
assert!(
parse_nuclei_cves("<html>error</html>").is_err(),
"an HTML page is not NDJSON → error"
);
assert!(
parse_nuclei_cves("").unwrap().is_empty(),
"empty input is a real empty"
);
}
#[test]
fn metasploit_extracts_cve_references_only() {
let json = r#"{
"exploit/multi/http/log4shell": {
"name": "Log4Shell",
"references": [ ["CVE", "2021-44228"], ["URL", "https://example/poc"] ]
},
"auxiliary/scanner/foo": {
"references": [ ["EDB", "50592"], ["CVE", "2021-45046"] ]
},
"exploit/no_refs": {}
}"#;
let set = parse_metasploit_cves(json).unwrap();
assert!(set.contains("CVE-2021-44228"));
assert!(set.contains("CVE-2021-45046"));
assert_eq!(set.len(), 2, "only CVE references, EDB/URL ignored");
}
#[test]
fn metasploit_malformed_is_an_error() {
assert!(parse_metasploit_cves("\"not an object\"").is_err());
}
#[test]
fn metasploit_feeds_the_same_exploit_lift() {
// Cross-source: a Metasploit CVE lifts an advisory exactly like a PoC one.
let mut advs = vec![Advisory::basic(
"CVE-2021-44228",
"maven",
"log4j-core",
vec![],
Severity::Medium,
"rce",
)];
let ex = parse_metasploit_cves(
r#"{ "exploit/x": { "references": [ ["CVE", "2021-44228"] ] } }"#,
)
.unwrap();
enrich_exploited(&mut advs, &ex);
assert_eq!(advs[0].severity, Severity::High);
assert!(advs[0].summary.starts_with(PUBLIC_EXPLOIT_MARKER));
}
#[test]
fn enrich_exploited_matches_via_alias() {
let mut advs = vec![
Advisory::basic("GHSA-x", "npm", "p", vec![], Severity::Low, "rce")
.with_aliases(["CVE-EXP"]),
];
let mut ex = HashSet::new();
ex.insert("CVE-EXP".to_string());
enrich_exploited(&mut advs, &ex);
assert_eq!(
advs[0].severity,
Severity::High,
"exploit matched via alias"
);
}
#[test]
fn malicious_feed_imports_as_critical() {
let json = r#"{
"id": "MAL-2024-1234",
"summary": "exfiltrates env vars on install",
"affected": [ { "package": { "ecosystem": "npm", "name": "evil-pkg" } } ]
}"#;
let advs = parse_malicious(json).unwrap();
assert_eq!(advs.len(), 1);
assert_eq!(advs[0].id, "MAL-2024-1234");
assert_eq!(advs[0].severity, Severity::Critical);
assert!(advs[0].summary.contains("malicious package"));
assert!(
advs[0].affected_versions.is_empty(),
"all versions malicious"
);
}
}