Step 14 (Security Audit) output for cycle 2. Verdict: FAIL — 2 Critical (F-INFRA-1, F-INFRA-2) + 4 High (F-INFRA-3, F-INFRA-4, F-AUTH-1, F-AUTH-2) block deploy. 13 cycle-2 findings total; cycle-1 closures confirmed for F-6, F-7, F-8, F-13, A09. Files: - security_report_cycle2.md (delta on cycle-1 report; FAIL verdict, tracker follow-ups filed as AZ-552..AZ-557 + 9 deferred Medium/Low) - owasp_review_cycle2.md (A01..A09 delta; 2 FAIL / 2 PASS_W_W / 5 PASS) - static_analysis_cycle2.md (F-AUTH-1..9 with locations + remediation) - infrastructure_review_cycle2.md (F-INFRA-1..6 with locations + remediation) - dependency_scan_cycle2.md (no new CVEs; cycle-1 deprecations re-flagged) Cycle-1 reports remain authoritative for non-cycle-2 surface. Co-authored-by: Cursor <cursoragent@cursor.com>
14 KiB
Security Audit Report — Cycle 2 (Auth Modernization Delta)
Date: 2026-05-14
Scope: cycle-2 surface only (AZ-531 through AZ-538 — refresh tokens, ES256/JWKS, mission tokens, MFA, Argon2id, lockout/rate-limit, CORS/HSTS/HTTPS). Cycle-1 findings and closures remain authoritative in security_report.md.
Verdict: FAIL — DO NOT DEPLOY. 2 Critical deploy-blockers + 2 High auth findings discovered. Cycle-2 also closes 3 cycle-1 hardening gaps (F-7 weak hashing, F-8 no rate-limit, F-13 no HTTPS) and 1 cycle-1 PASS_WITH_WARNINGS (A09 audit trail).
Summary
| Severity | Count | Closed in this audit |
|---|---|---|
| Critical | 2 | 0 |
| High | 4 | 0 (3 cycle-1 hardening gaps closed by cycle-2 code itself: F-7, F-8, F-13 — see "Cycle-1 closures" below) |
| Medium | 7 | 0 |
| Low | 2 | 0 |
Findings (cycle-2 only, severity-ranked)
| # | Severity | Category | Location | Title |
|---|---|---|---|---|
| F-2026Q2-INFRA-1 | Critical | A05 | scripts/start-services.sh:32 |
Deploy script hard-blocks on obsolete JwtConfig__Secret — cycle-2 cannot deploy with current scripts |
| F-2026Q2-INFRA-2 | Critical | A05 / A07 | scripts/start-services.sh:48-56, appsettings.json:15 |
ES256 keys folder not bind-mounted into container — JwtSigningKeyProvider fails-fast on startup |
| F-2026Q2-AUTH-1 | High | A07 | Azaion.Services/UserService.cs:120-148, BusinessException.cs:33-52 |
User enumeration via login error codes (NoEmailFound vs WrongPassword); upgraded to High because lockout amplifies the threat |
| F-2026Q2-AUTH-2 | High | A07 | Azaion.Services/MfaService.cs:247-278, Azaion.Services/AuditLog.cs:53-63 |
MFA brute-force not rate-limited — MfaLoginFailed events don't feed failed_login_count and aren't counted by CountRecentFailedLogins |
| F-2026Q2-INFRA-3 | High | A05 | Program.cs:147-160, scripts/start-services.sh |
DataProtection key store ephemeral by default — every container restart locks every MFA user out |
| F-2026Q2-INFRA-4 | High | A05 | secrets/README.md:50-55 |
Operator README still lists HS256-era JwtConfig__Secret; misses KeysFolder / ActiveKid / DataProtection__KeysFolder |
| F-2026Q2-AUTH-3 | Medium | A07 | Azaion.Services/UserService.cs:144-152 |
Disabled-account leak via auth ordering (IsEnabled checked AFTER password verify) |
| F-2026Q2-AUTH-4 | Medium | A01 | Azaion.AdminApi/Program.cs:452-481 |
/users/me/mfa/{enroll,confirm,disable} not rate-limited; stolen access token can brute-force MFA disable |
| F-2026Q2-AUTH-5 | Medium | A04 | Azaion.Services/MissionTokenService.cs:103-111, Program.cs:489 |
Mission tokens missing amr claim; verifiers can't enforce MFA-pilot policy |
| F-2026Q2-AUTH-6 | Medium | A04 | Azaion.Services/MissionTokenService.cs:46-87 |
Mission token issuance does NOT auto-revoke prior aircraft missions; violates AZ-533 AC-4 |
| F-2026Q2-AUTH-7 | Medium | A04 / A05 | Azaion.Services/JwtSigningKeyProvider.cs:73-86 |
Silent fallback to first PEM if ActiveKid unset; rotation accidents silently change signing key |
| F-2026Q2-INFRA-5 | Medium | A05 | Program.cs:217-225 |
HSTS preload+includeSubDomains may break legacy *.azaion.com subdomains; preload registration is hard to undo |
| F-2026Q2-AUTH-9 | Medium (deployment-coupled) | A07 | Azaion.Services/UserService.GetByEmail (cycle-1) consumed by cycle-2 lockout/MFA reads |
4-hour user cache holds lockout / MFA state stale across instances; safe at single-instance, breaks horizontal scaling |
| F-2026Q2-AUTH-8 | Low | A09 | Program.cs:261-280 |
/health/ready leaks DB exception type to anonymous callers (mitigated by management-interface-only exposure) |
| F-2026Q2-INFRA-6 | Low | A05 | env/db/07_auth_lockout_and_audit.sql |
audit_events table has no documented retention policy |
| F-2026Q2-DOCS-1 | Low | — | _docs/02_document/components/03_auth_and_security/description.md, data_model.md |
Cycle-2 docs drift from code (Argon2 params, lockout field names, recovery-code hash algorithm) |
Detailed evidence and remediation steps are in static_analysis_cycle2.md (F-AUTH-) and infrastructure_review_cycle2.md (F-INFRA-).
OWASP Top 10 (2021) — Cycle-2 Status
Full reasoning is in owasp_review_cycle2.md. Net category posture moves from cycle-1 3 FAIL / 4 PASS_WITH_WARNINGS / 2 PASS / 1 N/A to cycle-2 2 FAIL / 2 PASS_WITH_WARNINGS / 5 PASS / 1 N/A.
| Category | Cycle-1 | Cycle-2 | Reason |
|---|---|---|---|
| A01 Broken Access Control | FAIL | FAIL | F-2 still open + new F-AUTH-4 (MFA endpoints not rate-limited) |
| A02 Cryptographic Failures | PASS_WITH_WARNINGS | PASS | F-7 closed by Argon2id (AZ-536); ES256, IDataProtector, SHA-256 (high-entropy) all RFC-aligned |
| A04 Insecure Design | PASS_WITH_WARNINGS | PASS_WITH_WARNINGS | F-8 closed by hybrid rate-limit (AZ-537); new design risks F-AUTH-5/6/7 |
| A05 Security Misconfiguration | FAIL | FAIL | F-13 closed by HSTS+HttpsRedirection; F-6 closed by USER app; new criticals F-INFRA-1/2/3 + medium F-INFRA-5 |
| A07 Auth Failures | PASS_WITH_WARNINGS | PASS_WITH_WARNINGS | Cycle-2 modernized auth; new findings F-AUTH-1/2/3 |
| A09 Logging Failures | PASS_WITH_WARNINGS | PASS | IAuditLog + audit_events table closes the cycle-1 warning |
Cycle-1 Closures Confirmed in Cycle-2 Code
The cycle-2 implementation directly closes three cycle-1 open hardening items and one cycle-1 PASS_WITH_WARNINGS. These are NOT new audit work — they are verifications that the AZ-53x tickets did the security-relevant thing they promised.
| Cycle-1 finding | Cycle-2 resolution | Evidence |
|---|---|---|
| F-6 (container runs as root) | USER app directive added; /app/Content and /app/logs chowned |
Dockerfile:7-11, 39-40 |
| F-7 (SHA-384 password hash, no salt/KDF) | Argon2id with PHC string format, lazy migration on next login | Azaion.Services/Security.cs:1-135, AZ-536 spec |
F-8 (no rate limiting on /login) |
Per-IP sliding window (RateLimiter) + per-account DB-backed sliding window + lockout |
Program.cs:172-199, 308, 330, AuthConfig.cs, UserService.ValidateUser |
| F-13 (no HTTPS enforcement in code) | app.UseHsts() + app.UseHttpsRedirection() in non-Development |
Program.cs:217-240 |
| A09 (no security-event audit log) | IAuditLog writes login_success/failed/lockout, MFA enroll/confirm/disable/login_success/failed/recovery_used to audit_events with email + IP + timestamp |
Azaion.Services/AuditLog.cs:1-80, env/db/07_auth_lockout_and_audit.sql |
These closures can be verified by inspection during the next deploy gate.
Verdict Logic
FAIL — do not deploy because:
- F-INFRA-1 (Critical): the deploy script's
require_env ASPNETCORE_JwtConfig__Secretwill fail-fast on every cycle-2 deploy attempt that follows the new.env.example. The deploy literally cannot start. - F-INFRA-2 (Critical): even if the operator works around F-INFRA-1, the container will then fail-fast inside
JwtSigningKeyProviderbecausesecrets/jwt-keysis not bind-mounted. - F-INFRA-3 (High): even if F-INFRA-1 and F-INFRA-2 are bypassed by
docker cpand a dummy env var, the first container restart will lock every MFA-enrolled user out of their account because DataProtection master keys vanish. - F-AUTH-1 / F-AUTH-2 (Highs): the new auth surface is exploitable — user enumeration is amplified by lockout, and MFA can be brute-forced from rotating IPs without ever locking the account.
The combination of (1)+(2)+(3) means cycle-2 has no working deploy path. (4) means even if the deploy did work, the auth surface is below the cycle-1 baseline despite the modernization effort.
Recommendations
Pre-deploy (must be done before the next deploy attempt)
The following CRITICAL and HIGH findings block deploy. Each is small enough to fit a single PR; bundle them as one cycle-2 hotfix sprint.
- F-INFRA-1 —
scripts/start-services.sh: replacerequire_env ... ASPNETCORE_JwtConfig__Secretwithrequire_env ... ASPNETCORE_JwtConfig__KeysFolder ASPNETCORE_JwtConfig__ActiveKid. - F-INFRA-2 —
scripts/start-services.sh: add a--volume "$DEPLOY_HOST_JWT_KEYS_DIR:/etc/azaion/jwt-keys:ro"line; documentDEPLOY_HOST_JWT_KEYS_DIRin.env.exampleandsecrets/README.md. - F-INFRA-3 — (a) add a
--volume "$DEPLOY_HOST_DP_KEYS_DIR:/var/lib/azaion/dp-keys"line; (b) setASPNETCORE_DataProtection__KeysFolder=/var/lib/azaion/dp-keysinsecrets/<env>.public.env; (c) fail-fast inProgram.cs:151-160if Production andKeysFolderis unset. - F-INFRA-4 — rewrite
secrets/README.md"Schema" section to dropJwtConfig__Secretand addJwtConfig__KeysFolder,JwtConfig__ActiveKid,DataProtection__KeysFolder. - F-AUTH-1 —
UserService.ValidateUser+BusinessException.cs: introduce a singleInvalidCredentialserror code (e.g. 70). Map bothNoEmailFound-equivalent andWrongPassword-equivalent paths to it. Move theIsEnabledcheck before the password verify (closes F-AUTH-3 too). - F-AUTH-2 —
MfaService.VerifyForLogin: incrementfailed_login_countand check the per-account rate limit (call intoUserServiceor duplicate the logic). Also: extendAuditLog.CountRecentFailedLoginsto countMfaLoginFailedrows alongsideLoginFailed.
Short-term (Medium — file as separate tickets)
- F-AUTH-4 — attach
LoginPerIpPolicy(or a dedicatedMfaPerIpPolicy) to/users/me/mfa/{enroll,confirm,disable}inProgram.cs. - F-AUTH-5 —
MissionTokenService.MintToken: read the pilot's actualamrfrom the access token (or look it up viaSession.MfaAuthenticated) and stamp it on the mission token. Reject if mission policy requires MFA and pilot has none. - F-AUTH-6 —
MissionTokenService.Issue: callSessionService.RevokeMissionsForAircraft(aircraftId)immediately before inserting the new mission session row. Also fix the cycle-2 docs which falsely claim this auto-revoke is happening. - F-AUTH-7 —
JwtSigningKeyProvider: fail-fast whenActiveKidis unset and >1 PEM exists; warn-only when exactly 1 PEM exists. Update.env.exampleto markActiveKidas required for prod. - F-INFRA-5 — audit all
*.azaion.comsubdomains for HTTPS-only; document the inventory in_docs/04_deploy/; gatePreload = truebehind an env var so staging doesn't trigger preload-list submission attempts. - F-AUTH-9 — bypass the user cache for security-critical fields (
LockoutUntil,FailedLoginCount,MfaEnabled,MfaSecret,IsEnabled) by reading them directly inValidateUserandMfaService. Required before any horizontal scaling.
Long-term (Low / hardening)
- F-AUTH-8 —
/health/ready: logex.GetType().Nameinternally; return only{ status: "not-ready" }externally. - F-INFRA-6 — agree audit retention (≥1 year per CMMC); add a nightly cleanup job; document in
_docs/04_deploy/. Consider monthly partitioning if volume warrants. - F-DOCS-1 — sync cycle-2 docs to code:
AuthConfigdoes not havePasswordHashing;LockoutOptions.MaxAttempts(notConsecutiveFailureThreshold);LockoutOptions.DurationSeconds(notLockoutSeconds);RateLimitOptions.PerAccountPermitLimit(notPerAccountFailedThreshold); recovery codes are SHA-256 (not Argon2id).
Dependency Vulnerabilities (cycle 2)
dependency_scan_cycle2.md is authoritative. Summary: no new CVEs in cycle-2 packages (Konscious.Security.Cryptography.Argon2 1.3.1, Otp.NET 1.4.0, QRCoder 1.6.0, ASP.NET Core RateLimiting, DataProtection). Two cycle-1 deprecation items are re-emphasized: FluentValidation.AspNetCore 11.3.0 and System.IdentityModel.Tokens.Jwt 7.1.2 should both move to maintained alternatives in a focused upgrade ticket.
Tracker Follow-Ups
The 2 Critical + 4 High findings were filed as Jira tickets on 2026-05-14 as the cycle-2 hotfix sprint. Medium / Low items remain unfiled pending prioritization decision (see "Open" row below).
Filed (cycle-2 hotfix sprint — 2026-05-14)
| Ticket | Finding | Title | Severity | Points |
|---|---|---|---|---|
| AZ-552 | F-INFRA-1 | Deploy script hard-blocks on obsolete JwtConfig__Secret |
Critical | 1 |
| AZ-553 | F-INFRA-2 | Bind-mount ES256 key folder in deploy script + host-side procedure | Critical | 2 |
| AZ-554 | F-INFRA-3 | Persist DataProtection keys folder + fail-fast in Production | High | 2 |
| AZ-555 | F-INFRA-4 | Rewrite secrets/README.md schema for ES256 + DataProtection |
High | 1 |
| AZ-556 | F-AUTH-1 + F-AUTH-3 | Collapse login error codes to InvalidCredentials + reorder IsEnabled check |
High | 2 |
| AZ-557 | F-AUTH-2 | Lock MFA brute-force into per-account lockout/rate-limit pipeline | High | 3 |
Bundle: 11 story points. Labels: security, cycle-2-hotfix, AZ-530-followup. All gate the next deploy.
Open (Medium / Low — to be triaged)
| Ticket suggestion | Finding | Title | Severity | Points |
|---|---|---|---|---|
| AZ-NEW-7 | F-AUTH-4 | Add per-IP rate limiting to /users/me/mfa/* endpoints |
Medium | 2 |
| AZ-NEW-8 | F-AUTH-5 | Stamp amr on mission tokens; gate by mission policy |
Medium | 2 |
| AZ-NEW-9 | F-AUTH-6 | Auto-revoke prior aircraft mission on issuance (and fix the docs) | Medium | 2 |
| AZ-NEW-10 | F-AUTH-7 | Fail-fast on ambiguous ActiveKid; tighten .env.example |
Medium | 1 |
| AZ-NEW-11 | F-INFRA-5 | Audit *.azaion.com subdomains for HTTPS-only before preload |
Medium | 2 |
| AZ-NEW-12 | F-AUTH-9 | Bypass user-cache for security-critical fields (pre-scaling) | Medium | 3 |
| AZ-NEW-13 | F-AUTH-8 | /health/ready body redaction |
Low | 1 |
| AZ-NEW-14 | F-INFRA-6 | Audit retention policy + cleanup job | Low | 2 |
| AZ-NEW-15 | F-DOCS-1 | Sync cycle-2 auth/security docs to code | Low | 1 |
Self-Verification
- Every Critical / High in this report has location + remediation
- OWASP delta is reconciled against
owasp_review_cycle2.md - Cycle-1 closures verified by re-reading the cycle-2 code (F-6, F-7, F-8, F-13, A09)
- Verdict matches finding severity (FAIL because of 2 deploy-blocking Criticals, not a clerical "PASS_WITH_WARNINGS")
- Tracker follow-ups grouped by severity for prioritization