## Security/privacy review — changes required Reviewed the complete tracked diff and all five untracked files against `96df2a255dcfb73ea56257997a15e64d58824d64`. ### P0 — 0 ### P1 — 2 1. **Internal numeric document ID leaks through the private API** - `scripts/health/dashboard_v5/lab_review.py:321-334` - `transfer_preview.old` and `.new` are returned directly from staging JSON. `new` originates from `transfer_preview()`, where it contains the internal numeric `document_id`. - **Counterexample verified synthetically:** after staging candidate `cand_111…`, `/api/v1/lab-review` returned `transfer_preview.new.document_id = 1` as an integer. That exposes the identifier used by the legacy `/health-doc/1` namespace, defeating the otherwise opaque `api-document-…` boundary. - Return an allowlisted projection only; omit internal IDs and other staging-only fields. 2. **Advertised preview-revision binding is not enforced** - `scripts/health/health_dashboard_action_worker.py:1832-1840` - Related permissive validation: `scripts/health/health_dashboard_server.py:1143-1162` - `expected_candidate_revision` and `preview_revision` are optional, and the worker only compares the candidate revision. It never recomputes or compares `preview_revision`, although the preview hash binds the comparison digest, value, unit, date, reference, and target. - **Counterexample verified synthetically:** fetch a preview, change `document_candidate_matches.comparison_digest` without changing `candidate_revision`, then submit the stale action with the original `preview_revision`; the worker accepted it (`stale_preview_accepted=true`) and staged the candidate. - Require both bindings for all new lab candidate actions and compare the submitted hash with a transactionally recomputed current preview hash. ### P2 — 2 1. **Page query parser is not strict per selected tab** - `scripts/health/assets/health-assets/dashboard-v5-record.js:20-28,161-176` - Every filter name is added to one global allowlist, but only filters belonging to the selected tab are read. Parameters valid for another tab are silently ignored. - **Counterexample:** `?view=record&tab=labs&institution=SensitiveClinic` is accepted, while `institution` is discarded rather than rejecting the route. This also leaves unnecessary sensitive text in URL/history. - Validate keys against the selected tab’s exact key set plus its common route keys. 2. **Unsanitized provenance field can disclose a local path or filename** - `scripts/health/dashboard_v5/lab_review.py:299-305` - `document_candidates.engine` is copied verbatim into the browser response, bounded only by length. - **Counterexample:** an existing row with `engine='/home/alice/.hermes/bin/private-ocr'` returns that path as `details.provenance.engine`. - Map engines to a fixed public enum/label allowlist rather than projecting the stored string. ### Other checks - Private V5 page/API authentication, browser session, exact-origin checks, one-time CSRF, parameterized SQL, queue identity, transaction boundaries, and synthetic test path isolation showed no additional release-blocking issue. - New tests/fixtures use temporary databases and `/tmp` browser-matrix state; no production-write path was found. - `git diff --check` passed. - Review left the repository unchanged. - Full pytest execution was unavailable because pytest is not installed in the active environment; the two P1 counterexamples were exercised with disposable synthetic SQLite state instead.