mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
fix(server): find a glued bearer by its own header anchor, not the chain position
The scanner offered a candidate for a bearer glued to a neighbouring word
only at the chain's own first segment, and only right after a hyphen or an
underscore. A dotted prefix moved the glued segment later in the chain, and
a plain word with no delimiter at all left no offset to try, so the write
path still persisted a live bearer as plain text in both cases.
Every token this product mints starts its header segment with the literal
text "eyJ", the base64url form of the two bytes `{"`. The scanner now looks
for that text, and for a hyphen or an underscore, inside a window's own
first segment wherever that window sits in the chain. This closes the glue
class by the token's own content instead of adding another delimiter-
specific case.
A property test generates 200 random glue words per path (write, read while
live, and read after the expiry sweep) instead of naming one example per
shape, so a new, unnamed glue word cannot reopen this class again. A suffix
glued after the signature segment, with no dot to mark where the token
ends, stays open on the read path once the sweep clears a row's material:
closing it needs the registered value's own length, which the registry
does not store today.
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
8fbc49de8b
commit
f45b06a150
2 files changed
+188
-43
No files matched your search
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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-<bearer>") 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-<bearer>" and
|
||||
// "run<bearer>" 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<number>();
|
||||
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.<bearer>")
|
||||
// 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.<bearer>") moves
|
||||
// the bearer's own three segments later in the chain; a word glued with no
|
||||
// dot at all (for example "run<bearer>", or the combined
|
||||
// "paperclip.local.run-<bearer>") 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>): 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>): 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<string>): 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-<bearer>"
|
||||
// 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-<bearer>" and "paperclip.local.run-<bearer>"
|
||||
// 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>): 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>): 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
|
||||
|
||||
Reference in new issue
Block a user