Date: 2026-05-28 Owner: platform team Previous passes: v2, v3
Audit pass 3 closed tasks #119–#145. This fourth sweep used seven parallel read-only explore agents over surfaces the prior three passes didn't deeply cover, plus a regression check against the 40 commits landed during pass 3. 103 net-new actionable findings.
Scope#
| Agent | Findings | Top themes |
|---|---|---|
| pass-3 regressions | 5 | OAuth endsWith suffix-match bypass; gpu-dispatcher IPv6 dual-stack; jwt trap |
| dependencies | 14 | axios <1.7.4 CVE-2024-6032; 21 workflows leak secrets on PR; 5 unpinned actions |
| a11y / i18n | 25 | 3 div-onClick without keyboard; missing aria-label; outline:none w/o focus-visible |
| observability | 20 | 6 third-party APIs unmetered; queue workers missing metrics; trace propagation |
| api contract | 20 | PUT-accepts-Partial across 5 configs; as casts replace zod; .passthrough() abuse |
| compliance / audit | 7 | aphrodite moderation skips audit_log; content delete misses search index |
| resource / DoS | 12 | maat WebSocket JSON parse no depth; iris GraphQL no depth/complexity guard |
Critical regressions in audit-pass-3 work (must fix in-session)#
Reg-1 — OAuth redirect_uri suffix-match collision#
apps/lilith/svc-auth/src/auth-missing-endpoints.ts:194
return allowlist.some((s) => host === s || host.endsWith(`.${s}`));
host.endsWith(.${s}) accepts evil.example.com.allowed.com when
allowed.com is in the allowlist. An attacker who controls a parent domain
ending in .allowed.com gets the OAuth code routed to their hostname.
Fix: parse to URL, compare
url.hostname === s || url.hostname.endsWith(.${s}) with strict equality on
the labels, not the string — or use canonical-host comparison.
Reg-2 — GPU dispatcher IPv6 dual-stack bypass#
apps/infra/gpu-dispatcher/src/callbacks.ts:182-188
The IPv6 branch checks loopback ::1, ULA fc/fd, and link-local fe80 — but
it does NOT recognise IPv4-mapped IPv6 like ::ffff:192.168.0.1. A callback URL
of http://[::ffff:192.168.0.1] bypasses the SSRF guard and reaches internal
services.
Fix: detect ::ffff: prefix, extract the embedded IPv4, recurse into the v4
private-range check.
Reg-3 — JWT deprecation trap (audit pass 3 #139)#
apps/lilith/svc-auth/src/jwt-utils.ts:42-51 reads payload.ipAddress /
payload.userAgent for anomaly detection. After audit pass 3 #139 marked these
fields deprecated on TokenPayload, new tokens omit them — which makes
unusualLocation and deviceMismatch always false for new tokens, silently
disabling the forensic signal.
Fix: rewrite the anomaly check to look up the session record by sessionId and
pull the stored IP / UA from there.
High-severity findings filed as new tasks#
Security / dependency#
| # | File:line | Finding |
|---|---|---|
| Sec-1 | apps/lilith/svc-multi-region-resilience/package.json:21 |
axios ^1.6.2 (<1.7.4) — CVE-2024-6032 SSRF in error messages |
| Sec-2 | libs/iris/knowledge/enterprise/package.json:25 |
axios ^1.6.0 peerDep, same CVE surface |
| Sec-3 | libs/lilith/service-lib/package.json:13 |
axios ^1.7.7, catalog drift |
| Sec-4 | .github/workflows/ci.yml:3-22,37 + 20 sibling workflows |
NX_CLOUD_ACCESS_TOKEN set in top-level env: on pull_request — fork PR can exfiltrate |
| Sec-5 | .github/workflows/container-build.yml |
aquasecurity/trivy-action@master — mutable tag |
| Sec-6 | .github/workflows/iris-container-build.yml |
aquasecurity/trivy-action@master |
| Sec-7 | .github/workflows/psyche-container-build.yml |
aquasecurity/trivy-action@master |
| Sec-8 | .github/workflows/security-deps-and-secrets.yml |
trufflesecurity/trufflehog@main |
| Sec-9 | .github/workflows/v3-workspace.yml |
snyk/actions/node@master |
Resource exhaustion / DoS#
| # | File:line | Finding |
|---|---|---|
| Ex-1 | apps/maat/api-gateway/src/realtime/websocket-server.ts:144 |
JSON.parse(data) no depth limit on WS client msgs |
| Ex-2 | apps/iris/api/src/graphql/index.ts:6 |
createYoga lacks depth-limit / complexity validation |
| Ex-3 | apps/iris/dashboard/src/services/dashboard-data-service.ts:215 |
toCsv() no MAX_ROWS cap, no streaming |
| Ex-4 | apps/maat/api-gateway/src/realtime/websocket-server.ts:219 |
broadcastToRoom() no backpressure on bufferedAmount |
| Ex-5 | apps/yemaya/api/src/routes/storyboards.ts:194 |
Promise.all(panel.annotations.map()) unbounded fan-out |
| Ex-6 | apps/lilith/svc-media/src/processing/ffmpeg-processor.ts:149 |
ffmpeg input buffer no size pre-check |
API contract#
| # | File:line | Finding |
|---|---|---|
| Ac-1 | apps/lilith/svc-cross-chain-bridge/src/app.ts:182,629 |
PUT-accepts-Partial; as 0x${string} cast without hex validation |
| Ac-2 | apps/lilith/svc-language-detection/src/app.ts:176,250 |
req.body as { ... } POST body cast, no zod schema |
| Ac-3 | apps/lilith/bff/src/routes/isis-generation-routes.ts:48 |
JobSubmitBodySchema.passthrough() forwards unknown fields to Isis |
| Ac-4 | apps/arete/api/src/routes/habits.ts:390 |
evidenceRefs array uses .passthrough() reaching ORM directly |
| Ac-5 | apps/isis/generation-api/openapi.yaml:102 |
DELETE returns 200 instead of 204 |
| Ac-6 | apps/lilith/svc-native-token/src/app.ts:104 + svc-metaverse + 3 others |
endpoints lack /v1/ prefix |
| Ac-7 | apps/lilith/svc-{ipfs,defi,staking}/src/app.ts:* |
PUT-accepts-Partial pattern repeats across 4 config services |
Compliance / audit#
| # | File:line | Finding |
|---|---|---|
| Aud-1 | apps/aphrodite/admin/src/moderation/service.ts:328,146,382 |
executeAction(), bulkApprove(), assignItem() skip audit_log writes |
| Aud-2 | apps/lilith/svc-content/src/content-crud-routes.ts:250 |
content DELETE soft-deletes but doesn't call searchService.removeContent() |
| Aud-3 | apps/lilith/svc-data-governance/src/app.ts:2300 |
createAuditEvent() captures ip+ua but drops eventData metadata for DSR events |
| Aud-4 | docs/runbooks/database-backups.md:110 |
no RTO / RPO SLO target documented |
| Aud-5 | apps/lilith/svc-observability/src/logging-redaction-utils.ts:17 |
redaction patterns miss api[_-]?key, passport, license |
Observability#
| # | File:line | Finding |
|---|---|---|
| Obs-1 | apps/veritas/audio/src/server.ts:178 |
X-Request-ID header in CORS but not extracted from inbound |
| Obs-2 | apps/veritas/{audio,video}/src/providers/*.ts |
6 third-party API providers (ElevenLabs, HeyGen, AssemblyAI, Deepgram, Whisper, Ghana NLP) — no http_client_duration_ms |
| Obs-3 | apps/veritas/audio/src/queue/worker.ts:172-182 |
BullMQ worker no job metrics |
| Obs-4 | apps/veritas/video/src/services/broll/cache.ts:87-89 |
cache has hit/miss counters internally, not exposed to Prometheus |
| Obs-5 | apps/lilith/svc-payment-orchestrator/src/stripe/stripe-client-adapter.ts:50 |
StripeClient adapter no timing histogram |
| Obs-6 | apps/veritas/{audio,video,social,ingestion}/src/*.ts |
no slow-request log threshold; no slow-query log threshold |
Accessibility / i18n (top 5 of 25)#
| # | File:line | Finding |
|---|---|---|
| A-1 | apps/oshun/web/src/components/domains/nyx/NyxObservationLog.tsx:694 |
clickable <div onClick> no keyboard handler, no role="button" |
| A-2 | apps/oshun/web/src/components/domains/nyx/NyxSkyRenderer.tsx:1596 |
same pattern |
| A-3 | apps/oshun/web/src/app/not-found.tsx:179 |
search <input> no aria-label |
| A-4 | apps/oshun/web/src/app/atelier/new/AtelierNewSceneForm.tsx:222 + not-found:191 |
outline: 'none' no :focus-visible replacement |
| A-5 | n/a (project-wide) | No i18n library wired — 15+ hardcoded English strings flagged across web app |
What's being addressed this pass#
Tracked as #147+ (see TaskList). In-session, immediate-impact subset:
- Reg-1, Reg-2, Reg-3 (audit-pass-3 regressions — must fix before further pass-3 deployment)
- Sec-1 + Sec-2 + Sec-3 (axios bump to catalog)
- Sec-4 (move secrets from top-level env into per-job env on workflows; sweep ~21 yml files)
- Sec-5..Sec-9 (pin 5 third-party actions to commit SHAs)
- Ex-2 (iris GraphQL depth + complexity guard via graphql-armor)
- Ex-3 (iris dashboard CSV row cap)
- Aud-1 (aphrodite moderation audit_log writes for approve/reject/escalate/assign/bulk)
- Aud-2 (content DELETE → searchService.removeContent)
- Aud-5 (extend redaction patterns: api[_-]?key|passport|license)
What's filed for separate work#
Each remaining finding is filed as a discrete task in the tracker (audit pass 4 work expanded from the original closure-only items #147-#157). Tracker IDs landed for the full v4 finding-set:
| Finding | Task | Area |
|---|---|---|
| A-1/A-2 | #158 | div-onClick → button (2 nyx components) |
| A-3 | #159 | not-found search aria-label |
| A-4 | #160 | outline:none → :focus-visible (2 files) |
| A-5 | #161 | i18n library decision + 15+ hardcoded strings |
| Ac-1 | #162 | cross-chain-bridge PUT-Partial + hex cast |
| Ac-2 | #163 | language-detection req.body cast → zod |
| Ac-3 | #164 | isis-generation-routes drop .passthrough() |
| Ac-4 | #165 | arete habits evidenceRefs .passthrough() → strict |
| Ac-5 | #166 | isis DELETE 200 → 204 alignment |
| Ac-6 | #167 | 5 lilith services missing /v1/ prefix |
| Ac-7 | #168 | 4 config services repeat PUT-Partial pattern |
| Aud-3 | #169 | DSR createAuditEvent drops eventData metadata |
| Aud-4 | #170 | backup runbook RTO/RPO target (needs SLO team) |
| Obs-1 | #171 | veritas/audio doesn't read inbound X-Request-ID |
| Obs-2 | #172 | 6 veritas third-party providers — no http_client_durn |
| Obs-3 | #173 | veritas BullMQ workers — no job metrics |
| Obs-4 | #174 | broll cache hit/miss not exposed to Prometheus |
| Obs-5 | #175 | stripe adapter — no timing histogram |
| Obs-6 | #176 | veritas — no slow-request/slow-query log threshold |
| Ex-1 | #177 | maat WS JSON.parse no depth limit |
| Ex-4 | #178 | maat WS broadcastToRoom no bufferedAmount check |
| Ex-5 | #179 | yemaya storyboards unbounded Promise.all fan-out |
| Ex-6 | #180 | ffmpeg processor no input size pre-check |
| (batch) | #181 | a11y/i18n remaining 20 findings (gated on #161) |
Audit pass 5 (#182) was launched as a follow-up adversarial sweep covering test coverage, db/schema integrity, secrets/crypto/env, concurrency races, and error-handling null safety — areas not deeply enumerated by the 7 agents of pass 4. Findings will land as #183+.