diff --git a/docs/privacy-impact-assessment.md b/docs/privacy-impact-assessment.md index 053b946b3..9ffb25de0 100644 --- a/docs/privacy-impact-assessment.md +++ b/docs/privacy-impact-assessment.md @@ -42,15 +42,15 @@ material. **Top gaps (full register in §10):** -| ID | Risk | One-line | -| ----- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| PIA-1 | High | Cross-border disclosure to OpenAI (US) has no code-visible DPA/ZDR and no user-facing notice/consent (APP 8, APP 5). | -| PIA-2 | High | Query-hash HMAC downgrades to **unsalted, dictionary-reversible SHA-256** if `RAG_QUERY_HASH_SECRET` is unset — nothing enforces it in production. | -| PIA-3 | Resolved | Generated answer text is no longer persisted to `rag_queries` by default; answer-text persistence is gated behind `RAG_PERSIST_ANSWER_TEXT` (default off, blocked in production readiness). | -| PIA-4 | Medium | `rag_query_misses` has **no retention/purge job** (only `rag_queries` and `rag_retrieval_logs` do). | -| PIA-5 | Medium | No privacy policy / collection notice / data-handling documentation shipped (APP 1, APP 5). | -| PIA-6 | Low-Med | OpenAI **prompt-cache retention is forced to 24h** for gpt-5.5 regardless of config — query + retrieved excerpts persist ≤24h at OpenAI. | -| PIA-7 | Low | `RAG_PERSIST_RAW_QUERY_TEXT=true` would store raw PHI query text with no secondary safeguard beyond the 30-day purge. | +| ID | Risk | One-line | +| ----- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| PIA-1 | High | Cross-border disclosure to OpenAI (US) has no code-visible DPA/ZDR and no user-facing notice/consent (APP 8, APP 5). | +| PIA-2 | High | Query-hash HMAC would downgrade to **unsalted, dictionary-reversible SHA-256** without `RAG_QUERY_HASH_SECRET` — now **enforced**: production fails closed at boot; operator must place the secret. | +| PIA-3 | Resolved | Generated answer text is no longer persisted to `rag_queries` by default; answer-text persistence is gated behind `RAG_PERSIST_ANSWER_TEXT` (default off, blocked in production readiness). | +| PIA-4 | Medium | `rag_query_misses` has **no retention/purge job** (only `rag_queries` and `rag_retrieval_logs` do). | +| PIA-5 | Medium | No privacy policy / collection notice / data-handling documentation shipped (APP 1, APP 5). | +| PIA-6 | Low-Med | OpenAI **prompt-cache retention is forced to 24h** for gpt-5.5 regardless of config — query + retrieved excerpts persist ≤24h at OpenAI. | +| PIA-7 | Low | `RAG_PERSIST_RAW_QUERY_TEXT=true` would store raw PHI query text with no secondary safeguard beyond the 30-day purge. | --- @@ -185,7 +185,7 @@ service-role for writes). Redaction is applied centrally at every write site. | `rag_retrieval_logs` | No (hash placeholder) | same helpers; write at [search/route.ts:556-559](src/app/api/search/route.ts) | retrieval telemetry only | owner-read, [schema.sql:3938](supabase/schema.sql) | | `audit_logs` | N/A (no query text) | action/resource metadata only; error strings pass through `redactLogValue` ([privacy.ts:5-31](src/lib/privacy.ts)) | `owner_id`, `action`, `resource_id` | service-role-only, [schema.sql:3959](supabase/schema.sql) | -### 5.1 M15 HMAC query-hash fix — verified present, conditionally active +### 5.1 M15 HMAC query-hash fix — verified present, enforced in production The audit's **M15** remediation is in the code ([src/lib/query-privacy.ts:17-23](src/lib/query-privacy.ts)): @@ -208,10 +208,17 @@ export function hashQueryText(query: string) { and match rows, and can correlate the same query across rows. This defeats the redaction it is meant to provide. -**Nothing in the codebase forces the secret to be present in production.** `RAG_QUERY_HASH_SECRET` -is `z.string().min(16).optional()` ([src/lib/env.ts:104](src/lib/env.ts)) with no production guard. -This is gap **PIA-2** — the fix is real but its safety depends on an operator setting that is not -enforced. +**Status (PIA-2 — enforcement landed):** production now **fails closed** when the secret is absent. +`requireQueryHashSecret()` ([src/lib/env.ts](src/lib/env.ts)) throws at server startup +([src/instrumentation.ts](src/instrumentation.ts)) when `NODE_ENV=production` and +`RAG_QUERY_HASH_SECRET` is unset, so a misconfigured clinical server refuses to boot rather than +degrade to the unsalted digest. `npm run check:production-readiness` additionally asserts the boot +guard is wired into the startup path and that the secret is present, and +[tests/instrumentation.test.ts](tests/instrumentation.test.ts) / +[tests/env-query-hash-secret.test.ts](tests/env-query-hash-secret.test.ts) cover the fail-closed +behaviour. The schema stays `z.string().min(16).optional()` so dev/CI keep the legacy digest for +stored-row joins. **Remaining operator action:** place the secret in the deploy host's secret store — +the guard and assertion enforce its presence but cannot supply it. ### 5.2 Redaction helper coverage @@ -341,15 +348,20 @@ compliance-posture and PHI-minimisation gaps. offers **Australia data residency** (storage) — an option that postdates this PIA. The remaining step (execute DPA / apply ZDR / counsel sign-off) is operator/legal, not code. -### PIA-2 — Query-hash HMAC silently downgrades without the secret **(High)** +### PIA-2 — Query-hash HMAC silently downgrades without the secret **(High — enforcement landed)** - **Risk:** If `RAG_QUERY_HASH_SECRET` is unset in prod, stored query hashes are unsalted SHA-256 → dictionary-reversible and cross-row correlatable, defeating the redaction (undoes M15). -- **Evidence:** [query-privacy.ts:17-23](src/lib/query-privacy.ts); optional with no prod guard - ([env.ts:104](src/lib/env.ts)). -- **Fix:** Make `RAG_QUERY_HASH_SECRET` **mandatory in production** — fail closed at startup (mirror the - `requireServerEnv` pattern) when `NODE_ENV=production` and the secret is missing. Set it on the live - project now. +- **Evidence:** [query-privacy.ts:17-23](src/lib/query-privacy.ts); the secret is + `z.string().min(16).optional()` in [env.ts](src/lib/env.ts). +- **Fix (landed):** `requireQueryHashSecret()` now makes the secret **mandatory in production** — it + fails closed at startup ([instrumentation.ts](src/instrumentation.ts)) when `NODE_ENV=production` and + the secret is missing, mirroring the `requireServerEnv` pattern. `check:production-readiness` asserts + the boot guard is wired in and the secret is present; covered by + [instrumentation.test.ts](tests/instrumentation.test.ts) and + [env-query-hash-secret.test.ts](tests/env-query-hash-secret.test.ts). +- **Remaining (operator):** place `RAG_QUERY_HASH_SECRET` in the deploy host's secret store on the live + project (the code enforces its presence but cannot supply the value). ### PIA-3 — Generated answers stored un-redacted in `rag_queries` **(Resolved)** @@ -403,7 +415,8 @@ compliance-posture and PHI-minimisation gaps. ## 11. Recommendation Before the app is used with real patients in a WA clinical setting, close **PIA-1** and **PIA-2** as -launch-blockers (cross-border contractual basis + user notice; mandatory HMAC secret). **PIA-3** is +launch-blockers (cross-border contractual basis + user notice; the mandatory HMAC secret is now +enforced in code — the remaining step is placing it in the deploy host's secret store). **PIA-3** is closed (answer text is no longer persisted by default; gated behind `RAG_PERSIST_ANSWER_TEXT`); **PIA-4** remains as a fast follow-up (purge `rag_query_misses`), and ship the **PIA-5** privacy documentation. The data-at-rest security posture (Sydney residency, RLS, private storage, query hashing, automated purge) is already strong and should be highlighted in the privacy policy as diff --git a/package-lock.json b/package-lock.json index 41cebc91b..4d71786ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1852,9 +1852,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1871,9 +1868,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1890,9 +1884,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1909,9 +1900,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2558,9 +2546,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2578,9 +2563,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2598,9 +2580,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2618,9 +2597,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/scripts/production-readiness.ts b/scripts/production-readiness.ts index c2f2db5a8..d033e847a 100644 --- a/scripts/production-readiness.ts +++ b/scripts/production-readiness.ts @@ -136,6 +136,36 @@ async function checkFileForServiceRoleExposure() { } } +// PIA-2: the query-hash HMAC guard only redacts logged clinical queries if it is +// actually invoked at boot. Assert the fail-closed call is still wired into the +// startup path (src/instrumentation.ts) so a refactor can't silently drop it and let +// production start writing unsalted, dictionary-reversible SHA-256 hashes. The +// behavioural proof lives in tests/instrumentation.test.ts; this is a check-time +// signal that the guard is active in every environment, including CI where the +// secret-presence check below is intentionally quiet. The regex matches the call +// form (`requireQueryHashSecret(`), not the bare import destructuring. +async function checkQueryHashGuardWiring() { + const instrumentationPath = path.join(process.cwd(), "src", "instrumentation.ts"); + let source: string; + try { + source = await readFile(instrumentationPath, "utf8"); + } catch { + result.failures.push( + "Cannot read src/instrumentation.ts to verify the RAG_QUERY_HASH_SECRET boot guard is active.", + ); + return; + } + if (/\brequireQueryHashSecret\s*\(/.test(source)) { + result.passes.push( + "Boot guard invokes requireQueryHashSecret(); the query-hash HMAC fails closed in production (PIA-2).", + ); + } else { + result.failures.push( + "src/instrumentation.ts no longer invokes requireQueryHashSecret(); the query-hash HMAC boot guard (PIA-2) is not active.", + ); + } +} + async function main() { checkNodeRuntime(); recordNoAuthProductionCheck(); @@ -143,6 +173,7 @@ async function main() { recordRawQueryPersistenceProductionCheck(); recordAnswerPersistenceProductionCheck(); await checkFileForServiceRoleExposure(); + await checkQueryHashGuardWiring(); if (!(await checkRequiredFile(path.join(process.cwd(), "package-lock.json"), "package-lock.json is required"))) { // keep going so we can show all diagnostics @@ -198,9 +229,21 @@ async function main() { } } - const productionLike = process.env.NODE_ENV === "production" || process.env.VERCEL_ENV === "production"; - if (productionLike && !envModule.env.RAG_QUERY_HASH_SECRET) { - result.failures.push("RAG_QUERY_HASH_SECRET is required in a production-like environment."); + // Exercise the real boot guard so this check tracks its behaviour instead of + // re-encoding the env rule (mirrors requireServerEnv/requireOpenAIEnv above). A + // present secret passes in any environment; a missing one fails closed only in a + // production-like environment (dev/CI keep the legacy digest for stored-row joins). + try { + envModule.requireQueryHashSecret(); + result.passes.push( + "RAG_QUERY_HASH_SECRET is set; logged clinical-query hashes are keyed HMAC pseudonyms (PIA-2).", + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const productionLike = process.env.NODE_ENV === "production" || process.env.VERCEL_ENV === "production"; + if (productionLike) { + result.failures.push(`Query-hash secret issue: ${message}`); + } } if (placeholderLooksLikeExample(envModule.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ?? "")) {