diff --git a/src/lib/onboard/lifecycle-contracts.md b/src/lib/onboard/lifecycle-contracts.md index fa18520c4cc..00649a4068c 100644 --- a/src/lib/onboard/lifecycle-contracts.md +++ b/src/lib/onboard/lifecycle-contracts.md @@ -120,7 +120,7 @@ The schema and sanitation authority is `Session` plus `normalizeSession`/`filter | Session envelope | `version`, `sessionId`, `mode`, `startedAt`, `updatedAt`, `status`, `resumable` | `createSession`, save/update helpers, and completion/failure paths. Values are always known after creation. | | Progress and recovery | `lastStepStarted`, `lastCompletedStep`, `failure`, `steps`, `machine` | Step-mutation helpers and `OnboardRuntime`; nullable progress fields mean no recorded value, not a separately modeled decline. | | Target identity | `agent`, `sandboxName`, `metadata.gatewayName`, `metadata.fromDockerfile` | Onboard selection, sandbox handler/registration, and rebuild session preparation. Nullable identity currently conflates unset/cleared; a completed sandbox step is the trust gate for recorded name. | -| Inference intent | `provider`, `model`, `endpointUrl`, `credentialEnv`, `preferredInferenceApi`, `compatibleEndpointReasoning`, `nimContainer`, `webSearchConfig` | Provider/inference handlers and `runInferenceSet`. Known credential state is an environment-variable name or presence metadata, never the value. `redactUrl` masks userinfo, fragments, and sensitive-named parameters, but token-shaped values under benign parameter names remain a pending #6224 gap. | +| Inference intent | `provider`, `model`, `endpointUrl`, `credentialEnv`, `preferredInferenceApi`, `compatibleEndpointReasoning`, `nimContainer`, `webSearchConfig` | Provider/inference handlers and `runInferenceSet`. Known credential state is an environment-variable name or presence metadata, never the value. `redactUrl` masks userinfo and fragments, redacts values under sensitive parameter names, and redacts canonical token-shaped values even under benign parameter names. | | Agent and policy intent | `hermesAuthMethod`, `toolDisclosure`, `hermesToolGateways`, `policyPresets` | Agent setup and policy handling. Channel commands update matching-session `policyPresets` only best-effort. Nullable fields conflate unset, declined, and cleared where the CLI makes those distinctions. | | Messaging intent | `messagingPlan`, `telegramConfig`, `wechatConfig` | Onboard/rebuild write the session plan; channel commands instead own the compact registry plan. `telegramConfig` and `wechatConfig` are legacy fallback/preserved fields, while current channel input comes from the plan/manifests. Tokens stay outside the session. | | Runtime metadata | `routerPid`, `routerCredentialHash`, `gpuPassthrough` | Router and sandbox setup/recovery. PID is a live-process hint; credential hash is a digest; GPU is a concrete boolean. | @@ -161,6 +161,6 @@ PR #6218 separated secret-free create intent from effectful materialization. PR | Create intent/provider ordering and fail-closed credential drift | `sandbox-create-plan.test.ts` | Cross-module pre-delete ordering has no behavioral seam | | Messaging conflict validation before recreate | `sandbox-messaging-preflight.test.ts`, rebuild preflight tests | One shared declarative policy across all callers | | Resume identity | `test/onboard.test.ts`, `handlers/sandbox-resume.test.ts` | Interrupted live-flow identity (#5961) | -| Session sanitation and no-secret persistence | `src/lib/state/onboard-session.test.ts` | Benign-name token-shaped URL values (#6224); unset/declined/cleared modeling (#6228) | +| Session sanitation and no-secret persistence | `src/lib/state/onboard-session.test.ts` | Unset/declined/cleared modeling (#6228) | When a child issue changes one of these contracts, update the map and the narrow owning test in that same PR. Do not add source-text scans or production scaffolding solely to preserve current orchestration order. diff --git a/src/lib/security/credential-filter-secret-patterns.test.ts b/src/lib/security/credential-filter-secret-patterns.test.ts index e10cc4dabe6..bc948665e36 100644 --- a/src/lib/security/credential-filter-secret-patterns.test.ts +++ b/src/lib/security/credential-filter-secret-patterns.test.ts @@ -3,6 +3,11 @@ import { describe, expect, it } from "vitest"; +import { + makeEmptyClaimsJwtFixture, + makeJwtFixture, +} from "../../../test/helpers/security-token-fixtures"; + import { isCredentialField, stripCredentials, valueLooksLikeSecret } from "./credential-filter.js"; describe("isCredentialField", () => { @@ -120,6 +125,8 @@ describe("valueLooksLikeSecret", () => { expect(valueLooksLikeSecret("ghp_0123456789abcdef")).toBe(true); expect(valueLooksLikeSecret("sk-proj-0123456789abcdefghij")).toBe(true); expect(valueLooksLikeSecret("xoxb-123456789-abcdefghij")).toBe(true); + expect(valueLooksLikeSecret(makeJwtFixture())).toBe(true); + expect(valueLooksLikeSecret(makeEmptyClaimsJwtFixture())).toBe(true); expect(valueLooksLikeSecret("Bearer abcdef0123456789")).toBe(true); }); diff --git a/src/lib/security/redact-url.test.ts b/src/lib/security/redact-url.test.ts new file mode 100644 index 00000000000..711ce837131 --- /dev/null +++ b/src/lib/security/redact-url.test.ts @@ -0,0 +1,316 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + makeEmptyClaimsJwtFixture, + makeJwtFixture, +} from "../../../test/helpers/security-token-fixtures"; + +import { redact, redactUrl } from "./redact.js"; + +const credentialLabel = ["api", "Key"].join(""); + +describe("URL redaction", () => { + it.each([ + ["SOCKS", "socks5://socks-user:socks-password@proxy.example:1080"], + ["mixed-case FTP", "FtP://ftp-user:ftp-password@files.example/path"], + ["mixed-case HTTPS", "HTTPS://https-user:https-password@secure.example:8443"], + ])("redacts embedded credentials from %s URLs", (_label, value) => { + const result = redact(value); + + expect(result).toContain("****:****@"); + expect(result).not.toContain("-user"); + expect(result).not.toContain("-password"); + }); + + it("redacts a bracket-wrapped SOCKS URL without breaking its closing delimiter", () => { + const result = redact( + "proxy [socks5://bracket-user:bracket-password@proxy.example:1080] failed", + ); + + expect(result).toContain("socks5://****:****@proxy.example:1080]"); + expect(result).not.toContain("bracket-user"); + expect(result).not.toContain("bracket-password"); + }); + + it("bounds malformed wrapper parsing before falling back to userinfo redaction", () => { + const wrappers = "]".repeat(4_096); + const result = redact( + `proxy [socks5://bounded-user:bounded-password@proxy.example:1080${wrappers}`, + ); + + expect(result).toContain("socks5://****:****@proxy.example:1080"); + expect(result).not.toContain("bounded-user"); + expect(result).not.toContain("bounded-password"); + }); + + it("redacts userinfo when a malformed URL cannot be parsed", () => { + const value = "https://fallback-user:fallback-pass@[not-an-ip/path"; + const logResult = redact(value); + const persistedResult = redactUrl(value); + + expect(logResult).toBe("https://****:****@[not-an-ip/path"); + expect(persistedResult).toBe("https://[not-an-ip/path"); + expect(logResult).not.toContain("fallback-user"); + expect(persistedResult).not.toContain("fallback-pass"); + }); + + it("redacts encoded query secrets when a malformed URL cannot be parsed", () => { + const encodedSecret = "sk%2Dproj%2Dabcdefghijklmnopqrstuvwxyz"; + const value = `https://fallback-user:fallback-pass@[not-an-ip/path?model=${encodedSecret}&keep=yes`; + const logResult = redact(value); + const persistedResult = redactUrl(value); + + expect(logResult).toContain("model=****&keep=yes"); + expect(persistedResult).toContain("model=%3CREDACTED%3E&keep=yes"); + expect(logResult).not.toContain(encodedSecret); + expect(persistedResult).not.toContain(encodedSecret); + expect(logResult).not.toContain("sk-proj-abcdefghijklmnopqrstuvwxyz"); + expect(persistedResult).not.toContain("sk-proj-abcdefghijklmnopqrstuvwxyz"); + }); + + it.each([ + ["parsed", "https://endpoint.example/path"], + ["malformed", "https://fallback-user:fallback-pass@[not-an-ip/path"], + ])("redacts encoded fragment secrets in a %s URL", (_label, baseUrl) => { + const encodedSecret = "sk%2Dproj%2Dabcdefghijklmnopqrstuvwxyz"; + const value = `${baseUrl}#model=${encodedSecret}&keep=yes`; + const logResult = redact(value); + const persistedResult = redactUrl(value); + + expect(logResult).toContain("#model=****&keep=yes"); + expect(logResult).not.toContain(encodedSecret); + expect(logResult).not.toContain("sk-proj-abcdefghijklmnopqrstuvwxyz"); + expect(persistedResult).not.toContain("#"); + expect(persistedResult).not.toContain(encodedSecret); + }); + + it.each([ + ["parsed", "https://endpoint.example/path", "%ZZsk%2Dproj%2Dabcdefghijklmnopqrstuvwxyz"], + [ + "malformed", + "https://fallback-user:fallback-pass@[not-an-ip/path", + "sk%2Dproj%2Dabcdefghijklmnopqrstuvwxyz%ZZ", + ], + ])("redacts a standalone encoded fragment secret with malformed escapes in a %s URL", (_label, baseUrl, fragment) => { + const result = redact(`${baseUrl}#${fragment}`); + + expect(result).not.toContain("sk%2Dproj%2Dabcdefghijklmnopqrstuvwxyz"); + expect(result).not.toContain("sk-proj-abcdefghijklmnopqrstuvwxyz"); + }); + + it.each([ + ["parsed", "https://endpoint.example/path"], + ["malformed", "https://fallback-user:fallback-pass@[not-an-ip/path"], + ])("redacts a fully encoded sensitive fragment assignment in a %s URL", (_label, baseUrl) => { + const result = redact(`${baseUrl}#%74oken%3Dshort`); + + expect(result).toContain("#token=****"); + expect(result).not.toContain("short"); + }); + + it("preserves benign persisted malformed query parameters after a sensitive key", () => { + const value = + "https://fallback-user:fallback-pass@[not-an-ip/path?%74oken=opaque-value&keep=yes"; + + expect(redact(value)).not.toContain("opaque-value"); + expect(redactUrl(value)).toBe("https://[not-an-ip/path?token=%3CREDACTED%3E&keep=yes"); + }); + + it.each([ + ["parsed", "https://endpoint.example/path"], + ["malformed", "https://fallback-user:fallback-pass@[not-an-ip/path"], + ])("redacts an encoded token-shaped query name in a %s URL", (_label, baseUrl) => { + const encodedSecret = "sk%2Dproj%2Dabcdefghijklmnopqrstuvwxyz"; + const value = `${baseUrl}?${encodedSecret}=opaque&keep=yes`; + const logResult = redact(value); + const persistedResult = redactUrl(value); + + expect(logResult).not.toContain(encodedSecret); + expect(logResult).not.toContain("sk-proj-abcdefghijklmnopqrstuvwxyz"); + expect(persistedResult).not.toContain(encodedSecret); + expect(persistedResult).not.toContain("sk-proj-abcdefghijklmnopqrstuvwxyz"); + expect(persistedResult).toContain("%3CREDACTED%3E=opaque&keep=yes"); + }); + + it("redacts encoded query secrets after the bounded wrapper fallback (#6224)", () => { + const wrappers = "]".repeat(4_096); + const encodedSecret = "sk%2Dproj%2Dabcdefghijklmnopqrstuvwxyz"; + const value = `https://endpoint.example/api?model=${encodedSecret}${wrappers}`; + const logResult = redact(value); + const persistedResult = redactUrl(value); + + expect(logResult).toContain(`model=****${wrappers}`); + expect(logResult).not.toContain(encodedSecret); + expect(persistedResult).toContain(`model=%3CREDACTED%3E${wrappers}`); + expect(persistedResult).not.toContain(encodedSecret); + }); + + it.each([ + "file:///tmp/provider.json", + "custom+agent://runtime.example/session", + ])("redacts query secrets for the non-network URI %s", (baseUrl) => { + const value = `${baseUrl}?model=nvapi-abcdefghijklmnopqrstuvwxyz&keep=yes`; + + expect(redact(value)).toContain("model=****&keep=yes"); + expect(redactUrl(value)).toContain("model=%3CREDACTED%3E&keep=yes"); + }); + + it("preserves a credentialed IPv6 host while redacting its userinfo", () => { + const result = redact("proxy https://ipv6-user:ipv6-password@[::1]:8443/path failed"); + + expect(result).toContain("https://****:****@[::1]:8443/path"); + expect(result).not.toContain("ipv6-user"); + expect(result).not.toContain("ipv6-password"); + }); + + it.each([ + [ + "parentheses and comma", + "proxy (https://wrapped-user:wrapped-password@proxy.example/path), retry", + "(https://****:****@proxy.example/path), retry", + ], + [ + "angle brackets and semicolon", + "proxy ; retry", + "; retry", + ], + [ + "a trailing sentence period", + "proxy socks5://wrapped-user:wrapped-password@proxy.example:1080. retry", + "socks5://****:****@proxy.example:1080. retry", + ], + ])("keeps %s outside the redacted URL token", (_label, value, expected) => { + const result = redact(value); + + expect(result).toContain(expected); + expect(result).not.toContain("wrapped-user"); + expect(result).not.toContain("wrapped-password"); + }); + + it.each([ + ["semicolon", "pa;ssword"], + ["comma", "pa,ssword"], + ["balanced parentheses", "pa(ss)word"], + ])("redacts credentials containing valid %s punctuation", (_label, password) => { + const result = redact(`proxy https://userinfo-user:${password}@proxy.example/path failed`); + + expect(result).toContain("https://****:****@proxy.example/path"); + expect(result).not.toContain("userinfo-user"); + expect(result).not.toContain(password); + }); + + it("fully removes generic-scheme userinfo and sensitive query values", () => { + const result = redactUrl( + "FtP://ftp-user:ftp-password@files.example/path?token=secret-value#fragment", + ); + + expect(result).toBe("ftp://files.example/path?token=%3CREDACTED%3E"); + }); + + it.each([ + "api_key", + "apiKey", + "password", + "secret", + "authorization", + "credential", + ])("redacts opaque query values under credential key %s", (key) => { + const value = `https://endpoint.example/api?${key}=opaque-value`; + const result = redactUrl(value); + const logResult = redact(`endpoint failed: ${value}`); + + expect(result).not.toBeNull(); + expect(new URL(result as string).searchParams.get(key)).toBe(""); + expect(logResult).toContain(`${key}=****`); + expect(logResult).not.toContain("opaque-value"); + }); + + it("redacts encoded and repeated token-shaped query values under benign names", () => { + const secrets = { + anthropic: "sk-ant-abcdefghijklmnopqrstuvwxyz", + github: "ghp_abcdefghijklmnopqrstuvwxyz", + jwt: makeJwtFixture(), + jwtEmptyClaims: makeEmptyClaimsJwtFixture(), + nvidia: "nvapi-abcdefghijklmnopqrstuvwxyz", + openai: "sk-proj-abcdefghijklmnopqrstuvwxyz", + slack: "xoxb-1234567890-abcdefghij", + }; + const encodedOpenAi = secrets.openai.replaceAll("-", "%2D"); + const result = redactUrl( + `https://url-user:url-password@endpoint.example/api?value=${secrets.nvidia}&model=${encodedOpenAi}&provider=${secrets.anthropic}&session=${secrets.github}&channel=${secrets.slack}&assertion=${secrets.jwt}&compact=${secrets.jwtEmptyClaims}&repeat=safe&repeat=${secrets.github}&mixed=prefix:${secrets.nvidia}:suffix&token=opaque-value&keep=yes#session=${secrets.slack}`, + ); + const logResult = redact( + `request failed: https://endpoint.example/api?model=${encodedOpenAi}&keep=yes`, + ); + + expect(result).not.toBeNull(); + const parsed = new URL(result as string); + expect(parsed.username).toBe(""); + expect(parsed.password).toBe(""); + expect(parsed.hash).toBe(""); + expect(parsed.searchParams.get("value")).toBe(""); + expect(parsed.searchParams.get("model")).toBe(""); + expect(parsed.searchParams.get("provider")).toBe(""); + expect(parsed.searchParams.get("session")).toBe(""); + expect(parsed.searchParams.get("channel")).toBe(""); + expect(parsed.searchParams.get("assertion")).toBe(""); + expect(parsed.searchParams.get("compact")).toBe(""); + expect(parsed.searchParams.getAll("repeat")).toEqual(["safe", ""]); + expect(parsed.searchParams.get("mixed")).toBe("prefix::suffix"); + expect(parsed.searchParams.get("token")).toBe(""); + expect(parsed.searchParams.get("keep")).toBe("yes"); + expect(logResult).toContain("model=****&keep=yes"); + expect(logResult).not.toContain(encodedOpenAi); + expect(logResult).not.toContain(secrets.openai); + for (const secret of Object.values(secrets)) { + expect(result).not.toContain(secret); + } + }); + + it.each([ + ["Bearer credential", "Bearer abcdef0123456789", "Bearer ", "Bearer ****"], + [ + "credential assignment", + `${credentialLabel}=abcdef0123456789`, + `${credentialLabel}=`, + `${credentialLabel}=****`, + ], + ])("redacts a %s under a benign query name", (_label, secret, persistedValue, logValue) => { + const value = `https://endpoint.example/v1?model=${encodeURIComponent(secret)}&keep=yes`; + const persistedResult = redactUrl(value); + const logResult = redact(value); + + expect(persistedResult).not.toBeNull(); + expect(new URL(persistedResult as string).searchParams.get("model")).toBe(persistedValue); + expect(new URL(logResult).searchParams.get("model")).toBe(logValue); + expect(persistedResult).not.toContain("abcdef0123456789"); + expect(logResult).not.toContain("abcdef0123456789"); + }); + + it.each([ + ["non-JSON header", "abcde12345.payload12.signatureABCDEFGHI"], + ["short signature", "eyJheader123.payload12.short"], + ["two segments", "eyJheader123.payload12"], + ])("preserves the near-miss JWT shape %s", (_label, value) => { + const url = `https://endpoint.example/v1?model=${value}`; + + expect(new URL(redact(url)).searchParams.get("model")).toBe(value); + expect(new URL(redactUrl(url) as string).searchParams.get("model")).toBe(value); + }); + + it("preserves a long Unicode query value while redacting an adjacent encoded secret", () => { + const benign = `model-\u96ea-${"a".repeat(10_000)}`; + const encodedSecret = "sk%2Dproj%2Dabcdefghijklmnopqrstuvwxyz"; + const value = `https://endpoint.example/v1?model=${encodeURIComponent(benign)}&backup=${encodedSecret}`; + const logResult = redact(value); + const persistedResult = redactUrl(value) as string; + + expect(new URL(logResult).searchParams.get("model")).toBe(benign); + expect(new URL(persistedResult).searchParams.get("model")).toBe(benign); + expect(new URL(logResult).searchParams.get("backup")).toBe("****"); + expect(new URL(persistedResult).searchParams.get("backup")).toBe(""); + }); +}); diff --git a/src/lib/security/redact-url.ts b/src/lib/security/redact-url.ts new file mode 100644 index 00000000000..54c696d46dd --- /dev/null +++ b/src/lib/security/redact-url.ts @@ -0,0 +1,267 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CONTEXT_PATTERNS } from "./secret-patterns"; + +type SensitiveKeyDetector = (key: string) => boolean; +type StandaloneSecretRedactor = (text: string, replacement: string) => string; +type MalformedUrlRedactor = (text: string) => string | null; + +// Redaction intentionally accepts every RFC-style URI scheme. Proxy and +// custom-scheme URLs can carry credentials too; an allowlist here would create +// a bypass rather than enforce a network boundary. +export const URL_TOKEN_PATTERN = /[a-z][a-z0-9+.-]*:\/\/[^\s'"]+/gi; + +const URL_TRAILING_DELIMITERS = ")]}>.,;:!?"; +const MAX_URL_PARSE_ATTEMPTS = 9; + +function isUnmatchedClosingDelimiter(value: string, closing: string): boolean { + const openingByClosing: Record = { + ")": "(", + "]": "[", + "}": "{", + ">": "<", + }; + const opening = openingByClosing[closing]; + if (!opening) return false; + let balance = 0; + for (const character of value) { + if (character === opening) balance += 1; + else if (character === closing) balance -= 1; + } + return balance < 0; +} + +function isProseUrlSuffix(value: string, trailing: string): boolean { + return ".,;".includes(trailing) || isUnmatchedClosingDelimiter(value, trailing); +} + +function parseUrlToken(value: string): { url: URL; suffix: string } | null { + let candidate = value; + let suffix = ""; + for (let attempt = 0; candidate && attempt < MAX_URL_PARSE_ATTEMPTS; attempt += 1) { + const trailing = candidate.at(-1); + // Capture the complete token first so punctuation that is valid in + // userinfo cannot terminate redaction. Only then peel terminal prose + // punctuation and unmatched wrapper closers before URL parsing. + if (trailing && isProseUrlSuffix(candidate, trailing)) { + candidate = candidate.slice(0, -1); + suffix = `${trailing}${suffix}`; + continue; + } + try { + return { url: new URL(candidate), suffix }; + } catch { + if (!trailing || !URL_TRAILING_DELIMITERS.includes(trailing)) return null; + candidate = candidate.slice(0, -1); + suffix = `${trailing}${suffix}`; + } + } + return null; +} + +function parseUrlTokenForRedaction(value: string): { url: URL; suffix: string } | null { + const parsed = parseUrlToken(value); + if (parsed) return parsed; + + // After the bounded detailed parse, strip an arbitrarily long delimiter run + // in one linear pass and make one final parse attempt. This path stays + // deliberately silent: logging malformed input from a redactor could leak + // the very credential it is trying to contain. + let suffixStart = value.length; + while (suffixStart > 0 && URL_TRAILING_DELIMITERS.includes(value.charAt(suffixStart - 1))) { + suffixStart -= 1; + } + if (suffixStart === value.length) return null; + try { + return { url: new URL(value.slice(0, suffixStart)), suffix: value.slice(suffixStart) }; + } catch { + return null; + } +} + +function redactMalformedUrlUserinfo(value: string, replacement: string | null): string { + const schemeEnd = value.indexOf("://") + 3; + if (schemeEnd < 3) return value; + const relativeAuthorityEnd = value.slice(schemeEnd).search(/[/?#]/); + const authorityEnd = relativeAuthorityEnd < 0 ? value.length : schemeEnd + relativeAuthorityEnd; + const authority = value.slice(schemeEnd, authorityEnd); + const userinfoEnd = authority.lastIndexOf("@"); + if (userinfoEnd < 1) return value; + const userinfo = authority.slice(0, userinfoEnd); + const redactedUserinfo = + replacement === null ? "" : `${userinfo.includes(":") ? `${replacement}:` : ""}${replacement}@`; + return `${value.slice(0, schemeEnd)}${redactedUserinfo}${authority.slice(userinfoEnd + 1)}${value.slice(authorityEnd)}`; +} + +function isSensitiveUrlQueryKey(key: string, isSensitiveKey: SensitiveKeyDetector): boolean { + return isSensitiveKey(key) || /(^|[-_])(?:signature|sig|token|auth|access_token)$/i.test(key); +} + +function redactUrlQueryValue( + text: string, + replacement: string, + redactStandaloneSecrets: StandaloneSecretRedactor, +): string { + let result = redactStandaloneSecrets(text, replacement); + for (const pattern of CONTEXT_PATTERNS) { + pattern.lastIndex = 0; + result = result.replace(pattern, replacement); + } + return result; +} + +function redactSearchParams( + searchParams: URLSearchParams, + replacement: string, + isSensitiveKey: SensitiveKeyDetector, + redactStandaloneSecrets: StandaloneSecretRedactor, +): string { + const redactedSearchParams = new URLSearchParams(); + for (const [key, queryValue] of searchParams) { + // Query names are not a security boundary. Redact token-shaped names and + // values after URLSearchParams has decoded their percent escapes. + redactedSearchParams.append( + redactUrlQueryValue(key, replacement, redactStandaloneSecrets), + isSensitiveUrlQueryKey(key, isSensitiveKey) + ? replacement + : redactUrlQueryValue(queryValue, replacement, redactStandaloneSecrets), + ); + } + return redactedSearchParams.toString(); +} + +function redactUrlSearchParams( + url: URL, + replacement: string, + isSensitiveKey: SensitiveKeyDetector, + redactStandaloneSecrets: StandaloneSecretRedactor, +): void { + url.search = redactSearchParams( + url.searchParams, + replacement, + isSensitiveKey, + redactStandaloneSecrets, + ); +} + +function redactUrlFragment( + fragment: string, + replacement: string, + isSensitiveKey: SensitiveKeyDetector, + redactStandaloneSecrets: StandaloneSecretRedactor, +): string { + const prefix = fragment.startsWith("#") ? "#" : ""; + const value = prefix ? fragment.slice(1) : fragment; + if (!value) return fragment; + if (value.includes("=")) { + return `${prefix}${redactSearchParams( + new URLSearchParams(value), + replacement, + isSensitiveKey, + redactStandaloneSecrets, + )}`; + } + // A synthetic form value decodes valid percent triplets while preserving + // malformed escapes, so one bad escape cannot hide an encoded token. + const decodedValue = new URLSearchParams(`value=${value}`).get("value") ?? value; + if (decodedValue.includes("=")) { + return `${prefix}${redactSearchParams( + new URLSearchParams(decodedValue), + replacement, + isSensitiveKey, + redactStandaloneSecrets, + )}`; + } + const redactedValue = redactUrlQueryValue(decodedValue, replacement, redactStandaloneSecrets); + return redactedValue === decodedValue ? fragment : `${prefix}${redactedValue}`; +} + +function redactMalformedUrlQuery( + value: string, + replacement: string, + stripFragment: boolean, + isSensitiveKey: SensitiveKeyDetector, + redactStandaloneSecrets: StandaloneSecretRedactor, +): string { + const fragmentStart = value.indexOf("#"); + const bodyEnd = fragmentStart < 0 ? value.length : fragmentStart; + const body = value.slice(0, bodyEnd); + const suffix = + stripFragment || fragmentStart < 0 + ? "" + : redactUrlFragment( + value.slice(fragmentStart), + replacement, + isSensitiveKey, + redactStandaloneSecrets, + ); + const queryStart = body.indexOf("?"); + if (queryStart < 0) return `${body}${suffix}`; + const redactedQuery = redactSearchParams( + new URLSearchParams(body.slice(queryStart + 1)), + replacement, + isSensitiveKey, + redactStandaloneSecrets, + ); + return `${body.slice(0, queryStart + 1)}${redactedQuery}${suffix}`; +} + +export function redactUrlTokenPartial( + value: string, + isSensitiveKey: SensitiveKeyDetector, + redactStandaloneSecrets: StandaloneSecretRedactor, +): string { + if (value.length === 0) return value; + const parsed = parseUrlTokenForRedaction(value); + if (!parsed) { + return redactMalformedUrlQuery( + redactMalformedUrlUserinfo(value, "****"), + "****", + false, + isSensitiveKey, + redactStandaloneSecrets, + ); + } + if (parsed.url.username) parsed.url.username = "****"; + if (parsed.url.password) parsed.url.password = "****"; + redactUrlSearchParams(parsed.url, "****", isSensitiveKey, redactStandaloneSecrets); + parsed.url.hash = redactUrlFragment( + parsed.url.hash, + "****", + isSensitiveKey, + redactStandaloneSecrets, + ); + return `${parsed.url.toString()}${parsed.suffix}`; +} + +export function redactUrlTokenFull( + value: string, + isSensitiveKey: SensitiveKeyDetector, + redactStandaloneSecrets: StandaloneSecretRedactor, + redactMalformedUrl: MalformedUrlRedactor, +): string | null { + const parsed = parseUrlTokenForRedaction(value); + if (!parsed) { + const redactedValue = redactMalformedUrlQuery( + redactMalformedUrlUserinfo(value, null), + "", + true, + isSensitiveKey, + redactStandaloneSecrets, + ); + const queryStart = redactedValue.indexOf("?"); + if (queryStart < 0) return redactMalformedUrl(redactedValue); + const redactedPrefix = redactMalformedUrl(redactedValue.slice(0, queryStart)); + return redactedPrefix === null ? null : `${redactedPrefix}${redactedValue.slice(queryStart)}`; + } + if (parsed.url.username || parsed.url.password) { + parsed.url.username = ""; + parsed.url.password = ""; + } + redactUrlSearchParams(parsed.url, "", isSensitiveKey, redactStandaloneSecrets); + // Endpoint fragments are never sent to the server and can contain OAuth + // credentials, so persistence intentionally drops them instead of logging. + parsed.url.hash = ""; + return `${parsed.url.toString()}${parsed.suffix}`; +} diff --git a/src/lib/security/redact.test.ts b/src/lib/security/redact.test.ts index f815d02d200..4f8236ee592 100644 --- a/src/lib/security/redact.test.ts +++ b/src/lib/security/redact.test.ts @@ -3,101 +3,7 @@ import { describe, expect, it } from "vitest"; -import { - redact, - redactForLog, - redactFull, - redactLogSequence, - redactSensitiveText, - redactUrl, -} from "./redact.js"; - -describe("URL redaction", () => { - it.each([ - ["SOCKS", "socks5://socks-user:socks-password@proxy.example:1080"], - ["mixed-case FTP", "FtP://ftp-user:ftp-password@files.example/path"], - ["mixed-case HTTPS", "HTTPS://https-user:https-password@secure.example:8443"], - ])("redacts embedded credentials from %s URLs", (_label, value) => { - const result = redact(value); - - expect(result).toContain("****:****@"); - expect(result).not.toContain("-user"); - expect(result).not.toContain("-password"); - }); - - it("redacts a bracket-wrapped SOCKS URL without breaking its closing delimiter", () => { - const result = redact( - "proxy [socks5://bracket-user:bracket-password@proxy.example:1080] failed", - ); - - expect(result).toContain("socks5://****:****@proxy.example:1080]"); - expect(result).not.toContain("bracket-user"); - expect(result).not.toContain("bracket-password"); - }); - - it("bounds malformed wrapper parsing before falling back to userinfo redaction", () => { - const wrappers = "]".repeat(4_096); - const result = redact( - `proxy [socks5://bounded-user:bounded-password@proxy.example:1080${wrappers}`, - ); - - expect(result).toContain("socks5://****:****@proxy.example:1080"); - expect(result).not.toContain("bounded-user"); - expect(result).not.toContain("bounded-password"); - }); - - it("preserves a credentialed IPv6 host while redacting its userinfo", () => { - const result = redact("proxy https://ipv6-user:ipv6-password@[::1]:8443/path failed"); - - expect(result).toContain("https://****:****@[::1]:8443/path"); - expect(result).not.toContain("ipv6-user"); - expect(result).not.toContain("ipv6-password"); - }); - - it.each([ - [ - "parentheses and comma", - "proxy (https://wrapped-user:wrapped-password@proxy.example/path), retry", - "(https://****:****@proxy.example/path), retry", - ], - [ - "angle brackets and semicolon", - "proxy ; retry", - "; retry", - ], - [ - "a trailing sentence period", - "proxy socks5://wrapped-user:wrapped-password@proxy.example:1080. retry", - "socks5://****:****@proxy.example:1080. retry", - ], - ])("keeps %s outside the redacted URL token", (_label, value, expected) => { - const result = redact(value); - - expect(result).toContain(expected); - expect(result).not.toContain("wrapped-user"); - expect(result).not.toContain("wrapped-password"); - }); - - it.each([ - ["semicolon", "pa;ssword"], - ["comma", "pa,ssword"], - ["balanced parentheses", "pa(ss)word"], - ])("redacts credentials containing valid %s punctuation", (_label, password) => { - const result = redact(`proxy https://userinfo-user:${password}@proxy.example/path failed`); - - expect(result).toContain("https://****:****@proxy.example/path"); - expect(result).not.toContain("userinfo-user"); - expect(result).not.toContain(password); - }); - - it("fully removes generic-scheme userinfo and sensitive query values", () => { - const result = redactUrl( - "FtP://ftp-user:ftp-password@files.example/path?token=secret-value#fragment", - ); - - expect(result).toBe("ftp://files.example/path?token=%3CREDACTED%3E"); - }); -}); +import { redactForLog, redactFull, redactLogSequence, redactSensitiveText } from "./redact.js"; describe("redactForLog", () => { it("redacts pass aliases in structured keys and canonical text assignments", () => { diff --git a/src/lib/security/redact.ts b/src/lib/security/redact.ts index 61f9c59d458..7f90872b5ea 100644 --- a/src/lib/security/redact.ts +++ b/src/lib/security/redact.ts @@ -20,10 +20,12 @@ import type { StdioOptions } from "node:child_process"; import { listMessagingCredentialMetadata } from "../messaging/channels"; import { isCredentialField } from "./credential-filter"; +import { redactUrlTokenFull, redactUrlTokenPartial, URL_TOKEN_PATTERN } from "./redact-url"; import { CONTEXT_PATTERNS, SECRET_BLOCK_PATTERNS, SECRET_PATTERNS, + STRUCTURED_TOKEN_PATTERNS, TOKEN_PREFIX_PATTERNS, } from "./secret-patterns"; @@ -48,95 +50,17 @@ const SENSITIVE_ENV_ASSIGNMENT_PATTERN = new RegExp( "gi", ); -// Proxy variables and diagnostics are not limited to lowercase HTTP(S) URLs. -// Match any RFC-style URI scheme so credentials in uppercase or SOCKS proxy -// URLs receive the same URL-parser-backed redaction. -const URL_TOKEN_PATTERN = /[a-z][a-z0-9+.-]*:\/\/[^\s'"]+/gi; -const URL_TRAILING_DELIMITERS = ")]}>.,;:!?"; -const MAX_URL_PARSE_ATTEMPTS = 9; - // ── Partial redaction (runner.ts style) ───────────────────────── function redactMatch(match: string): string { return match.slice(0, 4) + "*".repeat(Math.min(match.length - 4, 20)); } -function isUnmatchedClosingDelimiter(value: string, closing: string): boolean { - const openingByClosing: Record = { - ")": "(", - "]": "[", - "}": "{", - ">": "<", - }; - const opening = openingByClosing[closing]; - if (!opening) return false; - let balance = 0; - for (const character of value) { - if (character === opening) balance += 1; - else if (character === closing) balance -= 1; - } - return balance < 0; -} - -function isProseUrlSuffix(value: string, trailing: string): boolean { - return ".,;".includes(trailing) || isUnmatchedClosingDelimiter(value, trailing); -} - -function parseUrlToken(value: string): { url: URL; suffix: string } | null { - let candidate = value; - let suffix = ""; - for (let attempt = 0; candidate && attempt < MAX_URL_PARSE_ATTEMPTS; attempt += 1) { - const trailing = candidate.at(-1); - // Capture the complete token first so punctuation that is valid in - // userinfo cannot terminate redaction. Only then peel terminal prose - // punctuation and unmatched wrapper closers before URL parsing. - if (trailing && isProseUrlSuffix(candidate, trailing)) { - candidate = candidate.slice(0, -1); - suffix = `${trailing}${suffix}`; - continue; - } - try { - return { url: new URL(candidate), suffix }; - } catch { - if (!trailing || !URL_TRAILING_DELIMITERS.includes(trailing)) return null; - candidate = candidate.slice(0, -1); - suffix = `${trailing}${suffix}`; - } - } - return null; -} - -function redactMalformedUrlUserinfo(value: string, replacement: string | null): string { - const schemeEnd = value.indexOf("://") + 3; - if (schemeEnd < 3) return value; - const relativeAuthorityEnd = value.slice(schemeEnd).search(/[/?#]/); - const authorityEnd = relativeAuthorityEnd < 0 ? value.length : schemeEnd + relativeAuthorityEnd; - const authority = value.slice(schemeEnd, authorityEnd); - const userinfoEnd = authority.lastIndexOf("@"); - if (userinfoEnd < 1) return value; - const userinfo = authority.slice(0, userinfoEnd); - const redactedUserinfo = - replacement === null ? "" : `${userinfo.includes(":") ? `${replacement}:` : ""}${replacement}@`; - return `${value.slice(0, schemeEnd)}${redactedUserinfo}${authority.slice(userinfoEnd + 1)}${value.slice(authorityEnd)}`; -} - -function redactUrlPartial(value: string): string { - if (typeof value !== "string" || value.length === 0) return value; - const parsed = parseUrlToken(value); - if (!parsed) return redactMalformedUrlUserinfo(value, "****"); - if (parsed.url.username) parsed.url.username = "****"; - if (parsed.url.password) parsed.url.password = "****"; - for (const key of [...parsed.url.searchParams.keys()]) { - if (/(^|[-_])(?:signature|sig|token|auth|access_token)$/i.test(key)) { - parsed.url.searchParams.set(key, "****"); - } - } - return `${parsed.url.toString()}${parsed.suffix}`; -} - export function redact(str: string): string { if (typeof str !== "string") return str; - let out = str.replace(URL_TOKEN_PATTERN, redactUrlPartial); + let out = str.replace(URL_TOKEN_PATTERN, (value) => + redactUrlTokenPartial(value, isSensitiveKey, redactStandaloneSecrets), + ); for (const pat of SECRET_PATTERNS) { pat.lastIndex = 0; out = out.replace(pat, redactMatch); @@ -234,6 +158,10 @@ const FULL_REDACT_PATTERNS: [RegExp, string][] = [ new RegExp(p.source, p.flags), "", ]), + ...STRUCTURED_TOKEN_PATTERNS.map((p): [RegExp, string] => [ + new RegExp(p.source, p.flags), + "", + ]), [/(Bearer )\S+/gi, "$1"], [/\/bot[^/\s]+\//g, "/bot/"], ]; @@ -247,14 +175,22 @@ export function redactFull(text: string): string { return result; } -/** Redact self-identifying tokens and secret blocks without rewriting surrounding structure. */ -export function redactStandaloneSecretsFull(text: string): string { +function redactStandaloneSecrets(text: string, replacement: string): string { let result = text; - for (const pattern of [...TOKEN_PREFIX_PATTERNS, ...SECRET_BLOCK_PATTERNS]) { + for (const pattern of [ + ...TOKEN_PREFIX_PATTERNS, + ...STRUCTURED_TOKEN_PATTERNS, + ...SECRET_BLOCK_PATTERNS, + ]) { pattern.lastIndex = 0; - result = result.replace(pattern, ""); + result = result.replace(pattern, replacement); } - return result.replace(/\/bot[^/\s]+\//g, "/bot/"); + return result.replace(/\/bot[^/\s]+\//g, `/bot${replacement}/`); +} + +/** Redact self-identifying tokens and secret blocks without rewriting surrounding structure. */ +export function redactStandaloneSecretsFull(text: string): string { + return redactStandaloneSecrets(text, ""); } // ── Sensitive text redaction (onboard-session.ts style) ───────── @@ -264,7 +200,12 @@ export function redactSensitiveText(value: unknown): string | null { let result = value .replace(SENSITIVE_ENV_ASSIGNMENT_PATTERN, "$1=") .replace(/Bearer\s+\S+/gi, "Bearer "); - for (const pattern of [...SECRET_BLOCK_PATTERNS, ...CONTEXT_PATTERNS, ...TOKEN_PREFIX_PATTERNS]) { + for (const pattern of [ + ...SECRET_BLOCK_PATTERNS, + ...CONTEXT_PATTERNS, + ...TOKEN_PREFIX_PATTERNS, + ...STRUCTURED_TOKEN_PATTERNS, + ]) { pattern.lastIndex = 0; result = result.replace(pattern, ""); } @@ -277,19 +218,7 @@ function escapeRegExp(value: string): string { export function redactUrl(value: unknown): string | null { if (typeof value !== "string" || value.length === 0) return null; - const parsed = parseUrlToken(value); - if (!parsed) return redactSensitiveText(redactMalformedUrlUserinfo(value, null)); - if (parsed.url.username || parsed.url.password) { - parsed.url.username = ""; - parsed.url.password = ""; - } - for (const key of [...parsed.url.searchParams.keys()]) { - if (/(^|[-_])(?:signature|sig|token|auth|access_token)$/i.test(key)) { - parsed.url.searchParams.set(key, ""); - } - } - parsed.url.hash = ""; - return `${parsed.url.toString()}${parsed.suffix}`; + return redactUrlTokenFull(value, isSensitiveKey, redactStandaloneSecrets, redactSensitiveText); } const SENSITIVE_KEY_WORDS: ReadonlySet = new Set([ diff --git a/src/lib/security/secret-patterns.ts b/src/lib/security/secret-patterns.ts index e254677c79d..08107db5e4f 100644 --- a/src/lib/security/secret-patterns.ts +++ b/src/lib/security/secret-patterns.ts @@ -48,6 +48,13 @@ export const TOKEN_PREFIX_PATTERNS: RegExp[] = [ /lsv2_(?:pt|sk)_[A-Za-z0-9]{10,}(?:_[A-Za-z0-9]+)*/g, ]; +/** Structured standalone tokens without a provider-specific prefix. */ +export const STRUCTURED_TOKEN_PATTERNS: RegExp[] = [ + // Compact JWT protected headers are JSON objects, whose base64url encoding + // starts with eyJ. Provider-prefixed opaque tokens are covered above. + /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{10,}\b/g, +]; + /** Context-anchored patterns (require a prefix like KEY=, Bearer, etc.). */ export const CONTEXT_PATTERNS: RegExp[] = [ /(?<=Bearer\s+)[A-Za-z0-9_.+/=-]{10,}/gi, @@ -80,6 +87,7 @@ export const SECRET_BLOCK_PATTERNS: RegExp[] = [ /** All secret patterns combined. */ export const SECRET_PATTERNS: RegExp[] = [ ...TOKEN_PREFIX_PATTERNS, + ...STRUCTURED_TOKEN_PATTERNS, ...SECRET_BLOCK_PATTERNS, ...CONTEXT_PATTERNS, ]; diff --git a/src/lib/state/onboard-session-redaction.test.ts b/src/lib/state/onboard-session-redaction.test.ts new file mode 100644 index 00000000000..6c61e8b2be6 --- /dev/null +++ b/src/lib/state/onboard-session-redaction.test.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-redaction-")); + vi.stubEnv("HOME", tmpDir); + vi.resetModules(); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe("onboard session endpoint redaction", () => { + it.each([ + ["provider token", "nvapi-sentinel-query-value-do-not-persist", ""], + ["Bearer credential", "Bearer abcdef0123456789", "Bearer "], + ])("does not persist a %s under a benign query name", async (_label, secret, expected) => { + const session = await import("./onboard-session.js"); + const endpointUrl = `https://endpoint.example/v1?model=${encodeURIComponent(secret)}&keep=yes`; + + session.saveSession(session.createSession({ endpointUrl })); + + const raw = fs.readFileSync(session.SESSION_FILE, "utf8"); + const persistedUrl = session.loadSession()?.endpointUrl; + expect(raw).not.toContain(secret.split(" ").at(-1)); + expect(persistedUrl).not.toBeNull(); + const parsed = new URL(persistedUrl as string); + expect(parsed.searchParams.get("model")).toBe(expected); + expect(parsed.searchParams.get("keep")).toBe("yes"); + }); + + it("does not persist secrets from a malformed endpoint URL", async () => { + const session = await import("./onboard-session.js"); + const decodedSecret = "sk-proj-abcdefghijklmnopqrstuvwxyz"; + const encodedSecret = "sk%2Dproj%2Dabcdefghijklmnopqrstuvwxyz"; + const endpointUrl = `https://user:pass@[not-an-ip/path?model=${encodedSecret}&${encodedSecret}=opaque&keep=yes#model=${encodedSecret}`; + + session.saveSession(session.createSession({ endpointUrl })); + + const raw = fs.readFileSync(session.SESSION_FILE, "utf8"); + expect(raw).not.toContain(encodedSecret); + expect(raw).not.toContain(decodedSecret); + expect(raw).not.toContain("user:pass"); + expect(session.loadSession()?.endpointUrl).toBe( + "https://[not-an-ip/path?model=%3CREDACTED%3E&%3CREDACTED%3E=opaque&keep=yes", + ); + }); +}); diff --git a/src/lib/state/onboard-session.test.ts b/src/lib/state/onboard-session.test.ts index caa6325c5f6..dbcfb61b919 100644 --- a/src/lib/state/onboard-session.test.ts +++ b/src/lib/state/onboard-session.test.ts @@ -563,7 +563,8 @@ describe("onboard session", () => { expect(loaded.provider).toBe("openai"); }); - // ── Session secret boundary, consolidated from #6225 (epic #6224) ── + // Session secret boundary from #6225. Endpoint query coverage for #6224 + // lives in onboard-session-redaction.test.ts. it("round-trips writer-shaped legacy migration hashes and drops non-string entries (#6225)", () => { // Digest shape mirrors legacyValueHash() in src/lib/onboard.ts, the only @@ -592,8 +593,8 @@ describe("onboard session", () => { expect(loaded.migratedLegacyValueHashes?.NVIDIA_API_KEY).toMatch(/^[0-9a-f]{64}$/); }); - it("serializes missing and explicit-null credentialEnv identically (#6224)", () => { - // #6224 contract gap: the schema cannot distinguish "never prompted", + it("serializes missing and explicit-null credentialEnv identically (#6228)", () => { + // #6228 contract gap: the schema cannot distinguish "never prompted", // "user declined", and "explicitly cleared" once they become null. session.saveSession(session.createSession()); const unset = JSON.parse(fs.readFileSync(session.SESSION_FILE, "utf8")).credentialEnv; @@ -610,9 +611,7 @@ describe("onboard session", () => { expect(requireLoadedSession(session.loadSession()).credentialEnv).toBeNull(); }); - // Desired behavior tracked by #6224. redactUrl() currently masks sensitive - // parameter names but not token-shaped values under otherwise benign names. - it.todo("redacts token-shaped values under benign endpoint query param names (#6224)"); + // Focused endpoint secret-persistence coverage lives in onboard-session-redaction.test.ts. it("only persists known Hermes auth methods", () => { session.saveSession(session.createSession()); diff --git a/test/e2e/fixtures/redaction.ts b/test/e2e/fixtures/redaction.ts index 588d263ff8a..4efbc7310a5 100644 --- a/test/e2e/fixtures/redaction.ts +++ b/test/e2e/fixtures/redaction.ts @@ -88,6 +88,11 @@ export const TOKEN_PREFIX_PATTERNS: RegExp[] = [ /lsv2_(?:pt|sk)_[A-Za-z0-9]{10,}(?:_[A-Za-z0-9]+)*/g, ]; +export const STRUCTURED_TOKEN_PATTERNS: RegExp[] = [ + // JSON Web Tokens (base64url header.payload.signature). + /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{10,}\b/g, +]; + export const CONTEXT_PATTERNS: RegExp[] = [ /(?<=Bearer\s+)[A-Za-z0-9_.+/=-]{10,}/gi, /(?<=(?:^|[^A-Za-z0-9])(?:[A-Za-z0-9]{1,128}_(?:KEY|TOKEN|SECRET|CREDENTIAL|PASSWORD|PASSWD|PASS)|(?:X[-_])?API[-_]KEY|TOKEN|SECRET|CREDENTIAL|PASSWORD|PASSWD|PASS)["']?(?:[ \t]{0,32}[=:][ \t]{0,32}|[ \t]{1,32})["']?)[^\s'"]{10,}/gi, @@ -101,7 +106,7 @@ export const SECRET_BLOCK_PATTERNS: RegExp[] = [ /** * Replace every secret-shaped token in `text` with ``. Uses - * the canonical TOKEN_PREFIX_PATTERNS + CONTEXT_PATTERNS sets. + * the canonical token, secret-block, and context pattern sets. * * When `explicitValues` is supplied, each non-empty value is replaced * verbatim with `[REDACTED]` before the regex passes run, so per-test @@ -121,6 +126,10 @@ function redactCanonicalShapes(text: string): string { p.lastIndex = 0; out = out.replace(p, REDACTED); } + for (const p of STRUCTURED_TOKEN_PATTERNS) { + p.lastIndex = 0; + out = out.replace(p, REDACTED); + } for (const p of SECRET_BLOCK_PATTERNS) { p.lastIndex = 0; out = out.replace(p, REDACTED); diff --git a/test/e2e/support/e2e-redaction-parity.test.ts b/test/e2e/support/e2e-redaction-parity.test.ts index 4a5dec86c97..5ea6cae1213 100644 --- a/test/e2e/support/e2e-redaction-parity.test.ts +++ b/test/e2e/support/e2e-redaction-parity.test.ts @@ -23,11 +23,13 @@ import { describe, expect, it } from "vitest"; import { CONTEXT_PATTERNS as PRODUCT_CONTEXT_PATTERNS, SECRET_BLOCK_PATTERNS as PRODUCT_SECRET_BLOCK_PATTERNS, + STRUCTURED_TOKEN_PATTERNS as PRODUCT_STRUCTURED_TOKEN_PATTERNS, TOKEN_PREFIX_PATTERNS as PRODUCT_TOKEN_PREFIX_PATTERNS, } from "../../../src/lib/security/secret-patterns.ts"; import { CONTEXT_PATTERNS as FIXTURE_CONTEXT_PATTERNS, SECRET_BLOCK_PATTERNS as FIXTURE_SECRET_BLOCK_PATTERNS, + STRUCTURED_TOKEN_PATTERNS as FIXTURE_STRUCTURED_TOKEN_PATTERNS, TOKEN_PREFIX_PATTERNS as FIXTURE_TOKEN_PREFIX_PATTERNS, } from "../fixtures/redaction.ts"; @@ -52,6 +54,12 @@ describe("fixture redaction parity with product source-of-truth", () => { expect(fixture).toEqual(product); }); + it("fixture structured token patterns match product structured token patterns", () => { + expect(fingerprint(FIXTURE_STRUCTURED_TOKEN_PATTERNS)).toEqual( + fingerprint(PRODUCT_STRUCTURED_TOKEN_PATTERNS), + ); + }); + it("fixture secret block patterns match product secret block patterns", () => { expect(fingerprint(FIXTURE_SECRET_BLOCK_PATTERNS)).toEqual( fingerprint(PRODUCT_SECRET_BLOCK_PATTERNS), diff --git a/test/helpers/security-token-fixtures.ts b/test/helpers/security-token-fixtures.ts new file mode 100644 index 00000000000..3120ddd5461 --- /dev/null +++ b/test/helpers/security-token-fixtures.ts @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export function makeJwtFixture(): string { + return ["eyJhbGciOiJIUzI1NiJ9", "eyJzdWIiOiIxMjM0NTY3ODkwIn0", "signatureABCDEFGHI"].join("."); +} + +export function makeEmptyClaimsJwtFixture(): string { + return ["eyJhbGciOiJIUzI1NiJ9", "e30", "signatureABCDEFGHI"].join("."); +} diff --git a/test/runner.test.ts b/test/runner.test.ts index 1946e47f2f1..27c27c31d3c 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -470,7 +470,7 @@ describe("redact", () => { it("masks dashboard URL hash tokens", () => { const token = "a".repeat(64); const output = redact(`http://127.0.0.1:18789/#token=${token}`); - expect(output).toBe("http://127.0.0.1:18789/#token=aaaa********************"); + expect(output).toBe("http://127.0.0.1:18789/#token=****"); expect(output).not.toContain(token); });