diff --git a/CLAUDE.md b/CLAUDE.md index f6a0a676..742e3096 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ The core stays an in-repo workspace crate on purpose (no git submodule) until it ## Runtime Configuration -Read in `run_from_env` (`src/lib.rs`): `BIND_ADDR` (default `127.0.0.1:8080`), `ADMIN_TOKEN` (write token for `X-Admin-Token`), `ADMIN_TOKENS` (comma-separated `token:actor` pairs for multi-token RBAC with per-token audit actors), `WAF_IDS_STATE_PATH` (optional JSON state file; omitted = seeded in-memory state), `DNSBL_ORIGIN` (default `dnsbl.local`), `EVENT_LIMIT` (default 1000, must be > 0), `RATE_LIMIT` / `RATE_LIMIT_WINDOW`. +Read in `run_from_env` (`src/lib.rs`): `BIND_ADDR` (default `127.0.0.1:8080`), `WAF_IDS_STATE_PATH` (optional JSON state file; omitted = seeded in-memory state), `DNSBL_ORIGIN` (default `dnsbl.local`), `EVENT_LIMIT` (default 1000, must be > 0), `RATE_LIMIT` / `RATE_LIMIT_WINDOW`, `WAF_IDS_CREDENTIALS_PATH` (optional JSON bootstrap file for process-local credentials/config), `ADMIN_TOKEN` (bootstrap transport for the shared write token), and `ADMIN_TOKENS` (bootstrap transport for comma-separated `token:actor[:role]` RBAC entries). `ADMIN_TOKEN` and `ADMIN_TOKENS` are loaded into `CredentialRegistry` before the server starts; handlers read the in-process registry/AppState copy, not raw env vars. KEV imports use the built-in CISA endpoint at runtime; only in-crate tests can override it through `AppState::with_kev_catalog_url` to point at a loopback mock server. ## Key Conventions diff --git a/crates/waf-ids-core/src/lib.rs b/crates/waf-ids-core/src/lib.rs index e3788698..f9673e0c 100644 --- a/crates/waf-ids-core/src/lib.rs +++ b/crates/waf-ids-core/src/lib.rs @@ -11,6 +11,8 @@ pub const TARGET_SALE_VALUE_KRW: u64 = 2_000_000_000; pub struct AppData { pub routes: Vec, pub threats: Vec, + #[serde(default)] + pub operator_threat_keys: Vec, pub dnsbl: Vec, pub events: Vec, pub next_event_id: u64, @@ -22,6 +24,8 @@ pub struct AppData { pub commercial: CommercialProfile, #[serde(default)] pub threat_feeds: Vec, + #[serde(default)] + pub threat_feed_ownership: Vec, } impl AppData { @@ -42,6 +46,7 @@ impl AppData { source: "seed:owasp-crs-shape".to_string(), ttl_seconds: 86_400, }], + operator_threat_keys: Vec::new(), dnsbl: vec![DnsblEntry { address: "203.0.113.10".parse().expect("seed IP address is valid"), code: "127.0.0.2".to_string(), @@ -56,6 +61,7 @@ impl AppData { next_audit_log_id: 1, commercial: CommercialProfile::seeded(), threat_feeds: Vec::new(), + threat_feed_ownership: Vec::new(), } } } @@ -179,6 +185,19 @@ pub struct ThreatFeedStatus { pub ttl_seconds: u64, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ThreatFeedOwnership { + pub feed_id: String, + pub threat_keys: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct ThreatIndicatorKey { + pub indicator_type: String, + pub value: String, + pub source: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ThreatFeedImport { pub feed_id: String, @@ -533,6 +552,32 @@ pub fn upsert_threat_feed( feed } +pub fn threat_indicator_key(indicator: &ThreatIndicator) -> ThreatIndicatorKey { + ThreatIndicatorKey { + indicator_type: indicator.indicator_type.clone(), + value: indicator.value.clone(), + source: indicator.source.clone(), + } +} + +pub fn replace_threat_feed_ownership( + ownership: &mut Vec, + feed_id: String, + threat_keys: Vec, +) -> Vec { + if let Some(existing) = ownership.iter_mut().find(|item| item.feed_id == feed_id) { + let previous = existing.threat_keys.clone(); + existing.threat_keys = threat_keys; + previous + } else { + ownership.push(ThreatFeedOwnership { + feed_id, + threat_keys, + }); + Vec::new() + } +} + pub fn record_audit_log(data: &mut AppData, entry: NewAuditLogEntry) -> AuditLogEntry { let audit_log = AuditLogEntry { id: data.next_audit_log_id, @@ -845,6 +890,14 @@ pub fn score_request( let kind = indicator.indicator_type.to_ascii_lowercase(); let matched = if matches!(kind.as_str(), "ip" | "client_ip" | "source_ip" | "src_ip") { client_ip.is_some_and(|ip| indicator.value.parse::().ok() == Some(ip)) + } else if kind == "cve" { + // A CVE identifier is vulnerability-catalog metadata (e.g. from a + // CISA KEV import), not a request-content observable: it can + // legitimately appear in a vulnerability-management or security + // tool's own traffic (`/api/cve/CVE-2021-44228`), so it must never + // drive content-substring scoring. It stays visible via the + // threat-indicator, feed-freshness, and buyer-evidence APIs. + false } else { haystack.contains(&indicator.value.to_lowercase()) }; @@ -1292,6 +1345,14 @@ fn buyer_evidence_endpoints() -> Vec { "OpenCTI observable/indicator JSON ingest into threat indicators and DNSBL (admin-auth)", false, ), + buyer_evidence_endpoint( + "cisa_kev_ingest", + "POST", + "/api/threat-intel/cisa-kev", + "application/json", + "CISA Known Exploited Vulnerabilities catalog pull into CVE threat indicators (admin-auth)", + false, + ), ] } @@ -1441,6 +1502,31 @@ mod tests { assert_eq!(miss.score, 0); } + #[test] + fn score_request_never_content_matches_cve_indicators() { + // A CVE indicator (e.g. from a CISA KEV import) is vulnerability + // metadata, not a request-content signature: a security-tooling + // request can legitimately carry the literal CVE string, and that + // must never contribute to the block score. + let threats = vec![ThreatIndicator { + value: "CVE-2021-44228".to_string(), + indicator_type: "cve".to_string(), + severity: Severity::Critical, + source: "feed:cisa-kev".to_string(), + ttl_seconds: 86_400, + }]; + let hit = score_request( + "/api/cve/CVE-2021-44228", + None, + "looking up CVE-2021-44228 details", + None, + &threats, + &[], + ); + assert_eq!(hit.score, 0); + assert_eq!(hit.reason, "no matching indicator"); + } + #[test] fn score_request_saturates_instead_of_overflowing_on_many_matches() { // Regression: `score` is a u16 accumulator. With enough matching diff --git a/docs/architecture.md b/docs/architecture.md index 89291bf5..e1ee578b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,10 +45,16 @@ flowchart LR - **WAF**: Coraza/OWASP CRS audit JSON/NDJSON ingest is available at `POST /api/waf/coraza/audit` (admin token). Interrupted transactions and CRS rule messages become `SecurityEvent` rows and feed gateway enforcement (DNSBL + `client_ip`/`path` threat indicators) so subsequent gateway decisions block matching clients. In-process Coraza embedding remains a follow-up — do not replace CRS with hand-rolled rules. - **IDS**: Suricata EVE JSON/NDJSON ingest is available at `POST /api/ids/suricata/eve` (admin token). Alert records become `SecurityEvent` rows for SOC export/KPI; full route correlation and live EVE tailing remain follow-ups. -- **Threat Intelligence**: STIX 2.x indicator/bundle ingest is available at `POST /api/threat-intel/stix` (admin token), MISP Event/attribute JSON ingest at `POST /api/threat-intel/misp` (admin token), TAXII 2.1 collection poll at `POST /api/threat-intel/taxii/poll` (admin token; Basic/Bearer optional), and OpenCTI observable/indicator export ingest at `POST /api/threat-intel/opencti` (admin token). All update `ThreatIndicator` / `DnsblEntry` plus feed freshness. Live MISP REST pull and live OpenCTI GraphQL pull remain follow-ups. +- **Threat Intelligence**: STIX 2.x indicator/bundle ingest is available at `POST /api/threat-intel/stix` (admin token), MISP Event/attribute JSON ingest at `POST /api/threat-intel/misp` (admin token), TAXII 2.1 collection poll at `POST /api/threat-intel/taxii/poll` (admin token; Basic/Bearer optional), OpenCTI observable/indicator export ingest at `POST /api/threat-intel/opencti` (admin token), and a CISA Known Exploited Vulnerabilities (KEV) catalog pull at `POST /api/threat-intel/cisa-kev` (admin token; fetches the official catalog and upserts one `cve` threat indicator per entry, severity escalated when CISA has tied the CVE to a known ransomware campaign). All update `ThreatIndicator` / `DnsblEntry` plus feed freshness. Live MISP REST pull and live OpenCTI GraphQL pull remain follow-ups. - **DNSBL Serving**: Hickory DNS should serve authoritative DNSBL responses directly after zone export semantics stabilize. - **AI SOC**: AI triage should summarize events, map likely ATT&CK tactics, and recommend actions. Enforcement-changing recommendations require human approval. +### Further reading (CISA KEV catalog pull) + +- CISA. (2021). *Binding Operational Directive 22-01: Reducing the Significant Risk of Known Exploited Vulnerabilities.* Cybersecurity and Infrastructure Security Agency. https://www.cisa.gov/known-exploited-vulnerabilities — the directive establishing the catalog's confirmed-active-exploitation inclusion criterion, which is why `kev_import.rs` treats catalog membership alone as at least `High` severity rather than deriving it from a numeric score. +- Jacobs, J., Romanosky, S., Edwards, B., Adjerid, I., & Roytman, M. (2021). Exploit Prediction Scoring System (EPSS). *Digital Threats: Research and Practice, 2*(3), Article 20. https://doi.org/10.1145/3436242 — the seminal data-driven framework establishing that confirmed/predicted exploitation likelihood is a stronger remediation-priority signal than static CVSS severity, motivating exploitation-evidence-first indicators like KEV over severity-only scoring. +- Shimizu, N., & Hashimoto, M. (2025). Vulnerability Management Chaining: An Integrated Framework for Efficient Cybersecurity Risk Prioritization. *arXiv:2506.01220* — [`papers/vulnerability-management-chaining-kev-epss-cvss-arxiv-2506.01220.pdf`](papers/vulnerability-management-chaining-kev-epss-cvss-arxiv-2506.01220.pdf) (CC BY 4.0). Demonstrates that KEV-membership-first filtering ahead of CVSS materially reduces urgent-remediation workload versus severity-only triage, and that KEV alone still misses exploited vulnerabilities EPSS catches — supporting this adapter's role as one input among several proven feeds (STIX/MISP/TAXII/OpenCTI), not a replacement for them. + ## Security Boundaries - Default bind address is localhost. diff --git a/docs/papers/vulnerability-management-chaining-kev-epss-cvss-arxiv-2506.01220.pdf b/docs/papers/vulnerability-management-chaining-kev-epss-cvss-arxiv-2506.01220.pdf new file mode 100644 index 00000000..cd339327 Binary files /dev/null and b/docs/papers/vulnerability-management-chaining-kev-epss-cvss-arxiv-2506.01220.pdf differ diff --git a/docs/security/compliance-mapping.md b/docs/security/compliance-mapping.md index 8251d794..b956bad7 100644 --- a/docs/security/compliance-mapping.md +++ b/docs/security/compliance-mapping.md @@ -11,7 +11,7 @@ This document maps the commercial baseline to common enterprise security review | Change Control | Route-scoped monitor/block modes | Approval workflow and rollback attestations | | Availability | Health endpoint, Kubernetes probes | HA storage, multi-replica state backend | | Incident Response | Operations runbook and support bundle | On-call process, SLA/SLO reporting | -| Threat Intelligence | Feed import API, STIX/MISP/OpenCTI document ingest, TAXII 2.1 poll, feed status | Signed feeds, live MISP REST / OpenCTI GraphQL pull | +| Threat Intelligence | Feed import API, STIX/MISP/OpenCTI document ingest, TAXII 2.1 poll, CISA KEV catalog pull, feed status | Signed feeds, live MISP REST / OpenCTI GraphQL pull | | DNSBL | Zone export and response-code validation | Authoritative DNS service and publication controls | | AI Governance | Human approval boundary documented | Model evals, prompt audit, recommendation traceability | diff --git a/src/credentials.rs b/src/credentials.rs index 02b7f39e..bcc07e50 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -1,4 +1,5 @@ -//! Secret-bearing configuration via a process-local credential registry. +//! Secret-bearing and fetch-sensitive configuration via a process-local +//! credential registry. //! //! Org guidance: runtime code must not treat raw environment variables as the //! source of secrets. Environment (and optional credentials file) are bootstrap @@ -8,7 +9,7 @@ use serde::{Deserialize, Serialize}; use std::{collections::HashMap, io::ErrorKind, path::Path}; -/// Well-known secret keys loaded into the registry at bootstrap. +/// Well-known credentials loaded into the registry at bootstrap. pub const CRED_ADMIN_TOKEN: &str = "admin_token"; pub const CRED_ADMIN_TOKENS: &str = "admin_tokens"; @@ -64,19 +65,23 @@ impl CredentialRegistry { .is_some_and(|v| !v.trim().is_empty()) } - /// Bootstrap secret-bearing credentials. + /// Bootstrap secret-bearing credentials plus the optional KEV fetch override. /// /// Precedence: JSON credentials file (when present) wins per-key; missing /// keys are filled from the env bootstrap values. Operational non-secret - /// config (bind address, limits, DNSBL origin) stays on env. + /// config (bind address, limits, DNSBL origin) stays on env. The KEV URL + /// defaults to the built-in CISA endpoint and is accepted here only as a + /// server-side override that must still satisfy the runtime allowlist. pub fn bootstrap_secrets( credentials_path: Option<&Path>, env_admin_token: Option, env_admin_tokens: Option, ) -> Result { let mut values = HashMap::new(); - let mut from_file = false; - let mut from_env = false; + // CredentialSource is documented (and reported via HealthStatus/support + // bundle) as admin-secret provenance specifically. + let mut admin_from_file = false; + let mut admin_from_env = false; if let Some(path) = credentials_path { match std::fs::read_to_string(path) { @@ -93,7 +98,7 @@ impl CredentialRegistry { let text = json_value_as_nonempty_string(raw); if let Some(text) = text { values.insert(key.to_string(), text); - from_file = true; + admin_from_file = true; } } } @@ -112,18 +117,17 @@ impl CredentialRegistry { && let Some(token) = env_admin_token.filter(|value| !value.is_empty()) { values.insert(CRED_ADMIN_TOKEN.to_string(), token); - from_env = true; + admin_from_env = true; } if !values.contains_key(CRED_ADMIN_TOKENS) && let Some(tokens) = env_admin_tokens.filter(|value| !value.is_empty()) { values.insert(CRED_ADMIN_TOKENS.to_string(), tokens); - from_env = true; + admin_from_env = true; } - - let source = if from_file { + let source = if admin_from_file { CredentialSource::File - } else if from_env { + } else if admin_from_env { CredentialSource::Env } else { CredentialSource::None diff --git a/src/kev_import.rs b/src/kev_import.rs new file mode 100644 index 00000000..68755e2b --- /dev/null +++ b/src/kev_import.rs @@ -0,0 +1,335 @@ +//! CISA Known Exploited Vulnerabilities (KEV) catalog import adapter. +//! +//! Parses the CISA KEV catalog JSON +//! () +//! into gateway [`ThreatIndicator`] rows. This is a proven federal +//! authoritative-source boundary — not a hand-rolled detection engine. +//! +//! The catalog is CVE-centric: entries carry no IP/domain/URL/hash +//! observable, so `dnsbl` stays empty. It is kept on [`KevImportMaterial`] +//! only for parity with the shared [`waf_ids_core::ThreatFeedImport`] shape +//! every adapter in this family produces. + +use waf_ids_core::{DnsblEntry, Severity, ThreatIndicator}; + +/// Parsed KEV import ready for the existing threat-feed upsert path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KevImportMaterial { + pub threats: Vec, + pub dnsbl: Vec, + pub skipped_entries: usize, +} + +/// Extract CVE indicators from a CISA KEV catalog JSON document. +/// +/// Accepts the real catalog shape (`{"vulnerabilities": [...], ...}`) or a +/// bare JSON array of vulnerability entries. +pub fn kev_material_from_value( + value: &serde_json::Value, + source: &str, + ttl_seconds: u64, +) -> Result { + let entries = match value { + serde_json::Value::Object(_) => value + .get("vulnerabilities") + .and_then(|v| v.as_array()) + .ok_or("KEV document must have a \"vulnerabilities\" array")?, + serde_json::Value::Array(items) => items, + _ => { + return Err( + "KEV document must be a catalog object or an array of vulnerability entries" + .to_string(), + ); + } + }; + if entries.is_empty() { + return Err("KEV catalog contained no vulnerability entries".to_string()); + } + + let mut threats = Vec::new(); + let mut skipped_entries = 0usize; + for entry in entries { + match kev_entry_outcome(entry, source, ttl_seconds) { + KevEntryOutcome::Mapped(threat) => threats.push(threat), + KevEntryOutcome::Skipped => skipped_entries += 1, + } + } + + if threats.is_empty() { + return Err("no KEV entries carried a usable cveID".to_string()); + } + // A refreshed import now reconciles: entries missing from this snapshot + // are treated as withdrawn and removed from enforcement (see + // apply_threat_feed_import). A catalog that's mostly unparsable -- + // truncated mid-transfer, or a CISA response format regression -- would + // otherwise look like a mass withdrawal of still-exploited CVEs instead + // of the bad fetch it actually is. Require a real majority of entries to + // have parsed before trusting this snapshot as authoritative. + if skipped_entries >= threats.len() { + return Err(format!( + "KEV catalog mostly unparsable: {skipped_entries} entries skipped vs {} usable -- refusing to treat as an authoritative snapshot", + threats.len() + )); + } + + Ok(KevImportMaterial { + threats, + dnsbl: Vec::new(), + skipped_entries, + }) +} + +/// Parse a CISA KEV catalog JSON body string. +pub fn parse_kev_document( + body: &str, + source: &str, + ttl_seconds: u64, +) -> Result { + let trimmed = body.trim(); + if trimmed.is_empty() { + return Err("empty KEV catalog body".to_string()); + } + let value: serde_json::Value = + serde_json::from_str(trimmed).map_err(|error| format!("invalid KEV JSON: {error}"))?; + kev_material_from_value(&value, source, ttl_seconds) +} + +enum KevEntryOutcome { + Mapped(ThreatIndicator), + Skipped, +} + +fn kev_entry_outcome(entry: &serde_json::Value, source: &str, ttl_seconds: u64) -> KevEntryOutcome { + let Some(cve_id) = entry + .get("cveID") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| is_valid_cve_id(s)) + else { + return KevEntryOutcome::Skipped; + }; + + // CISA's own inclusion criteria (confirmed active exploitation in the + // wild) already implies high severity; entries CISA has additionally + // tied to a known ransomware campaign are escalated to critical. + let known_ransomware = entry + .get("knownRansomwareCampaignUse") + .and_then(|v| v.as_str()) + .is_some_and(|s| s.eq_ignore_ascii_case("known")); + let severity = if known_ransomware { + Severity::Critical + } else { + Severity::High + }; + + KevEntryOutcome::Mapped(ThreatIndicator { + value: cve_id.to_ascii_uppercase(), + indicator_type: "cve".to_string(), + severity, + source: source.to_string(), + ttl_seconds, + }) +} + +/// Checks the `CVE-<4-digit year>-<4+ digit sequence>` syntax CVE.org +/// defines (), +/// case-insensitively. A malformed `cveID` (typo, placeholder, truncated +/// feed) would otherwise become an indistinguishable-looking `cve` threat +/// indicator with no signal that it never matched a real CVE record. +fn is_valid_cve_id(value: &str) -> bool { + // `str::get` (unlike slicing) returns None instead of panicking on a + // byte range that isn't a valid char boundary, so this stays panic-safe + // on arbitrary catalog input. + let Some(rest) = value + .get(0..4) + .filter(|prefix| prefix.eq_ignore_ascii_case("cve-")) + .map(|_| &value[4..]) + else { + return false; + }; + let Some((year, sequence)) = rest.split_once('-') else { + return false; + }; + year.len() == 4 + && year.bytes().all(|b| b.is_ascii_digit()) + && sequence.len() >= 4 + && sequence.bytes().all(|b| b.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_catalog() -> &'static str { + r#"{ + "title": "CISA Catalog of Known Exploited Vulnerabilities", + "catalogVersion": "2026.08.27", + "dateReleased": "2026-08-27T17:00:36.6632Z", + "count": 2, + "vulnerabilities": [ + { + "cveID": "cve-2023-49105", + "vendorProject": "ownCloud", + "product": "ownCloud", + "vulnerabilityName": "ownCloud Improper Authentication Vulnerability", + "dateAdded": "2026-08-27", + "shortDescription": "ownCloud contains an improper authentication vulnerability.", + "requiredAction": "Apply mitigations in accordance with vendor instructions.", + "dueDate": "2026-08-30", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://owncloud.org/security", + "cwes": ["CWE-287"] + }, + { + "cveID": "CVE-2021-44228", + "vendorProject": "Apache", + "product": "Log4j2", + "vulnerabilityName": "Apache Log4j2 Remote Code Execution Vulnerability", + "dateAdded": "2021-12-10", + "shortDescription": "Apache Log4j2 JNDI features do not protect against attacker controlled LDAP.", + "requiredAction": "Apply mitigations in accordance with vendor instructions.", + "dueDate": "2021-12-24", + "knownRansomwareCampaignUse": "Known", + "notes": "", + "cwes": ["CWE-917", "CWE-400"] + } + ] + }"# + } + + #[test] + fn maps_catalog_entries_and_escalates_ransomware_severity() { + let material = parse_kev_document(sample_catalog(), "feed:cisa-kev", 86_400).unwrap(); + assert_eq!(material.threats.len(), 2); + assert!(material.dnsbl.is_empty()); + assert_eq!(material.skipped_entries, 0); + + let owncloud = material + .threats + .iter() + .find(|t| t.value == "CVE-2023-49105") + .expect("owncloud CVE normalized to uppercase"); + assert_eq!(owncloud.indicator_type, "cve"); + assert_eq!(owncloud.severity, Severity::High); + assert_eq!(owncloud.source, "feed:cisa-kev"); + assert_eq!(owncloud.ttl_seconds, 86_400); + + let log4j = material + .threats + .iter() + .find(|t| t.value == "CVE-2021-44228") + .expect("log4j CVE present"); + assert_eq!(log4j.severity, Severity::Critical); + } + + #[test] + fn accepts_bare_array_of_entries() { + let raw = r#"[{"cveID":"CVE-2024-0001","knownRansomwareCampaignUse":"Unknown"}]"#; + let material = parse_kev_document(raw, "feed:cisa-kev", 3600).unwrap(); + assert_eq!(material.threats.len(), 1); + assert_eq!(material.threats[0].value, "CVE-2024-0001"); + } + + #[test] + fn skips_entries_missing_cve_id() { + let raw = r#"{"vulnerabilities": [ + {"vendorProject": "NoId Inc", "knownRansomwareCampaignUse": "Unknown"}, + {"cveID": "CVE-2024-9998", "knownRansomwareCampaignUse": "Unknown"}, + {"cveID": "CVE-2024-9999", "knownRansomwareCampaignUse": "Unknown"} + ]}"#; + let material = parse_kev_document(raw, "feed:cisa-kev", 3600).unwrap(); + assert_eq!(material.threats.len(), 2); + assert_eq!(material.skipped_entries, 1); + } + + #[test] + fn skips_entries_with_malformed_cve_id() { + let raw = r#"{"vulnerabilities": [ + {"cveID": "not-a-cve", "knownRansomwareCampaignUse": "Unknown"}, + {"cveID": "CVE-24-0001", "knownRansomwareCampaignUse": "Unknown"}, + {"cveID": "CVE-2024-0001", "knownRansomwareCampaignUse": "Unknown"}, + {"cveID": "CVE-2024-9998", "knownRansomwareCampaignUse": "Unknown"}, + {"cveID": "CVE-2024-9999", "knownRansomwareCampaignUse": "Unknown"} + ]}"#; + let material = parse_kev_document(raw, "feed:cisa-kev", 3600).unwrap(); + assert_eq!(material.threats.len(), 3); + assert!( + material + .threats + .iter() + .any(|threat| threat.value == "CVE-2024-9999") + ); + assert_eq!(material.skipped_entries, 2); + } + + #[test] + fn rejects_catalog_where_most_entries_are_unparsable() { + // A snapshot that's mostly garbage (truncated fetch, upstream format + // regression) must not be trusted as authoritative -- a refresh now + // reconciles, so treating this as "the current catalog" would read + // as a mass withdrawal of still-tracked CVEs. + let raw = r#"{"vulnerabilities": [ + {"cveID": "not-a-cve"}, + {"cveID": "also-not-a-cve"}, + {"cveID": "CVE-24-0001"}, + {"cveID": "CVE-2024-9999", "knownRansomwareCampaignUse": "Unknown"} + ]}"#; + let error = parse_kev_document(raw, "feed:cisa-kev", 3600).unwrap_err(); + assert!(error.contains("mostly unparsable"), "got: {error}"); + } + + #[test] + fn rejects_catalog_where_usable_and_skipped_entries_tie() { + let raw = r#"{"vulnerabilities": [ + {"cveID": "CVE-2024-0001", "knownRansomwareCampaignUse": "Unknown"}, + {"vendorProject": "missing-id"}, + {"cveID": "CVE-2024-0002", "knownRansomwareCampaignUse": "Unknown"}, + {"cveID": "not-a-cve"} + ]}"#; + let error = parse_kev_document(raw, "feed:cisa-kev", 3600).unwrap_err(); + assert!(error.contains("mostly unparsable"), "got: {error}"); + } + + #[test] + fn validates_cve_id_syntax() { + assert!(is_valid_cve_id("CVE-2024-0001")); + assert!(is_valid_cve_id("cve-2024-0001")); + assert!(is_valid_cve_id("CVE-2021-44228")); + assert!(is_valid_cve_id("CVE-2024-123456")); + assert!(!is_valid_cve_id("not-a-cve")); + assert!(!is_valid_cve_id("CVE-24-0001")); + assert!(!is_valid_cve_id("CVE-2024-001")); + assert!(!is_valid_cve_id("CVE-2024-")); + assert!(!is_valid_cve_id("CVE-")); + assert!(!is_valid_cve_id("")); + assert!(!is_valid_cve_id("CV")); + // A multi-byte char straddling the byte-4 prefix boundary must not panic. + assert!(!is_valid_cve_id("CVE\u{20ac}1234-0001")); + } + + #[test] + fn rejects_empty_and_non_kev_documents() { + assert!(parse_kev_document("", "s", 60).is_err()); + assert!(parse_kev_document("not-json", "s", 60).is_err()); + assert!(parse_kev_document(r#"{"foo":1}"#, "s", 60).is_err()); + assert!(parse_kev_document(r#"{"vulnerabilities": []}"#, "s", 60).is_err()); + assert!( + parse_kev_document(r#"{"vulnerabilities": [{"vendorProject":"x"}]}"#, "s", 60).is_err() + ); + } + + #[test] + fn parse_never_panics_on_arbitrary_text() { + for sample in [ + "", + "{", + "[]", + "null", + "\0", + "{\"vulnerabilities\":[]}", + "{\"vulnerabilities\":[{\"cveID\":\"CVE\u{20ac}1234-0001\"}]}", + ] { + let _ = parse_kev_document(sample, "s", 60); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 8f54751d..ab902cae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,9 +22,10 @@ use tokio::{ use waf_ids_core::{ AppData, BLOCK_SCORE, buyer_evidence_manifest_at, commercial_readiness_snapshot_at, enforce_event_limit, kpi_snapshot_at, prometheus_exposition, rate_limit_step, record_audit_log, - select_route, signature_catalog, threat_feed_freshness_snapshot, upsert_dnsbl, upsert_route, - upsert_threat, upsert_threat_feed, validate_commercial_profile, validate_dnsbl, validate_route, - validate_threat, validate_threat_feed_import, + replace_threat_feed_ownership, select_route, signature_catalog, threat_feed_freshness_snapshot, + threat_indicator_key, upsert_dnsbl, upsert_route, upsert_threat, upsert_threat_feed, + validate_commercial_profile, validate_dnsbl, validate_route, validate_threat, + validate_threat_feed_import, }; pub use waf_ids_core::{ AuditLogEntry, BuyerEvidenceEndpoint, BuyerEvidenceManifest, BuyerEvidenceRuntimeCounts, @@ -37,6 +38,7 @@ pub use waf_ids_core::{ mod coraza_audit; mod credentials; +mod kev_import; mod misp_import; mod opencti_import; mod stix_import; @@ -71,6 +73,10 @@ pub struct AppState { // Optional LLM SOC-analysis backend (OpenAI-compatible, e.g. the // contextual-orchestrator gateway). `None` unless configured. soc_llm: Option, + // Non-test runtime always fetches the built-in CISA KEV URL. Tests can + // override it to point at a loopback mock server. + #[cfg(test)] + kev_catalog_url: Option, } /// Configuration for the optional LLM-backed SOC analysis. Points at an @@ -135,9 +141,31 @@ impl AppState { max_body_bytes: 1_048_576, clearfolio: None, soc_llm: None, + #[cfg(test)] + kev_catalog_url: None, } } + /// Override the CISA KEV catalog URL (default: the real CISA feed). + /// Deployment-time config only, for pointing at a local mock server in + /// tests -- see `validate_kev_catalog_url`, which restricts the fetch to + /// CISA's own host (or loopback) with no mirror override. Builder-style. + #[cfg(test)] + pub fn with_kev_catalog_url(mut self, url: impl Into) -> Self { + self.kev_catalog_url = Some(url.into()); + self + } + + #[cfg(test)] + fn kev_catalog_url(&self) -> &str { + self.kev_catalog_url.as_deref().unwrap_or(KEV_DEFAULT_URL) + } + + #[cfg(not(test))] + fn kev_catalog_url(&self) -> &str { + KEV_DEFAULT_URL + } + /// Set the maximum accepted request body size in bytes; larger requests are /// rejected with 413 before the handler runs. Builder-style. pub fn with_max_body_size(mut self, max_body_bytes: usize) -> Self { @@ -424,6 +452,27 @@ fn phishing_database_default_severity() -> Severity { Severity::High } +const KEV_DEFAULT_FEED_ID: &str = "cisa-kev"; +const KEV_DEFAULT_SOURCE: &str = "feed:cisa-kev"; +const KEV_DEFAULT_URL: &str = + "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"; +const KEV_DEFAULT_TTL_SECONDS: u64 = 86_400; +// Runtime fetches stay fixed to the official CISA endpoint. Loopback remains +// allowed so integration tests can point AppState at a local mock server. +const KEV_ALLOWED_HOSTS: &[&str] = &["www.cisa.gov"]; + +fn kev_default_feed_id() -> String { + KEV_DEFAULT_FEED_ID.to_string() +} + +fn kev_default_source() -> String { + KEV_DEFAULT_SOURCE.to_string() +} + +fn kev_default_ttl_seconds() -> u64 { + KEV_DEFAULT_TTL_SECONDS +} + fn default_true() -> bool { true } @@ -473,6 +522,7 @@ pub fn build_app(state: AppState) -> Router { .route("/api/threat-intel/misp", post(import_misp_document)) .route("/api/threat-intel/taxii/poll", post(poll_taxii_collection)) .route("/api/threat-intel/opencti", post(import_opencti_document)) + .route("/api/threat-intel/cisa-kev", post(import_kev_feed)) .route("/api/clearfolio/config", get(clearfolio_config)) .route("/api/clearfolio/documents/{kind}", post(clearfolio_submit)) .route("/api/clearfolio/jobs/{job_id}", get(clearfolio_status)) @@ -727,6 +777,25 @@ struct PhishingDatabaseImportRequest { allow_non_default_hosts: bool, } +#[derive(Debug, Clone, Deserialize, Serialize)] +struct KevImportRequest { + #[serde(default = "kev_default_feed_id")] + feed_id: String, + #[serde(default = "kev_default_source")] + source: String, + #[serde(default = "kev_default_ttl_seconds")] + ttl_seconds: u64, +} + +#[derive(Debug, Serialize)] +struct KevImportResult { + feed_id: String, + upserted_threats: usize, + upserted_dnsbl: usize, + skipped_entries: usize, + last_updated_unix: u64, +} + #[derive(Serialize)] struct SocAnalyzeResponse { event_id: u64, @@ -890,6 +959,7 @@ async fn create_threat( match state .mutate_and_persist(|data| { let saved = upsert_threat(&mut data.threats, indicator.clone()); + mark_operator_threat_key(data, &saved); record_successful_audit_log( data, actor, @@ -2018,6 +2088,93 @@ fn validate_phishing_database_import_request( Ok(()) } +fn validate_kev_import_request(request: &KevImportRequest) -> Result<(), &'static str> { + if request.feed_id.trim().is_empty() { + return Err("feed_id is required"); + } + if request.source.trim().is_empty() { + return Err("source is required"); + } + if request.ttl_seconds == 0 { + return Err("ttl_seconds must be greater than zero"); + } + Ok(()) +} + +async fn import_kev_feed( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Response { + if !has_write_admin_credential(&state) { + return error( + StatusCode::SERVICE_UNAVAILABLE, + "KEV import requires a configured write-capable admin credential", + ); + } + if !admin_authorized(&state, &headers) { + return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + } + if let Err(message) = validate_kev_import_request(&request) { + return error(StatusCode::BAD_REQUEST, message); + } + + // Always fetches the deployment-configured CISA KEV URL (default: the + // real CISA feed; overridable only via server-side config, never by the + // request body) -- there is no request-controlled URL construction here + // at all, unlike the operator-URL adapters (phishing-database, TAXII). + // Uses its own fetch_kev_catalog rather than the shared fetch_text_feed + // so this config-only path never shares a function with (and can't be + // conflated by static analysis with) phishing-database's request-URL fetch. + let body_text = match fetch_kev_catalog(&state).await { + Ok(text) => text, + Err(message) => return error(StatusCode::BAD_GATEWAY, message), + }; + let material = match kev_import::parse_kev_document( + &body_text, + request.source.trim(), + request.ttl_seconds, + ) { + Ok(material) => material, + Err(message) => { + return error( + StatusCode::BAD_GATEWAY, + format!("invalid fetched KEV catalog: {message}"), + ); + } + }; + + let actor = audit_actor(&state, &headers); + let feed = ThreatFeedImport { + feed_id: request.feed_id.trim().to_string(), + source: request.source.trim().to_string(), + ttl_seconds: request.ttl_seconds, + threats: material.threats, + dnsbl: material.dnsbl, + }; + if let Err(message) = validate_threat_feed_import(&feed) { + return error( + StatusCode::BAD_GATEWAY, + format!("invalid fetched feed data: {message}"), + ); + } + let skipped_entries = material.skipped_entries; + match apply_threat_feed_import(&state, actor, "import_kev_feed", feed).await { + Ok(result) => ( + StatusCode::CREATED, + Json(KevImportResult { + feed_id: result.feed_id, + upserted_threats: result.upserted_threats, + upserted_dnsbl: result.upserted_dnsbl, + skipped_entries, + last_updated_unix: result.last_updated_unix, + }), + ) + .into_response(), + Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message), + } +} + fn validate_http_url(value: &str, allow_non_default_hosts: bool) -> Result<(), &'static str> { let parsed = reqwest::Url::parse(value).map_err(|_| "feed URL must be an absolute URL")?; let host = parsed.host_str().ok_or("feed URL host is required")?; @@ -2037,6 +2194,71 @@ fn validate_http_url(value: &str, allow_non_default_hosts: bool) -> Result<(), & Ok(()) } +// Deliberately separate from `fetch_text_feed`: that helper's `url` argument is +// fed by operator-supplied request URLs for phishing-database imports. KEV does +// not support a runtime URL override, so this fetch path stays structurally +// independent and fixed to the built-in CISA host; loopback is allowed only for +// tests that inject a local mock via `with_kev_catalog_url`. +fn validate_kev_catalog_url(url: &str) -> Result<(), String> { + validate_http_url(url, /* allow_non_default_hosts */ true) + .map_err(|message| format!("invalid KEV catalog URL {url}: {message}"))?; + let parsed = reqwest::Url::parse(url).map_err(|_| format!("invalid KEV catalog URL {url}"))?; + let host = parsed + .host_str() + .ok_or_else(|| format!("invalid KEV catalog URL {url}: host is required"))?; + if !KEV_ALLOWED_HOSTS + .iter() + .any(|allowed| host.eq_ignore_ascii_case(allowed)) + && !is_loopback_host(host) + { + return Err(format!( + "KEV catalog URL {url} host is not on the CISA KEV allowlist" + )); + } + Ok(()) +} + +async fn fetch_kev_catalog(state: &AppState) -> Result { + use futures_util::StreamExt; + + let url = state.kev_catalog_url(); + validate_kev_catalog_url(url)?; + let response = state + .feed_http + .get(url) + .timeout(std::time::Duration::from_secs( + PHISHING_DATABASE_FETCH_TIMEOUT_SECS, + )) + .send() + .await + .map_err(|error| format!("failed to fetch KEV catalog {url}: {error}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!("KEV catalog {url} returned HTTP {status}")); + } + if let Some(len) = response.content_length() + && len as usize > PHISHING_DATABASE_MAX_BODY_BYTES + { + return Err(format!( + "KEV catalog {url} body too large: {len} bytes (limit: {PHISHING_DATABASE_MAX_BODY_BYTES})" + )); + } + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk + .map_err(|error| format!("failed to read KEV catalog body from {url}: {error}"))?; + if bytes.len().saturating_add(chunk.len()) > PHISHING_DATABASE_MAX_BODY_BYTES { + return Err(format!( + "KEV catalog {url} body too large: limit {PHISHING_DATABASE_MAX_BODY_BYTES} bytes exceeded while streaming" + )); + } + bytes.extend_from_slice(&chunk); + } + String::from_utf8(bytes) + .map_err(|error| format!("KEV catalog {url} is not valid UTF-8 text: {error}")) +} + fn is_loopback_host(host: &str) -> bool { host.eq_ignore_ascii_case("localhost") || host @@ -2374,6 +2596,19 @@ fn admin_authorized(state: &AppState, headers: &HeaderMap) -> bool { presented.is_some_and(|actual| actual == expected) } +fn has_write_admin_credential(state: &AppState) -> bool { + if !state.admin_tokens.is_empty() { + return state + .admin_tokens + .values() + .any(|principal| principal.can_write); + } + state + .admin_token + .as_deref() + .is_some_and(|token| !token.is_empty()) +} + fn audit_actor(state: &AppState, headers: &HeaderMap) -> String { // Prefer the actor bound to the presented RBAC token, then an explicit // actor header, then a generic label. The token itself is never logged. @@ -2460,6 +2695,13 @@ fn threat_resource_id(indicator: &ThreatIndicator) -> String { ) } +fn mark_operator_threat_key(data: &mut AppData, indicator: &ThreatIndicator) { + let key = threat_indicator_key(indicator); + if !data.operator_threat_keys.contains(&key) { + data.operator_threat_keys.push(key); + } +} + async fn apply_threat_feed_import( state: &AppState, actor: String, @@ -2469,8 +2711,42 @@ async fn apply_threat_feed_import( let imported_at = now_unix(); state .mutate_and_persist(|data| { + let operator_owned: HashSet<_> = data.operator_threat_keys.iter().cloned().collect(); + let threat_keys: Vec<_> = feed.threats.iter().map(threat_indicator_key).collect(); + let previous_keys: HashSet<_> = replace_threat_feed_ownership( + &mut data.threat_feed_ownership, + feed.feed_id.clone(), + threat_keys, + ) + .into_iter() + .collect(); + if !previous_keys.is_empty() { + // A key this feed is dropping might still be owned by another + // feed (e.g. two feeds importing the same CVE under a shared + // `source`) -- only reap it once no feed's ownership record + // claims it any more, so a refresh on one feed can't make a + // still-relevant indicator vanish from enforcement. Also keep + // indicators an operator independently upserted via /api/threats. + let still_owned: HashSet<_> = data + .threat_feed_ownership + .iter() + .filter(|ownership| ownership.feed_id != feed.feed_id) + .flat_map(|ownership| ownership.threat_keys.iter().cloned()) + .collect(); + data.threats.retain(|threat| { + let key = threat_indicator_key(threat); + !previous_keys.contains(&key) + || still_owned.contains(&key) + || operator_owned.contains(&key) + }); + } + let mut upserted_threats = 0usize; for threat in feed.threats.iter().cloned() { + if operator_owned.contains(&threat_indicator_key(&threat)) { + continue; + } upsert_threat(&mut data.threats, threat); + upserted_threats += 1; } for entry in feed.dnsbl.iter().cloned() { upsert_dnsbl(&mut data.dnsbl, entry); @@ -2488,7 +2764,7 @@ async fn apply_threat_feed_import( ); let result = ThreatFeedImportResult { feed_id: feed.feed_id.clone(), - upserted_threats: feed.threats.len(), + upserted_threats, upserted_dnsbl: feed.dnsbl.len(), last_updated_unix: imported_at, }; @@ -2788,6 +3064,9 @@ input,select{font:inherit;min-height:44px;padding:0 12px;border:1px solid var(--

OpenCTI threat intelligence

POST admin-authenticated OpenCTI GraphQL/list export JSON to /api/threat-intel/opencti (optional query: feed_id, source, ttl_seconds). Maps IPv4/IPv6, Domain-Name, Url, file hashes, and STIX indicators into threats/DNSBL. Live OpenCTI GraphQL pull is a follow-up.

+

CISA KEV catalog

+

POST admin-authenticated JSON to /api/threat-intel/cisa-kev with optional feed_id, source, and ttl_seconds. Fetches the deployment-configured CISA Known Exploited Vulnerabilities catalog URL (server-side config only, not part of this request) and upserts a cve threat indicator per entry (severity escalated to critical when CISA has tied the CVE to a known ransomware campaign).

+