mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
fix(server): mask a registered bearer by fingerprint on every registry read
The redaction registry only replaced the exact stored value of a run's own registrations. A bearer minted on one run and pasted into another run's or issue's text stayed unmasked, and a row swept clear of its material could no longer resolve any plain text to match against. Add a second pass to the three read functions. It finds each JWT-shaped candidate in the value, hashes it, and checks the hash against the company's registry. A match needs no decryption, because the candidate found in the text already is the plain text, so a row whose material the sweep cleared still masks. The lookup scopes to the company only, so it also covers a value pasted across runs and issues. It batches its query over the whole candidate set instead of querying once per run or per candidate. Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
81b9ac1a42
commit
f3144b17ca
3 files changed
+217
-6
No files matched your search
@@ -35,6 +35,15 @@ d("run_secret_redactions migration", () => {
|
||||
const planText = plan.map((r) => Object.values(r)[0]).join("\n");
|
||||
expect(planText).toContain("run_secret_redactions_company_fingerprint_run_uq");
|
||||
|
||||
// A candidate-fingerprint lookup issues an `IN` list over many digests in
|
||||
// one query, not one query per digest. Prove that shape also uses the
|
||||
// index, not only the single-digest shape above.
|
||||
const inListPlan = await sql.unsafe(
|
||||
"EXPLAIN SELECT id FROM run_secret_redactions WHERE company_id = '00000000-0000-0000-0000-000000000001' AND fingerprint_sha256 IN ('x', 'y', 'z')",
|
||||
);
|
||||
const inListPlanText = inListPlan.map((r) => Object.values(r)[0]).join("\n");
|
||||
expect(inListPlanText).toContain("run_secret_redactions_company_fingerprint_run_uq");
|
||||
|
||||
const runLookupPlan = await sql.unsafe(
|
||||
"EXPLAIN SELECT id FROM run_secret_redactions WHERE company_id = '00000000-0000-0000-0000-000000000001' AND run_id = '00000000-0000-0000-0000-000000000002'",
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
@@ -335,4 +335,90 @@ describeEmbeddedPostgres("run secret redaction registry (company-scoped table)",
|
||||
expect(row.material).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fingerprint match masking", () => {
|
||||
// Shapes a JWT-like candidate: three non-empty base64url segments joined
|
||||
// by two dots, the same shape a run bearer has.
|
||||
function jwtShaped(seed: string): string {
|
||||
const segment = (label: string) => Buffer.from(`${label}-${seed}`).toString("base64url");
|
||||
return `${segment("header")}.${segment("payload")}.${segment("sig")}`;
|
||||
}
|
||||
|
||||
it("masks a bearer registered on one issue's run when it is pasted into a different issue's text", async () => {
|
||||
const { companyId, agentId, runId: runOnIssueX } = await seedRun();
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: "issue-x" } })
|
||||
.where(eq(heartbeatRuns.id, runOnIssueX));
|
||||
const runOnIssueY = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runOnIssueY, companyId, agentId, status: "running", contextSnapshot: { issueId: "issue-y" },
|
||||
});
|
||||
const registry = createRunSecretRedactionRegistry(db);
|
||||
const bearer = jwtShaped("cross-issue");
|
||||
await registry.register(companyId, runOnIssueX, bearer, farFutureRedactionExpiry());
|
||||
|
||||
const result = await registry.redactForIssue(companyId, "issue-y", `pasted ${bearer} here`);
|
||||
|
||||
expect(result).toBe(`pasted ${REDACTED_EVENT_VALUE} here`);
|
||||
});
|
||||
|
||||
it("still masks a bearer after the expiry sweep has cleared its row's material", async () => {
|
||||
const { companyId, runId } = await seedRun();
|
||||
const registry = createRunSecretRedactionRegistry(db);
|
||||
const bearer = jwtShaped("fingerprint-only");
|
||||
await registry.register(companyId, runId, bearer, new Date(0));
|
||||
|
||||
await createRunSecretRedactionReaper(db).sweep();
|
||||
|
||||
const [row] = await rowsForRun(companyId, runId);
|
||||
expect(row.material).toBeNull();
|
||||
expect(await registry.redactForRun(companyId, runId, `still has ${bearer}`))
|
||||
.toBe(`still has ${REDACTED_EVENT_VALUE}`);
|
||||
});
|
||||
|
||||
it("never matches a fingerprint registered by another company", async () => {
|
||||
const companyA = await seedRun();
|
||||
const companyB = await seedRun();
|
||||
const registry = createRunSecretRedactionRegistry(db);
|
||||
const bearer = jwtShaped("cross-company");
|
||||
await registry.register(companyA.companyId, companyA.runId, bearer, farFutureRedactionExpiry());
|
||||
|
||||
expect(await registry.redactForRun(companyB.companyId, companyB.runId, `token ${bearer} here`))
|
||||
.toBe(`token ${bearer} here`);
|
||||
});
|
||||
|
||||
it("leaves an unregistered JWT-shaped string and a literal environment variable reference unchanged", async () => {
|
||||
const { companyId, runId } = await seedRun();
|
||||
const registry = createRunSecretRedactionRegistry(db);
|
||||
const unregistered = jwtShaped("never-registered");
|
||||
|
||||
expect(await registry.redactForRun(companyId, runId, `${unregistered} and $PAPERCLIP_API_KEY`))
|
||||
.toBe(`${unregistered} and $PAPERCLIP_API_KEY`);
|
||||
});
|
||||
|
||||
it("issues a bounded number of queries for a batch of many runs and still applies each run's own value pass", async () => {
|
||||
const { companyId, agentId } = await seedRun();
|
||||
const registry = createRunSecretRedactionRegistry(db);
|
||||
const runIds = await Promise.all(Array.from({ length: 20 }, async () => {
|
||||
const id = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id, companyId, agentId, status: "running", contextSnapshot: {} });
|
||||
return id;
|
||||
}));
|
||||
const sharedBearer = jwtShaped("batch-shared");
|
||||
await registry.register(companyId, runIds[0]!, sharedBearer, farFutureRedactionExpiry());
|
||||
await registry.register(companyId, runIds[1]!, "own-value-secret", farFutureRedactionExpiry());
|
||||
|
||||
const selectSpy = vi.spyOn(db, "select");
|
||||
const runs = runIds.map((id, index) => ({
|
||||
id,
|
||||
text: index === 1 ? `has own-value-secret and ${sharedBearer}` : `only ${sharedBearer}`,
|
||||
}));
|
||||
|
||||
const redacted = await registry.redactForRuns(companyId, runs);
|
||||
|
||||
expect(selectSpy.mock.calls.length).toBeLessThan(runIds.length);
|
||||
expect(redacted.every((run) => !run.text.includes(sharedBearer))).toBe(true);
|
||||
expect(redacted[1]!.text).not.toContain("own-value-secret");
|
||||
selectSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,56 @@ function redactText(input: string, values: string[]) {
|
||||
);
|
||||
}
|
||||
|
||||
// A JWT-shaped candidate: three non-empty base64url segments joined by two
|
||||
// dot characters. A registered run bearer that appears in text is the plain
|
||||
// text of that bearer, so this pass hashes each candidate it finds and looks
|
||||
// up the hash. It never decrypts stored material to find a match.
|
||||
const JWT_CANDIDATE_RE = /[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
|
||||
|
||||
// A caller may supply a batch of many runs, or one run with a large payload.
|
||||
// This bounds each fingerprint lookup to a fixed number of query round trips
|
||||
// instead of one round trip per candidate.
|
||||
const FINGERPRINT_QUERY_BATCH_SIZE = 1000;
|
||||
|
||||
function collectJwtCandidates(value: unknown, into: Set<string>): void {
|
||||
if (typeof value === "string") {
|
||||
for (const match of value.matchAll(JWT_CANDIDATE_RE)) into.add(match[0]);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) collectJwtCandidates(item, into);
|
||||
return;
|
||||
}
|
||||
if (value instanceof Date) return;
|
||||
const record = asRecord(value);
|
||||
if (!record) return;
|
||||
for (const [key, entry] of Object.entries(record)) {
|
||||
if (key === REGISTRY_KEY) continue;
|
||||
collectJwtCandidates(entry, into);
|
||||
}
|
||||
}
|
||||
|
||||
// Runs after the value pass has already rebuilt the object graph and
|
||||
// stripped the legacy registry key, so this pass does not need to repeat
|
||||
// that filter. It only replaces a candidate substring whose fingerprint
|
||||
// matched; every other character stays exactly as the value pass left it.
|
||||
function replaceMatchedCandidates<T>(input: T, matched: Set<string>): T {
|
||||
if (matched.size === 0) return input;
|
||||
if (typeof input === "string") {
|
||||
return input.replace(
|
||||
JWT_CANDIDATE_RE,
|
||||
(candidate) => matched.has(candidate) ? REDACTED_EVENT_VALUE : candidate,
|
||||
) as T;
|
||||
}
|
||||
if (Array.isArray(input)) return input.map((item) => replaceMatchedCandidates(item, matched)) as T;
|
||||
if (input instanceof Date) return input;
|
||||
const record = asRecord(input);
|
||||
if (!record) return input;
|
||||
return Object.fromEntries(
|
||||
Object.entries(record).map(([key, value]) => [key, replaceMatchedCandidates(value, matched)]),
|
||||
) as T;
|
||||
}
|
||||
|
||||
// A proposed or bound company secret value carries no token-style expiry
|
||||
// claim the way a run bearer does, and the pre-move registry masked such a
|
||||
// value for as long as its run row existed. This far horizon keeps that same
|
||||
@@ -146,6 +196,67 @@ export function createRunSecretRedactionRegistry(db: Db) {
|
||||
return resolveEntries(entries, new Map());
|
||||
}
|
||||
|
||||
// Looks up a company-scoped set of digests against the registry, in query
|
||||
// batches bounded to `FINGERPRINT_QUERY_BATCH_SIZE`. The lookup filters on
|
||||
// `company_id` only, so a registered value masks wherever it appears in
|
||||
// that company's text, not only in the run or issue text that registered
|
||||
// it. It never filters on `run_id`, and it never matches a row of another
|
||||
// company.
|
||||
async function matchedFingerprints(companyId: string, digests: string[]): Promise<Set<string>> {
|
||||
const matched = new Set<string>();
|
||||
for (let start = 0; start < digests.length; start += FINGERPRINT_QUERY_BATCH_SIZE) {
|
||||
const batch = digests.slice(start, start + FINGERPRINT_QUERY_BATCH_SIZE);
|
||||
const rows = await db.select({ fingerprintSha256: runSecretRedactions.fingerprintSha256 })
|
||||
.from(runSecretRedactions)
|
||||
.where(and(
|
||||
eq(runSecretRedactions.companyId, companyId),
|
||||
inArray(runSecretRedactions.fingerprintSha256, batch),
|
||||
));
|
||||
for (const row of rows) matched.add(row.fingerprintSha256);
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
// Walks a value for JWT-shaped candidates, hashes each distinct candidate
|
||||
// once, and masks only the candidates whose hash the registry holds. A
|
||||
// fingerprint-only row (its material cleared by the expiry sweep) still
|
||||
// masks here, because this pass needs no decrypted plain text: the
|
||||
// candidate found in the value already is the plain text.
|
||||
async function maskByFingerprint<T>(companyId: string, value: T): Promise<T> {
|
||||
const candidates = new Set<string>();
|
||||
collectJwtCandidates(value, candidates);
|
||||
if (candidates.size === 0) return value;
|
||||
const digestByCandidate = new Map(
|
||||
[...candidates].map((candidate) => [candidate, createHash("sha256").update(candidate).digest("hex")] as const),
|
||||
);
|
||||
const digests = [...new Set(digestByCandidate.values())];
|
||||
const matched = await matchedFingerprints(companyId, digests);
|
||||
if (matched.size === 0) return value;
|
||||
const matchedCandidates = new Set(
|
||||
[...digestByCandidate.entries()].filter(([, digest]) => matched.has(digest)).map(([candidate]) => candidate),
|
||||
);
|
||||
return replaceMatchedCandidates(value, matchedCandidates);
|
||||
}
|
||||
|
||||
// The batch form of `maskByFingerprint`: it collects the candidates of the
|
||||
// whole batch of values first, then issues one bounded set of queries for
|
||||
// the whole batch, instead of one query per value.
|
||||
async function maskManyByFingerprint<T>(companyId: string, values: T[]): Promise<T[]> {
|
||||
const candidates = new Set<string>();
|
||||
for (const value of values) collectJwtCandidates(value, candidates);
|
||||
if (candidates.size === 0) return values;
|
||||
const digestByCandidate = new Map(
|
||||
[...candidates].map((candidate) => [candidate, createHash("sha256").update(candidate).digest("hex")] as const),
|
||||
);
|
||||
const digests = [...new Set(digestByCandidate.values())];
|
||||
const matched = await matchedFingerprints(companyId, digests);
|
||||
if (matched.size === 0) return values;
|
||||
const matchedCandidates = new Set(
|
||||
[...digestByCandidate.entries()].filter(([, digest]) => matched.has(digest)).map(([candidate]) => candidate),
|
||||
);
|
||||
return values.map((value) => replaceMatchedCandidates(value, matchedCandidates));
|
||||
}
|
||||
|
||||
return {
|
||||
// `expiresAt` must be the real expiry of the value, not a guess. For a
|
||||
// run bearer, this is the token's own JWT `exp`. The later sweep clears
|
||||
@@ -197,11 +308,16 @@ export function createRunSecretRedactionRegistry(db: Db) {
|
||||
const entries = [...(legacyByRun.get(runId) ?? []), ...tableEntries(tableByRun.get(runId) ?? [])];
|
||||
return [runId, await resolveEntries(entries, resolved)] as const;
|
||||
})));
|
||||
return runs.map((run) => redactRegisteredSecretValues(run, valuesByRun.get(run.id) ?? []));
|
||||
const valuePass = runs.map((run) => redactRegisteredSecretValues(run, valuesByRun.get(run.id) ?? []));
|
||||
return maskManyByFingerprint(companyId, valuePass);
|
||||
},
|
||||
redactForRun: async <T>(companyId: string, runId: string, value: T): Promise<T> => {
|
||||
const valuePass = redactRegisteredSecretValues(value, await valuesForRun(companyId, runId));
|
||||
return maskByFingerprint(companyId, valuePass);
|
||||
},
|
||||
redactForIssue: async <T>(companyId: string, issueId: string, value: T): Promise<T> => {
|
||||
const valuePass = redactRegisteredSecretValues(value, await valuesForIssue(companyId, issueId));
|
||||
return maskByFingerprint(companyId, valuePass);
|
||||
},
|
||||
redactForRun: async <T>(companyId: string, runId: string, value: T): Promise<T> =>
|
||||
redactRegisteredSecretValues(value, await valuesForRun(companyId, runId)),
|
||||
redactForIssue: async <T>(companyId: string, issueId: string, value: T): Promise<T> =>
|
||||
redactRegisteredSecretValues(value, await valuesForIssue(companyId, issueId)),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user