diff --git a/src/lib/__tests__/secret-shape.test.ts b/src/lib/__tests__/secret-shape.test.ts index 2cdbf3d26..3dfe578b8 100644 --- a/src/lib/__tests__/secret-shape.test.ts +++ b/src/lib/__tests__/secret-shape.test.ts @@ -9,6 +9,10 @@ describe("looksSecretShaped", () => { ["a share-link token", "see hls_def456"], ["an invite token", "hlv_0a1b2c3d"], ["an elevation token", "hle_0a1b2c3d"], + ["a native sign-in handoff code", "hlh_Ab9-xYz_12"], + ["an MCP authorization code", "hlac_eyJ0eXAi"], + ["an MCP refresh token", "hlrt_eyJ0eXAi"], + ["an MCP client id", "hlc_eyJ0eXAi"], ["an OpenAI key", "echoed sk-1234567890"], ["an Anthropic key", "sk-ant-api03-xyz12345"], ])("matches %s", (_label, text) => { diff --git a/src/lib/logging/__tests__/redact.test.ts b/src/lib/logging/__tests__/redact.test.ts index ddabcdc7e..57cd1f30c 100644 --- a/src/lib/logging/__tests__/redact.test.ts +++ b/src/lib/logging/__tests__/redact.test.ts @@ -117,6 +117,17 @@ describe("redactSecrets", () => { ).toBe('{"token": "[REDACTED]"}'); }); + it.each([ + ["an invite token", `hlv_${"0a".repeat(32)}`], + ["a session secret", `hls_${"ab".repeat(32)}`], + ["an elevation", `hle_${"cd".repeat(32)}`], + ["a native sign-in handoff code", `hlh_${"Ab9_-".repeat(9)}Ab`], + ["an MCP refresh token", `hlrt_${"x".repeat(40)}`], + ])("redacts %s wherever it appears", (_label, token) => { + expect(redactSecrets(`code=${token} next`)).not.toContain(token); + expect(redactSecrets(`value: ${token}`)).toBe("value: [REDACTED]"); + }); + it("does NOT redact non-token strings starting with `hl`", () => { expect(redactSecrets("html_render finished")).toBe("html_render finished"); expect(redactSecrets("healthcheck ok")).toBe("healthcheck ok"); diff --git a/src/lib/logging/redact.ts b/src/lib/logging/redact.ts index 426417981..51871e5d3 100644 --- a/src/lib/logging/redact.ts +++ b/src/lib/logging/redact.ts @@ -84,18 +84,21 @@ export function redactSecrets(input: string): string { // identifiers. Capture group 1 = the leading separator (preserved). .replace(/(^|[^A-Za-z0-9])sk-(?:ant-)?[A-Za-z0-9_-]{8,}/g, "$1[REDACTED]") // HealthLog hex-bodied credentials: `hlk_` access token, `hlr_` refresh - // token, `hls_` session cookie secret (v1.30.32), `hle_` step-up - // elevation (v1.30.34). Same boundary-aware shape as the `sk-` rule. The + // token, `hls_` session cookie secret (v1.30.32) and clinician share + // token, `hle_` step-up elevation (v1.30.34), `hlv_` registration invite. + // Same boundary-aware shape as the `sk-` rule. The // idempotency replay-cache already rejects bodies containing // these prefixes (CLAUDE.md headless-client-API patterns); this // is the matching egress guard for log/error surfaces. - .replace(/(^|[^A-Za-z0-9])hl[kres]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]") + .replace(/(^|[^A-Za-z0-9])hl[kresv]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]") // v1.22.0 — the MCP OAuth bridge artifacts: the authorization code // (`hlac_`), refresh token (`hlrt_`), and DCR client id (`hlc_`). All // are signed, base64url-bodied, and credential-bearing, so they are // scrubbed at the egress boundary like the native token formats above. + // `hlh_` is the one-time native sign-in handoff code, base64url too; it + // rides a custom-scheme redirect and is exchanged for a token pair. .replace( - /(^|[^A-Za-z0-9])hl(?:ac|rt|c)_[A-Za-z0-9_.-]{16,}/g, + /(^|[^A-Za-z0-9])hl(?:ac|rt|c|h)_[A-Za-z0-9_.-]{16,}/g, "$1[REDACTED]", ) // v1.7.0 — `insurance`/`insuranceNumber`/`kvnr` (German statutory- diff --git a/src/lib/secret-shape.ts b/src/lib/secret-shape.ts index 7786f7b59..482378132 100644 --- a/src/lib/secret-shape.ts +++ b/src/lib/secret-shape.ts @@ -4,7 +4,10 @@ * such text. * * `hlk_` access tokens, `hlr_` refresh tokens, `hls_` clinician share-link - * tokens, `hlv_` registration invite tokens, `hle_` elevation tokens; + * tokens and session secrets, `hlv_` registration invite tokens, `hle_` + * elevation tokens, `hlh_` native sign-in handoff codes, and the MCP OAuth + * artifacts `hlac_` (authorization code), `hlrt_` (refresh token) and `hlc_` + * (client id); * `sk-…` / `sk-ant-…` OpenAI and Anthropic keys, in the full token form * only: a bare `sk-` substring ("task-id", "risk-management") is not a key, * and matching it would refuse benign text. @@ -14,7 +17,7 @@ * that knows the secrets in play must also refuse text containing them. */ const SECRET_SHAPED = - /(?:\b(?:hlk_|hlr_|hls_|hlv_|hle_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/; + /(?:\b(?:hlk_|hlr_|hls_|hlv_|hle_|hlh_|hlac_|hlrt_|hlc_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/; export function looksSecretShaped(text: string): boolean { return SECRET_SHAPED.test(text);