diff --git a/docs/feedback-voting.md b/docs/feedback-voting.md index b9d40db7ad..5dea89ea12 100644 --- a/docs/feedback-voting.md +++ b/docs/feedback-voting.md @@ -110,6 +110,14 @@ feedback-export-20260331T120000Z.zip Exports are full by default. `traces/` keeps the Paperclip envelope, while `full-traces/` contains the richer per-trace bundle plus any recoverable adapter-native files. +Feedback text redaction preserves hostnames, dotted filenames, public schema IDs, +and authentication terms such as “bearer tokens.” Dotted text must have an encoded +JWT header to be masked as a token. Complete three-part signed tokens and +five-part encrypted tokens are masked, including tokens with short segments +and direct-encryption tokens with an empty encrypted-key segment. +Explicit credentials, secret fields, email addresses, and phone numbers remain +masked. The redaction summary counts text that was actually replaced. + ```bash # Custom server and output directory npx paperclipai feedback export --api-base http://127.0.0.1:3000 --company-id --out ./my-export diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index 4af35d09df..321866bf7c 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -11,10 +11,41 @@ describe("explicit diagnostic credential forms", () => { }); import { REDACTED_COMMAND_TEXT_VALUE, + looksLikeCredentialJwt, redactDiagnosticText, redactCommandText, } from "./command-redaction.js"; +describe("looksLikeCredentialJwt", () => { + it("recognizes encoded headers even with short payloads and signatures", () => { + const header = Buffer.from('{"alg":"HS256"}').toString("base64url"); + expect(looksLikeCredentialJwt(`${header}.e30.c2ln`)).toBe(true); + }); + + it("recognizes direct-encryption JWTs with an empty encrypted-key segment", () => { + const header = Buffer.from('{"alg":"dir","enc":"A256GCM"}').toString("base64url"); + expect(looksLikeCredentialJwt(`${header}..abcdefghijk.abcdefghijkl.abcdefghijk`)).toBe(true); + }); + + it.each([ + "eyJ..signature", + "eyJ.key..ciphertext.tag", + "eyJ.key.iv..tag", + "eyJ.key.iv.ciphertext.", + ])("rejects empty segments outside the JWE encrypted key: %s", (input) => { + expect(looksLikeCredentialJwt(input)).toBe(false); + }); + + it.each([ + "api.openai.com", + "agents.staging.example.com", + "deployment.credentials.example.md", + "paperclip.native-model-envelope.v3", + ])("rejects public dotted identifiers: %s", (input) => { + expect(looksLikeCredentialJwt(input)).toBe(false); + }); +}); + describe("redactDiagnosticText", () => { it("preserves credential metadata and dotted identifiers", () => { for (const text of [ diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 465255caf4..46eeb6f504 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -36,7 +36,12 @@ const COMMAND_JWT_RE = /** Recognize encoded JSON headers, without treating dotted identifiers as tokens. */ export function looksLikeCredentialJwt(value: string): boolean { const segments = value.split("."); - if (![3, 5].includes(segments.length) || segments.some((part) => !/^[A-Za-z0-9_-]{8,}$/.test(part))) return false; + if (![3, 5].includes(segments.length)) return false; + if (segments.some((part, index) => { + // Compact JWE direct encryption has no encrypted-key value. + if (segments.length === 5 && index === 1 && part === "") return false; + return !/^[A-Za-z0-9_-]+$/.test(part); + })) return false; if (segments[0].startsWith("eyJ")) return true; try { const header = JSON.parse(atob(segments[0].replace(/-/g, "+").replace(/_/g, "/"))); diff --git a/server/src/__tests__/feedback-redaction.test.ts b/server/src/__tests__/feedback-redaction.test.ts new file mode 100644 index 0000000000..868cbb399a --- /dev/null +++ b/server/src/__tests__/feedback-redaction.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { + createFeedbackRedactionState, + finalizeFeedbackRedactionSummary, + sanitizeFeedbackText, + sanitizeFeedbackValue, +} from "../services/feedback-redaction.js"; + +describe("feedback redaction", () => { + it.each([ + "https://agents.staging.example.com/team/chats/assistant", + "api.openai.com and deployment.credentials.example.md", + "paperclip.native-model-envelope.v3 and executor.coreTools.integrations.list", + "Use bearer tokens for authentication and bearer authentication for requests.", + "Document bearer authorization, bearer credentials, bearer schemes, and bearer flows.", + "Use (bearer tokens). Preserve [bearer authentication] and {bearer credentials}.", + ])("preserves non-secret chat context: %s", (input) => { + const state = createFeedbackRedactionState(); + + expect(sanitizeFeedbackText(input, state, "body", 10_000)).toBe(input); + expect(finalizeFeedbackRedactionSummary(state).redactedFields).toEqual([]); + expect(finalizeFeedbackRedactionSummary(state).counts).toEqual({}); + }); + + it("redacts JWTs without counting neighboring hostnames as credentials", () => { + const header = Buffer.from('{"alg":"HS256","typ":"JWT"}').toString("base64url"); + const jwt = `${header}.abcdefghijk.abcdefghijkl`; + const state = createFeedbackRedactionState(); + + expect(sanitizeFeedbackText(`Visit api.openai.com; token ${jwt}`, state, "body", 10_000)) + .toBe("Visit api.openai.com; token [REDACTED_JWT]"); + expect(finalizeFeedbackRedactionSummary(state).counts).toEqual({ jwt: 1 }); + expect(finalizeFeedbackRedactionSummary(state).redactedFields).toEqual(["body"]); + }); + + it("still redacts a JWT with a short payload and signature", () => { + const header = Buffer.from('{"alg":"HS256"}').toString("base64url"); + const state = createFeedbackRedactionState(); + const jwt = `${header}.e30.c2ln`; + + expect(sanitizeFeedbackText(jwt, state, "body", 10_000)).toBe("[REDACTED_JWT]"); + expect(finalizeFeedbackRedactionSummary(state).counts).toEqual({ jwt: 1 }); + }); + + it("redacts encrypted JWTs", () => { + const header = Buffer.from('{"alg":"RSA-OAEP","enc":"A256GCM"}').toString("base64url"); + const jwt = `${header}.abcdefghijk.abcdefghijkl.abcdefghijk.abcdefghijkl`; + const state = createFeedbackRedactionState(); + + expect(sanitizeFeedbackText(jwt, state, "body", 10_000)).toBe("[REDACTED_JWT]"); + expect(finalizeFeedbackRedactionSummary(state).counts).toEqual({ jwt: 1 }); + }); + + it("redacts direct-encryption JWTs with an empty encrypted-key segment", () => { + const header = Buffer.from('{"alg":"dir","enc":"A256GCM"}').toString("base64url"); + const jwt = `${header}..abcdefghijk.abcdefghijkl.abcdefghijk`; + const state = createFeedbackRedactionState(); + + expect(sanitizeFeedbackText(`Token ${jwt}; visit api.openai.com.`, state, "body", 10_000)) + .toBe("Token [REDACTED_JWT]; visit api.openai.com."); + expect(finalizeFeedbackRedactionSummary(state).counts).toEqual({ jwt: 1 }); + expect(finalizeFeedbackRedactionSummary(state).redactedFields).toEqual(["body"]); + }); + + it("still redacts explicit credentials and personal information", () => { + const state = createFeedbackRedactionState(); + const input = [ + "api_key=opaque-value", + "Bearer abcdefghijklmnop", + "ghp_abcdefghijklmnopqrstuv", + "sk-abcdefghijklmnopqrstuv", + "postgresql://admin:password@example.com/db", + "person@example.com", + "+1 (415) 555-1234", + ].join("\n"); + const output = sanitizeFeedbackText(input, state, "body", 10_000); + + for (const value of input.split("\n")) expect(output).not.toContain(value); + expect(output).toContain("Bearer [REDACTED_TOKEN]"); + expect(output).toContain("[REDACTED_EMAIL]"); + expect(output).toContain("[REDACTED_PHONE]"); + }); + + it("preserves nested metadata while masking structured secrets", () => { + const state = createFeedbackRedactionState(); + const input = { + body: "Visit api.openai.com and use bearer tokens.", + tokenPolicy: "least privilege", + nested: [{ apiKey: "opaque-value", path: "deployment.credentials.example.md" }], + }; + + expect(sanitizeFeedbackValue(input, state, "bundle", 10_000)).toEqual({ + ...input, + nested: [{ apiKey: "***REDACTED***", path: "deployment.credentials.example.md" }], + }); + }); +}); diff --git a/server/src/services/feedback-redaction.ts b/server/src/services/feedback-redaction.ts index 7c0ed097e2..e57341407d 100644 --- a/server/src/services/feedback-redaction.ts +++ b/server/src/services/feedback-redaction.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { looksLikeCredentialJwt } from "@paperclipai/adapter-utils/command-redaction"; import { redactCurrentUserText } from "../log-redaction.js"; import { sanitizeRecord } from "../redaction.js"; @@ -34,7 +35,7 @@ const FREE_TEXT_PATTERNS: RedactionPattern[] = [ }, { kind: "bearer_token", - regex: /Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, + regex: /\bBearer\s+(?!(?:tokens?|authentication|authorization|credentials?|schemes?|flows?)[.,;:!?]?(?:\s|[)\]}]|$))[A-Za-z0-9._~+/-]+=*/gi, replacement: "Bearer [REDACTED_TOKEN]", }, { @@ -49,8 +50,8 @@ const FREE_TEXT_PATTERNS: RedactionPattern[] = [ }, { kind: "jwt", - regex: /\b[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?\b/g, - replacement: "[REDACTED_JWT]", + regex: /\b[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+|\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b/g, + replacement: (match) => looksLikeCredentialJwt(match) ? "[REDACTED_JWT]" : match, }, { kind: "dsn", @@ -84,12 +85,14 @@ function recordField(state: FeedbackRedactionState, fieldPath: string) { } function applyPattern(input: string, pattern: RedactionPattern) { - const matches = Array.from(input.matchAll(pattern.regex)).length; - if (matches === 0) { - pattern.regex.lastIndex = 0; - return { output: input, matches: 0 }; - } - const output = input.replace(pattern.regex, pattern.replacement as never); + let matches = 0; + const output = input.replace(pattern.regex, (match: string, ...args: string[]) => { + const replacement = typeof pattern.replacement === "function" + ? pattern.replacement(match, ...args) + : pattern.replacement; + if (replacement !== match) matches += 1; + return replacement; + }); pattern.regex.lastIndex = 0; return { output, matches }; }