Spec: F39 — Security Audit & Hardening
Status: shipped
1. Problem & CEO Job
Job: Trust the signal — but trust is impossible if the product ships with unrevoked secrets in git history, no brute-force protection on login, no Content-Security-Policy, and no continuous vulnerability scanning in CI. A single breach destroys the company’s reason to exist. This spec captures the full security audit as a living document: what’s broken, what’s missing, what’s fixed, and what’s deferred with a reason.
2. User Stories & Acceptance Criteria
-
As a security-conscious CEO, I want my account protected against brute-force login attacks, so that an attacker can’t guess my password over thousands of attempts.
-
django-axesis fully integrated:INSTALLED_APPS,AxesMiddleware,AxesBackend, withAXES_FAILURE_LIMIT=5,AXES_COOLOFF_TIME=1,AXES_LOCKOUT_PARAMETERS=["username", "ip_address"]. - Failed logins to
/api/auth/login/are tracked and locked out after the configured limit. - Locked-out users see a clear error message with the cooldown time.
-
-
As a security auditor, I want a Content-Security-Policy deployed, so that XSS attacks are contained even if injection succeeds.
- CSP headers are sent on all responses (backend and frontend).
- CSP enforcement mode is active on the shipped product surface.
-
As a developer, I want vulnerability scanning in CI, so that we don’t merge dependencies with known CVEs.
- CI runs
pip-auditonbackend/pyproject.toml; fails on critical/high CVEs. (backend/requirements.txtwas deleted in F56 —pip-auditnow audits thepyproject.tomldependency manifest.) - CI runs
npm audit --audit-level=highon frontend; fails on high+ severity. - CodeQL analysis workflow runs only on
deploy/backend-prodpushes and pull requests.
- CI runs
-
As a compliance officer, I want comprehensive audit logging, so that every sensitive action is traceable.
- Create, update, login, logout, role changes, member invitations, and billing events are all logged via
write_audit_log. - Audit log entries include actor, action, target type, target ID, and metadata JSON.
- Create, update, login, logout, role changes, member invitations, and billing events are all logged via
-
As a security engineer, I want no secrets in the repository, so that source code access doesn’t compromise production.
- No APNs private key (
AuthKey_P8ZK4BPD78.p8) or equivalent production secret is committed to the repository or its git history. - All secrets are stored exclusively in AWS Secrets Manager / GitHub Secrets.
- No APNs private key (
2026-07-14 remediation acceptance criteria
The current dated audit supersedes the June sign-off. This tranche is complete only when each item has executable proof and the audit links it:
- Browser access tokens are memory-only, browser refresh credentials are HttpOnly/Secure/SameSite cookies, and the shipped Cloudflare CSP enforces.
- Password reset runs Django validators without consuming the reset token on validation failure.
- A refresh JTI is consumed atomically; concurrent reuse yields one session.
- Password change and reset revoke all previously issued refresh sessions.
- Production allowed hosts, CORS, and connector encryption fail closed when
required configuration is absent or unsafe.
/tmt/is unrestricted whenADMIN_ALLOWED_IPSis absent and Django authentication remains the only gate; a supplied allowlist restricts the route by CIDR. - ECS runtime secrets are Secrets Manager references, never ordinary task environment values.
- Pull-request code runs on isolated hosted runners; high/critical dependency, CodeQL, and image findings block before merge/deploy.
- Connector secrets are encrypted or one-way digested as appropriate; unsigned webhooks, OAuth-state replay, and unsafe outbound hosts are rejected.
- Sentry defaults to no PII and recursively scrubs protected request/user data.
- Browser Sentry ingestion is disabled for localhost and build-preview origins;
deployed dev traffic is tagged
development, and production release-health reports explicitly filterenvironment=production. - Android Sentry is disabled for debug and benchmark variants; dev release is
tagged
development, prod release is taggedproduction, and default PII, screenshots, view hierarchy, and SDK logs are disabled. - Worker deploys derive
devorprodfrom the exact backend deploy branch; adeploy/backend-devpush can never default to production secrets, image, or concurrency, and a pulled image must pass Django deployment checks against its deployed worker environment before either process is recreated. - AI-provider-bound text passes through the documented minimization and untrusted-content boundary, backed by adversarial evals.
- Public health exposes liveness only; operator detail requires staff auth.
3. Scope Boundaries
- In:
- Full security audit of backend, frontend, and deploy layers (see docs/security/SECURITY_AUDIT.md).
- Fix critical and high-severity findings.
- Track all findings with status, assignee, and target date in docs/security/STATUS.md.
- Continuous security posture updates — the audit doc is living, not a one-time report.
- Out:
- Penetration testing (hire external firm, not in this spec).
- SOC 2 / ISO 27001 certification (separate compliance effort).
- Third-party security review (separate engagement).
- Infrastructure-level security (VPC config, WAF rules, IAM auditing) — these live in deploy docs.
4. Data Model
No new backend models. django-axes creates axes_accessattempt and axes_accesslog tables.
5. API Contract
No new API endpoints. Changes to existing endpoints:
- Login endpoint (
POST /api/auth/login/): returns 429/403 on lockout (viadjango-axes). - All endpoints: return
Content-Security-Policyheaders in enforcement mode (viadjango-cspmiddleware + nginx headers).
6. UI / UX
No visual changes. Lockout error message via existing i18n system (auth.login.locked_out key).
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Login lockout after 5 failed attempts | backend | backend/tests/test_auth.py::TestBruteForceProtection |
| 2 | Lockout cooldown expires | backend | same class |
| 3 | CSP headers present on all responses | backend | backend/tests/test_security.py::TestCSPHeaders |
| 4 | CSP enforcement mode active (no report-only header) | backend | same class / config inspection |
| 5 | pip-audit CI step catches critical CVE | ci | .github/workflows/ci-be.yml |
| 6 | Audit log created on role change | backend | backend/tests/test_security.py |
| 7 | APNs key not in repository | ci | pre-commit hook or CI assertion |
8. Current Behavior
The June baseline remains in SECURITY_AUDIT.md; the authoritative current ledger is 2026-07-14-security-audit.md. Verified controls include django-axes lockout, one-hour access tokens, secure cookie settings, tenant-scoped authorization, upload existence/type/size checks, and configured signature checks. The current branch still has open findings:
- browser bearer tokens are stored in
localStorageand Cloudflare serves a report-only CSP; - reset validation, atomic refresh consumption, and password-session revocation are incomplete;
- production host/CORS/connector settings include fail-open paths; the admin network restriction is deliberately optional when its allowlist is absent;
- ECS task definitions receive secret values as ordinary environment entries;
- pull-request runner isolation and blocking security gates are incomplete;
- connector encryption/signature/OAuth/host validation has conditional paths;
- Sentry default PII, AI provider minimization, and public health metadata need explicit boundaries.
The APNs .p8 finding remains a verified false positive.
9. Error States
| Scenario | Behavior |
|---|---|
| User locked out via django-axes | 429 {"detail": "Account locked due to too many failed attempts. Try again later."} |
| CSP violation occurs | Report sent to /csp-report/ endpoint, logged to Sentry |
| Vulnerability scan fails in CI | Build blocked, output piped to PR comment |
| APNs key leak detected in CI | CI blocks merge — detect-secrets or custom assertion checks git history for known revoked keys; manual rotation + BFG scrub required to resolve |
10. Diagrams & Docs Touched
- New: docs/security/SECURITY_AUDIT.md — full finding-by-finding audit.
- New: docs/security/STATUS.md — tracker with status/assignee/target for each finding.
- backend/DIAGRAMS.md: add
django-axesto auth flow,django-cspmiddleware to request lifecycle. - deploy/DIAGRAMS.md: add security header enforcement and CI security scanning steps.
11. Implementation Audit (2026-06-12, updated 2026-06-13)
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | django-axes integrated (5-failure lockout, 1h cooldown, email+IP key); 4 lockout tests | ✅ match | test_auth.py::TestBruteForce*; B5 resolved; §13 2026-06-12 B-r12 |
| 2 | CSP in enforcement mode (CONTENT_SECURITY_POLICY in base.py + nginx CSP header) | ✅ match | B12/B13/B28; §13 2026-06-13 C-r05 — confirmed enforcement mode (no _REPORT_ONLY) |
| 3 | pip-audit in CI (fails on critical/high CVEs) + npm audit --audit-level=high | ✅ match | B46; .github/workflows/ci-be.yml, .github/workflows/ci-fe.yml |
| 4 | Audit log via write_audit_log: create/update/login/logout/role/invite/billing events | ✅ match | B32 (HQ read ops logged); B33 (django-auditlog for Org/User/Member) |
| 5 | Nginx security headers: X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, Strict-Transport-Security | ✅ match | B27; HSTS added to nginx frontend block in §13 2026-06-13 C-r05 |
| 6 | Access token lifetime reduced to 1h (from 12h); refresh token reuse detection (jti + cache) | ✅ match | B1/B2; §13 2026-06-12 B-r11; 6 tests in test_auth.py |
| 7 | Password validators (min 10 chars, common-password, numeric-password, user-attribute) | ✅ match | B4; RegisterSerializer.validate_password(); §13 2026-06-12 B-r11 |
| 8 | APNs private key: false positive — never committed; .p8 in .gitignore | ✅ match | B20 resolved B-r01; git log --all --diff-filter=A -- '*.p8' returns zero |
| 9 | CodeQL analysis workflow runs only on deploy/backend-prod pushes and pull requests | ✅ match | .github/workflows/security-codeql.yml (push + pull_request to deploy/backend-prod) |
| 10 | F39’s shipped CSP contract is enforcement mode, not a lingering report-only rollout step | ✅ match | CONTENT_SECURITY_POLICY is active; no CSP_REPORT_ONLY setting; nginx sends enforcing CSP header |
Summary: 10/10 match. The earlier CodeQL and CSP audit rows were documentation drift, not live implementation gaps.
Edge Cases Catalog (formerly §9, 6 items): E1 finding closed in one path but exploitable via another (mitigated by full-surface test suite; remains a conceptual gap requiring annual pen test); E2 package upgrade quieting scanner but breaking runtime (CI enforces correctness + pip-audit for CVEs — separate checks); E3 historical secrets in Git history (B20 verified no .p8 ever committed; detect-secrets in pre-commit); E4 controls relying on operator memory instead of enforcement (B5/B12/B27/B46 are code-enforced, not procedure-reliant); E5 compound attack surface from multiple Medium findings (STATUS.md tracks chain analysis — triage responsibility); E6 deferred findings without review cadence (STATUS.md is the review artifact; no automated cadence trigger yet).
12. Open Questions / Decisions
- httpOnly cookie tokens: Full migration to httpOnly cookie-based refresh tokens with BFF pattern is a significant architecture change. Decision: defer to future spec; document XSS risk; ensure CSP is deployed first as mitigation.
- Admin panel access: Add IP restriction to
/tmt/or require 2FA? Decision: add IP restriction in nginx for production; document access control. - WAF deployment: Deploy AWS WAF now or when traffic warrants? Decision: defer to when traffic warrants; document current exposure.
Database encryption at rest: Verify RDS encryption at rest is enabled in AWS console. Decision: verify and document; enable if not already.Resolved B-r01: Database is Neon PostgreSQL (encrypts at rest by default); verified and documented. No RDS to configure.
13. Bug / Change Log
2026-07-18 — Make the admin network allowlist explicitly optional
Type: Security boundary / deployment reliability
Shipped behavior:
- Treat an omitted
ADMIN_ALLOWED_IPSas unrestricted in every environment; Django authentication remains the only/tmt/gate in that mode. - Apply the CIDR restriction whenever the variable contains one or more values.
- Remove the production deploy and demo-seed blockers for an unset repository variable, while retaining explicit workflow rendering and regression proof.
2026-07-15 — Separate Android production telemetry from dev and local builds
Type: Observability boundary / privacy
Shipped behavior:
- Admit Android Sentry events only from release build types.
- Tag prod release as
productionand dev release asdevelopment. - Disable default PII, screenshots, view hierarchy, and SDK logs.
- Pin the variant and privacy matrix with Android and Contract Graph source tests.
2026-07-15 — Bind worker deploy environment to its trigger branch
Type: Deployment isolation / runtime-secret boundary
Shipped behavior:
- Map
deploy/backend-devpushes to the dev worker anddeploy/backend-prodpushes to the prod worker in both concurrency and rendered configuration. - Reject unknown push refs instead of defaulting them to production.
- Reject a pulled worker image before recreation when the deployed environment no longer satisfies current fail-closed settings.
- Add a repository policy regression so the branch mapping cannot silently drift back to a production default, plus a shell regression for the image preflight.
2026-07-15 — Separate deployed telemetry from local verification traffic
Type: Observability boundary / production-evidence integrity
Shipped behavior:
- Initialize browser Sentry only on the deployed Lumi frontend hosts
(
runlumi.app,lumi.locuno.com, anddev.runlumi.app). - Tag
dev.runlumi.appasdevelopment; keep production hosts taggedproduction; never emit localhost or Cloudflare preview-build events. - Make the W-SYS-02 session reporter query
environment=productionby default and print that filter in its evidence output. - Preserve an explicit post-fix observation window before using the new report to promote W-SYS-02.
2026-07-14 — Reopen the security contract from current repository evidence
Type: Security remediation plan
Summary:
- Replaced the stale June “all controls shipped” current-behavior claim with the evidence-backed dated audit.
- Added acceptance criteria for the 16 current findings and linked the P36 tests-first remediation plan.
- Closed the repository acceptance criteria with regression/configuration proof; live cloud and third-party evidence remains explicit in the Contract Graph rather than being conflated with repository completion.
2026-07-02 — Fix stale path: requirements.txt → pyproject.toml in §2 AC
Type: Documentation
Summary:
- §2 AC (“CI runs
pip-auditonbackend/requirements.txt”) referenced a file deleted in F56. - Updated to
backend/pyproject.toml, which is the dependency manifestpip-auditnow audits. - No code changes.
2026-06-14 — Restructure to TEMPLATE.md section order; expand §11 audit (SP fleet)
Type: Documentation Change
Summary:
-
Absorbed §9 Edge Cases (6 items) into §11 note; §10 Error States → §9; §11 Diagrams → §10; §12 Implementation Audit (narrative) → §11 (10-row table); §13 Open Questions → §12; §14 Bug/Change Log → §13.
-
§11 audit: 10 rows, 8 ✅, 2 gaps (CodeQL workflow unconfirmed, CSP graduation untracked). F39 is shipped.
-
2026-06-13 — C-r05 (T3-C): Closed remaining frontend security items. All F39 frontend column items marked ✅.
- CSP enforcement: Already in enforcement mode (
CONTENT_SECURITY_POLICYinbase.py, nginx CSP header active). No changes needed — CSP was graduated from report-only per spec §2. TheCSP_REPORT_ONLYsetting is absent from base.py (usesCONTENT_SECURITY_POLICYnot_REPORT_ONLY), confirming enforcement mode. nginx CSP coversdefault-src,script-src,style-src,img-src,font-src,connect-src,object-src,base-uri,form-action,frame-ancestors. Verified no violations via existing test suite. - dangerouslySetInnerHTML: Only in
Markdown.tsx(line 38), gated by DOMPurify. This was already well-documented as Positive (F1/F2 in SECURITY_AUDIT.md). No raw HTML rendering elsewhere. No changes needed. - Secrets in git history:
*.p8is in.gitignore. B20 already resolved as false positive (no.p8file was ever committed). Verified clean. - HSTS header added to nginx.conf:
Strict-Transport-Securityheader was present on backend responses (via DjangoSecurityMiddleware) but missing from frontend nginx — added to both the mainserverblock and the static assetslocationblock. This was a remaining sub-gap of B27. - nginx security headers audit: All required headers now present in nginx.conf:
Content-Security-Policy,Strict-Transport-Security(NEW),X-Content-Type-Options,X-Frame-Options,Referrer-Policy,Permissions-Policy. - MVP_SCOPE.md: F39 frontend column updated from 🔲 to ✅.
- Gate: frontend
tsc --noEmit+pnpm testpassing. - B20 (Critical — false positive): The audit finding “APNs private key in git history” was based on a pre-spec assumption.
git log --all --diff-filter=A -- '*.p8'andgit log --all --oneline -- '*apns*' '*AuthKey*'returned zero results — no.p8file was ever committed to any branch. The F36 push spec chose FCM over direct APNs (FCM relay), so an APNs key was never needed in backend git. The only.p8references are local-only App Store Connect API keys inios/scripts/(gitignored). B20 is a false positive — no BFG scrubbing needed. Marked 🟢 Fixed in STATUS.md. - D11 (Medium — verified): The production database is Neon PostgreSQL (serverless), not AWS RDS. Neon enables encryption at rest by default for all projects. Verified the Neon connection string and deploy plan confirm Neon as the production database. No RDS to configure — the D11 finding was a classification error. Added a deploy-docs note to verify encryption in the Neon/PostgreSQL console. Marked 🟢 Fixed in STATUS.md.
- CSP enforcement: Already in enforcement mode (
2026-07-16 — Restrict CodeQL to production backend promotion
Type: CI policy
- Scoped CodeQL push and pull-request execution to the exact
deploy/backend-prodbranch. - Updated the current F39 acceptance and implementation-audit truth alongside the executable trigger policy.
2026-07-01 — Close stale CodeQL / CSP truth drift
Type: CI hardening + documentation truth fix
Summary:
-
security-codeql.ymlnow runs on protected-branch pushes and pull requests in addition to its weekly scheduled sweep, matching the shipped security-scan contract. -
F39 no longer describes CSP enforcement as an unverified future graduation step; the spec now reflects the actual shipped contract: enforcing CSP headers are active today.
-
Cleaned up stale F39 wording that still described already-fixed pre-hardening findings as if they were current behavior.
-
2026-06-12 — lead (session 2): Fixed B12 (django-csp configured;
CONTENT_SECURITY_POLICYdict in base.py;CSPMiddlewareappended to MIDDLEWARE), B13 (nginx CSP header for frontend), B27 (nginx X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy), B28 (nginx CSP covers frontend HTML), B46 (pip-audit in CI), B47 (ruff S rules enabled; per-file ignores for tests/seed/settings). 1310 backend tests / 99.05% coverage; ruff clean. -
2026-06-12 — lead (session 1): Fixed B2 (refresh token reuse detection with jti tracking + cache-based rate limiting), B21 (PostHog key removed from .env.example), B32 (HQ read operations now log via
write_audit_log), B33 (django-auditlog in INSTALLED_APPS; Organization/User/OrganizationMember registered), B42 (S3 upload verification on complete), F6 (token removed from SSE URL query param), D10 (admin panel at /tmt/ IP restriction added in nginx for production). Gate: 1299 backend tests / 99.05% coverage. -
2026-06-12 — B-r12: Fixed B5 (login brute-force protection with
django-axes).axesadded toINSTALLED_APPS,AxesMiddlewareappended toMIDDLEWARE, andAxesStandaloneBackendinserted ahead ofModelBackend.- Login now calls
authenticate(request, ...)soaxescan observe failures and lockouts correctly. - Lockout policy:
AXES_FAILURE_LIMIT=5,AXES_COOLOFF_TIME=1,AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]],AXES_USERNAME_FORM_FIELD="email",AXES_RESET_ON_SUCCESS=True. - Test settings keep
axesinstalled but disabled by default; B5 tests enable it withoverride_settings(...)instead of stripping the integration out of the test runtime. - Tests:
backend/tests/test_auth.pyadds 4 B5 lockout/scoping tests covering threshold behavior, different username on same IP, different IP for same username, and pre-threshold normal login.
-
2026-06-12 — B-r11: Fixed B4 (password validators) and B1 (access token lifetime 12h → 1h).
AUTH_PASSWORD_VALIDATORSadded tobackend/config/settings/base.pywith four validators:UserAttributeSimilarityValidator,MinimumLengthValidator(min_length=10),CommonPasswordValidator,NumericPasswordValidator. Validation invoked viaRegisterSerializer.validate_password().ACCESS_TOKEN_LIFETIMEreduced fromtimedelta(hours=12)totimedelta(hours=1). Frontend refresh-on-401 interceptor handles transparent re-auth.- Tests:
backend/tests/test_auth.py— 6 tests covering B1+B4. Existing registration test passwords updated (test123→strongpass123). 1232 backend tests pass @ 99.11% coverage. - Remaining B-findings tracked in
docs/security/STATUS.md.