Plan: Security Culture Hardening

On this page

Status

COMPLETE — archived 2026-04-17 (plan-hygiene sweep). All six security features are shipped and active in the pre-push battery. The one residual (SBOM-on-release-tags in CI) is tracked as a standalone follow-up issue (see Errata below).

Step Description Status

1

Plan document + GitLab issue + branch

Done (pre-ADR-030)

2

cargo-deny in pre-push validate

Done (pre-ADR-030) — xtask/src/cmd/validate.rs runs cargo deny check as step 8/14

3

Content-Security-Policy headers

Done (pre-ADR-030) — crates/craig-api/src/lib.rs sets default-src 'none'; frame-ancestors 'none' on all 8 API services; services/craig-web/src/main.rs sets web-UI CSP (Alpine.js allowance)

4

API rate limiting via shared middleware

Done (pre-ADR-030) — crates/craig-api/src/rate_limit.rs with RATE_LIMIT_RPM env var (default 600, 0=disabled); 4 unit tests

5

gitleaks secret scanning in pre-push

Done (pre-ADR-030) — soft-fail integration in xtask/src/cmd/validate.rs

6

SBOM generation on release tags

Done (pre-ADR-030) — partial — xtask/src/sbom.rs implements cargo xtask sbom generating CycloneDX JSON; release-tag CI wiring tracked as #178

7

Security regression tests in pre-push

Done (pre-ADR-030) — xtask/src/cmd/security.rs phases 2-4 (auth boundary / injection / infra) run after k6 smoke, ~5 seconds

8

Unit tests for new code

Done (pre-ADR-030) — rate_limit has 4 tests; cargo-deny and gitleaks are CLI tools (no unit tests needed); sbom generator tested via E2E xtask run

9

Documentation updates + verification

Done (pre-ADR-030) — .claude/docs/security.md, .claude/docs/testing.md reflect shipped state; CHANGELOG.adoc entries under == Unreleased cover CORS hardening, HTML sanitizer, dead code removal, encrypted session cookies

Issues: #168 (closed as the work shipped across multiple MRs)
Branch: feature/security-culture-hardening (unused — work landed incrementally)

Context

CRAIG passes ZAP DAST + ffuf with 0 High/Medium/Low findings. The architecture prevents most vulnerability classes at compile time (forbid unsafe, parameterized queries, strum enums, strict serde). This plan addresses 5 remaining gaps to prevent regressions, catch supply chain issues, and add defense-in-depth.

4 items from the original 9-item audit are already done: dependency pinning (Cargo.lock committed + --locked), signed commits (validate enforces gpgsign), security scan persistence (test-results/security/ .gitignored), and Cargo.lock management.

Scope

In scope:

  • Content-Security-Policy header on all services + craig-web

  • Per-IP rate limiting on all 8 API services via shared craig-api middleware

  • cargo-deny license/advisory check in pre-push validate step

  • gitleaks secret scanning in pre-push (soft-fail if not installed)

  • SBOM (CycloneDX) generation on release tags + local xtask command

  • Security regression tests (phases 2-4) in pre-push hook

Out of scope:

  • Shared cross-instance rate limiting (would need Redis — deferred)

  • CSP nonce-based script loading (would require refactoring Alpine.js usage)

  • Signed SBOM attestation (deferred to production deployment)

Potential Improvements

  • Trusted proxy IP extraction (#169): Rate limiter uses X-Forwarded-For which can be spoofed. Production deployments behind a reverse proxy should use ConnectInfo (peer address) or configure trusted proxy IPs.

  • CSP nonce-based script-src: Replace 'unsafe-inline' with per-request nonces for Alpine.js scripts. Requires template changes to inject nonce on every <script> tag.

  • Shared rate limiting: Redis-backed rate limiter for horizontal scaling — all instances share the same quota per IP. Deferred to ADR-012 (service modularity) timeline.

  • SBOM attestation: Sign SBOM with cosign/sigstore for supply chain verification. Required for FedRAMP.

Errata

  • Step 4 deviation: Plan specified moving intake’s rate_limit.rs to craig-api as a shared module. Instead, created a new separate rate_limit.rs in craig-api. Reason: intake’s rate limiter is per-hour for unauthenticated public endpoints; craig-api’s is per-minute for authenticated API endpoints. Different quotas, different IP extraction strategies. Keeping them separate is cleaner.

Edit this page · latest