Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
45e33d3
fix(auth): fail closed without write-capable admin on public bind
codex Sep 1, 2026
bdb5fe6
fix(auth): reject unusable bootstrap credentials
codex Sep 1, 2026
a2e25b2
fix(auth): reject header-ambiguous admin secrets
seonghobae Sep 1, 2026
4acbcc4
docs(security): record research redistribution assessment
seonghobae Sep 1, 2026
913fc46
test(auth): strengthen strict admin-token properties
seonghobae Sep 1, 2026
2d7d9d9
test(auth): mirror strict admin-token rejection in fuzz target
seonghobae Sep 1, 2026
1d46ac3
test(auth): keep fuzz role semantics in sync
seonghobae Sep 1, 2026
913b804
test(auth): property-check credential and RBAC boundary
seonghobae Sep 1, 2026
30771a5
test(auth): exercise fuzz invariants for arbitrary bytes
seonghobae Sep 1, 2026
3346686
fix(auth): align health and write denial auth semantics
codex Sep 1, 2026
f480d48
test(auth): reject fixed smoke administrator credential
seonghobae Sep 1, 2026
f2f591e
fix(auth): mint ephemeral smoke administrator credential
seonghobae Sep 1, 2026
efca66a
docs(security): preserve fail-closed auth traceability
seonghobae Sep 1, 2026
78b21b9
test(auth): bind smoke token generation to forwarding
seonghobae Sep 1, 2026
0af8f83
docs(auth): retain redistributable NIST SSDF evidence
seonghobae Sep 1, 2026
6d99ed7
merge: synchronize fail-closed management auth with protected main
seonghobae Sep 1, 2026
6b0898c
docs(auth): preserve external-secret deployment lifecycle after restack
seonghobae Sep 1, 2026
e6f05d7
docs(security): reconcile NIST artifact provenance
seonghobae Sep 1, 2026
f74ff25
chore: integrate protected main into fail-closed auth
seonghobae Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Open `http://127.0.0.1:8080/admin`.
Useful environment variables:

- `BIND_ADDR`: listen address, default `127.0.0.1:8080`
- `ADMIN_TOKEN`: optional write token for management writes via `X-Admin-Token`
- write-capable admin credential for management writes via `X-Admin-Token`: provide either `ADMIN_TOKEN`, a write-capable `ADMIN_TOKENS` principal, or `WAF_IDS_CREDENTIALS_PATH`. Optional only for numeric loopback binds (`127.0.0.0/8` or `::1`). Required before readiness on any other `BIND_ADDR` (`0.0.0.0`, `::`, LAN, public). See [docs/runbooks/operations.md](docs/runbooks/operations.md) and [docs/security/threat-model.md](docs/security/threat-model.md).
- `WAF_IDS_STATE_PATH`: optional JSON state path. When omitted, the service runs with seeded in-memory state.
- `DNSBL_ORIGIN`: DNSBL zone origin, default `dnsbl.local`
- `EVENT_LIMIT`: retained event count, default `1000`; must be greater than zero
Expand Down
2 changes: 1 addition & 1 deletion docs/deployment/production.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Failure, recovery, verification, and evidence requirements are documented in [`.
- Terminate TLS in front of the service.
- Expose `/admin` and `/api/*` only through identity-aware access.
- Configure upstream allowlists and egress policy.
- Store `ADMIN_TOKEN` in a secret manager.
- Store the write-capable administrator credential in a secret manager. The process will not become ready on any non-loopback `BIND_ADDR` if no usable `ADMIN_TOKEN`, write-capable `ADMIN_TOKENS` principal, or `WAF_IDS_CREDENTIALS_PATH` credential is configured. Recovery is to provision the secret authority and restart; do not disable the gate. This fail-closed bootstrap aligns with the threat model and the NIST guidance cited in [docs/security/threat-model.md](../security/threat-model.md).
- Mount persistent state or replace JSON persistence with a database.
- Run `scripts/smoke.sh` before promoting a release.
- Keep block mode route-scoped and reversible.
41 changes: 41 additions & 0 deletions docs/doctoring/fail-closed-management-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Doctoring — fail-closed management authentication

This note grounds the issue #78 implementation: non-loopback listeners refuse to become ready without a write-capable administrator principal, authentication and authorization failures remain distinct, and presented administrator secrets are compared without early-exit content comparison. IEEE PDFs are not redistributed; freely accessible standards are cited by stable locators.

## Adopted standards and literature

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

- **Design impact:** Fail-safe defaults require missing access authority to deny rather than silently enable management writes. Wardnet therefore refuses readiness when a non-loopback listener lacks a write-capable administrator credential.

OWASP Foundation. (2025). *OWASP Application Security Verification Standard 5.0.0*. https://owasp.org/www-project-application-security-verification-standard/

- **Design impact:** Administrative functions require authentication and authorization. Wardnet returns `401` for an unauthenticated management request and `403` when an authenticated readonly principal attempts a mutation, without disclosing the expected secret or role.

National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) version 1.1* (NIST SP 800-218). https://doi.org/10.6028/NIST.SP.800-218

- **Design impact:** Authentication data is loaded through the credential bootstrap boundary rather than embedded in distributable assets. Health output exposes only non-secret configuration state such as authentication mode and credential source.

MITRE. (n.d.). *CWE-306: Missing authentication for critical function*. https://cwe.mitre.org/data/definitions/306.html

- **Design impact:** Management APIs that mutate routes, threat indicators, DNSBL entries, license state, or feeds are critical functions. The runtime gate rejects the unsafe combination of a reachable non-loopback listener and missing write-capable authentication.

## Redistributable research artifact

`docs/papers/nist-sp-800-218-ssdf.pdf` is the NIST SP 800-218 Version 1.1 PDF published by the National Institute of Standards and Technology. Authoritative source: https://doi.org/10.6028/NIST.SP.800-218 (NIST publication record and official PDF). NIST states that SP 800-series publications are not subject to copyright in the United States and that attribution is appreciated; NIST's Technical Series policy also grants a worldwide royalty-free right to reprint covered NIST works. The repository therefore retains the exact PDF as research evidence with this attribution: “Republished courtesy of the National Institute of Standards and Technology.” The publication remains authoritative at NIST; the repository copy is evidence only and does not supersede the official source.

NIST SP 800-218 Rev. 1 / SSDF 1.2 is still an Initial Public Draft as of this doctoring update, so the implemented control continues to cite final SP 800-218 Version 1.1 rather than presenting the draft as a final standard.

## Implementation binding

| Decision | Implementation boundary |
| --- | --- |
| Fail closed on public bind | `require_write_auth_for_bind` before listener readiness |
| Loopback development remains usable | loopback-only listener detection and `/healthz.auth_mode=development` |
| Authentication vs authorization | management write rejection distinguishes `401` and `403` |
| Constant-time credential handling | administrator-token comparison uses a bounded constant-work comparison path |
| Blank credential path | an empty or whitespace credentials-path bootstrap value is treated as unset |
| Smoke-test credential | `scripts/smoke.sh` creates a per-process administrator token instead of shipping a repository credential |
| Ambiguous token registry | strict administrator-token parsing rejects duplicate, blank, or unknown-role entries |

PII is not blanket-masked from security evidence when doing so would make incident response unusable. Purpose-bound authorization, least privilege, auditability, retention controls, and encryption are the preferred controls for operationally necessary security data.
Binary file added docs/papers/nist-sp-800-218-ssdf.pdf
Binary file not shown.
19 changes: 18 additions & 1 deletion docs/security/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,22 @@
- Operators use management APIs and the embedded admin console.
- Upstream services are outside the process trust boundary.
- The state file is trusted only after JSON deserialization succeeds.
- A non-loopback listener is untrusted until a write-capable admin principal exists in the credential registry. This follows the fail-secure and authenticator-management posture documented in the production guide and runbook: start closed, bootstrap secrets into the registry, then expose the listener only after a usable write credential exists.
- Threat feed import payloads are untrusted operator-supplied data.

## Security Grounding

The startup gate and secret-handling path in this PR are aligned with NIST guidance that authentication secrets need lifecycle control and protected handling, and that authenticators should fail securely instead of silently degrading to weaker access. Wardnet applies that by preferring `WAF_IDS_CREDENTIALS_PATH`, allowing env only as bootstrap transport, and refusing non-loopback readiness when no usable write credential can be presented through `X-Admin-Token`. The operator recovery path is documented in [docs/deployment/production.md](../deployment/production.md), and the accepted bootstrap sources and RBAC shapes are documented in [docs/runbooks/operations.md](../runbooks/operations.md).

### Research artifact redistribution assessment

The authentication-specific NIST SP 800-57 Part 1 Rev. 5 and NIST SP 800-63B sources below remain linked to their authoritative publication records and summarized here; this PR does not republish copies of those two PDFs because the exact retrieved artifacts were not independently assessed for redistribution during this change. Separately, the branch retains `docs/papers/nist-sp-800-218-ssdf.pdf` as redistributable NIST SP 800-218 Version 1.1 evidence for the secure-development and credential-bootstrap boundary. Its authoritative source, redistribution basis, attribution, and final-versus-draft status are recorded in [docs/doctoring/fail-closed-management-auth.md](../doctoring/fail-closed-management-auth.md). The repository copy is evidence only and does not supersede NIST's publication.

## Primary Threats

| Threat | Impact | Current Control | Required Hardening |
| --- | --- | --- | --- |
| Unauthorized management write | Route takeover or false blocking | `X-Admin-Token` write gate; multi-token RBAC with actor labels and readonly role; audit log for successful writes | SSO/OIDC, mTLS or identity proxy, SCIM |
| Unauthorized management write | Route takeover or false blocking | `X-Admin-Token` write gate; multi-token RBAC with actor labels and readonly role; fail-closed startup on non-loopback bind without a write-capable principal; `401` vs `403` without revealing the expected role; audit log for successful writes | SSO/OIDC, mTLS or identity proxy, SCIM |
| Malicious threat feed import | False positives or broad blocks | Validation, route-scoped enforcement | Source signing, feed confidence, staged promotion |
| State file corruption | Startup failure or stale policy | JSON parse failure surfaces startup error | Database, backup, schema migration |
| Upstream SSRF through routes | Internal network exposure | Upstream scheme validation | Upstream allowlists, egress policy |
Expand All @@ -33,3 +42,11 @@
## Human Approval Boundary

AI SOC recommendations may explain, summarize, or suggest actions, but enforcement-changing decisions must remain human-approved until audit trails, rollback, and policy simulation are implemented.

## References

Barker, E. (2020). *Recommendation for key management: Part 1 - General* (NIST SP 800-57 Part 1 Rev. 5). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-57pt1r5

Grassi, P. A., Garcia, M. E., & Fenton, J. L. (2020). *Digital identity guidelines: Authentication and lifecycle management* (NIST SP 800-63B). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-63b

National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) version 1.1* (NIST SP 800-218). https://doi.org/10.6028/NIST.SP.800-218
70 changes: 67 additions & 3 deletions fuzz/fuzz_targets/fuzz_parse_admin_tokens.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,71 @@
#![no_main]
//! Fuzz the admin-token config parser: `waf_ids_ai_soc::parse_admin_tokens`.
//! Fuzz the admin-token config parser: `waf_ids_ai_soc::parse_admin_tokens`
//! and the strict startup mirror.
//!
//! This parses the `ADMIN_TOKENS` operator config string
//! (`token:actor[:role],...`) into an RBAC principal map. Malformed or
//! adversarial config must never panic, and the parser's structural invariants
//! must hold for every input:
//! * no empty token key ever ends up in the map;
//! * every actor value is non-empty (defaults to "admin").
//! * every actor value is non-empty (defaults to "admin");
//! * the strict startup parser either rejects ambiguous input or yields the
//! same non-empty token/actor invariants;
//! * strict startup always rejects duplicate secrets, blank list entries, and
//! unknown roles, matching the stable proptest mirror;
//! * accepted write/readonly aliases preserve their authorization semantics.

use libfuzzer_sys::fuzz_target;
use waf_ids_ai_soc::parse_admin_tokens;
use std::fmt::Write as _;
use waf_ids_ai_soc::{parse_admin_tokens, parse_admin_tokens_strict};

fuzz_target!(|data: &[u8]| {
// Derive a header-safe token from arbitrary bytes before UTF-8 decoding so
// every libFuzzer execution reaches the deterministic security invariants.
// The arbitrary parser path below remains limited to valid UTF-8 because
// ADMIN_TOKENS is a string-valued configuration contract.
let mut seed = String::from("fuzz");
for byte in data.iter().take(8) {
write!(&mut seed, "{byte:02x}").expect("writing to String cannot fail");
}

let duplicate = format!("{seed}:alice,{seed}:bob");
assert!(
parse_admin_tokens_strict(&duplicate).is_err(),
"strict startup must reject duplicate secrets"
);

let blank_entry = format!("{seed}:alice,,other:bob");
assert!(
parse_admin_tokens_strict(&blank_entry).is_err(),
"strict startup must reject blank list entries"
);

let unknown_role = format!("{seed}:alice:not-a-role");
assert!(
parse_admin_tokens_strict(&unknown_role).is_err(),
"strict startup must reject unknown roles"
);

let writer = format!("{seed}:alice:operator");
let writer_tokens =
parse_admin_tokens_strict(&writer).expect("operator role must remain accepted");
assert!(
writer_tokens
.get(&seed)
.is_some_and(|principal| principal.can_write),
"operator role must remain write-capable"
);

let reader = format!("{seed}:alice:readonly");
let reader_tokens =
parse_admin_tokens_strict(&reader).expect("readonly role must remain accepted");
assert!(
reader_tokens
.get(&seed)
.is_some_and(|principal| !principal.can_write),
"readonly role must remain non-writing"
);

let Ok(raw) = std::str::from_utf8(data) else {
return;
};
Expand All @@ -24,4 +78,14 @@ fuzz_target!(|data: &[u8]| {
"actor value must never be empty"
);
}

if let Ok(tokens) = parse_admin_tokens_strict(raw) {
for (token, principal) in &tokens {
assert!(!token.is_empty(), "strict token key must never be empty");
assert!(
!principal.actor.is_empty(),
"strict actor value must never be empty"
);
}
}
});
16 changes: 13 additions & 3 deletions scripts/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP_DIR="$(mktemp -d)"
STATE_FILE="$TMP_DIR/state.json"
LOG_FILE="$TMP_DIR/server.log"
ADMIN_TOKEN_VALUE="dev-secret"
ADMIN_TOKEN_VALUE="$(python3 - <<'PY'
import secrets
print(secrets.token_hex(16))
PY
)"
PORT="$(python3 - <<'PY'
import socket
s = socket.socket()
Expand All @@ -27,10 +31,14 @@ cleanup() {
trap cleanup EXIT

start_server() {
# Compile before the health wait so rustc time is not counted as a hang.
cargo build --quiet --manifest-path "$ROOT_DIR/Cargo.toml"
(
cd "$ROOT_DIR"
BIND_ADDR="127.0.0.1:$PORT" \
ADMIN_TOKEN="$ADMIN_TOKEN_VALUE" \
ADMIN_TOKENS= \
WAF_IDS_CREDENTIALS_PATH= \
WAF_IDS_STATE_PATH="$STATE_FILE" \
DNSBL_ORIGIN="dnsbl.test" \
EVENT_LIMIT="5" \
Expand Down Expand Up @@ -77,6 +85,8 @@ assert_json_field "$health" 'data["status"] == "ok"'
assert_json_field "$health" 'data["persistence"] == "file"'
assert_json_field "$health" 'data["dnsbl_origin"] == "dnsbl.test"'
assert_json_field "$health" 'data["event_limit"] == 5'
assert_json_field "$health" 'data["admin_auth_configured"] is True'
assert_json_field "$health" 'data["auth_mode"] == "production"'

curl -fsS "$BASE_URL/admin" | grep -q "ContextualWisdomLab WAF/IDS/AI SOC Gateway"

Expand Down Expand Up @@ -187,7 +197,7 @@ assert_json_field "$support_bundle" 'data["kpis"]["fresh_threat_feed_count"] ==
assert_json_field "$support_bundle" 'data["audit_log_count"] >= 3'
assert_json_field "$support_bundle" 'data["threat_feed_freshness"][0]["stale"] is False'

audit_logs="$(curl -fsS "$BASE_URL/api/audit-logs")"
audit_logs="$(curl -fsS -H "x-admin-token: $ADMIN_TOKEN_VALUE" "$BASE_URL/api/audit-logs")"
assert_json_field "$audit_logs" 'any(log["action"] == "upsert_route" and log["resource_id"] == "block" for log in data)'
assert_json_field "$audit_logs" 'any(log["action"] == "update_commercial_license" and log["resource_id"] == "cwlab-enterprise" for log in data)'
assert_json_field "$audit_logs" 'any(log["action"] == "import_threat_feed" and log["resource_id"] == "misp-seoul" for log in data)'
Expand All @@ -212,7 +222,7 @@ license="$(curl -fsS "$BASE_URL/api/commercial/license")"
assert_json_field "$license" 'data["license_status"] == "active"'
feeds="$(curl -fsS "$BASE_URL/api/threat-feeds")"
assert_json_field "$feeds" 'len(data) == 1'
audit_logs="$(curl -fsS "$BASE_URL/api/audit-logs")"
audit_logs="$(curl -fsS -H "x-admin-token: $ADMIN_TOKEN_VALUE" "$BASE_URL/api/audit-logs")"
assert_json_field "$audit_logs" 'len(data) >= 3'

echo "smoke ok: $BASE_URL with state $STATE_FILE"
Loading
Loading