Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
cd063cc
fix(auth): fail closed without write-capable admin on public bind
seonghobae Aug 23, 2026
300ec4e
docs: record PR #94 in the product-technical gap baseline
seonghobae Aug 23, 2026
5baab54
feat(security): fail-closed destination policy for outbound HTTP
seonghobae Aug 23, 2026
e0892be
feat(waf): enforce proven-engine payloads for any client IP
seonghobae Aug 23, 2026
868a7e5
chore(pr): remove unrelated destination and WAF drift
seonghobae Aug 23, 2026
c1b0372
test(auth): cover credential comparison and blank path regressions
seonghobae Aug 23, 2026
b69a22e
fix(auth): harden credential path and comparison invariants
seonghobae Aug 23, 2026
96ca869
test(startup): prove state validation precedes readiness
seonghobae Aug 23, 2026
b9daeb5
fix(auth): generate a per-process smoke-test admin token
seonghobae Aug 23, 2026
f31d960
fix(startup): validate persisted state before reporting readiness
seonghobae Aug 23, 2026
ee72b1e
test(fuzz): cover strict admin token parser
seonghobae Aug 24, 2026
75e950c
docs(gaps): correct snapshot and admin credential boundary
seonghobae Aug 24, 2026
b150132
docs(a11y): remove shared token example and correct target-size claims
seonghobae Aug 24, 2026
5a62909
docs(a11y): make target-size evidence fail closed
seonghobae Aug 24, 2026
e811a0b
test(auth): reject whitespace-only admin token bootstrap
seonghobae Aug 24, 2026
5014129
fix(auth): normalize whitespace-only admin credentials
seonghobae Aug 24, 2026
341a385
fix(ci): apply rustfmt to credential regression test
seonghobae Aug 25, 2026
9999e00
Merge branch 'main' into fix/issue-78-fail-closed-credentials
seonghobae Aug 26, 2026
2b2ed4e
Merge branch 'main' into fix/issue-78-fail-closed-credentials
seonghobae Aug 26, 2026
7c6dc3d
Merge branch 'main' into fix/issue-78-fail-closed-credentials
seonghobae Aug 26, 2026
e2d5c91
fix(auth): reject explicit blank file credentials
seonghobae Aug 26, 2026
094f653
fix(auth): require literal loopback bind addresses
seonghobae Aug 26, 2026
d7fa9a1
docs(auth): clarify numeric loopback exception
seonghobae Aug 26, 2026
2b1ab2d
Merge branch 'main' into fix/issue-78-fail-closed-credentials
seonghobae Aug 30, 2026
0d2c495
merge(main): keep fail-closed public-bind auth with KEV catalog
seonghobae Aug 31, 2026
7b13e0e
fix(auth): preserve state and write-gate semantics
codex Aug 31, 2026
a0cbcd5
docs(auth): clarify blank bootstrap path semantics
codex Aug 31, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/target
/waf-ids-state*.json
/runtime-state*.json
/.codegraph
2 changes: 1 addition & 1 deletion AGENTS.md
Comment thread
seonghobae marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Cross-agent conventions for any agent (Claude, Codex, Cursor, opencode, …) wor

### Code exploration

- There is no `.codegraph/` index in this repo, so use normal search (grep/ripgrep, `cargo` tooling, editor navigation). If a `.codegraph/` index is added later, prefer CodeGraph (`codegraph explore "<query>"` or the code-review-graph MCP tools) before grep/find — it surfaces callers/callees/impact that text search misses.
- A local `.codegraph/` index may exist at the repo root (gitignored, not committed). If it is present, prefer CodeGraph (`codegraph explore "<query>"` or the code-review-graph MCP tools) before grep/find — it surfaces callers/callees/impact that text search misses. If it is absent, use grep/`cargo`/editor navigation, and `codegraph init` is permitted. If `codegraph status` reports an unhealthy index, run `codegraph sync`.

### Config & secrets (KV, not env)

Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Changelog

All notable changes to this project are documented in this file.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Security

- Fail closed before readiness when `BIND_ADDR` is not loopback-only and no write-capable admin principal is configured (`ADMIN_TOKEN`, `ADMIN_TOKENS`, or `WAF_IDS_CREDENTIALS_PATH`). Loopback development may still start without a token and reports `auth_mode=development` on `/healthz`.
- A blank `WAF_IDS_STATE_PATH` is treated as in-memory state instead of becoming ready and then failing to replace an empty path.
- Management writes now distinguish `401` (unauthenticated) from `403` (authenticated, not permitted to write) without naming the expected role.
- Presented admin secrets are compared in constant time, including when lengths differ by a multiple of 256. Duplicate, blank, and unknown `ADMIN_TOKENS` roles fail startup. A blank `WAF_IDS_CREDENTIALS_PATH` is treated as unset.
- `scripts/smoke.sh` generates a per-process admin token instead of embedding a shared secret (CWE-798).

### Documentation

- Product/technical gap baseline at `docs/product-technical-gap-baseline.md` (open PRs/Issues inventory, operator-perceptible gaps, Figma file IDs, UI-UX areas).
- File://-openable admin-console scene and edge-case inventory at `docs/ui-ux/storybook-scene-inventory.md`.
- Figma file IDs recorded in `docs/adr/0001-figma-and-design-system.md` and `docs/architecture.md`.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ The core stays an in-repo workspace crate on purpose (no git submodule) until it

## Runtime Configuration

Read in `run_from_env` (`src/lib.rs`): `BIND_ADDR` (default `127.0.0.1:8080`), `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.
Read in `run_from_env` (`src/lib.rs`): `BIND_ADDR` (default `127.0.0.1:8080`; non-loopback requires a write-capable admin principal before bind), `WAF_IDS_STATE_PATH` (optional JSON state file; omitted or blank = 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; omitted or blank = unset; file values take precedence over environment values for the same key), `ADMIN_TOKEN` (bootstrap transport for the shared write token), and `ADMIN_TOKENS` (bootstrap transport for comma-separated `token:actor[:role]` RBAC entries). `ADMIN_TOKEN` and `ADMIN_TOKENS` are loaded into `CredentialRegistry` before the server starts; handlers read the in-process registry/AppState copy, not raw env vars. KEV imports use the built-in CISA endpoint at runtime; only in-crate tests can override it through `AppState::with_kev_catalog_url` to point at a loopback mock server.

## Key Conventions

Expand Down
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`
- `ADMIN_TOKEN`: write token for management writes via `X-Admin-Token`. Optional only for numeric loopback binds (`127.0.0.0/8` or `::1`). Hostnames such as `localhost` fail closed because Wardnet cannot prove before binding that every resolved address is loopback. Required before readiness on any other `BIND_ADDR` (`0.0.0.0`, `::`, LAN, public).
- `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
40 changes: 40 additions & 0 deletions docs/adr/0001-figma-and-design-system.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# ADR 0001 — Figma design-system source and embedded admin console

Status: accepted
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Date: 2026-08-23

## Context

Wardnet ships an operator console as vanilla HTML/CSS/JS embedded in the Rust
binary (`ADMIN_HTML` in `src/lib.rs`, served at `GET /` and `/admin`). Repeating
objects (KPI tiles, cards, tables, badges, buttons, forms, toasts) must stay
token-based. A Node Storybook toolchain cannot be loaded by that console without
a separate static site.

## Decision

- Canonical design-system tokens live in CSS custom properties on `:root`
(`docs/design-system.md` matches the running `/admin` CSS).
- Figma is the visual mirror, not a runtime dependency. **Figma Code Connect is
not used** (repo `AGENTS.md`).
- Record file IDs here so operators and agents can open the same files.

## Figma file IDs

| Artifact | File ID | URL |
| --- | --- | --- |
| Design system / console frames | `QTH5UuU0FJv2VyM2xb02Fp` | https://www.figma.com/design/QTH5UuU0FJv2VyM2xb02Fp |
| Enterprise product architecture FigJam | `JExziD87eUWKLERECUGhWQ` | https://www.figma.com/board/JExziD87eUWKLERECUGhWQ |

## Scene and edge-case events

Scene-by-scene and edge-case event definitions for the ten UI-UX areas live in
`docs/ui-ux/storybook-scene-inventory.md`, which opens from disk (`file://`)
without a Node Storybook server. That inventory is the Storybook-equivalent
contract for this embedded-console architecture.

## Consequences

- Token changes must land in `ADMIN_HTML` and `docs/design-system.md` together.
- Do not add a frontend framework to `/admin`; it would break the
binary-embedded load path used by `scripts/smoke.sh`.
11 changes: 11 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ flowchart LR

- Default bind address is localhost.
- Remote management requires `ADMIN_TOKEN` plus external TLS and identity controls.
- Non-loopback listeners fail closed before readiness unless a write-capable admin principal is configured.
- `WAF_IDS_STATE_PATH` enables JSON state persistence for standalone operation. Without it, the service uses seeded in-memory state.
- File-backed writes use temporary sibling files followed by atomic rename. Management API mutations roll back in memory if the state file cannot be replaced.
- Block mode is route-scoped to avoid global accidental enforcement.
Expand All @@ -68,7 +69,17 @@ flowchart LR

## Product Architecture Evidence

- Figma design-system file ID: `QTH5UuU0FJv2VyM2xb02Fp` (see `docs/design-system.md` and `docs/adr/0001-figma-and-design-system.md`)
- FigJam architecture file ID: `JExziD87eUWKLERECUGhWQ` (`docs/figma/enterprise-product-architecture.md`)
- FigJam: `docs/figma/enterprise-product-architecture.md`
- Product workflows: `docs/product-design/enterprise-operator-workflows.md`
- Enterprise scorecard: `docs/analytics/enterprise-value-scorecard.md`
- Complexity audit: `docs/ponytail/2026-07-02-complexity-audit.md`
- UI-UX scene / edge-case inventory (file://-openable): `docs/ui-ux/storybook-scene-inventory.md`
- Product/technical gap baseline: `docs/product-technical-gap-baseline.md`

## Security Boundaries (credentials)

- Default bind address is loopback. Loopback-only development may start without an admin token and reports `auth_mode=development` on `/healthz`.
- Any non-loopback `BIND_ADDR` fails closed before readiness unless a write-capable principal is present in the credential registry (`ADMIN_TOKEN`, `ADMIN_TOKENS`, or `WAF_IDS_CREDENTIALS_PATH`).
- Management writes return `401` when unauthenticated and `403` when authenticated but not permitted to write. Response bodies do not name the expected role.
2 changes: 1 addition & 1 deletion docs/deployment/production.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ kubectl apply -f deploy/kubernetes/waf-ids-ai-soc.yaml
- 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 `ADMIN_TOKEN` in a secret manager. The process **will not become ready** on `BIND_ADDR=0.0.0.0:8080` (or any non-loopback address) if no write-capable credential is configured. That is intentional fail-closed behavior (issue #78). Recovery: inject the Secret, restart; do not disable the gate.
- 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.
21 changes: 17 additions & 4 deletions docs/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@ carried by token swaps only, no component markup changes between modes.
| `--fs-metric` | 28px/700 | KPI tile value |
| `--radius` | 8px | cards, inputs (6px), badges (pill) |

Controls (`button`, `input`, `select`) are `min-height: 44px` (WCAG 2.5.5 target size).
Primary form controls (`button`, `input`, `select`) use `min-height: 44px` as a
Wardnet ergonomic floor. **Height alone is not target-size conformance evidence.**
WCAG 2.2 SC 2.5.8 Target Size (Minimum), Level AA, requires a pointer target to be
at least 24 × 24 CSS pixels or satisfy one of the criterion's specified exceptions,
including its spacing rule. SC 2.5.5 Target Size (Enhanced), Level AAA, requires
44 × 44 CSS pixels except its specified exceptions. Do not claim SC 2.5.5 from the
44px height rule unless both axes of the actual target/hit area have been measured.

## Components

Expand Down Expand Up @@ -87,7 +93,7 @@ Each entry: **anatomy · states · usage · a11y · data**.

### Button
- **Variants** `btn-primary` (brand fill — one primary action per form), `btn-secondary` (bordered, on surface), `btn-ghost` (in the brand header).
- **States** default / `:focus-visible` ring / `aria-pressed` (toggle). 44px min.
- **States** default / `:focus-visible` ring / `aria-pressed` (toggle). Runtime CSS guarantees a 44px minimum height; target width/hit area must still be verified before making a WCAG target-size claim.

### Form field
- **Anatomy** `label.field` wrapping caption + control + optional `.field-help`.
Expand Down Expand Up @@ -124,7 +130,8 @@ First tab stop, off-screen until focused, jumps to `#main`.
## Accessibility checklist (per screen)

- [ ] All text pairs ≥ 4.5:1 (see table); non-text state has a text label too.
- [ ] Every control ≥ 44×44 and reachable by keyboard with a visible focus ring.
- [ ] Every pointer target is verified against WCAG 2.2 SC 2.5.8: at least 24 × 24 CSS px or a documented applicable exception/spacing result. Important controls should aim for 44 × 44 CSS px; SC 2.5.5 Level AAA is not claimed from height alone.
- [ ] Every interactive control is reachable by keyboard with a visible focus ring.
- [ ] Tables use `<table>/<th scope>`; forms use wrapping `<label>`.
- [ ] Live regions announce KPI refresh and write results.
- [ ] High Contrast mode usable (borders `#000`, text ≥ 17:1).
Expand All @@ -144,4 +151,10 @@ never inline hex on an element; put it in a token first.
A single admin-token field in the header authorizes writes (`X-Admin-Token`);
server validation is surfaced verbatim via toast.
- Single brand; only Default + High Contrast themes (no dark, no density).
- No automated a11y/visual regression test yet; contrast is verified manually.
- No automated accessibility/visual target-size regression test yet; contrast is verified manually and target-size claims remain fail-closed until measured.

## Standards evidence (APA 7)

World Wide Web Consortium. (n.d.). *Understanding Success Criterion 2.5.8: Target Size (Minimum).* Retrieved August 25, 2026, from https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html

World Wide Web Consortium. (n.d.). *Understanding Success Criterion 2.5.5: Target Size (Enhanced).* Retrieved August 25, 2026, from https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html
60 changes: 60 additions & 0 deletions docs/doctoring/fail-closed-management-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Doctoring — fail-closed management authentication

This note grounds the issue #78 implementation (non-loopback listeners refuse to
become ready without a write-capable admin principal; `401` vs `403`;
constant-time secret compare). IEEE PDFs are not redistributed; freely licensed
standards are cited by URL.

## 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 — a missing access rule is deny, not
allow. Wardnet previously treated an empty credential registry as “auth
disabled” for management writes. That violates fail-safe defaults as soon as
`BIND_ADDR` is not loopback-only. Startup now refuses readiness in that case.

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

- **Design impact:** ASVS V6 authentication and V4 access control require
authentication for administrative functions and distinct authorization
outcomes. Management writes use `401` when no valid principal is presented and
`403` when a readonly principal attempts a mutation. Bodies do not name 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:** PW.1 / PW.5 — produce well-secured software and protect
authentication data. Secrets bootstrap into `CredentialRegistry`; health
exposes `credentials_source` and `auth_mode` labels only.

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, license, and feeds are critical functions. The CWE-306 anti-pattern is
“auth optional on a reachable listener.” The shipped gate is
`require_write_auth_for_bind` in `src/credentials.rs`, invoked from
`run_from_env` before `TcpListener::bind`.

## Exact-head binding

| Decision | Implementation |
| --- | --- |
| Fail closed on public bind | `require_write_auth_for_bind` + `run_from_env` |
| Loopback development remains usable | `listen_is_loopback_only`; `/healthz.auth_mode=development` |
| 401 vs 403 | `reject_management_write` |
| Constant-time compare | `constant_time_eq` mixes a length-inequality flag, not `(len ^ len) as u8` |
| Blank credentials path | empty/whitespace `WAF_IDS_CREDENTIALS_PATH` is unset |
| Smoke-test token | `scripts/smoke.sh` generates a per-process secret (CWE-798) |
| Ambiguous token registry | `parse_admin_tokens_strict` (duplicate / blank / unknown role) |

PII is **not** masked on security events: SOC operators cannot do their job if
client IPs, paths, and indicator values are redacted. Access control, audit,
and encryption-at-rest (when a durable store lands) are the alternatives to
masking. See `docs/product-technical-gap-baseline.md`.
2 changes: 1 addition & 1 deletion docs/fuzzing.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ entry points for arbitrary input.
| --------------------------- | ------------------------------------------- | ---------- |
| `fuzz_score_request` | `waf_ids_core::score_request` | no panic on arbitrary path/query/body/IP; `reason` never empty; scoring deterministic |
| `fuzz_appdata_json` | `serde_json::from_str::<AppData>` (state file) | no panic; parsed values round-trip through serde |
| `fuzz_parse_admin_tokens` | `waf_ids_ai_soc::parse_admin_tokens` | no panic; no empty token key; no empty principal actor value |
| `fuzz_parse_admin_tokens` | `waf_ids_ai_soc::parse_admin_tokens` / `parse_credentials_json` | no panic; no empty token key or actor; present blank/null file credentials are rejected instead of falling back to environment transport |
| `fuzz_dnsbl_zone` | `waf_ids_core::export_dnsbl_zone` / `validate_dnsbl` | no panic; every TXT payload fully escaped (no zone break-out); every published A-record response code is an IPv4 loopback literal (127.0.0.0/8) |

## Layout
Expand Down
Loading
Loading