Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8fd83bc
feat(threat-intel): add CISA KEV catalog ingestion adapter
claude Aug 30, 2026
1414f89
fix(waf): never content-match CVE threat indicators in scoring
claude Aug 30, 2026
477b638
fix(shutdown): port SIGTERM/Ctrl-C race fix from PR #132
claude Aug 30, 2026
449bc73
docs(research): ground CISA KEV severity policy in EPSS/BOD literature
claude Aug 30, 2026
65d60a9
fix(security): don't route request-supplied kev_url into the fetch by…
claude Aug 30, 2026
c2ae3d7
fix(kev): stop validating kev_url when it won't be used
claude Aug 30, 2026
1cf1010
fix(security): remove kev_url from the request contract entirely
claude Aug 30, 2026
8915b16
fix(kev): decouple KEV catalog fetch from the shared request-URL sink
claude Aug 30, 2026
3a47ce8
fix(kev): restrict KEV_CATALOG_URL to CISA's own host
claude Aug 30, 2026
0760e7b
docs(kev): correct stale "internal mirror" language for the new host …
claude Aug 30, 2026
9494144
fix: validate CVE ID syntax on KEV import; scope Windows cfg precisely
claude Aug 30, 2026
192ffba
Merge origin/main into feat/cisa-kev-catalog-ingest
codex Aug 30, 2026
64dcbfc
fix(threat-feeds): don't reap indicators still owned by another feed
claude Aug 30, 2026
e36a198
fix(kev): fail closed without write creds
codex Aug 30, 2026
40453f3
fix(kev): reject mostly-unparsable catalogs; scope credential source …
claude Aug 30, 2026
b5115dc
fix(kev): remove runtime catalog URL override
codex Aug 30, 2026
3c6eed6
fix(kev): preserve operator indicators on feed refresh
codex Aug 30, 2026
8b7954d
fix(kev): preserve operator-owned threat payloads
codex Aug 30, 2026
c4f4526
fix(threat-feeds): report actual upserted count, not submitted count
claude Aug 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
86 changes: 86 additions & 0 deletions crates/waf-ids-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ pub const TARGET_SALE_VALUE_KRW: u64 = 2_000_000_000;
pub struct AppData {
pub routes: Vec<RouteConfig>,
pub threats: Vec<ThreatIndicator>,
#[serde(default)]
pub operator_threat_keys: Vec<ThreatIndicatorKey>,
pub dnsbl: Vec<DnsblEntry>,
pub events: Vec<SecurityEvent>,
pub next_event_id: u64,
Expand All @@ -22,6 +24,8 @@ pub struct AppData {
pub commercial: CommercialProfile,
#[serde(default)]
pub threat_feeds: Vec<ThreatFeedStatus>,
#[serde(default)]
pub threat_feed_ownership: Vec<ThreatFeedOwnership>,
}

impl AppData {
Expand All @@ -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(),
Expand All @@ -56,6 +61,7 @@ impl AppData {
next_audit_log_id: 1,
commercial: CommercialProfile::seeded(),
threat_feeds: Vec::new(),
threat_feed_ownership: Vec::new(),
}
}
}
Expand Down Expand Up @@ -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<ThreatIndicatorKey>,
}
Comment thread
seonghobae marked this conversation as resolved.

#[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,
Expand Down Expand Up @@ -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<ThreatFeedOwnership>,
feed_id: String,
threat_keys: Vec<ThreatIndicatorKey>,
) -> Vec<ThreatIndicatorKey> {
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,
Expand Down Expand Up @@ -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::<IpAddr>().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
Comment thread
seonghobae marked this conversation as resolved.
} else {
haystack.contains(&indicator.value.to_lowercase())
};
Expand Down Expand Up @@ -1292,6 +1345,14 @@ fn buyer_evidence_endpoints() -> Vec<BuyerEvidenceEndpoint> {
"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,
),
]
}

Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Binary file not shown.
2 changes: 1 addition & 1 deletion docs/security/compliance-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
28 changes: 16 additions & 12 deletions src/credentials.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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";

Expand Down Expand Up @@ -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<String>,
env_admin_tokens: Option<String>,
) -> Result<Self, String> {
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) {
Expand All @@ -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;
}
}
}
Expand All @@ -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
Comment thread
seonghobae marked this conversation as resolved.
Expand Down
Loading
Loading