From 06dc4be72bcbdebb991ab8ad5c8d2e2eb67fcc59 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:35:42 +0800 Subject: [PATCH 1/2] chore(readiness): assert query-hash HMAC guard is active (PIA-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the operational half of PIA-2 by making `npm run check:production-readiness` positively assert the query-hash redaction guard, instead of the prior silent no-op that only fired inside a production-like environment. - Add checkQueryHashGuardWiring(): reads src/instrumentation.ts and asserts it still invokes requireQueryHashSecret(), so the fail-closed boot guard can't be silently dropped by a refactor. Emits an observable PASS in every environment, including CI (where the secret-presence check stays intentionally quiet). - Exercise the real requireQueryHashSecret() guard rather than re-encoding the env rule (mirrors requireServerEnv/requireOpenAIEnv): PASS when the secret is set; fail closed only in a production-like env. - Correct stale PIA-2 docs (privacy-impact-assessment.md §5.1/§10 + summary) that still claimed nothing enforced the secret in production. The code guard, boot wiring, unit tests, and GitHub secret already shipped; the remaining PIA-2 step is the operator placing RAG_QUERY_HASH_SECRET in the deploy host's secret store. Co-Authored-By: Claude Opus 4.8 --- docs/privacy-impact-assessment.md | 55 +++++++++++++++++++------------ scripts/production-readiness.ts | 49 +++++++++++++++++++++++++-- 2 files changed, 80 insertions(+), 24 deletions(-) diff --git a/docs/privacy-impact-assessment.md b/docs/privacy-impact-assessment.md index fae5b515f..eabaa8375 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 | Medium | Generated **answer text is stored un-redacted** in `rag_queries`, and can restate the patient details from the query. | -| 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 | Medium | Generated **answer text is stored un-redacted** in `rag_queries`, and can restate the patient details from the query. | +| 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 @@ -333,15 +340,20 @@ compliance-posture and PHI-minimisation gaps. policy. (3) Consider an **on-query PHI reminder** ("do not enter identifiable patient details"). (4) Optionally, a lightweight PHI-scrub / entity-strip on the outbound query as defence-in-depth. -### 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` **(Medium)** @@ -388,7 +400,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), then **PIA-3/4** +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), then **PIA-3/4** as fast follow-ups (minimise answer retention; 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/scripts/production-readiness.ts b/scripts/production-readiness.ts index a63b05d3a..8d3d91e8d 100644 --- a/scripts/production-readiness.ts +++ b/scripts/production-readiness.ts @@ -127,12 +127,43 @@ 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(); recordDemoModeProductionCheck(); recordRawQueryPersistenceProductionCheck(); 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 @@ -188,9 +219,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 ?? "")) { From 417470e988745380c8ee32946d0cf8d7774ee1b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:37:34 +0000 Subject: [PATCH 2/2] Merge remote-tracking branch 'origin/main' into claude/query-hash-hmac-secret-0e44d3 --- package-lock.json | 24 ------------------------ 1 file changed, 24 deletions(-) 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": [