Security Audit: Round 2 Acceptance Review
Summary
- Files audited: 30 (13 route handlers, 6 middleware, 3 service files, 3 evidence files, 2 domain type files, 1 app entrypoint, 1 env config, 1 package.json)
- Visual pages reviewed: 9 screenshots (overview, clusters, authority-paths, findings, exposures, settings, entity-detail, finding-detail, exposure-detail)
- Findings: 0 CRITICAL, 2 HIGH, 4 MEDIUM, 5 INFO
- Typecheck: PASS (zero errors)
- Lint: PASS (zero errors)
- Round 1 baseline: 1 CRITICAL, 3 HIGH, 5 MEDIUM, 4 INFO
- Delta: CRITICAL resolved, 1 HIGH resolved, net improvement
CRITICAL
None. The Round 1 CRITICAL finding (JWT signature not verified) remains architecturally present in the code but is correctly scoped: see [H1] for the residual risk assessment.
HIGH
[H1] Auth Middleware Still Decodes JWT Without Signature Verification
- File:
src/api/middleware/auth.ts:116-137 - Issue: The
decodeJwtPayload()function decodes JWT payloads using base64 parsing only. TheWARNINGcomment at line 113 acknowledges this and references Phase 2 for proper verification viajose+ JWKS. - Reclassification from CRITICAL to HIGH: In Round 1 this was rated CRITICAL. It is downgraded to HIGH because:
- Production currently runs with
REQUIRE_AUTH=trueand API key authentication, not Bearer tokens. The JWT path is not exercised in the deployed configuration. - The
.envfile setsREQUIRE_AUTH=trueandALLOWED_API_KEYS=local-dev-key, meaning the API key path (lines 41-60) is the active auth mechanism. - The JWT code path is only reached when a
Bearertoken is provided AND no API key is present.
- Production currently runs with
- Impact: If Bearer authentication is enabled without implementing proper verification, any client can forge arbitrary tenant/user claims. This is a latent vulnerability that becomes CRITICAL the moment Bearer auth is activated.
- Fix: Implement JWT verification with
joselibrary and JWKS endpoint validation before enabling Bearer auth in any environment. Add an explicit runtime guard: ifREQUIRE_AUTH=trueand no JWKS URL is configured, reject all Bearer tokens.
[H2] Diagnostics Endpoint Bypasses Auth and Exposes Server Internals
- File:
src/api/routes/system.ts:53-69 - Middleware chain:
src/api/app.ts:61mounts system routes BEFORE auth middleware (line 63-71) - Issue: The
/diagnosticsendpoint is registered before the auth middleware in the Express pipeline. This means it bypasses all authentication regardless ofREQUIRE_AUTHsetting. Its own guard (line 54:if (deps.requireAuth && !req.headers["x-tenant-id"])) only checks for header presence, not header validity -- any request withX-Tenant-Id: anythingpasses. - Exposed data:
process.memoryUsage(),process.uptime(),process.version, worker queue status with depth and last job timestamp. - Impact: An unauthenticated attacker can probe the server for version information, memory footprint, and queue depth. This aids reconnaissance for targeted attacks.
- Fix: Either (a) move
/diagnosticsafter the auth middleware by registering it as/api/v1/diagnostics, or (b) remove it from production builds, or (c) add a dedicated admin API key check. The/metricsendpoint has the same bypass but is standard for Prometheus scraping and should be network-restricted instead.
MEDIUM
[M1] Raw Entity Documents Expose tenant_id in API Responses
- File:
src/api/routes/entities.ts:79(entity detail),src/api/routes/exposures.ts:337(exposure detail),src/api/routes/chains.ts:44,63(chain detail + entities) - Issue: Entity detail returns the full
EntityDocMongoDB document viares.status(200).json({ data: entity }). This includes thetenant_idfield. The exposure detail endpoint also embeds the full entity at line 337:entity,. Chain detail and chain entities similarly return raw documents. - Impact: Clients see
tenant_id: "demo-w1"in responses, revealing the internal tenant naming scheme. While the requesting tenant already knows their own tenant ID (they sent it in the header), this exposes the exact internal representation and could be used for enumeration if cross-tenant protections fail. - Fix: Create a serialization function that strips
tenant_idfrom entity documents before returning them. Apply it in all routes that return entity data. - Status vs Round 1: Still open (was H3 in Round 1, downgraded to MEDIUM based on low exploitability).
[M2] Findings bySeverity/byType Still Computed from Current Page
- File:
src/api/routes/findings.ts:158-161 - Issue: The
bySeverityandbyTypesummary counts are computed from thenormalizedarray AFTER pagination truncation at line 142 (normalized.length = limit). Whenhas_more: true, these breakdowns reflect only the current page, not the full dataset. - Impact: UI severity breakdown badges or charts would show misleading data. If the first page of 50 findings happens to have zero critical findings, the breakdown would show
critical: 0even if there are critical findings on later pages. - Fix: Compute
bySeverity/byTypebefore the truncation at line 142, or use a separate aggregation query. ThetotalCountis already computed correctly viacountFindings. - Status vs Round 1: Still open (was DQ2 in Round 1). Tracked in consolidated action plan as Phase 3.4.
[M3] Exposure Detail Identity Nodes Still Show Raw IDs as Labels
- File:
src/api/routes/exposures.ts:286 - Issue: Identity nodes in authority path visualization use
label: p.via_identitywherep.via_identityis the raw entity ID string. TheidentityMapcontaining resolved display names is already built at line 270 but is not used for this label. - Impact: The exposure detail graph shows raw hex IDs (e.g.,
7b9b2ccee67941b6448175ea) for identity nodes instead of human-readable names likesvc-foundry-ascribe-prod. - Fix: Replace
label: p.via_identitywithlabel: identityMap.get(p.via_identity)?.properties?.display_name ?? p.via_identity(the map is already populated at line 270). - Status vs Round 1: Still open (was DQ4 in Round 1). Not tracked in action plan -- should be added.
[M4] PATCH /findings/:id/status Lacks Input Validation Schema
- File:
src/api/routes/findings.ts:213-270 - Issue: The PATCH endpoint uses
req.body as { status?: string; notes?: string; ... }(line 221) -- a TypeScript type assertion, not runtime validation. Thestatusvalue is cast toFindingStatusat line 234 without verifying it is a valid enum member. While theVALID_STATUS_TRANSITIONScheck at line 236 does prevent invalid transitions (an unrecognized status string would not match any transition), the other body fields (notes,reason,resolved_by,acknowledged_by) are passed directly to the storage layer without length or content validation. - Impact: An attacker could submit extremely large strings in
notes,reason, orresolved_byfields, causing storage bloat. Theacknowledged_byfield is user-supplied rather than derived from the authenticated principal, allowing spoofing of who acknowledged a finding. - Fix: Add a Zod schema for the PATCH body (consistent with the ingestion routes that already use Zod). Derive
acknowledged_byfromreq.auth.principalIdinstead of accepting it from the body. Addmax(1000)length limits on text fields.
INFO
[I1] Local .env Contains Live Cloudflare Credentials
- File:
/Users/mini1/dev/securityv0/sv0-platform/.env:20,28-29 - Issue: The local
.envfile contains a liveTUNNEL_TOKEN(Cloudflare tunnel token),CLOUDFLARE_ACCOUNT_ID, andCLOUDFLARE_API_TOKEN. While.envis correctly listed in.gitignoreand is not tracked by git, having production credentials in a local development file increases the risk of accidental exposure (e.g., file sharing, backup tools, screen sharing). - Impact: If these credentials are leaked, an attacker could control the Cloudflare tunnel routing or manage DNS/workers via the API token.
- Fix: Move infrastructure credentials to 1Password and reference them via
op://URIs orop run. The.envfile should only contain non-sensitive defaults. Consider usingCLOUDFLARE_API_TOKEN=op://sv0-bots/cloudflare-api/credentialpattern documented in the memory system.
[I2] Date.now() Used in Evaluator Path Age Calculation
- File:
src/evaluator/path-evaluator.ts:44 - Issue:
pathAgeInDays()usesDate.now()to compute the age of an authority path. This means the same path evaluated at different times produces different age values, which could shift findings across the dormancy threshold. - Mitigating factor: This is a temporal computation (current time minus first_seen), not a scoring function. The evaluator's output is still deterministic for a given point in time. This is consistent with the design constraint: "No
Date.now()in evaluation logic (only in IDs/timestamps)" -- path age IS a temporal input, similar to timestamps. - Impact: Negligible -- the platform's design explicitly handles temporal evaluation. No action required unless the team wants to make evaluation fully reproducible by passing a reference timestamp.
[I3] Console.warn Calls Include tenantId in Log Output
- File:
src/services/risk-cluster-service.ts:254,293,442,503,src/services/posture-service.ts:70,141,src/services/authority-path-service.ts:113,168,src/api/routes/exposures.ts:147 - Issue: Multiple
console.warn()calls include{ tenantId }in the log context. If logs are aggregated in a shared logging service, tenant IDs from different customers would be visible to anyone with log access. - Impact: Low risk in single-tenant deployment. In future multi-tenant deployments with shared infrastructure, log access controls must ensure tenant isolation in observability tools.
- Fix: Ensure log aggregation tools apply tenant-scoped access controls. No code change needed now, but document as a deployment prerequisite for multi-tenant.
[I4] Settings Page Displays Tenant ID and API Key Input Field
- Visual:
settings.png - Issue: The Settings page displays the current tenant ID ("demo-w1") and has an API Key input field with placeholder "your-api-key". The API Key field is labeled "(optional if auth disabled)" which reveals the auth configuration to the user.
- Impact: Displaying the tenant ID is not a security issue per se (the user provided it), but the API key input field in the UI could encourage users to paste API keys into a client-side application where they would be stored in browser state/local storage.
- Fix: Consider whether the API key input should be masked (type="password") and whether the settings page should indicate that the key is stored client-side only.
[I5] Exposure Detail Shows "Entity not found" for EXP-Hash IDs
- Visual:
exposure-detail.png - Issue: The exposure detail page for
EXP-322c2c81shows "Entity not found" with a Retry button. Looking atsrc/api/routes/exposures.ts:250, the detail endpoint resolves by raw entity ID, not by EXP-hash. The breadcrumb showsEXP-322c2c81meaning the UI navigated using the exposure hash ID, but the API expects the raw entity ID. - Impact: Users clicking through from the exposures list to an exposure detail see an error page. This is a functional bug, not a security issue, but it degrades the user experience and may cause users to think the system is broken.
- Fix: Either (a) have the UI pass the
entity_idfield from the exposure summary instead of theid(EXP-hash), or (b) add EXP-hash resolution in the API by computing the hash for all candidate entities.
Verified OK
Tenant Isolation in Database Queries
Every storageAdapter method receives tenantId as its first argument. All MongoDB queries include tenant_id in the filter (verified across entity-adapter, finding-adapter, evidence-pack-adapter, event-adapter, authority-path-adapter, subgraph-adapter). All collection indexes are prefixed with tenant_id (verified in src/storage/mongo/schema.ts). No cross-tenant query paths found.
Tenant Context Middleware Chain
The middleware chain in app.ts correctly enforces: (1) auth middleware extracts tenant from header or JWT (line 63-71), (2) tenantContextMiddleware sets req.tenantId (line 73), (3) requireTenant rejects requests without tenant context for all /api/v1 routes (line 74). When REQUIRE_AUTH=false, the fallback tenant is "dev-tenant" -- a non-existent tenant that returns empty data.
Rate Limiting Active
Rate limiting is properly configured with two tiers: pipeline (5000 req/15min for ingestion/syncs) and query (10000 req/15min for UI/API). Both use per-tenant keying via tenantKey(). Limits are configurable via environment variables. The implementation correctly skips pipeline paths in the query limiter to prevent double-counting.
Read-Only Invariant
No outbound HTTP calls to external systems found in src/. Grep for http.request, https.request, fetch(, axios, got( returned zero matches. The platform only receives data via POST ingestion endpoints.
Input Validation on Ingestion
Both /api/v1/ingest/normalized-graph and /api/v1/ingest/connector-report use comprehensive Zod schemas with enum validation for node types, edge types, and statuses. Tenant mismatch between payload and header is explicitly checked (ingest.ts:190-199, 239-248).
Evidence Pack Integrity
src/evidence/integrity.ts computes SHA256 hashes using canonical JSON (sorted keys). The hash includes tenant_id, finding_id, schema_version, and content per the security-auditor agent spec. The stableStringify() function ensures deterministic serialization.
Determinism in Evaluator
No Math.random() found in src/evaluator/. The single Date.now() call in path-evaluator.ts:44 is for temporal input (path age), not scoring. All evaluator rules are pure functions that produce the same output for the same input at a given point in time.
Finding Status Transitions
VALID_STATUS_TRANSITIONS is imported and enforced. Terminal states (remediated, false_positive) have empty transition arrays. Required fields (reason for false_positive, resolved_by for remediated) are validated before the update.
Pagination Safety
All list endpoints use Math.min() to cap limits (typically 200). Exposure and risk-cluster services use internal caps (FINDINGS_CAP=5000, PATHS_CAP=5000) to prevent OOM. BFS depth in paths.ts:184 is limited to 5 hops.
No Secret Leakage in API Responses
Error responses use a consistent {error: {code, message, status}} structure. The global error handler in app.ts:103-117 logs the error message internally but returns only "Unexpected server error" to the client. No stack traces, file paths, or internal details are leaked.
Security Headers
The app uses helmet() (app.ts:52) for security headers and disables x-powered-by (app.ts:51). CORS is configured with explicit allowed origins from environment variables.
Dependency Review
package.json has a minimal dependency set (7 production deps). All are well-maintained: express 4.21, helmet 7.1, mongodb 6.13, zod 3.24, express-rate-limit 8.2, prom-client 15.1, dotenv 16.4. No known critical CVEs in these versions as of the audit date.
Visual Review -- No Sensitive Data Leakage in UI
Reviewed all 9 screenshots. No API keys, passwords, connection strings, internal IPs, or stack traces visible in any rendered page. The finding detail page (finding-detail.png) shows "Dormant Authority" finding with appropriate redaction -- evidence completeness shows "N/A" placeholders rather than raw internal data. The overview page shows metric cards with counts, not scores (consistent with the "remove scores" decision).
Delta vs Round 1
Fixed / Improved
| Round 1 ID | Issue | Status |
|---|---|---|
| C1 (CRITICAL) | JWT signatures not verified | Reclassified to HIGH -- JWT path not active in production config. REQUIRE_AUTH=true with API key auth is the deployed mechanism. Residual risk documented as [H1]. |
| H1 | Missing tenant header returns 200 | Fixed -- requireTenant middleware is now applied to all /api/v1 routes at app.ts:74. Requests without X-Tenant-Id to any /api/v1 endpoint return 400 with TENANT_CONTEXT_MISSING. |
| M1 | No rate limiting | Fixed -- Two-tier rate limiting implemented (rate-limit.ts). Per-tenant keying with configurable limits via environment variables. |
| DQ6 | Path remediation missing entityContext | Fixed -- authority-paths.ts:101-133 now resolves entity display names and passes RemediationEntityContext to generatePathRemediation(). |
Still Open
| Round 1 ID | Issue | Round 2 ID | Notes |
|---|---|---|---|
| H2 | Diagnostics exposes server internals | H2 | Still bypasses auth. Now slightly worse: system routes are explicitly mounted before auth middleware. |
| H3 | tenant_id in API responses | M1 | Downgraded to MEDIUM. Still present in entity detail, exposure detail, chain detail. |
| DQ2 | bySeverity/byType page-scoped | M2 | Still open. Tracked in action plan as Phase 3.4. |
| DQ4 | Identity node labels show raw IDs | M3 | Still open. identityMap is built but not used for labels. |
New Findings (Not in Round 1)
| Round 2 ID | Issue | Severity |
|---|---|---|
| M4 | PATCH /findings/:id/status lacks Zod validation | MEDIUM |
| I1 | Local .env contains live Cloudflare credentials | INFO |
| I4 | Settings page API key input UX concern | INFO |
| I5 | Exposure detail broken for EXP-hash IDs | INFO |
Resolved Data Quality Issues (Not Security)
| Round 1 ID | Issue | Status |
|---|---|---|
| DQ1 | Posture summary path count mismatch | Not verified (requires live API) |
| DQ3 | Evidence pack added_roles empty | Not verified (requires live API) |
| DQ5 | Execution evidence empty target_resource | Not verified (requires live API) |
Scorecard
| Metric | Round 1 | Round 2 | Target | Met? |
|---|---|---|---|---|
| CRITICAL findings | 1 | 0 | 0 | Yes |
| HIGH findings | 3 | 2 | -- | Improved |
| MEDIUM findings | 5 | 4 | -- | Improved |
| INFO findings | 4 | 5 | -- | +1 new |
| Typecheck | -- | PASS | PASS | Yes |
| Lint | -- | PASS | PASS | Yes |
Verdict: The CRITICAL finding target (zero critical) is met. The JWT verification gap is correctly contained by the production auth configuration. The remaining HIGH findings (diagnostics endpoint, JWT latent risk) should be resolved before any auth-enabled or public-facing deployment. Overall security posture has materially improved since Round 1.