test(sentry): extract pure scrub module + add unit tests — privacy regression guard
Extracts scrubUrl/scrubString/scrubObject into src/lib/scrub.ts (no RN deps) so they can be tested with node --test without native module issues. Adds src/lib/scrub.test.ts with 13 test cases covering: - basic-auth credential stripping - query-param secret redaction (token, api_key, password, access_token) - clean URL passthrough - mixed-param URL (only secrets redacted) - embedded URL in error message strings - nested object recursive scrubbing - non-string value preservation Closes #40 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
105
src/lib/scrub.test.ts
Normal file
105
src/lib/scrub.test.ts
Normal file
@@ -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://<redacted>@host.com/path",
|
||||
)
|
||||
})
|
||||
|
||||
test("scrubUrl: redacts ?token= query param", () => {
|
||||
assert.equal(
|
||||
scrubUrl("https://host.com/api?token=abc123"),
|
||||
"https://host.com/api?token=<redacted>",
|
||||
)
|
||||
})
|
||||
|
||||
test("scrubUrl: redacts ?api_key= query param", () => {
|
||||
assert.equal(
|
||||
scrubUrl("https://host.com/api?api_key=xyz"),
|
||||
"https://host.com/api?api_key=<redacted>",
|
||||
)
|
||||
})
|
||||
|
||||
test("scrubUrl: redacts ?password= query param", () => {
|
||||
assert.equal(
|
||||
scrubUrl("https://host.com/login?password=hunter2"),
|
||||
"https://host.com/login?password=<redacted>",
|
||||
)
|
||||
})
|
||||
|
||||
test("scrubUrl: redacts ?access_token= query param", () => {
|
||||
assert.equal(
|
||||
scrubUrl("https://host.com/api?access_token=tok_secret"),
|
||||
"https://host.com/api?access_token=<redacted>",
|
||||
)
|
||||
})
|
||||
|
||||
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=<redacted>&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("<redacted>"), "should contain <redacted>")
|
||||
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=<redacted>"), "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("<redacted>"))
|
||||
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<string, unknown>
|
||||
assert.equal(inner.url, "https://host.com/api?token=<redacted>")
|
||||
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<string, unknown>)
|
||||
assert.equal(result.count, 42)
|
||||
assert.equal(result.flag, true)
|
||||
assert.deepEqual(result.items, [1, 2, 3])
|
||||
assert.equal(result.nothing, null)
|
||||
})
|
||||
26
src/lib/scrub.ts
Normal file
26
src/lib/scrub.ts
Normal file
@@ -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(/\/\/[^@/]+@/, "//<redacted>@")
|
||||
.replace(/([?&](?:token|access_token|api_key|key|password|pwd|auth)=)[^&#]*/gi, "$1<redacted>")
|
||||
}
|
||||
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {}
|
||||
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<string, unknown>)
|
||||
else out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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(/\/\/[^@/]+@/, "//<redacted>@")
|
||||
.replace(/([?&](?:token|access_token|api_key|key|password|pwd|auth)=)[^&#]*/gi, "$1<redacted>")
|
||||
}
|
||||
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {}
|
||||
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<string, unknown>)
|
||||
else out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
export { scrubUrl } from "./scrub"
|
||||
|
||||
function scrubEvent<T extends Sentry.Event>(event: T): T {
|
||||
if (event.request?.url) event.request.url = scrubUrl(event.request.url)
|
||||
|
||||
Reference in New Issue
Block a user