diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..83d8068 --- /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/deploy/kubernetes/waf-ids-ai-soc.yaml b/deploy/kubernetes/waf-ids-ai-soc.yaml index fd58c39..c098873 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: @@ -68,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/0001-standalone-rust-gateway-workspace-core.md b/docs/adr/0001-standalone-rust-gateway-workspace-core.md new file mode 100644 index 0000000..7c4a530 --- /dev/null +++ b/docs/adr/0001-standalone-rust-gateway-workspace-core.md @@ -0,0 +1,64 @@ +# ADR 0001: Standalone Rust gateway with in-workspace `waf-ids-core` + +- Status: Accepted +- Date: 2026-08-25 +- Recorded from: current `main` (`README.md` workspace notes; + `docs/architecture.md` components and security boundaries; root + `Cargo.toml` workspace members) + +## Context + +wardnet (crate name `waf-ids-ai-soc`) is the WAF / IDS / AI SOC gateway +and control-plane leaf for ContextualWisdomLab. Operators need a binary +that starts, serves management and gateway HTTP, and scores requests +without checking out sibling products. + +Domain logic (models, validation, upserts, scoring, DNSBL zone text, +event retention, feed freshness, KPI snapshots) is reusable. Splitting +that logic into a git submodule before an independently versioned +engine or SDK exists would add release and review overhead without an +external consumer. + +Cargo workspaces keep multiple packages on one lockfile and one +`cargo test --workspace` surface (The Cargo Book, n.d.). + +## Decision + +1. Ship a **standalone Rust gateway**. The process runs by itself with + optional operator configuration. No sibling checkout is required. +2. Keep reusable domain code in **`crates/waf-ids-core`**, a member of + the same Cargo workspace (`path` dependency), not a git submodule, + until an independently versioned engine, SDK, or adapter needs its + own release lifecycle. +3. Treat sibling ContextualWisdomLab products as **optional composition + callers** over HTTP or documented contracts: + - **naruon** and **gyeot** may call or be called when an operator + wires them; they are not required tree members. + - **contextual-orchestrator** is the intended front door for the + optional SOC LLM path. Current `main` exposes the `SocLlmConfig` + runtime hook, but `run_from_env` does **not** yet wire + `SOC_LLM_BASE_URL`; absent explicit in-process configuration, SOC + assist stays off. + - **Clearfolio** is an optional document-viewer relay target. + Current `main` exposes the `ClearfolioConfig` runtime hook, but + `run_from_env` does **not** yet wire `CLEARFOLIO_BASE_URL`; + absent explicit in-process configuration, that surface stays + disabled. + Existing optional caller links stay in place. Do not require those + services to start the gateway. + +## Consequences + +- Operators can `cargo run` and use `/admin`, `/gateway/{path}`, and + `/dnsbl/zone` on a single binary. +- `waf-ids-core` stays free of async/HTTP dependencies so domain tests + and fuzz mirrors do not pull the Axum crate graph. +- A later submodule or crates.io publish is deferred until a real + second consumer and release cadence exist. +- Optional Clearfolio and orchestrator hooks must remain inert when + unconfigured so the leaf stays independently runnable. + +## References + +The Cargo Book. (n.d.). *Workspaces*. +https://doc.rust-lang.org/cargo/reference/workspaces.html diff --git a/docs/adr/0002-optional-json-state-standalone-durability.md b/docs/adr/0002-optional-json-state-standalone-durability.md new file mode 100644 index 0000000..bbaae07 --- /dev/null +++ b/docs/adr/0002-optional-json-state-standalone-durability.md @@ -0,0 +1,55 @@ +# ADR 0002: Optional JSON state for standalone durability + +- Status: Accepted +- Date: 2026-08-25 +- Recorded from: current `main` (`README.md` run notes; + `docs/architecture.md` security boundaries; `docs/runbooks/operations.md` + persistence behavior) + +## Context + +The gateway must keep operator-managed routes, threat indicators, DNSBL +entries, events, and license metadata across a local restart when an +operator asks for durability. Many lab and smoke runs do not need a +file at all. + +JSON is the Internet Standard data interchange format for this class of +text documents (Bray, 2017, RFC 8259 / STD 90). A single pretty-printed +object is enough for a standalone process. It is not a multi-operator +database, a backup system, or an audited change workflow. + +## Decision + +1. `WAF_IDS_STATE_PATH` is **optional**. When unset, the process uses + seeded in-memory state. Health reports `persistence: memory`. +2. When the path is set, load JSON from that file (or seed and create + it). Persist with a **temporary sibling file** and **atomic rename** + onto the configured path. Health reports `persistence: file`. +3. If a management write cannot replace the state file, **roll back** + the in-memory mutation and return an operator-visible error. +4. Treat this JSON file as **baseline standalone durability only**. It + is not a production control-plane database, not a backup plan, and + not an audited change-management system. + +A production database is **not** an accepted architecture decision on +current `main`. + +## Consequences + +- `scripts/smoke.sh` can prove restart persistence with a temporary + JSON file and no external datastore. +- Parse failures on a configured path fail startup rather than silently + ignoring a corrupt file (`docs/security/threat-model.md`). +- Concurrent writers and disaster recovery remain out of scope until a + later durable store is accepted. +- Schema evolution is the application's JSON shape, not a migration + framework. + +## References + +Bray, T. (Ed.). (2017). *The JavaScript Object Notation (JSON) data +interchange format* (RFC 8259). RFC Editor. +https://doi.org/10.17487/RFC8259 + +*(Internet Standard, STD 90. Live-checked 2026-08-25 via +https://www.rfc-editor.org/info/rfc8259 and the DOI above.)* diff --git a/docs/adr/0003-owasp-crs-coraza-waf-authority.md b/docs/adr/0003-owasp-crs-coraza-waf-authority.md new file mode 100644 index 0000000..1c659f1 --- /dev/null +++ b/docs/adr/0003-owasp-crs-coraza-waf-authority.md @@ -0,0 +1,73 @@ +# ADR 0003: OWASP CRS / Coraza as WAF authority + +- Status: Accepted +- Date: 2026-08-25 +- Recorded from: current `main` (`README.md` production-coverage note; + `docs/architecture.md` near-term WAF integration) + +## Context + +The gateway scores requests from local threat indicators and DNSBL +entries. That baseline is not a replacement for a maintained WAF rule +set. Inventing an in-house rule language would duplicate work the +OWASP Core Rule Set already does for generic attack detection +(OWASP Foundation / CRS Project, n.d.). + +OWASP Coraza is an open-source WAF engine documented as compatible +with OWASP CRS (OWASP Coraza, n.d.). Current `main` already accepts +Coraza/CRS **audit** documents; it does not embed Coraza in-process. + +## Decision + +1. **OWASP CRS remains the WAF rule authority.** Do not replace CRS + with a hand-rolled rule engine or claim that Wardnet's local + signatures are equivalent to CRS. +2. Accept admin-authenticated Coraza / OWASP CRS **audit JSON/NDJSON** + at `POST /api/waf/coraza/audit`. Interrupted transactions and CRS + rule messages become `SecurityEvent` rows. Block-grade hits may seed + DNSBL and `client_ip` / path threat indicators so later gateway + decisions can enforce matching clients. +3. Run Coraza **outside** this process for now. **In-process Coraza + embedding is a follow-up**, not an accepted replacement of CRS and + not an accepted replacement of the audit ingest path. +4. Current `main` may still apply bounded built-in signatures and a + lightweight anomaly heuristic during gateway scoring. Those are + supplemental local heuristics for first-pass triage and blocking; + they are **not** presented as WAF authority, CRS parity, or a + substitute for Coraza-backed enforcement. + Bitussi and Doriguzzi-Corin (2026) show why HTTP anomaly detection + benefits from explainable, model-backed request analysis but also + why detector quality depends on trustworthy training data and + calibration. Wardnet therefore keeps its lightweight scoring path + explicitly bounded and subordinate to CRS authority rather than + claiming heuristic parity with a maintained WAF rule set. + +Related accepted ingest on the same `main` (IDS, not WAF authority): +admin-authenticated Suricata EVE JSON/NDJSON at +`POST /api/ids/suricata/eve` (Eve JSON output, n.d.). Full route +correlation and live EVE tailing remain follow-ups. + +## Consequences + +- Operators can attach an external Coraza/CRS deployment and still use + this gateway for scoring, events, and route-scoped block mode. +- CRS versioning and rule quality stay with the CRS project (latest + line observed 2026-08-25: 4.29.0 on https://coreruleset.org/). +- Embedding Coraza later must still consume CRS; it must not become a + pretext for a parallel hand-written rule pack. + +## References + +OWASP Coraza. (n.d.). *Documentation*. https://coraza.io/docs/ + +OWASP Coraza. (n.d.). *OWASP Coraza WAF*. https://coraza.io/ + +OWASP Foundation / CRS Project. (n.d.). *OWASP Core Rule Set*. +https://coreruleset.org/ + +Eve JSON output. (n.d.). In *Suricata documentation*. +https://docs.suricata.io/en/latest/output/eve/eve-json-output.html + +Bitussi, M., & Doriguzzi-Corin, R. (2026). *X-WAD: eXplainable web +anomaly detection* [Preprint]. arXiv. +https://arxiv.org/abs/2608.27172 diff --git a/docs/adr/0004-rfc-5782-style-dnsbl-zone-export.md b/docs/adr/0004-rfc-5782-style-dnsbl-zone-export.md new file mode 100644 index 0000000..2efc183 --- /dev/null +++ b/docs/adr/0004-rfc-5782-style-dnsbl-zone-export.md @@ -0,0 +1,69 @@ +# ADR 0004: RFC 5782-style DNSBL zone export + +- Status: Accepted +- Date: 2026-08-25 +- Recorded from: current `main` (`README.md` DNSBL notes; + `docs/architecture.md` `/dnsbl/zone` and DNSBL serving follow-up; + `docs/fuzzing.md` zone-export invariants) + +## Context + +Operators publish listed addresses so mail and gateway scorers can +query a DNS blacklist. Levine (2010) describes DNS blacklists and +whitelists as **IRTF Informational** practice: this is **not** an +Internet Standards Track specification. The conventional listing +record is an A resource record whose address is a **response code**, +not a destination to connect to. Those A values SHOULD lie in +`127.0.0.0/8` so a mistaken use as an IP address stays on loopback +(Levine, 2010, RFC 5782). + +Zone and resource-record structure follows DNS concepts and the DNS +implementation specification (Mockapetris, 1987a, RFC 1034 / STD 13; +Mockapetris, 1987b, RFC 1035 / STD 13). + +## Decision + +1. Export an **RFC 5782-style DNSBL zone** at `GET /dnsbl/zone` using + the configured `DNSBL_ORIGIN`. The `dnsbl.local` default is for local + development only; authoritative deployments must explicitly configure a + non-`.local` origin because `.local.` is reserved for mDNS. +2. Require every published DNSBL **response code** to be an IPv4 + loopback-style address in **`127.0.0.0/8`**. Reject codes outside + that range at the management API. +3. Treat the export as **zone text suitable for an authoritative DNS + server**. This process does **not** serve DNS on port 53. +4. **Hickory DNS authoritative serving is a follow-up**, to be + considered after zone-export semantics stabilize. It is not + accepted on current `main`. + +## Consequences + +- Management upserts stay keyed by listed `address`; the A-record + payload is the validated `127.0.0.0/8` code. +- Fuzz and property tests require every published A-record code to + remain a loopback literal and every TXT payload to stay escaped + (`docs/fuzzing.md`). +- RFC 5782 remains Informational. Local validation is stricter + (`MUST` in this gateway) than the RFC `SHOULD` on A values. +- IPv6 DNSxL layout in RFC 5782 is not an accepted serving mode here. + +## References + +Cheshire, S., & Krochmal, M. (2013). *Multicast DNS* (RFC 6762). +RFC Editor. https://doi.org/10.17487/RFC6762 + +Levine, J. (2010). *DNS blacklists and whitelists* (RFC 5782). RFC +Editor. https://doi.org/10.17487/RFC5782 + +*(IRTF Informational; not Standards Track. Also +https://www.rfc-editor.org/info/rfc5782. Live-checked 2026-08-25.)* + +Mockapetris, P. (1987a). *Domain names—concepts and facilities* +(RFC 1034). RFC Editor. https://doi.org/10.17487/RFC1034 + +*(Internet Standard, STD 13.)* + +Mockapetris, P. (1987b). *Domain names—implementation and +specification* (RFC 1035). RFC Editor. https://doi.org/10.17487/RFC1035 + +*(Internet Standard, STD 13.)* diff --git a/docs/adr/0005-coverage-guided-fuzzing-untrusted-inputs.md b/docs/adr/0005-coverage-guided-fuzzing-untrusted-inputs.md new file mode 100644 index 0000000..14a5b79 --- /dev/null +++ b/docs/adr/0005-coverage-guided-fuzzing-untrusted-inputs.md @@ -0,0 +1,73 @@ +# ADR 0005: Coverage-guided fuzzing of untrusted-input surfaces + +- Status: Accepted +- Date: 2026-08-25 +- Recorded from: current `main` (`docs/fuzzing.md`; README verification + note; in-repo preprint PDF + `docs/papers/fuzzing-art-science-engineering-survey-arxiv-1812.00140.pdf`) + +## Context + +The gateway parses attacker-controlled path, query, body, and client +IP on every request. Startup also deserializes untrusted JSON state +and admin-token configuration. DNSBL zone generation emits text from +operator-supplied reasons and codes. + +Manès et al. (2021) survey fuzzing as repeated execution with +generated, often malformed inputs, and treat coverage-guided fuzzing +as a primary engineering method for finding crashes and invariant +violations on those surfaces. This ADR cites that **published** IEEE +Transactions on Software Engineering article as primary. The 2018 +arXiv posting is the **preprint** of the same work (Manès et al., +2018) and is already vendored in-repo. No other fuzzing paper is +cited. + +## Decision + +1. Exercise the untrusted-input surfaces with **coverage-guided + fuzzing** (cargo-fuzz / libFuzzer) on nightly: + - `fuzz_score_request` — `waf_ids_core::score_request` + - `fuzz_appdata_json` — `AppData` state-file JSON + - `fuzz_parse_admin_tokens` — admin-token configuration parser + - `fuzz_dnsbl_zone` — DNSBL zone export / validation +2. Keep a **stable property-test mirror** (`proptest`) in + `crates/waf-ids-core/tests/fuzz_invariants.rs` and + `tests/fuzz_invariants.rs` so the same invariants run on stable in + `cargo test --workspace`. +3. Isolate fuzz targets in the `fuzz/` Cargo workspace so root + `cargo test --workspace` never builds libFuzzer targets. +4. When an untrusted-input surface changes, keep the libFuzzer target + and the property-test mirror in sync (`docs/fuzzing.md`). + +## Consequences + +- Invariants include: no panic on arbitrary valid UTF-8 JSON input for the + state parser; non-empty score + reasons; deterministic scoring; serde round-trip of parsed state; + no empty token key or empty actor; TXT payloads fully escaped; + every published A-record code in `127.0.0.0/8`. +- Pull-request CI smoke-fuzzes each target for a bounded budget; + nightly runs a longer budget. Those workflows are operational, not + additional papers. +- Coverage-gate stubs and cancelled scanner runs are not evidence for + this decision. + +## References + +Manès, V. J. M., Han, H., Han, C., Cha, S. K., Egele, M., Schwartz, +E. J., & Woo, M. (2021). The art, science, and engineering of +fuzzing: A survey. *IEEE Transactions on Software Engineering, +47*(11), 2312–2331. https://doi.org/10.1109/TSE.2019.2946563 + +*(Primary published version. Crossref record confirmed 2026-08-25: +title, volume 47 issue 11, pages 2312–2331, date 2021-11-01. DOI +resolver reached `https://ieeexplore.ieee.org/document/8863940/`.)* + +Manès, V. J. M., Han, H., Han, C., Cha, S. K., Egele, M., Schwartz, +E. J., & Woo, M. (2018). The art, science, and engineering of +fuzzing: A survey. *arXiv*. +https://doi.org/10.48550/arXiv.1812.00140 + +*(Preprint; arXiv:1812.00140. Submitted 2018-12-01, revised 2019-04-08 +as v4. Local copy: +`docs/papers/fuzzing-art-science-engineering-survey-arxiv-1812.00140.pdf`.)* diff --git a/docs/adr/0006-admin-token-threat-intel-document-ingest.md b/docs/adr/0006-admin-token-threat-intel-document-ingest.md new file mode 100644 index 0000000..99c7048 --- /dev/null +++ b/docs/adr/0006-admin-token-threat-intel-document-ingest.md @@ -0,0 +1,96 @@ +# ADR 0006: Admin-token threat-intel document ingest + +- Status: Accepted +- Date: 2026-08-25 +- Recorded from: current `main` (`docs/architecture.md` threat + intelligence paragraph; admin console copy for the ingest routes) + +## Context + +Gateway scoring needs threat indicators and DNSBL entries. Operators +already hold documents from STIX/TAXII, MISP, and OpenCTI. Those +documents are untrusted until validated. Live pull jobs against a +MISP REST API or an OpenCTI GraphQL endpoint are a different +operational surface (credentials, scheduling, pagination) and are +**not** accepted on current `main`. + +STIX 2.1 is an OASIS Standard for exchanging cyber threat +intelligence objects (Jordan et al., 2021a). TAXII 2.1 is the OASIS +Standard for transporting STIX over HTTP collections (Jordan & +Varner, 2021). MISP is an open threat-intelligence sharing platform +and associated open standards (MISP Project, n.d.; MISP Standard, +n.d.). OpenCTI documents an open CTI platform with official +operator documentation (OpenCTI, n.d.). + +## Decision + +1. Ingest **operator-posted documents** on admin-authenticated routes: + - `POST /api/threat-intel/stix` — STIX 2.x indicator or bundle JSON + - `POST /api/threat-intel/misp` — MISP Event / attribute JSON + (`to_ids=false` attributes skipped) + - `POST /api/threat-intel/opencti` — OpenCTI observable / indicator + export JSON +2. Support operator-initiated remote TAXII ingestion at + `POST /api/threat-intel/taxii/poll`: receive a TAXII 2.1 objects URL + (or API root plus collection id) and optional credentials, fetch the + external endpoint, normalize its response to STIX, then upsert it. + That remote-fetch path remains bounded by the shared outbound policy: + absolute URLs only, no embedded credentials or fragments, HTTPS off + loopback, and no automatic redirect following while credentials are + in scope. Deeper DNS-aware egress validation remains production + hardening work until it lands on protected `main`. +3. Map supported IP, domain, URL, and hash material into + `ThreatIndicator` and `DnsblEntry` rows and update feed freshness. +4. **Live MISP REST pull** and **live OpenCTI GraphQL pull** remain + follow-ups. They are not accepted replacements for document ingest. +5. Never write TAXII or admin credentials into audit-log payloads. + +## Consequences + +- An operator (or an external poller they control) can push reviewed + intelligence without this process holding a standing MISP or + OpenCTI session. +- STIX/TAXII citations are the OASIS Standard HTML editions fetched + 2026-08-25, not drafts. +- MISP Internet-Draft HTML for a “core format” exists on + misp-standard.org; this ADR does **not** treat that draft as a + published RFC or Standards Track document. The accepted references + are the official project and standard landings. +- TAXII poll still performs an outbound HTTP GET of objects the + operator named; that is document transport, not a live MISP/OpenCTI + product puller. +- Mavroeidis and Bromander (2021) argue that CTI-sharing formats must + remain machine-readable and unambiguous to support interoperable + analysis, while Arikkat et al. (2024) emphasize provenance, + trustworthiness, and quality controls around shared CTI. Wardnet uses + that boundary to justify validation, source labeling, and the rule + that TAXII or admin credentials must never be copied into audit logs. + +## References + +Jordan, B., Piazza, R., & Darley, T. (Eds.). (2021, June 10). *STIX +Version 2.1* (OASIS Standard). OASIS Open. +https://docs.oasis-open.org/cti/stix/v2.1/os/stix-v2.1-os.html + +Jordan, B., & Varner, D. (Eds.). (2021, June 10). *TAXII Version 2.1* +(OASIS Standard). OASIS Open. +https://docs.oasis-open.org/cti/taxii/v2.1/os/taxii-v2.1-os.html + +MISP Project. (n.d.). *MISP open source threat intelligence platform +& open standards for threat intelligence sharing*. +https://www.misp-project.org/ + +MISP Standard. (n.d.). *MISP standard*. +https://www.misp-standard.org/ + +OpenCTI. (n.d.). *OpenCTI documentation*. +https://docs.opencti.io/latest/ + +Mavroeidis, V., & Bromander, S. (2021). *Cyber threat intelligence +model: An evaluation of taxonomies, sharing standards, and ontologies +within cyber threat intelligence* [Preprint]. arXiv. +https://arxiv.org/abs/2103.03530 + +Arikkat, D. R., Cihangiroglu, M., Conti, M., Rehiman K. A., R., Nicolazzo, +S., Nocera, A., & Vinod P. (2024). *SeCTIS: A framework to secure CTI +sharing* [Preprint]. arXiv. https://arxiv.org/abs/2406.14102 diff --git a/docs/adr/0007-localhost-default-bind-remote-management.md b/docs/adr/0007-localhost-default-bind-remote-management.md new file mode 100644 index 0000000..a4d0e14 --- /dev/null +++ b/docs/adr/0007-localhost-default-bind-remote-management.md @@ -0,0 +1,68 @@ +# ADR 0007: Localhost default bind; remote management requires token plus external TLS/identity + +- Status: Accepted +- Date: 2026-08-25 +- Recorded from: current `main` (`README.md` bind default and + hardening note; `docs/architecture.md` security boundaries; + `docs/security/threat-model.md` trust boundaries) + +## Context + +The management API can change routes, threat lists, and DNSBL +entries. Binding to all interfaces by default would expose that +surface on the network before TLS or identity are in place. + +`127.0.0.0/8` is the IPv4 loopback block (Cotton et al., 2013, +RFC 6890, Best Current Practice; Internet Assigned Numbers +Authority, n.d.). A default listen address of `127.0.0.1:8080` +keeps the process on that block unless an operator sets `BIND_ADDR`. + +Block mode must not flip the whole process into global enforcement +from one mistaken write. + +## Decision + +1. Default `BIND_ADDR` is **`127.0.0.1:8080`** (localhost). +2. **Remote management** is accepted only with a configured + **`ADMIN_TOKEN`** (or `ADMIN_TOKENS` / credential-registry + equivalent) **and** external TLS plus identity controls in front of + the process. This binary does not terminate public TLS or SSO by + itself. +3. **Block mode is route-scoped.** A route's `mode` applies to that + route's path prefix only. +4. Public clients enter through `/gateway/{path}`. Management writes + use `X-Admin-Token` and remain upserts. + +## Consequences + +- `cargo run` without extra config is a local lab listener, not an + internet-facing deployment. +- Current `main` does **not** yet fail closed when an operator binds to + a non-loopback address without admin credentials: the fallback + `admin_authorized` path still treats missing credentials as auth + disabled. That insecure configuration is therefore outside this + accepted deployment boundary and remains an implementation gap rather + than accepted evidence of safe remote management. +- Operators who bind to a non-loopback address must supply TLS, + identity-aware access, upstream allowlists, and rollback procedures + before production traffic (`README.md` completion baseline). +- Unauthorized management writes remain the primary control-plane + threat; token gates and audit logs are the current control, not a + substitute for SSO or mTLS (`docs/security/threat-model.md`). +- RFC 6890 is a Best Current Practice for special-purpose address + registries; it is not a WAF protocol. + +## References + +Cotton, M., Vegoda, L., Bonica, R., & Haberman, B. (2013). +*Special-purpose IP address registries* (RFC 6890). RFC Editor. +https://doi.org/10.17487/RFC6890 + +*(Best Current Practice. Documents `127.0.0.0/8` as Loopback. +Live-checked 2026-08-25 via https://www.rfc-editor.org/info/rfc6890.)* + +Internet Assigned Numbers Authority. (n.d.). *IPv4 special-purpose +address space*. +https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml + +*(Live-checked 2026-08-25: `127.0.0.0/8` named Loopback.)* diff --git a/docs/adr/0008-ai-soc-assist-advisory-human-enforcement.md b/docs/adr/0008-ai-soc-assist-advisory-human-enforcement.md new file mode 100644 index 0000000..d569c8c --- /dev/null +++ b/docs/adr/0008-ai-soc-assist-advisory-human-enforcement.md @@ -0,0 +1,73 @@ +# ADR 0008: AI SOC assist is advisory; enforcement changes require a human + +- Status: Accepted +- Date: 2026-08-25 +- Recorded from: current `main` (`docs/architecture.md` AI SOC + paragraph; `docs/security/threat-model.md` human approval boundary; + optional `/api/soc` LLM analyze path) + +## Context + +SOC operators benefit from a short triage note on a recorded event +(likely attack class, severity judgement, recommended action). That +text is easy to mistake for an automated block. + +On current `main`, optional LLM assist posts one chat-completions +request through a configured OpenAI-compatible base URL. The request +sets `orchestration_mode: "auto"`, delegating model topology and +reasoning depth to contextual-orchestrator as recorded in ADR 0010, +and returns **analysis text only**. It does not upsert routes, threats, +or DNSBL entries. + +Nelson et al. (2025) place high-impact response actions (for example +shutting down or rebuilding critical services) under leadership +decision-making and tell incident handlers to keep the ability to +**manually** select containment instead of or in addition to +automation (NIST SP 800-61r3). That is incident-handling guidance, +not a product certification. + +## Decision + +1. **AI SOC assist is advisory.** It may summarize an event, suggest + a class or severity, and recommend an action. +2. **Enforcement-changing recommendations require a human.** No LLM + output may by itself enable block mode, add a deny route, or + publish a DNSBL listing. +3. When enabled, delegate workflow depth and model selection through + the explicit adaptive orchestration contract in ADR 0010. Wardnet + retains authorization, evidence collection, and enforcement. +4. Leave the LLM backend **optional**. Current `main` exposes the + `SocLlmConfig` runtime hook, but `run_from_env` does **not** yet + wire `SOC_LLM_BASE_URL`; absent explicit in-process configuration, + assist is unavailable and the gateway still enforces + operator-written policy. +5. Do not treat cancelled scanner runs, unmerged drafts, or coverage + stubs as evidence that assist is safe to auto-enforce. + +## Consequences + +- Operators can wire contextual-orchestrator (or another compatible + endpoint) without giving that hook control-plane write authority. The + current runtime sends `orchestration_mode` but cannot prove that a generic + OpenAI-compatible endpoint honored it; such endpoints are not evidence of + ADR 0010 compliance until an acknowledgement contract is implemented. +- Adaptive orchestration changes inference execution, not Wardnet's + human enforcement boundary; see ADR 0010 for its accepted contract. +- Human approval stays required until audit trails, rollback, and + policy simulation exist for machine-proposed enforcement + (`docs/security/threat-model.md`). +- Mapping events to ATT&CK tactics is a roadmap item in + `docs/architecture.md`; it is not an accepted automated enforcer. +- NIST SP 800-61r3 supersedes SP 800-61r2; this ADR cites r3 only. + +## References + +Nelson, A., Rekhi, S., Souppaya, M., & Scarfone, K. (2025). +*Incident response recommendations and considerations for +cybersecurity risk management: A CSF 2.0 community profile* +(NIST SP 800-61r3). National Institute of Standards and Technology. +https://doi.org/10.6028/NIST.SP.800-61r3 + +*(DOI GET returned the official PDF 2026-08-25; CSRC landing +https://csrc.nist.gov/pubs/sp/800/61/r3/final also 200. Authors and +April 2025 imprint taken from that PDF front matter.)* diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..c873314 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,58 @@ +# Architecture Decision Records + +This directory records **accepted** architecture decisions that are already +true on current `main`. It does not propose new product work. + +Narrative sources on `main`: + +- [`docs/architecture.md`](../architecture.md) — component map, security + boundaries, and adapter roadmap +- [`docs/fuzzing.md`](../fuzzing.md) — untrusted-input fuzz targets and + property-test mirror +- Repository `README.md` — operator-facing gateway, DNSBL, and workspace notes + +Each ADR is **Accepted**. Follow-ups named in an ADR (in-process Coraza +embedding, Hickory DNS authoritative serving, live MISP REST pull, live +OpenCTI GraphQL pull, a production database) are **not** accepted on `main`. + +wardnet is a standalone leaf: the binary must run by itself. Sibling +ContextualWisdomLab products (naruon, gyeot, contextual-orchestrator, +Clearfolio) are optional HTTP or contract callers, not required checkouts. + +## Series + +| ADR | Title | Status | +| --- | --- | --- | +| [0001](0001-standalone-rust-gateway-workspace-core.md) | Standalone Rust gateway with in-workspace `waf-ids-core` | Accepted | +| [0002](0002-optional-json-state-standalone-durability.md) | Optional JSON state for standalone durability | Accepted | +| [0003](0003-owasp-crs-coraza-waf-authority.md) | OWASP CRS / Coraza as WAF authority | Accepted | +| [0004](0004-rfc-5782-style-dnsbl-zone-export.md) | RFC 5782-style DNSBL zone export | Accepted | +| [0005](0005-coverage-guided-fuzzing-untrusted-inputs.md) | Coverage-guided fuzzing of untrusted-input surfaces | Accepted | +| [0006](0006-admin-token-threat-intel-document-ingest.md) | Admin-token threat-intel document ingest | Accepted | +| [0007](0007-localhost-default-bind-remote-management.md) | Localhost default bind; remote management requires token plus external TLS/identity | Accepted | +| [0008](0008-ai-soc-assist-advisory-human-enforcement.md) | AI SOC assist is advisory; enforcement changes require a human | Accepted | +| [0010](0010-adaptive-contextual-orchestrator-default.md) | SOC analysis delegates default execution to contextual-orchestrator auto | Accepted | + +## Citation policy + +References use APA 7th. Every external locator was fetched live on +2026-08-25. Informational RFCs stay labeled informational. Drafts, +unmerged pull requests, and unpublished scans are not cited as papers or +standards. Local PDF copies are attached only when redistribution is +permissible; otherwise the ADR cites, links, and summarizes the source +without vendoring the full text. + +## Template + +```markdown +# ADR NNNN: Title + +- Status: Accepted +- Date: YYYY-MM-DD +- Recorded from: current `main` (path list) + +## Context +## Decision +## Consequences +## References +``` diff --git a/docs/deployment/production.md b/docs/deployment/production.md index 34ff197..1c46ac7 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 0000000..0251866 --- /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 0000000..4c1eff0 Binary files /dev/null and b/docs/papers/nist-sp-800-57-part-1-rev-5.pdf differ diff --git a/docs/papers/sectis-secure-cti-sharing-arxiv-2406.14102.pdf b/docs/papers/sectis-secure-cti-sharing-arxiv-2406.14102.pdf new file mode 100644 index 0000000..0538d0b Binary files /dev/null and b/docs/papers/sectis-secure-cti-sharing-arxiv-2406.14102.pdf differ diff --git a/docs/papers/x-wad-explainable-web-anomaly-detection-arxiv-2608.27172.pdf b/docs/papers/x-wad-explainable-web-anomaly-detection-arxiv-2608.27172.pdf new file mode 100644 index 0000000..858275e Binary files /dev/null and b/docs/papers/x-wad-explainable-web-anomaly-detection-arxiv-2608.27172.pdf differ diff --git a/tests/deployment_manifest.rs b/tests/deployment_manifest.rs new file mode 100644 index 0000000..e169913 --- /dev/null +++ b/tests/deployment_manifest.rs @@ -0,0 +1,650 @@ +//! Regression contracts for the production Kubernetes deployment manifest. + +use std::borrow::Cow; + +const MANIFEST: &str = include_str!("../deploy/kubernetes/waf-ids-ai-soc.yaml"); +const PRODUCTION_GUIDE: &str = include_str!("../docs/deployment/production.md"); + +/// Secret coordinates the gateway Deployment must consume for `ADMIN_TOKEN`. +#[derive(Debug, PartialEq, Eq)] +struct ExternalAdminSecretRef<'a> { + namespace: &'a str, + secret_name: &'a str, + secret_key: &'a str, +} + +/// Count leading ASCII spaces so YAML indent is compared structurally. +fn leading_spaces(line: &str) -> usize { + line.len() - line.trim_start_matches(' ').len() +} + +/// Parse a single-line YAML scalar, ignoring trailing comments and normalizing +/// matching quote wrappers. +fn normalized_yaml_scalar(value: &str) -> Cow<'_, str> { + let trimmed = value.trim(); + let mut in_single = false; + let mut in_double = false; + let mut previous = None; + let mut end = trimmed.len(); + for (index, ch) in trimmed.char_indices() { + match ch { + '\'' if !in_double => in_single = !in_single, + '"' if !in_single && previous != Some('\\') => in_double = !in_double, + '#' if !in_single && !in_double && previous.is_none_or(char::is_whitespace) => { + end = index; + break; + } + _ => {} + } + previous = Some(ch); + } + let scalar = trimmed[..end].trim_end(); + if scalar.len() >= 2 { + let bytes = scalar.as_bytes(); + let first = bytes[0]; + let last = bytes[scalar.len() - 1]; + if first == b'"' && last == b'"' { + return decode_double_quoted_yaml_scalar(&scalar[1..scalar.len() - 1]); + } + if first == b'\'' && last == b'\'' { + return Cow::Owned(scalar[1..scalar.len() - 1].replace("''", "'")); + } + } + Cow::Borrowed(scalar) +} + +/// Decode the YAML escape sequences relevant to duplicate env-name detection. +fn decode_double_quoted_yaml_scalar(value: &str) -> Cow<'_, str> { + if !value.contains('\\') { + return Cow::Borrowed(value); + } + + let mut decoded = String::with_capacity(value.len()); + let mut chars = value.chars(); + while let Some(ch) = chars.next() { + if ch != '\\' { + decoded.push(ch); + continue; + } + + let Some(escape) = chars.next() else { + decoded.push('\\'); + break; + }; + match escape { + '0' => decoded.push('\0'), + 'a' => decoded.push('\u{0007}'), + 'b' => decoded.push('\u{0008}'), + 't' | '\t' => decoded.push('\t'), + 'n' => decoded.push('\n'), + 'v' => decoded.push('\u{000B}'), + 'f' => decoded.push('\u{000C}'), + 'r' => decoded.push('\r'), + 'e' => decoded.push('\u{001B}'), + ' ' => decoded.push(' '), + '"' => decoded.push('"'), + '/' => decoded.push('/'), + '\\' => decoded.push('\\'), + 'N' => decoded.push('\u{0085}'), + '_' => decoded.push('\u{00A0}'), + 'L' => decoded.push('\u{2028}'), + 'P' => decoded.push('\u{2029}'), + 'x' => push_escaped_codepoint(&mut decoded, &mut chars, 2, "\\x"), + 'u' => push_escaped_codepoint(&mut decoded, &mut chars, 4, "\\u"), + 'U' => push_escaped_codepoint(&mut decoded, &mut chars, 8, "\\U"), + other => { + decoded.push('\\'); + decoded.push(other); + } + } + } + + Cow::Owned(decoded) +} + +fn push_escaped_codepoint( + decoded: &mut String, + chars: &mut std::str::Chars<'_>, + digits: usize, + marker: &str, +) { + let mut hex = String::with_capacity(digits); + for _ in 0..digits { + let Some(ch) = chars.next() else { + decoded.push_str(marker); + decoded.push_str(&hex); + return; + }; + if !ch.is_ascii_hexdigit() { + decoded.push_str(marker); + decoded.push_str(&hex); + decoded.push(ch); + return; + } + hex.push(ch); + } + + if let Ok(value) = u32::from_str_radix(&hex, 16) + && let Some(codepoint) = char::from_u32(value) + { + decoded.push(codepoint); + return; + } + + decoded.push_str(marker); + decoded.push_str(&hex); +} + +/// Whether a `- name:` YAML line names the expected entry, with quote tolerance. +fn yaml_named_entry_matches(line: &str, item_indent: usize, expected_name: &str) -> bool { + if leading_spaces(line) != item_indent { + return false; + } + let Some(raw_name) = line.trim().strip_prefix("- name:") else { + return false; + }; + normalized_yaml_scalar(raw_name) == expected_name +} + +/// Read `child_key` from the mapping that starts at `parent_key`/`parent_indent`. +fn mapping_value<'a>( + lines: &[&'a str], + parent_key: &str, + parent_indent: usize, + child_key: &str, +) -> Option<&'a str> { + let parent_index = lines + .iter() + .position(|line| leading_spaces(line) == parent_indent && line.trim() == parent_key)?; + + lines[parent_index + 1..] + .iter() + .take_while(|line| line.trim().is_empty() || leading_spaces(line) > parent_indent) + .find_map(|line| { + line.trim() + .strip_prefix(child_key) + .map(str::trim) + .filter(|value| !value.is_empty()) + }) +} + +/// Lines that belong to the YAML block nested under `parent_key`. +fn nested_block<'a>(lines: &[&'a str], parent_key: &str, parent_indent: usize) -> Vec<&'a str> { + let Some(parent_index) = lines + .iter() + .position(|line| leading_spaces(line) == parent_indent && line.trim() == parent_key) + else { + return Vec::new(); + }; + + lines[parent_index + 1..] + .iter() + .take_while(|line| line.trim().is_empty() || leading_spaces(line) > parent_indent) + .copied() + .collect() +} + +/// Slice of a YAML list item whose `- name:` equals `item_name`. +fn named_list_item_block<'a>( + lines: &[&'a str], + item_name: &str, + item_indent: usize, +) -> Vec<&'a str> { + let expected = format!("- name: {item_name}"); + let Some(item_index) = lines + .iter() + .position(|line| leading_spaces(line) == item_indent && line.trim() == expected) + else { + return Vec::new(); + }; + + lines[item_index..] + .iter() + .enumerate() + .take_while(|(offset, line)| { + *offset == 0 || line.trim().is_empty() || leading_spaces(line) > item_indent + }) + .map(|(_, line)| *line) + .collect() +} + +/// Locate `ADMIN_TOKEN` on the `waf-ids-ai-soc` gateway container only. +/// +/// Duplicate entries, literal fallback values, and `secretKeyRef.optional: true` +/// are treated as absent (fail closed). +fn external_admin_secret_ref(manifest: &str) -> Option> { + manifest.split("\n---\n").find_map(|document| { + let lines = document.lines().collect::>(); + if !lines.iter().any(|line| line.trim() == "kind: Deployment") { + return None; + } + + if mapping_value(&lines, "metadata:", 0, "name:") != Some("waf-ids-ai-soc") { + return None; + } + + let namespace = mapping_value(&lines, "metadata:", 0, "namespace:")?; + let workload_spec = nested_block(&lines, "spec:", 0); + let pod_template = nested_block(&workload_spec, "template:", 2); + let pod_spec = nested_block(&pod_template, "spec:", 4); + let containers = nested_block(&pod_spec, "containers:", 6); + let gateway = named_list_item_block(&containers, "gateway", 8); + let env = nested_block(&gateway, "env:", 10); + let admin_token_entries = env + .iter() + .filter(|line| yaml_named_entry_matches(line, 12, "ADMIN_TOKEN")) + .count(); + if admin_token_entries != 1 { + return None; + } + + let env_block = named_list_item_block(&env, "ADMIN_TOKEN", 12); + if env_block + .iter() + .any(|line| line.trim().starts_with("value:")) + { + return None; + } + let secret_ref_index = env_block + .iter() + .position(|line| line.trim() == "secretKeyRef:")?; + let secret_ref_indent = leading_spaces(env_block[secret_ref_index]); + let secret_ref_block = env_block[secret_ref_index + 1..] + .iter() + .take_while(|line| line.trim().is_empty() || leading_spaces(line) > secret_ref_indent) + .copied() + .collect::>(); + + let secret_name = secret_ref_block.iter().find_map(|line| { + line.trim() + .strip_prefix("name:") + .map(str::trim) + .filter(|value| !value.is_empty()) + })?; + let secret_key = secret_ref_block.iter().find_map(|line| { + line.trim() + .strip_prefix("key:") + .map(str::trim) + .filter(|value| !value.is_empty()) + })?; + match secret_ref_block.iter().find_map(|line| { + line.trim() + .strip_prefix("optional:") + .map(str::trim) + .filter(|value| !value.is_empty()) + }) { + None | Some("false") => {} + Some(_) => return None, + } + + Some(ExternalAdminSecretRef { + namespace, + secret_name, + secret_key, + }) + }) +} + +#[test] +fn shipped_manifest_contains_no_admin_secret_object() { + assert!( + !MANIFEST.lines().any(|line| line.trim() == "kind: Secret"), + "the distributable manifest must not create an administrator Secret" + ); + assert!( + !MANIFEST.contains("replace-with-secret-manager-sync"), + "the distributable manifest must not contain a reusable administrator credential" + ); +} + +#[test] +fn deployment_requires_the_external_admin_secret_contract() { + assert_eq!( + external_admin_secret_ref(MANIFEST), + Some(ExternalAdminSecretRef { + namespace: "waf-ids-ai-soc", + secret_name: "waf-ids-ai-soc-admin", + secret_key: "ADMIN_TOKEN", + }) + ); +} + +#[test] +fn decoy_secret_text_cannot_satisfy_the_structural_contract() { + let decoy_manifest = r#"apiVersion: apps/v1 +kind: Deployment +metadata: + name: waf-ids-ai-soc + namespace: another-namespace +spec: + template: + spec: + containers: + - name: gateway + env: + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: another-secret + key: ANOTHER_KEY +# name: waf-ids-ai-soc-admin +# key: ADMIN_TOKEN +"#; + + assert_ne!( + external_admin_secret_ref(decoy_manifest), + Some(ExternalAdminSecretRef { + namespace: "waf-ids-ai-soc", + secret_name: "waf-ids-ai-soc-admin", + secret_key: "ADMIN_TOKEN", + }) + ); +} + +#[test] +fn another_deployment_cannot_satisfy_the_target_secret_contract() { + let reverse_order_manifest = r#"apiVersion: apps/v1 +kind: Deployment +metadata: + name: unrelated-worker + namespace: waf-ids-ai-soc +spec: + template: + spec: + containers: + - name: worker + env: + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: waf-ids-ai-soc-admin + key: ADMIN_TOKEN +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: waf-ids-ai-soc + namespace: waf-ids-ai-soc +spec: + template: + spec: + containers: + - name: gateway + env: + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: wrong-secret + key: WRONG_KEY +"#; + + assert_eq!( + external_admin_secret_ref(reverse_order_manifest), + Some(ExternalAdminSecretRef { + namespace: "waf-ids-ai-soc", + secret_name: "wrong-secret", + secret_key: "WRONG_KEY", + }) + ); +} + +#[test] +fn init_container_cannot_satisfy_the_gateway_secret_contract() { + let init_container_decoy = r#"apiVersion: apps/v1 +kind: Deployment +metadata: + name: waf-ids-ai-soc + namespace: waf-ids-ai-soc +spec: + template: + spec: + initContainers: + - name: decoy + env: + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: waf-ids-ai-soc-admin + key: ADMIN_TOKEN + containers: + - name: gateway + env: + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: wrong-secret + key: WRONG_KEY +"#; + + assert_eq!( + external_admin_secret_ref(init_container_decoy), + Some(ExternalAdminSecretRef { + namespace: "waf-ids-ai-soc", + secret_name: "wrong-secret", + secret_key: "WRONG_KEY", + }) + ); +} + +#[test] +fn optional_admin_secret_reference_fails_closed() { + let optional_secret = r#"apiVersion: apps/v1 +kind: Deployment +metadata: + name: waf-ids-ai-soc + namespace: waf-ids-ai-soc +spec: + template: + spec: + containers: + - name: gateway + env: + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: waf-ids-ai-soc-admin + key: ADMIN_TOKEN + optional: true +"#; + + assert_eq!(external_admin_secret_ref(optional_secret), None); +} + +#[test] +fn explicitly_required_admin_secret_reference_is_accepted() { + let required_secret = r#"apiVersion: apps/v1 +kind: Deployment +metadata: + name: waf-ids-ai-soc + namespace: waf-ids-ai-soc +spec: + template: + spec: + containers: + - name: gateway + env: + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: waf-ids-ai-soc-admin + key: ADMIN_TOKEN + optional: false +"#; + + assert_eq!( + external_admin_secret_ref(required_secret), + Some(ExternalAdminSecretRef { + namespace: "waf-ids-ai-soc", + secret_name: "waf-ids-ai-soc-admin", + secret_key: "ADMIN_TOKEN", + }) + ); +} + +#[test] +fn duplicate_admin_token_entries_fail_closed() { + let duplicate_admin_token = r#"apiVersion: apps/v1 +kind: Deployment +metadata: + name: waf-ids-ai-soc + namespace: waf-ids-ai-soc +spec: + template: + spec: + containers: + - name: gateway + env: + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: waf-ids-ai-soc-admin + key: ADMIN_TOKEN + optional: false + - name: ADMIN_TOKEN + value: another-repository-visible-fallback +"#; + + assert_eq!(external_admin_secret_ref(duplicate_admin_token), None); +} + +#[test] +fn quoted_duplicate_admin_token_entries_fail_closed() { + let double_quoted_duplicate = r#"apiVersion: apps/v1 +kind: Deployment +metadata: + name: waf-ids-ai-soc + namespace: waf-ids-ai-soc +spec: + template: + spec: + containers: + - name: gateway + env: + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: waf-ids-ai-soc-admin + key: ADMIN_TOKEN + optional: false + - name: "ADMIN_TOKEN" + value: another-repository-visible-fallback +"#; + let single_quoted_duplicate = r#"apiVersion: apps/v1 +kind: Deployment +metadata: + name: waf-ids-ai-soc + namespace: waf-ids-ai-soc +spec: + template: + spec: + containers: + - name: gateway + env: + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: waf-ids-ai-soc-admin + key: ADMIN_TOKEN + optional: false + - name: 'ADMIN_TOKEN' + value: another-repository-visible-fallback +"#; + + assert_eq!(external_admin_secret_ref(double_quoted_duplicate), None); + assert_eq!(external_admin_secret_ref(single_quoted_duplicate), None); +} + +#[test] +fn commented_quoted_duplicate_admin_token_entries_fail_closed() { + let commented_duplicate = r#"apiVersion: apps/v1 +kind: Deployment +metadata: + name: waf-ids-ai-soc + namespace: waf-ids-ai-soc +spec: + template: + spec: + containers: + - name: gateway + env: + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: waf-ids-ai-soc-admin + key: ADMIN_TOKEN + optional: false + - name: "ADMIN_TOKEN" # duplicated fallback entry + value: another-repository-visible-fallback +"#; + + assert_eq!(external_admin_secret_ref(commented_duplicate), None); +} + +#[test] +fn hex_escaped_duplicate_admin_token_entries_fail_closed() { + let escaped_duplicate = r#"apiVersion: apps/v1 +kind: Deployment +metadata: + name: waf-ids-ai-soc + namespace: waf-ids-ai-soc +spec: + template: + spec: + containers: + - name: gateway + env: + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: waf-ids-ai-soc-admin + key: ADMIN_TOKEN + optional: false + - name: "\x41DMIN_TOKEN" + value: another-repository-visible-fallback +"#; + + assert_eq!(external_admin_secret_ref(escaped_duplicate), None); +} + +#[test] +fn unicode_escaped_duplicate_admin_token_entries_fail_closed() { + let escaped_duplicate = r#"apiVersion: apps/v1 +kind: Deployment +metadata: + name: waf-ids-ai-soc + namespace: waf-ids-ai-soc +spec: + template: + spec: + containers: + - name: gateway + env: + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: waf-ids-ai-soc-admin + key: ADMIN_TOKEN + optional: false + - name: "\u0041DMIN_TOKEN" + value: another-repository-visible-fallback +"#; + + assert_eq!(external_admin_secret_ref(escaped_duplicate), None); +} + +#[test] +fn fresh_install_bootstraps_namespace_before_secret_provisioning() { + let namespace_bootstrap = + "kubectl create namespace waf-ids-ai-soc --dry-run=client -o yaml | kubectl apply -f -"; + let bootstrap_index = PRODUCTION_GUIDE + .find(namespace_bootstrap) + .expect("fresh-install instructions must create the namespace idempotently first"); + let secret_index = PRODUCTION_GUIDE + .find("secret-management control plane") + .expect("production guide must retain external secret provisioning"); + + assert!( + bootstrap_index < secret_index, + "namespace bootstrap must precede namespaced Secret provisioning" + ); +}