diff --git a/src/lib/scrub.test.ts b/src/lib/scrub.test.ts new file mode 100644 index 0000000..30b2b65 --- /dev/null +++ b/src/lib/scrub.test.ts @@ -0,0 +1,105 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { scrubUrl, scrubString, scrubObject } from "./scrub.ts" + +// scrubUrl ---------------------------------------------------------------- + +test("scrubUrl: strips basic-auth credentials", () => { + assert.equal( + scrubUrl("https://user:secret@host.com/path"), + "https://@host.com/path", + ) +}) + +test("scrubUrl: redacts ?token= query param", () => { + assert.equal( + scrubUrl("https://host.com/api?token=abc123"), + "https://host.com/api?token=", + ) +}) + +test("scrubUrl: redacts ?api_key= query param", () => { + assert.equal( + scrubUrl("https://host.com/api?api_key=xyz"), + "https://host.com/api?api_key=", + ) +}) + +test("scrubUrl: redacts ?password= query param", () => { + assert.equal( + scrubUrl("https://host.com/login?password=hunter2"), + "https://host.com/login?password=", + ) +}) + +test("scrubUrl: redacts ?access_token= query param", () => { + assert.equal( + scrubUrl("https://host.com/api?access_token=tok_secret"), + "https://host.com/api?access_token=", + ) +}) + +test("scrubUrl: leaves clean URL unchanged", () => { + const clean = "http://100.108.64.76:4096/session" + assert.equal(scrubUrl(clean), clean) +}) + +test("scrubUrl: redacts only secret params, leaves others intact", () => { + const result = scrubUrl("https://host.com/api?foo=bar&token=secret&baz=qux") + assert.equal(result, "https://host.com/api?foo=bar&token=&baz=qux") +}) + +// scrubString ------------------------------------------------------------- + +test("scrubString: replaces credentials in a URL embedded in a message", () => { + const msg = "fetch failed: https://admin:pass@myserver.com/api" + const result = scrubString(msg) + assert.ok(result.includes(""), "should contain ") + assert.ok(!result.includes("admin"), "should not contain username") + assert.ok(!result.includes("pass"), "should not contain password") +}) + +test("scrubString: redacts token query param inside a message", () => { + const msg = "request to https://api.example.com/data?token=s3cr3t failed" + const result = scrubString(msg) + assert.ok(result.includes("token="), "token should be redacted") + assert.ok(!result.includes("s3cr3t"), "secret value should be gone") +}) + +test("scrubString: leaves plain strings without URLs unchanged", () => { + const plain = "something went wrong during connection" + assert.equal(scrubString(plain), plain) +}) + +// scrubObject ------------------------------------------------------------- + +test("scrubObject: scrubs string values containing URLs", () => { + const obj = { url: "https://user:pw@host.com/path" } + const result = scrubObject(obj) + assert.ok((result.url as string).includes("")) + assert.ok(!(result.url as string).includes("pw")) +}) + +test("scrubObject: recursively scrubs nested objects", () => { + const obj = { + outer: "clean", + inner: { + url: "https://host.com/api?token=secret", + label: "safe text", + }, + } + const result = scrubObject(obj) + const inner = result.inner as Record + assert.equal(inner.url, "https://host.com/api?token=") + assert.equal(inner.label, "safe text") + assert.equal(result.outer, "clean") +}) + +test("scrubObject: preserves non-string, non-object values as-is", () => { + const obj = { count: 42, flag: true, items: [1, 2, 3], nothing: null } + const result = scrubObject(obj as Record) + assert.equal(result.count, 42) + assert.equal(result.flag, true) + assert.deepEqual(result.items, [1, 2, 3]) + assert.equal(result.nothing, null) +}) diff --git a/src/lib/scrub.ts b/src/lib/scrub.ts new file mode 100644 index 0000000..b75038e --- /dev/null +++ b/src/lib/scrub.ts @@ -0,0 +1,26 @@ +// Pure URL/string scrubbing utilities — no React Native or Sentry deps. +// Extracted here so they can be unit-tested with plain `node --test`. + +// Strip basic-auth credentials and any `?token=` style query secrets so URLs +// can be safely sent or logged. +export function scrubUrl(url: string): string { + return url + .replace(/\/\/[^@/]+@/, "//@") + .replace(/([?&](?:token|access_token|api_key|key|password|pwd|auth)=)[^&#]*/gi, "$1") +} + +export function scrubString(s: string): string { + // Catch any embedded URL inside a free-text string (error messages often + // contain them, e.g. "fetch failed: https://user:pw@host/..."). + return s.replace(/https?:\/\/\S+/g, (m) => scrubUrl(m)) +} + +export function scrubObject(obj: Record): Record { + const out: Record = {} + for (const [k, v] of Object.entries(obj)) { + if (typeof v === "string") out[k] = scrubString(v) + else if (v && typeof v === "object" && !Array.isArray(v)) out[k] = scrubObject(v as Record) + else out[k] = v + } + return out +} diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts index 9fef1a2..015f3a1 100644 --- a/src/lib/sentry.ts +++ b/src/lib/sentry.ts @@ -13,6 +13,7 @@ import * as Sentry from "@sentry/react-native" import appJson from "../../app.json" import { log } from "./logbuffer" import type { DiagnosticReport } from "./diagnostics" +import { scrubUrl, scrubString, scrubObject } from "./scrub" const DSN = process.env.EXPO_PUBLIC_SENTRY_DSN const APP_VERSION = (appJson as { expo?: { version?: string } }).expo?.version ?? "unknown" @@ -124,31 +125,9 @@ function toError(value: unknown): Error { } } -// --- Scrubbing ----------------------------------------------------------- +// --- Scrubbing (pure functions live in ./scrub for testability) ---------- -// Strip basic-auth credentials and any `?token=` style query secrets so URLs -// can be safely sent or logged. -export function scrubUrl(url: string): string { - return url - .replace(/\/\/[^@/]+@/, "//@") - .replace(/([?&](?:token|access_token|api_key|key|password|pwd|auth)=)[^&#]*/gi, "$1") -} - -function scrubString(s: string): string { - // Catch any embedded URL inside a free-text string (error messages often - // contain them, e.g. "fetch failed: https://user:pw@host/..."). - return s.replace(/https?:\/\/\S+/g, (m) => scrubUrl(m)) -} - -function scrubObject(obj: Record): Record { - const out: Record = {} - for (const [k, v] of Object.entries(obj)) { - if (typeof v === "string") out[k] = scrubString(v) - else if (v && typeof v === "object" && !Array.isArray(v)) out[k] = scrubObject(v as Record) - else out[k] = v - } - return out -} +export { scrubUrl } from "./scrub" function scrubEvent(event: T): T { if (event.request?.url) event.request.url = scrubUrl(event.request.url)