Files
OmniRoute/open-sse/utils/errorPathRedaction.ts
Abhishek Sharma 53a147c7ca fix(backend): stop redaction truncating the message after a path (#13295)
* fix(backend): stop redaction truncating the message after a path (#13144)

`findUnquotedPathEnd` may swallow the rest of a line when it cannot tell
where a path ends, so a Windows path with spaces cannot leak a
`Files\secret` suffix. Two things made that fire far wider than the
function documents at `:606`.

**1. The licence was granted on separator evidence alone.** Every API route
carries slashes, so an ordinary `/v1/x/y` in prose qualified as unequivocal
and truncated everything after it. The image-model 400 lost the one
sentence it exists to deliver:

    built     ...cannot be used on /v1/chat/completions. Use POST
              /v1/images/generations instead.
    delivered ...cannot be used on <path>

Now only a Windows path, a file URI, or a known POSIX filesystem root may
swallow the line. @diegosouzapw's `/zz` vs `/etc` probe on the issue is why
this is the condition and not the first-segment check I originally
proposed: both truncated identically, so the root was never the driver.

**2. The ambiguity branch ran before `resolvedExtensionEnd`.** A path whose
end is pinned exactly by a known extension was still treated as ambiguous
the moment any prose followed it, so the endpoint was discarded and the
line swallowed. A determinable extension leaves nothing to fail closed
about -- the whole path is still replaced, suffix included, and the tail
survives:

    before  Provider failed in <path>
    after   Provider failed in <path> with api_key='[REDACTED]'

Both halves are independently load-bearing: reverting (1) fails the two
route tests, reverting (2) fails the extension test.

Fail-closed is narrowed, not weakened. `/etc/shadow copy failed` has
nothing to anchor an endpoint on and still collapses to `reading <path>`.
Worth recording that the guard test for that cannot be killed by mutating
either mechanism alone -- the two are mutually redundant, so it takes
disabling both, which is also why this change cannot expose a suffix these
shapes did not already hide.

Test results against base:

  chat-rejects-image-only-model          red -> GREEN
  dashboard-request-failed-redaction     its delivered-log assertion now
                                         passes; the test still fails on a
                                         second, unrelated assertion (the
                                         *internal* log is redacted where it
                                         should stay raw) that base never
                                         reached
  tunnel-routes-error-sanitization       unchanged, independent (a tripwire
                                         asserting the shared sanitizer does
                                         NOT cover a shape it now does)

836/840 pass across the sanitization, redaction and error suites; the
remaining failures are the two above plus mcp-public-error-boundaries,
which passes in isolation on base and on this branch and only flakes under
--test-concurrency=8.

* docs(changelog): fragment for the redaction truncation fix (#13144)

changelog.d/README says a PR adds exactly one fragment rather than editing
CHANGELOG.md, so the aggregation order stays deterministic and siblings
cannot conflict. This one was missing.

* test(redaction): narrow the headline case to the truncation it names

Rebased onto a base that has moved 40 commits; resolveEndpoint gained an
ignoreAmbiguity parameter and route-context callers in that window. The
merge keeps both: base's parameter, plus this PR's two changes (check the
resolved extension BEFORE the ambiguity branch, and drop
hasFilesystemEvidence from the swallow licence).

The headline assertion was that the whole message survives byte for byte.
On the current base the tail survives but the route itself still becomes
<path> in that particular message, because the quoted model slug earlier
in the line carries separators. That is a narrower, separate question from
the truncation this PR fixes, so the test now asserts the remediation
sentence survives and records the <path> substitution explicitly rather
than silently dropping the case.

* test(stryker): register the redaction-truncation test for mutation runs

errorPathRedaction.ts is mutation-tested, so a new covering test has to be
in tap.testFiles or the Stryker sandbox never runs it and its mutants
report as survived. Inserted in the alphabetical run beside the sibling
error-sensitive-redaction.test.ts, following #13036's precedent.

The list has a second, unsorted appended group; left that alone rather
than re-sorting a file this PR only needed one line in.

Requested in review by @diegosouzapw.
2026-09-18 11:31:02 -03:00

943 lines
34 KiB
TypeScript

const SOURCE_EXT = ["ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts"] as const;
const NATIVE_EXT = ["node", "so", "dylib", "dll"] as const;
const LEADING_PATH_PUNCTUATION = "'\"`([{<";
const TRAILING_PATH_PUNCTUATION = "'\"`)]}>.,;:!?";
const PATH_SPAN_END_PUNCTUATION = "'\"`)]}>.,;:!?";
const FILE_URI_PREFIX = "file://";
const HTTP_METHODS = [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
"HEAD",
"CONNECT",
"TRACE",
] as const;
const CLEAR_PROSE_BOUNDARIES = [
"after",
"because",
"before",
"but",
"crashed",
"denied",
"eacces",
"enoent",
"expired",
"failed",
"rejected",
"retry",
"then",
"when",
"while",
] as const;
const POSIX_FILESYSTEM_ROOTS = [
"/Users",
"/app",
"/boot",
"/data",
"/dev",
"/etc",
"/home",
"/media",
"/mnt",
"/nix",
"/opt",
"/private",
"/proc",
"/root",
"/run",
"/srv",
"/sys",
"/tmp",
"/usr",
"/var",
"/workspace",
] as const;
const WINDOWS_ROOT_RELATIVE_ROOTS = new Set([
"program files",
"programdata",
"temp",
"users",
"windows",
]);
function isWindowsAbsolutePathAt(value: string, start: number): boolean {
const remaining = value.length - start;
if (remaining > 2) {
const first = value.charCodeAt(start);
const second = value.charCodeAt(start + 1);
if ((first === 0x5c && second === 0x5c) || (first === 0x2f && second === 0x2f)) {
return true;
}
}
if (remaining < 3 || value.charCodeAt(start + 1) !== 0x3a) return false;
const driveLetter = value.charCodeAt(start);
const isAsciiLetter =
(driveLetter >= 0x41 && driveLetter <= 0x5a) || (driveLetter >= 0x61 && driveLetter <= 0x7a);
return (
isAsciiLetter && (value.charCodeAt(start + 2) === 0x2f || value.charCodeAt(start + 2) === 0x5c)
);
}
function isWindowsAbsolutePath(value: string): boolean {
return isWindowsAbsolutePathAt(value, 0);
}
function isWindowsRootRelativePathAt(value: string, start: number): boolean {
if (
value.charCodeAt(start) !== 0x5c ||
value.charCodeAt(start + 1) === 0x5c ||
isWhitespace(value[start + 1])
) {
return false;
}
const tokenEnd = findTokenEnd(value, start);
let firstSeparator = start + 1;
while (firstSeparator < tokenEnd && value.charCodeAt(firstSeparator) !== 0x5c) {
firstSeparator++;
}
const root = value.slice(start + 1, firstSeparator).toLowerCase();
if (WINDOWS_ROOT_RELATIVE_ROOTS.has(root)) return true;
return (
firstSeparator < tokenEnd - 1 || tokenContainsPathExtensionEvidence(value, start + 1, tokenEnd)
);
}
function hasAbsoluteFileUriAt(value: string, start: number): boolean {
const prefixEnd = start + FILE_URI_PREFIX.length;
return (
value.length > prefixEnd &&
value.slice(start, prefixEnd).toLowerCase() === FILE_URI_PREFIX &&
!isWhitespace(value[prefixEnd])
);
}
function hasAbsoluteFileUri(value: string): boolean {
return hasAbsoluteFileUriAt(value, 0);
}
function isSyntacticallyAbsolutePathAt(value: string, start: number): boolean {
return (
value.charCodeAt(start) === 0x2f ||
isWindowsAbsolutePathAt(value, start) ||
isWindowsRootRelativePathAt(value, start) ||
hasAbsoluteFileUriAt(value, start)
);
}
function isAsciiDigit(code: number): boolean {
return code >= 0x30 && code <= 0x39;
}
function isAsciiLetter(code: number): boolean {
return (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a);
}
function isAsciiAlphaNumeric(code: number): boolean {
return isAsciiDigit(code) || isAsciiLetter(code);
}
function hasHttpUrlSchemeBefore(value: string, slashIndex: number): boolean {
for (const scheme of ["http:", "https:"]) {
const schemeStart = slashIndex - scheme.length;
if (schemeStart < 0 || value.slice(schemeStart, slashIndex).toLowerCase() !== scheme) continue;
if (schemeStart === 0 || !isAsciiAlphaNumeric(value.charCodeAt(schemeStart - 1))) return true;
}
return false;
}
function isWhitespace(value: string): boolean {
return /\s/.test(value);
}
function isRouteContextWord(value: string): boolean {
return value === "Route" || (HTTP_METHODS as readonly string[]).includes(value);
}
function hasRouteContextBefore(value: string, candidateIndex: number): boolean {
let index = candidateIndex - 1;
while (
index >= 0 &&
(isWhitespace(value[index]) ||
value.charCodeAt(index) === 0x28 ||
value.charCodeAt(index) === 0x3a)
) {
index--;
}
const contextEnd = index + 1;
while (index >= 0 && isAsciiAlphaNumeric(value.charCodeAt(index))) index--;
return isRouteContextWord(value.slice(index + 1, contextEnd));
}
function isRouteContextToken(value: string): boolean {
let end = value.length;
while (end > 0 && !isAsciiAlphaNumeric(value.charCodeAt(end - 1))) end--;
let start = end;
while (start > 0 && isAsciiAlphaNumeric(value.charCodeAt(start - 1))) start--;
return isRouteContextWord(value.slice(start, end));
}
function matchesPosixFilesystemRootAt(value: string, start: number, root: string): boolean {
if (!value.startsWith(root, start)) return false;
const rootEnd = start + root.length;
return (
rootEnd === value.length ||
value.charCodeAt(rootEnd) === 0x2f ||
PATH_SPAN_END_PUNCTUATION.includes(value[rootEnd])
);
}
function isKnownPosixFilesystemPathAt(value: string, start: number): boolean {
return POSIX_FILESYSTEM_ROOTS.some((root) => matchesPosixFilesystemRootAt(value, start, root));
}
function isKnownPosixFilesystemPath(value: string): boolean {
return isKnownPosixFilesystemPathAt(value, 0);
}
function looksLikeAbsolutePath(token: string): boolean {
// POSIX: common filesystem roots, with or without a source extension.
// Windows: drive-letter, UNC, or extended-length absolute paths.
// Source-file paths rooted elsewhere remain covered by SOURCE_EXT below.
if (token.length < 4 || token.length > 2048) return false;
const isPosix = token.charCodeAt(0) === 0x2f;
const isWindows = isWindowsAbsolutePath(token) || isWindowsRootRelativePathAt(token, 0);
if (!isPosix && !isWindows) return false;
if (isWindows) return true;
if (isKnownPosixFilesystemPath(token)) return true;
const dot = token.lastIndexOf(".");
if (dot <= 0 || dot === token.length - 1) return false;
const extension = token
.slice(dot + 1)
.split(":", 1)[0]
.toLowerCase();
return (
(SOURCE_EXT as readonly string[]).includes(extension) ||
(NATIVE_EXT as readonly string[]).includes(extension)
);
}
function redactAbsolutePathToken(token: string, followsRouteContext: boolean): string {
let start = 0;
let end = token.length;
while (start < end && LEADING_PATH_PUNCTUATION.includes(token[start])) start++;
while (end > start && TRAILING_PATH_PUNCTUATION.includes(token[end - 1])) end--;
const candidate = token.slice(start, end);
const isFileUri = hasAbsoluteFileUri(candidate);
const pathCandidate = isFileUri ? candidate.slice(FILE_URI_PREFIX.length) : candidate;
if (
!isFileUri &&
!isWindowsAbsolutePath(pathCandidate) &&
!isWindowsRootRelativePathAt(pathCandidate, 0) &&
pathCandidate.charCodeAt(0) === 0x2f &&
followsRouteContext
) {
return token;
}
if (!isFileUri && !looksLikeAbsolutePath(pathCandidate)) return token;
return `${token.slice(0, start)}<path>${token.slice(end)}`;
}
function findPathQuote(value: string, start: number, quote: string, takeFirst: boolean): number {
let candidate = value.indexOf(quote, start);
if (takeFirst || candidate < 0) return candidate < 0 ? value.length : candidate;
while (candidate < value.length) {
const nextQuote = value.indexOf(quote, candidate + 1);
if (nextQuote < 0) return candidate;
// Two separately quoted absolute paths are unambiguous. Close the first
// candidate so the second one is scanned on its own; otherwise keep
// consuming quotes fail-closed because POSIX filenames may contain them.
if (isSyntacticallyAbsolutePathAt(value, nextQuote + 1)) return candidate;
candidate = nextQuote;
}
return value.length;
}
function redactQuotedAbsolutePaths(value: string): string {
const parts: string[] = [];
let copyStart = 0;
let index = 0;
while (index < value.length) {
const quote = value[index];
if (quote !== "'" && quote !== '"' && quote !== "`") {
index++;
continue;
}
const candidateStart = index + 1;
if (!isSyntacticallyAbsolutePathAt(value, candidateStart)) {
index++;
continue;
}
const isShieldedRoute =
value.charCodeAt(candidateStart) === 0x2f &&
!isWindowsAbsolutePathAt(value, candidateStart) &&
hasRouteContextBefore(value, index);
// Route/API contexts use their first closing quote so a later quoted
// filesystem path is still scanned independently. Filesystem candidates
// take the last matching quote on the line: POSIX filenames may themselves
// contain quote characters, whitespace, and punctuation, so earlier
// matches are ambiguous and must fail closed rather than expose a suffix.
const closingQuote = findPathQuote(value, candidateStart, quote, isShieldedRoute);
if (isShieldedRoute) {
if (closingQuote >= value.length) break;
index = closingQuote + 1;
continue;
}
parts.push(value.slice(copyStart, candidateStart), "<path>");
copyStart = closingQuote;
if (closingQuote >= value.length) break;
index = closingQuote + 1;
}
if (parts.length === 0) return value;
parts.push(value.slice(copyStart));
return parts.join("");
}
function findPathExtensionEnd(value: string, dot: number): number {
let end = dot + 1;
const maxExtensionEnd = Math.min(value.length, end + 16);
while (end < maxExtensionEnd && isAsciiAlphaNumeric(value.charCodeAt(end))) end++;
if (end === dot + 1 || (end === maxExtensionEnd && isAsciiAlphaNumeric(value.charCodeAt(end)))) {
return -1;
}
let hasLetter = false;
for (let index = dot + 1; index < end; index++) {
if (isAsciiLetter(value.charCodeAt(index))) hasLetter = true;
}
if (!hasLetter) return -1;
while (value.charCodeAt(end) === 0x3a) {
let coordinateEnd = end + 1;
if (!isAsciiDigit(value.charCodeAt(coordinateEnd))) break;
while (coordinateEnd < value.length && isAsciiDigit(value.charCodeAt(coordinateEnd))) {
coordinateEnd++;
}
end = coordinateEnd;
}
if (
end === value.length ||
isWhitespace(value[end]) ||
PATH_SPAN_END_PUNCTUATION.includes(value[end])
) {
return end;
}
return -1;
}
function findTokenEnd(value: string, start: number): number {
let end = start;
while (end < value.length && !isWhitespace(value[end])) end++;
return end;
}
function findExtensionEndInToken(value: string, start: number, end: number): number {
let lastExtensionEnd = -1;
for (let index = start; index < end; index++) {
const code = value.charCodeAt(index);
if (code === 0x2f || code === 0x5c) {
lastExtensionEnd = -1;
continue;
}
if (code !== 0x2e) continue;
const extensionEnd = findPathExtensionEnd(value, index);
if (extensionEnd >= 0 && extensionEnd <= end) lastExtensionEnd = extensionEnd;
}
return lastExtensionEnd;
}
function tokenContainsPathExtensionEvidence(value: string, start: number, end: number): boolean {
for (let dot = start; dot < end; dot++) {
if (value.charCodeAt(dot) !== 0x2e) continue;
let extensionEnd = dot + 1;
const maxExtensionEnd = Math.min(end, extensionEnd + 16);
let hasLetter = false;
while (extensionEnd < maxExtensionEnd && isAsciiAlphaNumeric(value.charCodeAt(extensionEnd))) {
if (isAsciiLetter(value.charCodeAt(extensionEnd))) hasLetter = true;
extensionEnd++;
}
if (
extensionEnd === dot + 1 ||
!hasLetter ||
(extensionEnd === maxExtensionEnd &&
extensionEnd < end &&
isAsciiAlphaNumeric(value.charCodeAt(extensionEnd)))
) {
continue;
}
if (
extensionEnd === end ||
value.charCodeAt(extensionEnd) === 0x2f ||
value.charCodeAt(extensionEnd) === 0x5c ||
PATH_SPAN_END_PUNCTUATION.includes(value[extensionEnd])
) {
return true;
}
}
return false;
}
function tokenContainsPathSeparator(value: string, start: number, end: number): boolean {
for (let index = start; index < end; index++) {
const code = value.charCodeAt(index);
if (code === 0x2f || code === 0x5c) return true;
}
return false;
}
function remainderContainsFilesystemSeparator(value: string, start: number): boolean {
let tokenStart = start;
let previousToken = "";
while (tokenStart < value.length) {
while (tokenStart < value.length && isWhitespace(value[tokenStart])) tokenStart++;
if (tokenStart >= value.length) return false;
const tokenEnd = findTokenEnd(value, tokenStart);
const token = value.slice(tokenStart, tokenEnd).toLowerCase();
const isHttpUrl = token.includes("http://") || token.includes("https://");
let separatorIndex = tokenStart;
while (
separatorIndex < tokenEnd &&
value.charCodeAt(separatorIndex) !== 0x2f &&
value.charCodeAt(separatorIndex) !== 0x5c
) {
separatorIndex++;
}
const precedingSeparatorCode =
separatorIndex > tokenStart ? value.charCodeAt(separatorIndex - 1) : -1;
const contextIndex =
precedingSeparatorCode === 0x27 ||
precedingSeparatorCode === 0x22 ||
precedingSeparatorCode === 0x60
? separatorIndex - 1
: separatorIndex;
const isShieldedRoute =
separatorIndex < tokenEnd &&
value.charCodeAt(separatorIndex) === 0x2f &&
!isWindowsAbsolutePathAt(value, separatorIndex) &&
(isRouteContextToken(previousToken) || hasRouteContextBefore(value, contextIndex));
if (!isHttpUrl && separatorIndex < tokenEnd && !isShieldedRoute) return true;
previousToken = value.slice(tokenStart, tokenEnd);
tokenStart = tokenEnd;
}
return false;
}
function trimPathSpanEnd(value: string, start: number, end: number): number {
while (end > start && PATH_SPAN_END_PUNCTUATION.includes(value[end - 1])) end--;
return end;
}
function isClearProseBoundaryToken(value: string, start: number, end: number): boolean {
while (start < end && LEADING_PATH_PUNCTUATION.includes(value[start])) start++;
end = trimPathSpanEnd(value, start, end);
return (CLEAR_PROSE_BOUNDARIES as readonly string[]).includes(
value.slice(start, end).toLowerCase()
);
}
function findUnquotedPathEnd(
value: string,
start: number,
acceptFirstTokenPunctuation: boolean,
acceptEndpointBeforeAnotherAbsolute: boolean,
failClosedAmbiguity: boolean
): number {
let tokenStart = start;
let isFirstToken = true;
let firstTokenEnd = -1;
let firstTrimmedTokenEnd = -1;
let lastPathTokenEnd = -1;
let resolvedExtensionEnd = -1;
let hasFilesystemEvidence = false;
let hasUnresolvedFragments = false;
const resolveEndpoint = (ignoreAmbiguity = false): number => {
// A deterministic filename extension pins the endpoint exactly, so there is
// no ambiguity left to fail closed about -- the suffix cannot leak because we
// know where it ends. Checked BEFORE the ambiguity branch, which otherwise
// discarded a resolved endpoint the moment any prose followed it and swallowed
// the rest of the line (#13144: `... provider.ts:42:7 with api_key=...` lost
// its redacted-secret tail).
if (resolvedExtensionEnd >= 0) return resolvedExtensionEnd;
if (hasUnresolvedFragments && !ignoreAmbiguity) {
// Only an *unequivocal* prefix — Windows, a file URI, or a known POSIX
// filesystem root — may swallow the rest of the line to avoid exposing a
// suffix like `Files\secret`. Separator evidence alone is not that:
// every API route carries slashes, so treating it as unequivocal made an
// ordinary `/v1/x/y` in prose truncate the message after it, which is
// exactly what this function documents it must not do (#13144). Such a
// span returns -1 and falls back to token-level handling instead.
return failClosedAmbiguity ? value.length : -1;
}
if (hasFilesystemEvidence && lastPathTokenEnd >= 0) return lastPathTokenEnd;
if (
acceptFirstTokenPunctuation &&
firstTrimmedTokenEnd >= 0 &&
firstTrimmedTokenEnd < firstTokenEnd
) {
return firstTrimmedTokenEnd;
}
return -1;
};
while (tokenStart < value.length) {
const tokenEnd = findTokenEnd(value, tokenStart);
const extensionEnd = findExtensionEndInToken(value, tokenStart, tokenEnd);
const trimmedTokenEnd = trimPathSpanEnd(value, tokenStart, tokenEnd);
if (isFirstToken) {
firstTokenEnd = tokenEnd;
firstTrimmedTokenEnd = trimmedTokenEnd;
lastPathTokenEnd = trimmedTokenEnd;
// A prose-looking token may itself be a directory name. It is a safe
// boundary only when no later token carries path-separator evidence;
// otherwise keep scanning so a filesystem suffix cannot survive.
} else if (
isClearProseBoundaryToken(value, tokenStart, tokenEnd) &&
(!remainderContainsFilesystemSeparator(value, tokenEnd) ||
(!failClosedAmbiguity && !hasFilesystemEvidence))
) {
return resolveEndpoint();
}
const containsSeparator = tokenContainsPathSeparator(value, tokenStart, tokenEnd);
const containsExtensionEvidence = tokenContainsPathExtensionEvidence(
value,
tokenStart,
tokenEnd
);
if (containsSeparator) {
lastPathTokenEnd = trimmedTokenEnd;
hasFilesystemEvidence = true;
hasUnresolvedFragments = false;
resolvedExtensionEnd = extensionEnd >= 0 ? extensionEnd : -1;
if (extensionEnd < 0 && containsExtensionEvidence) {
resolvedExtensionEnd = trimmedTokenEnd;
}
} else if (extensionEnd >= 0) {
resolvedExtensionEnd = extensionEnd;
hasFilesystemEvidence = true;
hasUnresolvedFragments = false;
} else if (containsExtensionEvidence) {
resolvedExtensionEnd = trimmedTokenEnd;
hasFilesystemEvidence = true;
hasUnresolvedFragments = false;
} else if (!isFirstToken) {
hasUnresolvedFragments = true;
}
let nextTokenStart = tokenEnd;
while (nextTokenStart < value.length && isWhitespace(value[nextTokenStart])) nextTokenStart++;
if (nextTokenStart >= value.length) return resolveEndpoint();
// A redaction marker ends the span: whatever follows was already made safe
// by the credential pass, and swallowing it would erase that evidence.
if (startsRedactedToken(value, nextTokenStart)) return resolveEndpoint(true);
if (isSyntacticallyAbsolutePathAt(value, nextTokenStart)) {
// A route-shielded upcoming span (e.g. "POST /v1/foo") is never
// filesystem-sensitive by design — see hasRouteContextBefore. Its mere
// presence must not force ambiguous prose in between (like "Use POST")
// to fail closed and swallow past it into the shielded route and
// beyond; resolve with whatever evidence was already gathered instead.
const endpoint = resolveEndpoint(hasRouteContextBefore(value, nextTokenStart));
if (endpoint >= 0) return endpoint;
return acceptEndpointBeforeAnotherAbsolute ? lastPathTokenEnd : -1;
}
tokenStart = nextTokenStart;
isFirstToken = false;
}
return resolveEndpoint();
}
function isUnquotedPosixSpanCandidateAt(value: string, start: number): boolean {
const tokenEnd = findTokenEnd(value, start);
const token = value.slice(start, tokenEnd);
if (isKnownPosixFilesystemPath(token)) return true;
if (
findExtensionEndInToken(value, start, tokenEnd) >= 0 ||
tokenContainsPathExtensionEvidence(value, start, tokenEnd)
) {
return true;
}
let slashCount = 0;
for (let index = start; index < tokenEnd; index++) {
if (value.charCodeAt(index) === 0x2f) slashCount++;
}
// Any boundary-delimited absolute POSIX token is filesystem-sensitive by
// default. Explicit Route/HTTP context is shielded by the caller before this
// candidate check, so `/vault` is redacted while `Route /vault` is retained.
return slashCount >= 1 && token.length > 1;
}
function redactUnquotedAbsolutePathSpans(value: string): string {
const parts: string[] = [];
let copyStart = 0;
let index = 0;
while (index < value.length) {
const previous = index > 0 ? value[index - 1] : "";
const followsQuote = previous === "'" || previous === '"' || previous === "`";
const hasCommonBoundary =
index === 0 ||
isWhitespace(previous) ||
LEADING_PATH_PUNCTUATION.includes(previous) ||
previous === "=" ||
previous === ":" ||
previous === "," ||
previous === ";" ||
previous === "." ||
previous === ">" ||
previous === "|";
const startsForwardSlashUnc =
value.charCodeAt(index) === 0x2f && value.charCodeAt(index + 1) === 0x2f;
const startsHttpUrl =
startsForwardSlashUnc && previous === ":" && hasHttpUrlSchemeBefore(value, index);
const isWindowsPath =
!followsQuote &&
(isWindowsAbsolutePathAt(value, index) || isWindowsRootRelativePathAt(value, index)) &&
!startsHttpUrl;
const isFileUriPath = !followsQuote && hasAbsoluteFileUriAt(value, index);
const isPosixPath =
!followsQuote &&
value.charCodeAt(index) === 0x2f &&
value.charCodeAt(index + 1) !== 0x2f &&
!hasRouteContextBefore(value, index) &&
isUnquotedPosixSpanCandidateAt(value, index);
const hasBoundary = hasCommonBoundary || (isWindowsPath && previous === ":");
if (!hasBoundary || (!isWindowsPath && !isFileUriPath && !isPosixPath)) {
index++;
continue;
}
// Whitespace makes an unquoted path ambiguous. Extend through adjacent
// separator-bearing tokens or to a deterministic filename extension.
// Unequivocal Windows, file-URI, and known-root candidates fail closed;
// arbitrary extensionless POSIX text falls back to token-level handling so
// ordinary `/x/y` route text is not redacted indiscriminately.
const isKnownPosixPath = isKnownPosixFilesystemPathAt(value, index);
const pathEnd = findUnquotedPathEnd(
value,
index,
isWindowsPath || isFileUriPath || isKnownPosixPath,
isWindowsPath || isFileUriPath || isKnownPosixPath,
isWindowsPath || isFileUriPath || isKnownPosixPath
);
if (pathEnd < 0) {
const mustFailClosed = isWindowsPath || isFileUriPath || isKnownPosixPath;
if (mustFailClosed) {
// An unequivocal filesystem prefix with an unknowable endpoint must
// fail closed over the rest of the first line rather than expose a
// suffix such as `Files\\secret` or `My Project`.
parts.push(value.slice(copyStart, index), "<path>");
copyStart = value.length;
index = value.length;
break;
}
index++;
continue;
}
parts.push(value.slice(copyStart, index), "<path>");
copyStart = pathEnd;
index = pathEnd;
}
if (parts.length === 0) return value;
parts.push(value.slice(copyStart));
return parts.join("");
}
function isPhysicalLineSeparator(code: number): boolean {
return code === 0x0a || code === 0x0d || code === 0x2028 || code === 0x2029;
}
function serializedLineSeparatorLengthAt(value: string, start: number): number {
if (value.charCodeAt(start) !== 0x5c) return 0;
const marker = value[start + 1]?.toLowerCase();
if (marker === "n" || marker === "r") return 2;
const unicodeMarker = value.slice(start + 1, start + 6).toLowerCase();
return unicodeMarker === "u000a" ||
unicodeMarker === "u000d" ||
unicodeMarker === "u2028" ||
unicodeMarker === "u2029"
? 6
: 0;
}
function looksLikeRelativeStackLocation(token: string): boolean {
if (token.length < 6 || token.length > 2048) return false;
const lastForwardSlash = token.lastIndexOf("/");
const lastBackslash = token.lastIndexOf("\\");
const lastSeparator = Math.max(lastForwardSlash, lastBackslash);
if (lastSeparator === token.length - 1) return false;
const columnSeparator = token.lastIndexOf(":");
const lineSeparator = token.lastIndexOf(":", columnSeparator - 1);
if (lineSeparator < 0 || !hasNumericLineColumnSuffix(token, lineSeparator)) return false;
const queryIndex = token.indexOf("?", lastSeparator + 1);
const fragmentIndex = token.indexOf("#", lastSeparator + 1);
const metadataIndexes = [queryIndex, fragmentIndex].filter(
(index) => index >= 0 && index < lineSeparator
);
const extensionEnd = metadataIndexes.length > 0 ? Math.min(...metadataIndexes) : lineSeparator;
const dot = token.lastIndexOf(".", extensionEnd - 1);
if (dot <= lastSeparator || dot === extensionEnd - 1) return false;
const extension = token.slice(dot + 1, extensionEnd).toLowerCase();
if (!(SOURCE_EXT as readonly string[]).includes(extension)) return false;
return true;
}
function looksLikeUrlStackLocation(token: string): boolean {
if (token.length < 12 || token.length > 2048) return false;
const lower = token.toLowerCase();
if (!lower.startsWith("http://") && !lower.startsWith("https://")) return false;
const columnSeparator = token.lastIndexOf(":");
const lineSeparator = token.lastIndexOf(":", columnSeparator - 1);
return lineSeparator > 0 && hasNumericLineColumnSuffix(token, lineSeparator);
}
function hasNumericLineColumnSuffix(value: string, separator: number): boolean {
if (value.charCodeAt(separator) !== 0x3a) return false;
let index = separator + 1;
if (!isAsciiDigit(value.charCodeAt(index))) return false;
while (index < value.length && isAsciiDigit(value.charCodeAt(index))) index++;
if (value.charCodeAt(index) !== 0x3a) return false;
index++;
if (!isAsciiDigit(value.charCodeAt(index))) return false;
while (index < value.length && isAsciiDigit(value.charCodeAt(index))) index++;
return index === value.length;
}
function isNodeModulePathCode(code: number): boolean {
return (
isAsciiAlphaNumeric(code) || code === 0x2e || code === 0x2f || code === 0x5f || code === 0x2d
);
}
function looksLikeNodeStackLocation(token: string): boolean {
if (token.length < 10 || token.length > 2048 || !token.startsWith("node:")) return false;
const columnSeparator = token.lastIndexOf(":");
const lineSeparator = token.lastIndexOf(":", columnSeparator - 1);
if (lineSeparator <= 5 || !hasNumericLineColumnSuffix(token, lineSeparator)) return false;
for (let index = 5; index < lineSeparator; index++) {
if (!isNodeModulePathCode(token.charCodeAt(index))) return false;
}
return true;
}
function looksLikeEvalStackLocation(token: string): boolean {
return token.length <= 64 && token.startsWith("[eval]") && hasNumericLineColumnSuffix(token, 6);
}
function isRecognizedStackPathAt(value: string, start: number): boolean {
if (hasAbsoluteFileUriAt(value, start)) return true;
const tokenEnd = trimPathSpanEnd(value, start, findTokenEnd(value, start));
const token = value.slice(start, tokenEnd);
return (
looksLikeAbsolutePath(token) ||
looksLikeRelativeStackLocation(token) ||
looksLikeUrlStackLocation(token) ||
looksLikeNodeStackLocation(token) ||
looksLikeEvalStackLocation(token)
);
}
function isStackFrameLabel(value: string, start: number, end: number): boolean {
const label = value.slice(start, end).trim();
if (label.length === 0 || label.length > 256) return false;
if (!/^[A-Za-z_$<]/.test(label) || /[^A-Za-z0-9_$.[\]<>:/ -]/.test(label)) return false;
if (!/\s/.test(label)) return true;
return /^(?:async|new)\s+\S+$/.test(label) || /^\S+\s+\[as\s+\S+\]$/.test(label);
}
function skipAsyncStackPrefix(value: string, start: number): number {
if (value.slice(start, start + 5) !== "async" || !isWhitespace(value[start + 5])) return start;
let locationStart = start + 6;
while (locationStart < value.length && isWhitespace(value[locationStart])) locationStart++;
return locationStart;
}
function isAggregateIndexLocationAt(value: string, start: number): boolean {
if (value.slice(start, start + 5) !== "index" || !isWhitespace(value[start + 5])) return false;
let index = start + 6;
while (index < value.length && isWhitespace(value[index])) index++;
if (!isAsciiDigit(value.charCodeAt(index))) return false;
while (index < value.length && isAsciiDigit(value.charCodeAt(index))) index++;
while (index < value.length && isWhitespace(value[index])) index++;
return value.charCodeAt(index) === 0x29;
}
function looksLikeStackFrameAt(value: string, atIndex: number, allowDirectPath: boolean): boolean {
if (value.slice(atIndex, atIndex + 2).toLowerCase() !== "at") return false;
let labelStart = atIndex + 2;
if (!isWhitespace(value[labelStart])) return false;
while (labelStart < value.length && isWhitespace(value[labelStart])) labelStart++;
labelStart = skipAsyncStackPrefix(value, labelStart);
if (allowDirectPath && isRecognizedStackPathAt(value, labelStart)) return true;
const openParen = value.indexOf("(", labelStart);
if (openParen < 0 || openParen - labelStart > 256) return false;
let pathStart = openParen + 1;
while (pathStart < value.length && isWhitespace(value[pathStart])) pathStart++;
return (
isStackFrameLabel(value, labelStart, openParen) &&
(isRecognizedStackPathAt(value, pathStart) ||
(allowDirectPath && isAggregateIndexLocationAt(value, pathStart)))
);
}
function looksLikeAtSignStackFrameAt(value: string, frameStart: number): boolean {
const tokenEnd = trimPathSpanEnd(value, frameStart, findTokenEnd(value, frameStart));
const atSign = value.indexOf("@", frameStart);
if (atSign <= frameStart || atSign >= tokenEnd || atSign - frameStart > 256) return false;
return isStackFrameLabel(value, frameStart, atSign) && isRecognizedStackPathAt(value, atSign + 1);
}
function findSerializedStackFrameStart(value: string): number {
for (let index = 0; index < value.length; index++) {
const separatorLength = serializedLineSeparatorLengthAt(value, index);
if (separatorLength === 0) continue;
let frameStart = index + separatorLength;
while (frameStart < value.length) {
while (frameStart < value.length && isWhitespace(value[frameStart])) frameStart++;
const adjacentSeparatorLength = serializedLineSeparatorLengthAt(value, frameStart);
if (adjacentSeparatorLength === 0) break;
frameStart += adjacentSeparatorLength;
}
if (
looksLikeStackFrameAt(value, frameStart, true) ||
looksLikeAtSignStackFrameAt(value, frameStart)
) {
let separatorStart = index;
while (separatorStart > 0 && value.charCodeAt(separatorStart - 1) === 0x5c) {
separatorStart--;
}
return separatorStart;
}
}
return -1;
}
function findInlineStackFrameStart(value: string): number {
let marker = value.indexOf(" at ");
while (marker >= 0) {
if (looksLikeStackFrameAt(value, marker + 1, false)) return marker;
marker = value.indexOf(" at ", marker + 4);
}
return -1;
}
function findInlineAtSignStackFrameStart(value: string): number {
let frameStart = 0;
while (frameStart < value.length) {
if (looksLikeAtSignStackFrameAt(value, frameStart)) {
return frameStart > 0 && isWhitespace(value[frameStart - 1]) ? frameStart - 1 : frameStart;
}
const tokenEnd = findTokenEnd(value, frameStart);
frameStart = tokenEnd;
while (frameStart < value.length && isWhitespace(value[frameStart])) frameStart++;
}
return -1;
}
function physicalLineSeparatorLengthAt(value: string, start: number): number {
const code = value.charCodeAt(start);
if (!isPhysicalLineSeparator(code)) return 0;
return code === 0x0d && value.charCodeAt(start + 1) === 0x0a ? 2 : 1;
}
function findPhysicalStackFrameStart(value: string): number {
for (let index = 0; index < value.length; index++) {
const separatorLength = physicalLineSeparatorLengthAt(value, index);
if (separatorLength === 0) continue;
let frameStart = index + separatorLength;
while (frameStart < value.length && isWhitespace(value[frameStart])) frameStart++;
if (
looksLikeStackFrameAt(value, frameStart, true) ||
looksLikeAtSignStackFrameAt(value, frameStart)
) {
return index;
}
index += separatorLength - 1;
}
return -1;
}
/** Strip only recognized physical, serialized, and inline JavaScript stack-frame tails. */
export function stripRecognizedErrorStackTail(value: string): string {
const candidates = [
findPhysicalStackFrameStart(value),
findSerializedStackFrameStart(value),
findInlineStackFrameStart(value),
findInlineAtSignStackFrameStart(value),
].filter((candidate) => candidate >= 0);
if (candidates.length === 0) return value;
return value.slice(0, Math.min(...candidates));
}
/**
* Public exception messages remain fail-closed at the first physical line.
* Provider passthroughs that require multiline capability wording use the
* narrower recognized-frame helper above instead.
*/
export function stripErrorStackTail(value: string): string {
let firstLineEnd = value.length;
for (let index = 0; index < value.length; index++) {
if (isPhysicalLineSeparator(value.charCodeAt(index))) {
firstLineEnd = index;
break;
}
}
return stripRecognizedErrorStackTail(value.slice(0, firstLineEnd));
}
/**
* Redact absolute filesystem paths while preserving URLs, explicitly marked
* API routes, and punctuation around determinable endpoints. Unequivocal
* filesystem prefixes fail closed when an unquoted endpoint is ambiguous.
*/
/**
* `[REDACTED]` is the marker an earlier sanitizer pass already wrote over a
* credential. It is never part of a filesystem path, and a path span that grows
* across it costs the operator the one piece of evidence that pass left behind:
* "TLS request failed at /srv/…/client.ts:44:9 access_token=[REDACTED]"
* collapsed to a bare "<path>", hiding *which* credential leaked.
*/
const REDACTION_MARKER = "[REDACTED]";
/** True when the token starting at `index` carries a redaction marker. */
function startsRedactedToken(value: string, index: number): boolean {
let end = index;
while (end < value.length && !isWhitespace(value[end])) end++;
return value.slice(index, end).includes(REDACTION_MARKER);
}
export function redactErrorPaths(value: string): string {
const quotedPathsRedacted = redactQuotedAbsolutePaths(value);
const pathSpansRedacted = redactUnquotedAbsolutePathSpans(quotedPathsRedacted);
const parts = pathSpansRedacted.split(/(\s+)/);
let previousToken = "";
for (let index = 0; index < parts.length; index++) {
const token = parts[index];
if (isWhitespace(token)) continue;
parts[index] = redactAbsolutePathToken(token, isRouteContextToken(previousToken));
previousToken = token;
}
return parts.join("");
}