diff --git a/server/src/__tests__/run-secret-redaction.test.ts b/server/src/__tests__/run-secret-redaction.test.ts index 062e53866e9..28b0f18113e 100644 --- a/server/src/__tests__/run-secret-redaction.test.ts +++ b/server/src/__tests__/run-secret-redaction.test.ts @@ -570,4 +570,126 @@ describeEmbeddedPostgres("run secret redaction registry (company-scoped table)", .rejects.toThrow("simulated fingerprint lookup failure"); }); }); + + // The scanner finds a registered bearer inside a chain by its own content, + // an "eyJ" anchor at the start of its header segment, not only by the + // character right before it. A named table names a regression in its + // failure output; a property test generates many glue words instead of + // one example per delimiter, so a new, unnamed glue word cannot reopen + // this class the way a delimiter-specific fix did three times before. + describe("bearer glued to a neighbouring word, closed by the header's own start anchor", () => { + // A candidate whose header segment decodes as a real JWT header, the + // shape the start anchor requires: every header this product mints + // starts with the literal text "eyJ". + function anchoredBearer(seed: string): string { + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); + const payload = Buffer.from(`payload-${seed}`).toString("base64url"); + const signature = Buffer.from(`sig-${seed}`).toString("base64url"); + return `${header}.${payload}.${signature}`; + } + + const BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + + // A random run of base64url characters, 0 to `maxLength` of them. This + // can end in a plain letter or digit as easily as in a hyphen or an + // underscore, so it exercises a glue word the delimiter-only mechanism + // could never find. + function randomGlueWord(maxLength: number): string { + const length = Math.floor(Math.random() * (maxLength + 1)); + let word = ""; + for (let index = 0; index < length; index += 1) { + word += BASE64URL_ALPHABET[Math.floor(Math.random() * BASE64URL_ALPHABET.length)]!; + } + return word; + } + + const NAMED_GLUE_SHAPES: Array<{ id: string; name: string; glue: (bearer: string) => string }> = [ + { id: "dotted-hyphen", name: "a dotted prefix and a hyphen glued together", glue: (bearer) => `paperclip.local.run-${bearer}` }, + { id: "dotted-underscore", name: "a dotted prefix and an underscore glued together", glue: (bearer) => `svc.host.tok_${bearer}` }, + { id: "short-dotted-hyphen", name: "a short dotted prefix and a hyphen glued together", glue: (bearer) => `a.b.c-${bearer}` }, + { id: "no-delimiter", name: "an ordinary word glued with no delimiter at all", glue: (bearer) => `run${bearer}` }, + { id: "hyphen-and-trailing-word", name: "a dotted prefix, a hyphen glue, and a trailing word", glue: (bearer) => `run-${bearer}-old` }, + ]; + + it.each(NAMED_GLUE_SHAPES)( + "masks a bearer glued by $name, on the write path", + async ({ id, glue }) => { + const { companyId, runId } = await seedRun(); + const registry = createRunSecretRedactionRegistry(db); + const bearer = anchoredBearer(`named-write-${id}`); + await registry.register(companyId, runId, bearer, farFutureRedactionExpiry()); + + const result = await redactAuthoredRunBearer(db, companyId, glue(bearer)); + + expect(result).not.toContain(bearer); + }, + ); + + it("masks a registered bearer glued by a random prefix and a random suffix, on the write path (property test, 200 generated cases)", async () => { + const { companyId, runId } = await seedRun(); + const registry = createRunSecretRedactionRegistry(db); + const trials = Array.from({ length: 200 }, (_, trial) => ({ + bearer: anchoredBearer(`write-property-${trial}`), + prefix: randomGlueWord(8), + suffix: randomGlueWord(8), + })); + + await Promise.all(trials.map((trial) => + registry.register(companyId, runId, trial.bearer, farFutureRedactionExpiry()))); + const results = await Promise.all(trials.map((trial) => + redactAuthoredRunBearer(db, companyId, `${trial.prefix}${trial.bearer}${trial.suffix}`))); + + results.forEach((result, index) => expect(result).not.toContain(trials[index]!.bearer)); + }); + + it("masks a registered bearer glued by a random prefix and a random suffix, on the read path while its material is live (property test, 200 generated cases)", async () => { + // Each trial gets its own run, so each read below resolves only its + // own registered value instead of every other trial's value too. + const { companyId, agentId } = await seedRun(); + const registry = createRunSecretRedactionRegistry(db); + const trials = await Promise.all(Array.from({ length: 200 }, async (_, trial) => { + const runId = randomUUID(); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", contextSnapshot: {} }); + return { + runId, + bearer: anchoredBearer(`read-live-property-${trial}`), + prefix: randomGlueWord(8), + suffix: randomGlueWord(8), + }; + })); + + await Promise.all(trials.map((trial) => + registry.register(companyId, trial.runId, trial.bearer, farFutureRedactionExpiry()))); + const results = await Promise.all(trials.map((trial) => + registry.redactForRun(companyId, trial.runId, `${trial.prefix}${trial.bearer}${trial.suffix}`))); + + results.forEach((result, index) => expect(result).not.toContain(trials[index]!.bearer)); + }); + + it("masks a registered bearer glued by a random prefix, on the read path after the expiry sweep clears its material (property test, 200 generated cases)", async () => { + // The read path has no decrypted plain text left once the sweep clears + // a row's material, so this exercises the fingerprint candidate scan + // alone. It generates only a random prefix. A random SUFFIX glued + // after the signature segment extends that segment with no dot to + // mark where the real token ends, so the extended candidate's + // fingerprint never equals the registered token's fingerprint. + // Closing that side needs the registered value's own length, which + // the registry does not store today. That is a known, stated + // residual; this test does not claim to cover it. + const { companyId, agentId } = await seedRun(); + const registry = createRunSecretRedactionRegistry(db); + const trials = await Promise.all(Array.from({ length: 200 }, async (_, trial) => { + const runId = randomUUID(); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", contextSnapshot: {} }); + return { runId, bearer: anchoredBearer(`read-swept-property-${trial}`), prefix: randomGlueWord(8) }; + })); + + await Promise.all(trials.map((trial) => registry.register(companyId, trial.runId, trial.bearer, new Date(0)))); + await createRunSecretRedactionReaper(db).sweep(); + const results = await Promise.all(trials.map((trial) => + registry.redactForRun(companyId, trial.runId, `${trial.prefix}${trial.bearer}`))); + + results.forEach((result, index) => expect(result).not.toContain(trials[index]!.bearer)); + }); + }); }); diff --git a/server/src/services/run-secret-redaction.ts b/server/src/services/run-secret-redaction.ts index 59d1c6acb7a..8cbd8b8ee11 100644 --- a/server/src/services/run-secret-redaction.ts +++ b/server/src/services/run-secret-redaction.ts @@ -60,59 +60,81 @@ const JWT_CHAIN_RE = /[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+){2,}/g; // instead of one round trip per candidate. const FINGERPRINT_QUERY_BATCH_SIZE = 1000; -// The base64url alphabet includes the hyphen and the underscore, so a word -// right before a bearer that ends in one of those characters (for example -// "run-") joins into the chain's first segment with no dot to mark -// the join. This returns every offset right after a hyphen or an underscore -// inside `segment`, so a caller can try the text starting there as an -// alternate first segment. The count is bounded by the segment's own -// length, at most one extra offset per delimiter character in it. -function offsetsAfterDelimiter(segment: string): number[] { - const offsets: number[] = []; +// Every token this product mints starts its header segment with the three +// characters "eyJ", the base64url form of the two bytes `{"`, which begins +// every JSON Web Token header. The base64url alphabet also includes the +// hyphen and the underscore, so a word right before a bearer can join into +// a chain segment with no dot to mark the join, whether or not that word +// ends in one of those two characters (for example "run-" and +// "run" both glue with no dot). +// +// This returns every offset inside `segment` where a candidate might start: +// right after a hyphen, right after an underscore, and at each occurrence +// of the literal text "eyJ". A caller can try the text starting at each +// offset as an alternate first segment. The delimiter check alone misses a +// glued word that ends in an ordinary letter or digit; the "eyJ" check +// looks at the token's own text instead of at its neighbour, so it finds +// the join whatever character precedes it. The offset count is bounded by +// the segment's own length: a delimiter offset needs one character and an +// "eyJ" offset needs three, so a segment of length N yields at most N +// offsets. +function candidateStartOffsetsIn(segment: string): number[] { + const offsets = new Set(); for (let index = 0; index < segment.length - 1; index += 1) { - if (segment[index] === "-" || segment[index] === "_") offsets.push(index + 1); + if (segment[index] === "-" || segment[index] === "_") offsets.add(index + 1); } - return offsets; + let from = 0; + while (from <= segment.length - 3) { + const found = segment.indexOf("eyJ", from); + if (found === -1) break; + offsets.add(found); + from = found + 1; + } + return [...offsets].sort((left, right) => left - right); } -// Every window of three consecutive segments inside one maximal chain. A -// dotted word right before a bearer (for example "paperclip.local.") -// joins into one longer chain, so the bearer's own three segments are not -// always the chain's first three. Offering every window, not only the -// first, closes that gap. No window is dropped here: a later pass bounds -// its database lookups, but this candidate set itself stays unbounded. +// Every window of three consecutive segments inside one maximal chain, plus +// every window whose own first segment starts partway through a chain +// segment, at one of the offsets `candidateStartOffsetsIn` finds. A dotted +// word right before a bearer (for example "paperclip.local.") moves +// the bearer's own three segments later in the chain; a word glued with no +// dot at all (for example "run", or the combined +// "paperclip.local.run-") moves the bearer's start partway through +// one segment, wherever that segment sits in the chain. Offering both kinds +// of window closes each case, not only a glued word at the chain's own +// start. No window is dropped here: a later pass bounds its database +// lookups, but this candidate set itself stays unbounded. // -// Only the chain's own first segment can carry a glued, undelimited prefix: -// every later segment starts right after a literal dot, a real boundary -// nothing can glue across. So the extra windows below only ever replace -// segment zero, each with the text starting after one of its internal -// hyphens or underscores. The added candidate count is bounded by the -// length of the first segment, not by the number of windows already found. +// The added candidate count is bounded by the sum of each window's own +// first-segment length, one extra candidate per offset in that segment, so +// the total stays linear in the chain's own length, not quadratic in the +// number of windows. function jwtWindowsInChain(chain: string): string[] { const segments = chain.split("."); const windows: string[] = []; for (let start = 0; start + 3 <= segments.length; start += 1) { windows.push(segments.slice(start, start + 3).join(".")); } - if (segments.length >= 3) { - const firstSegment = segments[0]!; - for (const offset of offsetsAfterDelimiter(firstSegment)) { - windows.push([firstSegment.slice(offset), segments[1], segments[2]].join(".")); + for (let start = 0; start + 3 <= segments.length; start += 1) { + const segment = segments[start]!; + for (const offset of candidateStartOffsetsIn(segment)) { + if (offset === 0) continue; + windows.push([segment.slice(offset), segments[start + 1], segments[start + 2]].join(".")); } } return windows; } -// Finds a match for a candidate that starts inside the chain's first -// segment, after one of its hyphens or underscores. Returns the literal -// text to keep in front of the match (for example "run-"), so a caller can -// splice the redaction marker in without disturbing that prefix. -function firstSegmentSpliceMatch(segments: string[], matched: Set): string | null { - if (segments.length < 3) return null; - const firstSegment = segments[0]!; - for (const offset of offsetsAfterDelimiter(firstSegment)) { - const candidate = [firstSegment.slice(offset), segments[1], segments[2]].join("."); - if (matched.has(candidate)) return firstSegment.slice(0, offset); +// Finds a match that starts partway through `segment`, a window's own first +// segment, at one of the offsets `candidateStartOffsetsIn` finds. Returns +// the literal text to keep in front of the match (for example "run-" or +// "paperclip.local.run-"), so a caller can splice the redaction marker in +// without disturbing that prefix. +function spliceMatchAtSegment(segment: string, next: [string, string], matched: Set): string | null { + for (const offset of candidateStartOffsetsIn(segment)) { + if (offset === 0) continue; + const candidate = [segment.slice(offset), ...next].join("."); + if (matched.has(candidate)) return segment.slice(0, offset); } return null; } @@ -149,10 +171,11 @@ function collectJwtCandidates(value: unknown, into: Set): void { // other character of the chain, including a dotted prefix such as // "paperclip.local.", stays exactly as it was. // -// At index zero this also tries a match that starts inside the first -// segment, after one of its hyphens or underscores (the "run-" -// shape). The text before that offset, for example "run-", is kept byte for -// byte and only the matched suffix becomes the redaction marker. +// At every position this also tries a match that starts partway through the +// window's own first segment, at a hyphen, an underscore, or the token's +// own "eyJ" anchor (the "run-" and "paperclip.local.run-" +// shapes). The text before that offset is kept byte for byte and only the +// matched suffix becomes the redaction marker. function replaceJwtWindowsInText(text: string, matched: Set): string { if (matched.size === 0) return text; return text.replace(JWT_CHAIN_RE, (chain) => { @@ -168,8 +191,8 @@ function replaceJwtWindowsInText(text: string, matched: Set): string { index += 3; continue; } - if (index === 0) { - const keptPrefix = firstSegmentSpliceMatch(segments, matched); + if (window) { + const keptPrefix = spliceMatchAtSegment(segments[index]!, [segments[index + 1]!, segments[index + 2]!], matched); if (keptPrefix !== null) { // One token, not two: `keptPrefix` and the marker sit inside the // same original segment, with no dot between them. Pushing them