diff --git a/.github/workflows/scorecard-analysis.yml b/.github/workflows/scorecard-analysis.yml index bf1f03a4..dfa64206 100644 --- a/.github/workflows/scorecard-analysis.yml +++ b/.github/workflows/scorecard-analysis.yml @@ -29,6 +29,6 @@ jobs: results_format: sarif publish_results: false - name: Upload SARIF - uses: github/codeql-action/upload-sarif@d1ba80a13dd99fba24a470575428917156a28b43 # v4 + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 with: sarif_file: results.sarif diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..83d80680 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## Unreleased + +### Security + +- Removed the distributable Kubernetes administrator `Secret` and historical placeholder credential. Production deployments must provision `waf-ids-ai-soc-admin` / `ADMIN_TOKEN` through the external secret-management control plane; the workload's `secretKeyRef` is explicitly non-optional. +- Added a structural regression contract that rejects shipped administrator Secret objects, placeholder credentials, decoy workloads, init-container false positives, and optional administrator Secret references. + +### Operations + +- Documented administrator credential provisioning, rotation, rollout verification, rollback, evidence handling, and the boundary with the separate runtime-authentication fail-closed work tracked in issue #78. 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/Cargo.lock b/Cargo.lock index dc09e461..c696190f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -206,44 +206,44 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-io", 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/deploy/kubernetes/waf-ids-ai-soc.yaml b/deploy/kubernetes/waf-ids-ai-soc.yaml index f811ecb4..c0988736 100644 --- a/deploy/kubernetes/waf-ids-ai-soc.yaml +++ b/deploy/kubernetes/waf-ids-ai-soc.yaml @@ -3,15 +3,12 @@ kind: Namespace metadata: name: waf-ids-ai-soc --- -apiVersion: v1 -kind: Secret -metadata: - name: waf-ids-ai-soc-admin - namespace: waf-ids-ai-soc -type: Opaque -stringData: - ADMIN_TOKEN: replace-with-secret-manager-sync ---- +# The administrator Secret is intentionally not distributed with Wardnet. +# For a fresh install, create this Namespace idempotently first, provision +# `waf-ids-ai-soc-admin` through the organization's secret-management control +# plane, wait for synchronization, then apply this complete manifest. The +# Deployment has no fallback value and therefore fails closed when the Secret +# or `ADMIN_TOKEN` key is absent. apiVersion: v1 kind: PersistentVolumeClaim metadata: @@ -41,6 +38,7 @@ spec: labels: app.kubernetes.io/name: waf-ids-ai-soc spec: + automountServiceAccountToken: false securityContext: runAsNonRoot: true fsGroup: 10001 @@ -67,6 +65,7 @@ spec: secretKeyRef: name: waf-ids-ai-soc-admin key: ADMIN_TOKEN + optional: false volumeMounts: - name: state mountPath: /var/lib/waf-ids-ai-soc diff --git a/docs/adr/0010-adaptive-contextual-orchestrator-default.md b/docs/adr/0010-adaptive-contextual-orchestrator-default.md new file mode 100644 index 00000000..1138e8bc --- /dev/null +++ b/docs/adr/0010-adaptive-contextual-orchestrator-default.md @@ -0,0 +1,90 @@ +# ADR-0010: SOC analysis delegates default execution to contextual-orchestrator auto + +- Status: Accepted +- Date: 2026-08-16 + +## Context + +Wardnet's optional SOC analysis endpoint calls the organization LLM gateway, but a +model/messages-only request leaves policy implicit and can collapse into a fixed +single-worker path. Security analysis ranges from bounded enrichment to high-risk, +multi-step investigation. The edge gateway must not hard-code one provider, one +model, one topology, or one reasoning budget for every incident. + +The orchestration literature also distinguishes delegation policy from the workers +that perform the task. TRINITY assigns Thinker, Worker, and Verifier roles turn by +turn; Conductor learns communication topologies and targeted worker instructions; +and Fugu adapts the resulting scaffold to the query. These results support an +adaptive default, but they do not authorize the orchestrator to own Wardnet's +security evidence, permissions, audit trail, or operator decision. + +## Literature-to-decision mapping + +| Source | Mechanism | Reported ablation/metric | Decision item it grounds | +| --- | --- | --- | --- | +| Xu et al. (2025), *TRINITY* | A lightweight (~0.6B parameter) coordinator assigns Thinker/Worker/Verifier roles turn-by-turn without modifying constituent model weights. | 86.2% pass@1 on LiveCodeBench at publication; the ablation attributes the gain to the coordinator's hidden-state contextualization versus RL/imitation-learning coordinators under the same budget. | Decision item 2 (role-separated worker and verifier execution) -- justifies delegating *when* to add an independent checking role to the orchestrator rather than hard-coding it in Wardnet. | +| Nielsen et al. (2025), *Conductor* | A 7B RL-trained coordinator adapts topology to task difficulty: single-query for factual tasks, planner-executor-verifier pipelines for hard tasks, with "Recursive Test-Time Scaling" (the coordinator can select itself as a worker for self-correction). | Record-setting 83.9% LiveCodeBench and 87.5% GPQA-Diamond; measured cost-efficiency gains over Mixture-of-Agents baselines. | Decision item 3 (conducted/recursive workflow for complex investigations) -- the query-adaptive depth and recursive self-correction are exactly the "decomposition, additional evidence, or iterative verification" case this item reserves for the orchestrator, not Wardnet. | +| Tang et al. (2026), *Sakana Fugu* | Fugu/Fugu-Ultra devise dynamic agentic scaffolds (large-scale fine-tuning + evolutionary search + RL) that vary with the query, orchestrating a team of diverse SOTA models. | State-of-the-art on SWE-Bench Pro, Terminal-Bench, LiveCodeBench, GPQA-Diamond, and Humanity's Last Exam; training explicitly optimizes the performance/latency trade-off via evolutionary search. | Decision item 1 (a quality-sufficient single route for bounded, low-ambiguity enrichment) and the "quality and safety take precedence over latency" rule -- query-adaptive scaffolding is the mechanism that keeps bounded events on a cheap route without Wardnet pre-selecting depth. | +| Omidvar & Akhlaghi (2026) | Models LLM sampling as a discrete stochastic channel, unifying retry/majority-vote/self-consistency into six operators, plus a cost-aware semantic-nearest-neighbor router with a single Lagrangian parameter traversing the quality-cost Pareto frontier. | ~56% lower normalized cost at matched quality, and ~7% quality improvement at matched cost (26% over single-shot), on MMLU/GSM8K/HumanEval. | The cost/quality tie-breaking rule ("known cost may break ties only after capability and safety constraints") -- grounds treating cost as a Pareto-frontier parameter subordinate to quality/safety, not an independent optimization target. | + +The scheduled evaluations required by the Decision section (comparing single-route, +worker-verifier, and deeper orchestration modes on grounded SOC outcomes) are the +Wardnet-side analogue of each paper's own ablation methodology, applied to this +system's incident corpus instead of LiveCodeBench/GPQA/MMLU. + +## Decision + +The SOC request explicitly includes `orchestration_mode: "auto"`. The central +orchestrator may select, within its published contract: + +1. a quality-sufficient single route for bounded, low-ambiguity enrichment; +2. role-separated worker and verifier execution when independent checking is + warranted; or +3. a conducted or recursive workflow for complex investigations that require + decomposition, additional evidence, or iterative verification. + +Role-specific reasoning effort, workflow depth, tool access, and stopping policy are +owned by contextual-orchestrator. Quality and safety requirements take precedence +over latency. Known cost may break ties only after capability and safety constraints; +unpriced providers are not treated as free. Scheduled evaluations must compare the +single-route, worker-verifier, and deeper orchestration modes so that increased +inference depth is retained only when it improves grounded SOC outcomes. + +Wardnet retains WAF/IDS evidence collection, authorization, bounded request handling, +audit records, security-domain validation, and operator presentation. The +orchestrator receives only the bounded event representation constructed by Wardnet, +and its response remains untrusted analysis until Wardnet validates and presents it. +Explicit fixed modes remain controlled experiments and rollback controls, not the +product default. Unsupported orchestration capabilities or malformed responses fail +closed rather than silently changing the security decision path. + +## Consequences + +- Adding or removing worker models does not change the Wardnet API contract. +- The gateway continues to make enforcement decisions from deterministic Wardnet + evidence; LLM output cannot directly block, allow, mutate policy, or obtain an + administrative capability. +- Audit and evaluation evidence must identify the selected orchestration mode and + model roles without copying secrets or unrelated personal data. +- A future live-model test lane must measure grounding, unsupported-claim rate, + decision consistency, and the marginal benefit of deeper orchestration on a fixed + incident corpus. + +## References + +Nielsen, S., Cetin, E., Schwendeman, P., Sun, Q., Xu, J., & Tang, Y. (2025). +*Learning to orchestrate agents in natural language with the Conductor* [Preprint]. +arXiv. https://doi.org/10.48550/arXiv.2512.04388 + +Omidvar, H., & Akhlaghi, V. (2026). *A communication-theoretic framework for LLM +agents: Cost-aware adaptive reliability* [Preprint]. arXiv. +https://doi.org/10.48550/arXiv.2605.09121 + +Tang, Y., Cetin, E., Xu, J., Sun, Q., Nielsen, S., Richard, V., Goda, H., Tymchenko, +I., Nguyen, N., Lee, H., Ashiga, M., Kotyan, S., Kuroki, S., & Clanuwat, T. (2026). +*Sakana Fugu technical report* [Technical report]. arXiv. +https://doi.org/10.48550/arXiv.2606.21228 + +Xu, J., Sun, Q., Schwendeman, P., Nielsen, S., Cetin, E., & Tang, Y. (2025). +*TRINITY: An evolved LLM coordinator* [Preprint]. arXiv. +https://doi.org/10.48550/arXiv.2512.04695 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/deployment/production.md b/docs/deployment/production.md index 34ff1978..1c46ac73 100644 --- a/docs/deployment/production.md +++ b/docs/deployment/production.md @@ -29,12 +29,31 @@ ADMIN_TOKEN=replace-me docker compose up --build ## Kubernetes -Review `deploy/kubernetes/waf-ids-ai-soc.yaml` before applying. Replace the placeholder admin secret with a secret-manager synchronization flow. +The distributable manifest does not create an administrator Secret. A fresh cluster must create the namespace before any namespaced Secret or ExternalSecret can exist. Bootstrap the namespace idempotently first: + +```bash +kubectl create namespace waf-ids-ai-soc --dry-run=client -o yaml | kubectl apply -f - +``` + +Then use the organization's secret-management control plane to provision an Opaque Secret named `waf-ids-ai-soc-admin` in namespace `waf-ids-ai-soc` with key `ADMIN_TOKEN`. Keep access to that Secret limited to the workload and operational identities that require it. Existing installations may run the same namespace-bootstrap command safely; it converges on the existing Namespace rather than replacing it. + +The Deployment binds `ADMIN_TOKEN` only through that `secretKeyRef` with `optional: false`. If the Secret or key is absent, the workload does not start; there is no repository-provided fallback credential. + +After the external secret controller reports successful synchronization, apply the complete manifest. Its Namespace object remains in the declarative asset so later applies retain the same ownership boundary: ```bash kubectl apply -f deploy/kubernetes/waf-ids-ai-soc.yaml ``` +When rotating `ADMIN_TOKEN`, wait for the updated Secret to synchronize, then restart the Deployment because environment-variable-backed Secret values are fixed when a container starts. Verify the rollout and readiness before revoking the previous token: + +```bash +kubectl -n waf-ids-ai-soc rollout restart deployment/waf-ids-ai-soc +kubectl -n waf-ids-ai-soc rollout status deployment/waf-ids-ai-soc +``` + +Failure, recovery, verification, and evidence requirements are documented in [`../doctoring/kubernetes-admin-secret-boundary.md`](../doctoring/kubernetes-admin-secret-boundary.md). + ## Production Requirements - Terminate TLS in front of the service. diff --git a/docs/doctoring/kubernetes-admin-secret-boundary.md b/docs/doctoring/kubernetes-admin-secret-boundary.md new file mode 100644 index 00000000..02518661 --- /dev/null +++ b/docs/doctoring/kubernetes-admin-secret-boundary.md @@ -0,0 +1,91 @@ +# Kubernetes administrator Secret boundary + +## Decision + +Wardnet's distributable Kubernetes manifest must not create or embed an administrator credential. The manifest consumes one externally provisioned Secret only: + +- namespace: `waf-ids-ai-soc` +- Secret: `waf-ids-ai-soc-admin` +- key: `ADMIN_TOKEN` +- consumer: Deployment `waf-ids-ai-soc`, container `gateway` +- reference: `env[name=ADMIN_TOKEN].valueFrom.secretKeyRef` +- availability contract: `optional: false` + +The secret-management control plane owns generation, storage, synchronization, rotation, recovery, and revocation. Wardnet owns the fail-closed consumption contract and must never add a repository-visible fallback value. + +This boundary is deliberately narrow. It removes the distributable placeholder credential; it does **not** close the separate runtime-authentication problem tracked in issue #78, where a non-loopback process must also refuse readiness when no write-capable authentication authority is configured. + +## Why this is a production boundary + +A reusable value committed in a deployment asset is part of the product's distributed attack surface even when its text says "replace me". Operators can apply the asset without editing it, scanners and downstream forks retain it, and a common value can become an implicit shared administrator credential. MITRE classifies hard-coded credentials as CWE-798. Kubernetes likewise warns against sharing Secret manifests and recommends limiting Secret access to only the containers that require it. + +The replacement therefore follows fail-safe defaults and least privilege: a missing credential prevents the workload from starting rather than silently selecting a repository default, and the Secret is referenced only by the gateway container that consumes it. + +## Provisioning and deployment + +Kubernetes objects are namespaced, so a fresh cluster cannot materialize `waf-ids-ai-soc-admin` until namespace `waf-ids-ai-soc` exists. Bootstrap the namespace idempotently first: + +```bash +kubectl create namespace waf-ids-ai-soc --dry-run=client -o yaml | kubectl apply -f - +``` + +The deployment authority must then confirm that its external secret manager/controller has materialized `waf-ids-ai-soc-admin` in that namespace with a non-empty `ADMIN_TOKEN` key. The repository does not prescribe a vendor-specific controller; the integration boundary is the Kubernetes Secret coordinates above. + +Apply `deploy/kubernetes/waf-ids-ai-soc.yaml` only after synchronization succeeds. The manifest retains its Namespace object so fresh installs and upgrades converge on the same declarative namespace ownership. Kubernetes resolves the `secretKeyRef` when creating the container. Because the reference is explicitly non-optional, absence of the Secret or key is an operator-visible startup failure instead of an authentication downgrade. + +## Rotation + +`ADMIN_TOKEN` is injected as an environment variable. Kubernetes documents that a container does not observe an updated Secret-backed environment variable until the container is restarted. Rotation therefore uses this order: + +1. Generate a new credential in the authoritative secret manager and synchronize it to the Kubernetes Secret. +2. Confirm the synchronized Secret exists and contains the expected key without printing its value. +3. Run a controlled `rollout restart` of Deployment `waf-ids-ai-soc`. +4. Wait for `rollout status` and application readiness to succeed. +5. Exercise an authenticated management request with the new credential through the approved operational path. +6. Revoke the previous credential only after the new workload is healthy. + +If rollout or authentication verification fails, keep or restore the previous credential in the external authority, resynchronize, restart the Deployment again, and verify readiness before resuming normal operations. Do not add a literal emergency token to this repository or manifest as a recovery shortcut. + +## Verification contract + +`tests/deployment_manifest.rs` is the permanent regression boundary. It fails if the shipped manifest contains a `kind: Secret` document or the historical placeholder value. It structurally selects Deployment `waf-ids-ai-soc`, scopes the lookup to the `gateway` runtime container, requires exactly one `ADMIN_TOKEN` environment entry, rejects literal fallback values and duplicate `ADMIN_TOKEN` entries, and validates the expected namespace, Secret name, key, and non-optional reference. Decoy Deployments, `initContainers`, comments, duplicate environment entries, literal fallbacks, and `optional: true` cannot satisfy the contract. The same regression suite requires the production guide to bootstrap the namespace before namespaced Secret provisioning. + +For release evidence, run the repository's normal formatting, workspace test, Clippy, fuzz, SAST, and Security Scan gates on the exact PR head. A predecessor-head success, skipped required job, or security scan from another merge tree is not evidence for the current artifact. + +## Audit and incident handling + +Evidence suitable for deployment/change review should record the external-secret synchronization result, Deployment revision, rollout completion, readiness result, and credential-rotation event identifier. It must not contain the credential value, a recoverable encoding of it, request headers carrying it, or Secret-object dumps. + +If a repository-visible credential is discovered later, treat it as compromised regardless of whether it was intended as an example: remove the value from distributable assets, rotate the external credential, check Git and artifact history for exposure scope, invalidate affected credentials, and retain the remediation evidence required by the organization's incident process. + +## Research and standards traceability + +Kubernetes' current Secret documentation defines `env[].valueFrom.secretKeyRef` as the environment-variable consumption mechanism and requires the referenced non-optional Secret and key to exist. Its security good-practices guidance recommends restricting Secret access to only the containers that require it and warns against checking Secret manifests into source repositories. Kubernetes also documents that Secret-backed environment variables require a container restart to observe a changed value. These contracts directly support the bootstrap order, manifest shape, and rotation procedure used here. + +NIST SP 800-57 Part 1 Rev. 5 remains the cited final Recommendation for general key-management practice in this document set. Revision 6 is cited separately as an Initial Public Draft published December 5, 2025; its public-comment period closed February 5, 2026. Although `ADMIN_TOKEN` is an authentication secret rather than necessarily cryptographic keying material, the lifecycle principles around protected storage, access control, compromise response, replacement, and recovery are applicable to secret-management operations. + +NIST states that SP 800-series publications are not subject to copyright in the United States and that attribution is appreciated. SP 800-57 Part 1 Rev. 5 itself carries the same notice. It is therefore an approved candidate for the repository research-document collection while retaining the canonical DOI and NIST source link below. + +Saltzer and Schroeder's fail-safe-defaults and least-privilege principles support making absence of the external Secret an explicit deployment failure and limiting its consumption boundary. Krause et al.'s mixed-methods study of source-repository secret leakage found that developers continue to encounter secret exposure and remediation difficulties; the practical implication here is to remove the credential value from version control entirely rather than relying on an instruction to replace it later. + +The IEEE article is not redistributed under a repository-compatible open license, and the USENIX paper is publicly downloadable but its conference open-access statement does not by itself establish a redistribution license for repackaging in this repository. Both are cited and linked instead. + +## References + +Barker, E. (2020). *Recommendation for key management: Part 1—General* (NIST Special Publication 800-57 Part 1 Rev. 5). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-57pt1r5 + +Barker, E., & Barker, W. (2025). *Recommendation for key management: Part 1—General* (NIST Special Publication 800-57 Part 1 Rev. 6, Initial Public Draft). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-57pt1r6.ipd + +Krause, A., Klemmer, J. H., Huaman, N., Wermke, D., Acar, Y., & Fahl, S. (2023). Pushed by accident: A mixed-methods study on strategies of handling secret information in source code repositories. In *32nd USENIX Security Symposium (USENIX Security 23)* (pp. 2527–2544). USENIX Association. https://www.usenix.org/conference/usenixsecurity23/presentation/krause + +Kubernetes Authors. (2025). *Good practices for Kubernetes Secrets*. Kubernetes. https://kubernetes.io/docs/concepts/security/secrets-good-practices/ + +Kubernetes Authors. (2026). *Secrets*. Kubernetes. https://kubernetes.io/docs/concepts/configuration/secret/ + +Kubernetes Authors. (2026). *Distribute credentials securely using Secrets*. Kubernetes. https://kubernetes.io/docs/tasks/inject-data-application/distribute-credentials-secure/ + +MITRE. (2026). *CWE-798: Use of hard-coded credentials*. Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/798.html + +National Institute of Standards and Technology. (2024). *NIST Special Publication 800-series general information*. https://www.nist.gov/itl/publications-0/nist-special-publication-800-series-general-information + +Saltzer, J. H., & Schroeder, M. D. (1975). The protection of information in computer systems. *Proceedings of the IEEE, 63*(9), 1278–1308. https://doi.org/10.1109/PROC.1975.9939 diff --git a/docs/papers/nist-sp-800-57-part-1-rev-5.pdf b/docs/papers/nist-sp-800-57-part-1-rev-5.pdf new file mode 100644 index 00000000..4c1eff0c Binary files /dev/null and b/docs/papers/nist-sp-800-57-part-1-rev-5.pdf differ 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 c84737fc..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)) @@ -659,6 +709,7 @@ fn soc_llm_chat_body(model: &str, event: &SecurityEvent) -> serde_json::Value { ); serde_json::json!({ "model": model, + "orchestration_mode": "auto", "messages": [ { "role": "system", @@ -726,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, @@ -889,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, @@ -2017,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")?; @@ -2036,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 @@ -2373,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. @@ -2459,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, @@ -2468,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); @@ -2487,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, }; @@ -2787,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).

+