Skip to content
Merged
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
eb2a99d
feat(security): fail-closed destination policy for outbound HTTP
seonghobae Aug 23, 2026
cf8adf1
docs: record PR #96 in the product-technical gap baseline
seonghobae Aug 23, 2026
5aca11d
feat(security): harden destination policy per-IP CIDR and readiness o…
seonghobae Aug 23, 2026
7cacaf1
feat(security): pin outbound HTTP to evaluated destination addresses
seonghobae Aug 23, 2026
4b13313
feat(waf): evaluate live gateway transactions with in-process libcoraza
seonghobae Aug 23, 2026
d22ad23
docs: record PR #97 in the product-technical gap baseline
seonghobae Aug 23, 2026
ea62198
feat(store): require PostgreSQL as the production control plane
seonghobae Aug 23, 2026
3a19ed9
feat(store): transactional outbox and leased workers
seonghobae Aug 23, 2026
02ca9b9
feat(store): rustls for production PostgreSQL sslmode=require (#100)
seonghobae Aug 23, 2026
f3ea6ba
feat(store): optimistic concurrency on postgres snapshots
seonghobae Aug 23, 2026
0812630
docs: record PR #105 in the product-technical gap baseline
seonghobae Aug 23, 2026
f84ece0
fix(store): keep postgres snapshot_version aligned after startup save
seonghobae Aug 23, 2026
5516d38
feat(store): outbox consumers for TAXII, Clearfolio, and orchestrator
seonghobae Aug 23, 2026
18fd115
docs: record PR #106 in the product-technical gap baseline
seonghobae Aug 23, 2026
6831fbc
Merge pull request #106 from ContextualWisdomLab/feat/issue-81-outbox…
seonghobae Aug 25, 2026
6a31ef0
feat(release): tagged GitHub Release with SHA-256 and immutable GHCR …
seonghobae Aug 26, 2026
41e7047
Merge updated PR #95 base into optimistic concurrency stack
seonghobae Aug 26, 2026
f92f3aa
fix(control-plane): close OCC and credential race gaps
seonghobae Aug 26, 2026
935b971
fix(release): capture pushed image digest
seonghobae Aug 26, 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
132 changes: 132 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
name: Release

on:
push:
tags:
- "v*.*.*"

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 binary and binary SBOM
run: |
mkdir -p dist
cp target/release/waf-ids-ai-soc dist/waf-ids-ai-soc-linux-x86_64
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 '{for (i=1;i<=NF;i++) if ($i ~ /^sha256:/) d=$i} 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 \
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Security

- 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.


- PostgreSQL `security_event` is HASH-partitioned by `tenant_id` (8 children). Unpartitioned tables convert in place and keep unmasked client IPs and paths. `/healthz.event_partitions` reports the child count (0 on file/memory). Logical restore still accepts schema 2 through the current migration version; HASH does not change the snapshot shape.
- PostgreSQL snapshot persist is optimistic-concurrency: `tenant_account.snapshot_version` must match the loaded token or the write returns a snapshot conflict (HTTP 409). Restores overwrite. File/memory adapters stay single-writer.
- PostgreSQL control-plane runtime is `wardnet_runtime` (NOSUPERUSER, NOBYPASSRLS, not the table owner). Migrations run as the login role, then `SET ROLE` so FORCE RLS binds even when the URL user is a superuser. Missing `wardnet.tenant_id` yields no rows. DDL (`DROP TABLE`, `DISABLE ROW LEVEL SECURITY`) is denied. Logical restore accepts schema 2 through the current migration version so a role-only upgrade cannot void the last pre-upgrade backup.
- PostgreSQL control-plane logical backup and isolated restore drill (issue #80 remainder). `GET /api/backup` exports a hashed tenant snapshot (policy, events, outbox, receipts). `POST /api/backup` restores after schema and payload-hash checks. `POST /api/backup/drill` restores into an isolated tenant, compares unmasked invariants, and drops the drill tenant. Declared RPO is the last successful export; declared RTO is 60 seconds. File/memory adapters report `/healthz.backup=disabled`. Client IPs, paths, and actor names stay unmasked.
- PostgreSQL control-plane mutations enqueue a transactional outbox row in the same transaction (issue #81). Security events append incrementally instead of rewriting the snapshot. A leased worker claims with `FOR UPDATE SKIP LOCKED`, retries with bounded backoff, dead-letters exhausted/permanent failures, and records unique receipts. Stdout SIEM export is at-least-once; the receipt is the exactly-once ack. `/healthz.outbox` and `GET /api/outbox` are operator-visible; `POST /api/outbox/{id}/replay` requeues dead letters with audit. File/memory adapters report `outbox=disabled`. `GET /api/outbox` is bounded to `EVENT_LIMIT` (dead letters and pending first). Processed `outbox_message` rows are pruned to that same cap on append, snapshot save, and worker ack; receipts stay as the exactly-once ack.
Expand Down
21 changes: 21 additions & 0 deletions crates/waf-ids-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ pub struct AppData {
pub commercial: CommercialProfile,
#[serde(default)]
pub threat_feeds: Vec<ThreatFeedStatus>,
/// Postgres optimistic-concurrency token. File/memory ignore it.
#[serde(default, skip)]
pub snapshot_version: u64,
Comment thread
seonghobae marked this conversation as resolved.
}

impl AppData {
Expand Down Expand Up @@ -56,6 +59,7 @@ impl AppData {
next_audit_log_id: 1,
commercial: CommercialProfile::seeded(),
threat_feeds: Vec::new(),
snapshot_version: 0,
}
}
}
Expand Down Expand Up @@ -1123,12 +1127,16 @@ pub fn commercial_readiness_snapshot_at(data: &AppData, now_unix: u64) -> Commer
"Dockerfile".to_string(),
"deploy/docker-compose.yml".to_string(),
"deploy/kubernetes/waf-ids-ai-soc.yaml".to_string(),
".github/workflows/release.yml".to_string(),
],
buyer_evidence: vec![
"docs/commercial/20b-krw-sale-readiness.md".to_string(),
"docs/commercial/buyer-due-diligence.md".to_string(),
"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(),
],
}
}
Expand Down Expand Up @@ -1165,6 +1173,9 @@ pub fn buyer_evidence_manifest_at(data: &AppData, now_unix: u64) -> BuyerEvidenc
"docs/product-design/enterprise-operator-workflows.md".to_string(),
"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,
}
Expand Down Expand Up @@ -1731,4 +1742,14 @@ mod tests {
assert_eq!(data.next_audit_log_id, 3);
assert_eq!(data.audit_logs[0].resource_id, "edge");
}

#[test]
fn snapshot_version_is_runtime_only_in_serialized_state() {
let mut data = AppData::seeded();
data.snapshot_version = 42;
let json = serde_json::to_string(&data).unwrap();
assert!(!json.contains("snapshot_version"));
let restored: AppData = serde_json::from_str(&json).unwrap();
assert_eq!(restored.snapshot_version, 0);
}
}
5 changes: 3 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,13 @@ flowchart LR

- `src/main.rs`: process startup and operator configuration from `BIND_ADDR`, `ADMIN_TOKEN`, `WAF_IDS_STATE_PATH`, `DNSBL_ORIGIN`, and `EVENT_LIMIT`.
- `src/lib.rs`: Axum app, routing, management APIs, optional JSON persistence, gateway handler, upstream proxying, admin console, support bundle assembly, NDJSON event export, and in-crate HTTP tests. Persistence, destination-list, and sidecar settings validate before the readiness line is printed.
- `src/control_plane.rs`: PostgreSQL production authority (issue #80). Non-loopback binds require `CONTROL_PLANE_DATABASE_URL`. Tenant isolation is default-deny RLS under `wardnet_runtime` (not superuser/owner). `sslmode=require` uses rustls. `security_event` is HASH-partitioned by `tenant_id` (`/healthz.event_partitions`). The JSON file adapter remains loopback/community only.
- `src/outbox.rs`: transactional outbox + leased workers (issue #81). Security events append incrementally with an outbox row in the same transaction. Workers claim with `SKIP LOCKED`. `GET /api/outbox` is bounded to `EVENT_LIMIT` (processed rows pruned; receipts kept). `/healthz.outbox` is operator-visible.
- `src/control_plane.rs`: PostgreSQL production authority (issue #80). Non-loopback binds require `CONTROL_PLANE_DATABASE_URL`. Tenant isolation is default-deny RLS under `wardnet_runtime` (not superuser/owner). Snapshot persist is optimistic-concurrency on `snapshot_version` (HTTP 409). `sslmode=require` uses rustls. `security_event` is HASH-partitioned by `tenant_id` (`/healthz.event_partitions`). The JSON file adapter remains loopback/community only.
- `src/outbox.rs`: transactional outbox + leased workers (issue #81). Security events append incrementally with an outbox row in the same transaction. Workers claim with `SKIP LOCKED`. `GET /api/outbox` is bounded to `EVENT_LIMIT` (processed rows pruned; receipts kept). `GET /api/outbox/{id}` returns receipt evidence for TAXII, Clearfolio, and SOC analysis consumers. `/healthz.outbox` is operator-visible. HTTP consumers release the PostgreSQL client lock before outbound I/O.
- `src/destination.rs`: fail-closed outbound URL policy (issue #79) for every `http`/`https` send. CIDR allowlist exceptions are per resolved address; blocking DNS is offloaded from Tokio workers. The outbound HTTP client DNS resolver returns only addresses that already passed policy (TCP peer pin / DNS-rebinding TOCTOU close).
- `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`: 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.
Expand Down
71 changes: 71 additions & 0 deletions docs/doctoring/ci-attack-evidence-battery.md
Original file line number Diff line number Diff line change
@@ -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 <id>`, 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.
9 changes: 6 additions & 3 deletions docs/doctoring/outbox-workers.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ the exactly-once ack. Dead letters are never pruned.

Loopback file/memory adapters keep in-process stdout SIEM and report
`outbox=disabled`. `security_event` HASH partitioning is on the PostgreSQL
plane. Remaining consumers: TAXII poll, Clearfolio, contextual-orchestrator
on the same message/receipt contract. Backup/restore drill is on the
PostgreSQL plane.
plane. TAXII poll, Clearfolio submit, and contextual-orchestrator analysis
use the same message/receipt contract on PostgreSQL (`GET /api/outbox/{id}`
for receipt evidence). HTTP dispatch releases the database lock for the
outbound call. Inline TAXII secrets are rejected on the durable path;
`taxii_bearer` is a credential-registry secret. Backup/restore drill is on
the PostgreSQL plane.
Loading
Loading