Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 34 additions & 21 deletions docs/privacy-impact-assessment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

---

Expand Down Expand Up @@ -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)):
Expand All @@ -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.
Comment thread
BigSimmo marked this conversation as resolved.

### 5.2 Redaction helper coverage

Expand Down Expand Up @@ -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)**

Expand Down Expand Up @@ -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
Expand Down
24 changes: 0 additions & 24 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 46 additions & 3 deletions scripts/production-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,44 @@ 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).",
Comment thread
BigSimmo marked this conversation as resolved.
);
} else {
result.failures.push(
"src/instrumentation.ts no longer invokes requireQueryHashSecret(); the query-hash HMAC boot guard (PIA-2) is not active.",
);
}
Comment thread
BigSimmo marked this conversation as resolved.
}

async function main() {
checkNodeRuntime();
recordNoAuthProductionCheck();
recordDemoModeProductionCheck();
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
Expand Down Expand Up @@ -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}`);
}
Comment thread
BigSimmo marked this conversation as resolved.
}

if (placeholderLooksLikeExample(envModule.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ?? "")) {
Expand Down
Loading