Files
HealthLog/src/lib/logging/__tests__/redact.test.ts
T
Marc-André Bombeck ba70e19cf9 fix(logging): scrub every credential prefix the app mints
The log and error redactor missed the registration invite token (hlv_) and
the native sign-in handoff code (hlh_). The filter that keeps secrets out
of the idempotency replay cache and out of relayed upstream error bodies
missed the handoff code and the three MCP OAuth artifacts (hlac_, hlrt_,
hlc_). Both now cover every prefix the app issues: hlk_, hlr_, hls_, hle_,
hlv_, hlh_, hlac_, hlrt_ and hlc_.
2026-09-26 20:10:57 +02:00

256 lines
9.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { redactSecrets, redactOptional, PATH_SECRET_PATHS } from "../redact";
describe("redactSecrets", () => {
it("redacts Bearer tokens", () => {
expect(redactSecrets("Authorization: Bearer hlk_abc123def456")).toBe(
"Authorization: Bearer [REDACTED]",
);
expect(redactSecrets("auth=Bearer whitespace_token")).toBe(
"auth=Bearer [REDACTED]",
);
});
it("redacts Telegram bot tokens in path-style URLs", () => {
expect(
redactSecrets(
"fetch failed: https://api.telegram.org/bot1234567890:AAEhBP-w-secret/sendMessage",
),
).toBe("fetch failed: https://api.telegram.org/bot[REDACTED]/sendMessage");
});
it("redacts query-string secrets", () => {
expect(redactSecrets("Withings webhook ?secret=abc123 failed")).toBe(
"Withings webhook ?secret=[REDACTED] failed",
);
expect(redactSecrets("OAuth ?code=xyz&state=abc")).toBe(
"OAuth ?code=[REDACTED]&state=abc",
);
expect(redactSecrets("api?api_key=secret")).toBe("api?api_key=[REDACTED]");
// v1.7.0 — health-insurance identity in a stray query string.
expect(redactSecrets("profile ?kvnr=A123456780 saved")).toBe(
"profile ?kvnr=[REDACTED] saved",
);
expect(redactSecrets("export ?insuranceNumber=A123456780&days=30")).toBe(
"export ?insuranceNumber=[REDACTED]&days=30",
);
// v1.12.2 — one-time WHOOP connect ticket in the connect URL.
expect(
redactSecrets(
"GET /api/whoop/connect?ticket=abc_DEF-123&return_scheme=x",
),
).toBe("GET /api/whoop/connect?ticket=[REDACTED]&return_scheme=x");
});
it("redacts OpenAI and Anthropic API keys", () => {
expect(redactSecrets("error: invalid key sk-1234567890abcdefABCDEF")).toBe(
"error: invalid key [REDACTED]",
);
expect(
redactSecrets("Authorization: Bearer sk-ant-api03-xyzABC_42-token"),
).toBe("Authorization: Bearer [REDACTED]");
// Standalone Anthropic key (no Bearer prefix) — make sure the sk-ant-
// path matches independently of the Bearer rule.
expect(redactSecrets("body={apiKey: sk-ant-abcDEF123_-}")).toBe(
"body={apiKey: [REDACTED]}",
);
});
it("leaves non-secret-shaped strings alone", () => {
expect(redactSecrets("HTTP 503 from upstream")).toBe(
"HTTP 503 from upstream",
);
expect(redactSecrets("user-7 measurement saved")).toBe(
"user-7 measurement saved",
);
});
// v1.4.6 P11 hardening: the original `/sk-(?:ant-)?[A-Za-z0-9_-]+/`
// regex over-matched any word ending in `sk-…`, redacting common
// English compounds in error messages and PR descriptions. v1.5
// tightens the regex to require a non-alphanumeric prefix so the
// `sk-` token must actually start a secret.
it("does NOT redact common English compounds containing 'sk-'", () => {
expect(redactSecrets("task-force ABC")).toBe("task-force ABC");
expect(redactSecrets("risk-management note")).toBe("risk-management note");
expect(redactSecrets("disk-io 1234")).toBe("disk-io 1234");
// Mid-word matches must also be ignored (the `sk-` is preceded by a
// letter / digit, so it's part of an identifier, not a secret).
expect(redactSecrets("workflow:risk-management")).toBe(
"workflow:risk-management",
);
expect(redactSecrets("e2e_disk-io_check")).toBe("e2e_disk-io_check");
});
it("still redacts genuine sk- and sk-ant- API keys", () => {
// Start of string.
expect(redactSecrets("sk-1234567890abcdef")).toBe("[REDACTED]");
expect(redactSecrets("sk-ant-1234567890abcdef")).toBe("[REDACTED]");
// Preceded by whitespace, equals, brace, comma, etc. — the
// non-alphanumeric-prefix requirement.
expect(redactSecrets("apiKey=sk-1234567890abcdef end")).toBe(
"apiKey=[REDACTED] end",
);
expect(redactSecrets("OPENAI_API_KEY=sk-prod-abcDEF123456789012")).toBe(
"OPENAI_API_KEY=[REDACTED]",
);
// Trailing context preserved, leading separator preserved.
expect(redactSecrets(",sk-1234567890abcdef,")).toBe(",[REDACTED],");
});
it("redacts standalone HealthLog native API tokens (hlk_ / hlr_)", () => {
// Start of string.
expect(
redactSecrets("hlk_abc123def4567890abcdef1234567890ABCDEF1234567890abcd"),
).toBe("[REDACTED]");
expect(
redactSecrets("hlr_abc123def4567890abcdef1234567890ABCDEF1234567890abcd"),
).toBe("[REDACTED]");
// Preceded by separator, leading separator preserved.
expect(
redactSecrets(
"refresh: hlr_abc123def4567890abcdef1234567890ABCDEF1234567890abcd end",
),
).toBe("refresh: [REDACTED] end");
expect(
redactSecrets('{"token": "hlk_0123456789abcdef0123456789abcdef0123"}'),
).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");
// Too-short trailing hex — keep as-is.
expect(redactSecrets("hlk_short")).toBe("hlk_short");
});
it("handles multiple secrets in one string", () => {
// Bearer matches `\S+` so it greedily consumes `hlk_x;` — that's
// safe (over-redaction is fine when the alternative is leaking).
expect(redactSecrets("Bearer hlk_x bot999:tok ?code=abc123")).toBe(
"Bearer [REDACTED] bot[REDACTED] ?code=[REDACTED]",
);
});
// v1.4.25 W21 Fix-J: `WITHINGS_WEBHOOK_SECRET` travels as the trailing
// path segment on `/api/withings/webhook/<secret>`. The Wide Event
// builder feeds `http.path` and `http.route` through `redactSecrets`,
// so the path-segment rule must rewrite the secret to `[REDACTED]`
// before stdout / ring-buffer / Loki egress.
describe("path-segment secret redaction", () => {
it("registers the Withings webhook prefix in PATH_SECRET_PATHS", () => {
expect(PATH_SECRET_PATHS.map((entry) => entry.prefix)).toContain(
"/api/withings/webhook/",
);
});
it("redacts the Withings webhook secret segment", () => {
expect(redactSecrets("/api/withings/webhook/test-secret")).toBe(
"/api/withings/webhook/[REDACTED]",
);
expect(
redactSecrets(
"/api/withings/webhook/abc123_DEADBEEF-cafe.0123456789abcdef",
),
).toBe("/api/withings/webhook/[REDACTED]");
});
it("preserves trailing path segments after the secret", () => {
expect(redactSecrets("/api/withings/webhook/test-secret/")).toBe(
"/api/withings/webhook/[REDACTED]/",
);
expect(redactSecrets("/api/withings/webhook/test-secret/verify")).toBe(
"/api/withings/webhook/[REDACTED]/verify",
);
});
it("preserves query strings after the secret segment", () => {
// Note: the existing query-string rule then redacts the
// `?secret=…` payload too; both layers compose.
expect(redactSecrets("/api/withings/webhook/test-secret?retries=2")).toBe(
"/api/withings/webhook/[REDACTED]?retries=2",
);
});
it("redacts an absolute URL form (origin + path-segment secret)", () => {
expect(
redactSecrets(
"https://app.healthlog.dev/api/withings/webhook/super-secret",
),
).toBe("https://app.healthlog.dev/api/withings/webhook/[REDACTED]");
});
it("leaves the legacy query-string webhook form alone (no path-segment secret)", () => {
// `/api/withings/webhook` (no trailing segment) keeps shape; the
// `?secret=` rule scrubs the query payload via the existing rule.
expect(redactSecrets("/api/withings/webhook?secret=abc123")).toBe(
"/api/withings/webhook?secret=[REDACTED]",
);
expect(redactSecrets("/api/withings/webhook")).toBe(
"/api/withings/webhook",
);
});
it("does not over-redact sibling routes that share the prefix root", () => {
// Sibling paths (`/api/withings/measure`, `/api/withings/auth`)
// must not match the webhook-specific prefix.
expect(redactSecrets("/api/withings/measure/123")).toBe(
"/api/withings/measure/123",
);
expect(redactSecrets("/api/withings/auth/callback")).toBe(
"/api/withings/auth/callback",
);
});
// v1.11.0 (Epic C, C6) — the raw `hls_` clinician share token rides as
// the trailing path segment of `/c/<token>`. It must be scrubbed from
// `http.path` / `http.route` before the Wide Event leaves the process.
it("registers the clinician share-view prefix in PATH_SECRET_PATHS", () => {
expect(PATH_SECRET_PATHS.map((entry) => entry.prefix)).toContain("/c/");
});
it("redacts the share token in the `/c/<token>` path", () => {
expect(
redactSecrets(
"/c/hls_0123456789abcdef0123456789abcdef0123456789abcdef",
),
).toBe("/c/[REDACTED]");
});
it("redacts the share token in an absolute URL form", () => {
expect(
redactSecrets(
"https://app.healthlog.dev/c/hls_0123456789abcdef0123456789abcdef0123456789abcdef",
),
).toBe("https://app.healthlog.dev/c/[REDACTED]");
});
it("redacts the share token even with a trailing query string", () => {
expect(redactSecrets("/c/hls_deadbeef?print=1")).toBe(
"/c/[REDACTED]?print=1",
);
});
});
});
describe("redactOptional", () => {
it("returns undefined for undefined input", () => {
expect(redactOptional(undefined)).toBeUndefined();
});
it("redacts when present", () => {
expect(redactOptional("Bearer x")).toBe("Bearer [REDACTED]");
});
});