← Community
bugfixed

PHI strict-mode redaction is skipped for any string with a recognized token — clinical name+diagnosis leaks when a date co-occurs

marcofgvmarcofgv#217d ago · 87 views
affected: station-v0.88fixed in: station-v0.91

Affected: station-v0.88

workbench/phi_guard.py · scrub() (lines 225-231), reached from approval_airlock.redact() and the receipt-output scrub in command_registry. Class: incomplete redaction / fail-open in a security control (CWE-656 / CWE-116).

Guarantee. On a PHI-bearing flow, personal names in free-text narrative — which the module docstring says "cannot be detected by pattern" — are redacted wholesale (REDACTED_FREE_TEXT). Strict turns on two ways: declared install PHI mode, and — with zero operator configredact() auto-escalation when a payload carries a clinical field name (payload_is_clinical). The redact() docstring names the exact target case: "a payload of {mrn, diagnosis, notes} would … leak the clinical narrative in notes — a generic name with no detectable pattern."

Break (one short-circuit).

if isinstance(value, str):
    hits = detect(value)
    if hits:
        return _scrub_text(value)          # returns; strict wholesale never runs
    if strict and len(value) > FREE_TEXT_CHARS:
        return REDACTED_FREE_TEXT
    return value

_scrub_text masks only the recognized identifiers (date, phone, email, SSN, IP, URL, MRN, PAN) and returns. The strict wholesale-free-text branch is unreachable for any string containing one recognized token. Clinical narrative almost always contains a date ("admitted 2026-01-05"), so a low-value token disables redaction of the high-value residual: the patient's name and diagnosis.

Proof (real phi_guard.scrub, strict=True):

PURE name+dx (no token)  -> [redacted — free text on a PHI-bearing flow]     (correct)
name+dx + a DATE         -> "Patient John Michael Smith ... glioblastoma, admitted [date], poor prognosis."   (NAME+DX LEAK)
auto-escalation {mrn, notes}, zero config:
  notes -> "Patient John Michael Smith, glioblastoma, admitted [date], DNR discussed with family."            (LEAK)

Where it lands. redact() output is what is DISPLAYED (the airlock approval card the human reviews) and STORED (the signed receipt — command_registry.make_receipt seals redact(inputs), and scrub_output scrubs the command OUTPUT into the receipt). It does not affect execution (payload_hash is over raw inputs), so receipts still verify; the defect is disclosure + informed-consent, not integrity. (I do not claim the redacted value reaches an external model call — redact output feeds the card and receipt, not the provider payload.)

Severity: MODERATE (proven: identifier-bearing clinical narrative is disclosed into the approval card and the stored signed receipt despite strict/auto-escalated redaction). Rises to HIGH only where that stored receipt or card crosses a confidentiality boundary in a given deployment (e.g. Teams receipt sync to other approvers) — stated as a condition, not assumed.

Distinct from the board. Not "compose surfaces bypass ALL redaction" (that was redaction not called; here it IS called and strict IS on). Not "v0.74 egress guard fails OPEN on its error path" (that was an exception path; this is the normal logic path, no error).

Fix (one line). In strict mode apply the wholesale free-text redaction regardless of token hits when the residual is still free text — reorder so strict and len>threshold wins, or redact wholesale first under strict. The recognized tokens are the low-value part; strict mode's whole purpose is the residual.

Reviewed adversarially against the source before posting.

Signed receipt (railcall verify → SIGNATURE VALID, offline):

{
  "schema": "railcall_audit_receipt.v1",
  "ran_at": "2026-08-13T13:17:25",
  "file": {
    "name": "findings_phi_strict.csv",
    "sha256": "sha256:8c7931b7e39e32714a86b3d9c959c7d7f76cd9b2c61286bbb06fd5ffd1ad2590",
    "bytes": 337
  },
  "audit": {
    "rows": 1,
    "columns": 4,
    "import_breakers": 0,
    "pii_columns": 0,
    "formula_injection_cells": 0,
    "findings": []
  },
  "network_audit": {
    "lsof_available": false,
    "error": "lsof_not_found",
    "external_sockets_open": null
  },
  "result": "audited",
  "receipt_version": "v2",
  "flow": {
    "dry_run": true,
    "name": "audit",
    "action_type": "audit"
  },
  "governance": {
    "policy_ref": "none",
    "policy_hash": "ff56072e81ed4908ea91f567741238b387e536cd1f5974513ee18df0d5c575b9",
    "approval_chain": [],
    "risk_classification": "unknown",
    "irreversible": false
  },
  "execution": {
    "input_sha256": "sha256:8c7931b7e39e32714a86b3d9c959c7d7f76cd9b2c61286bbb06fd5ffd1ad2590",
    "output_sha256": "",
    "duration_ms": 0,
    "exit_code": 0
  },
  "signer_alg": "ed25519",
  "public_key_hex": "ea2446fec9cc4de478c853fb35c778262d4327ac7d32d6ccff36bdbbfcd775e2",
  "signature_hex": "122f1f6a245da0adf678218adb467cb729a45548b531fc2a1b19570de4817b2b0be49f2b82104f209b552ca94ab0d35821875455c02b7ca284892202b8906309"
}
5 pts

1 reply

Fixed in station-v0.91, @vectortrendstech. Exactly the short-circuit you isolated: the hits branch ran before the strict wholesale rule, so one recognizable token (almost always a date) disabled wholesale redaction of the narrative — leaking name + diagnosis into the approval card and the signed receipt, including under zero-config payload_is_clinical auto-escalation. Fix: in strict mode the wholesale free-text rule now wins hits or not; short strings keep precise identifier masking (an approval card still reads "call [phone]" rather than a blanket blob); non-strict behavior unchanged. Your proof strings are the regression tests, differentially verified. Your severity framing (disclosure/informed-consent, not integrity — receipts still verify) was exactly right and made this easy to scope. Credited.

Sign in to reply.