diff --git a/integrations/filesystem-watcher/watcher.mjs b/integrations/filesystem-watcher/watcher.mjs index a548142e..e2344f0e 100644 --- a/integrations/filesystem-watcher/watcher.mjs +++ b/integrations/filesystem-watcher/watcher.mjs @@ -28,6 +28,45 @@ const DEFAULT_IGNORE = [ const MAX_PREVIEW_BYTES = 4096; const DEBOUNCE_MS = 500; +const REDACTED = "[REDACTED]"; + +function isDotEnvPath(path) { + const name = basename(path).toLowerCase(); + return name === ".env" || name.startsWith(".env."); +} + +function isSensitiveKey(key) { + const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase(); + return [ + "apikey", + "accesstoken", + "accesskey", + "authorization", + "bearer", + "clientsecret", + "password", + "passwd", + "privatekey", + "pwd", + "secret", + "token", + ].some((needle) => normalized.includes(needle)); +} + +function redactSensitiveLine(line) { + const assignment = line.match( + /^(\s*(?:export\s+)?["']?([A-Za-z_][A-Za-z0-9_.-]*)["']?\s*([=:])\s*)(.*)$/, + ); + if (assignment && isSensitiveKey(assignment[2])) { + const bearer = assignment[3] === ":" ? assignment[4].match(/^(Bearer\s+).+/i) : null; + return `${assignment[1]}${bearer ? bearer[1] : ""}${REDACTED}`; + } + return line.replace(/\b(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}\b/gi, `$1${REDACTED}`); +} + +function redactSensitivePreview(preview) { + return preview.split("\n").map(redactSensitiveLine).join("\n"); +} export class FilesystemWatcher { constructor(config = {}) { @@ -54,7 +93,7 @@ export class FilesystemWatcher { isTextFile(path) { if (this.allowBinary) return true; const ext = extname(path).toLowerCase(); - return TEXT_EXTENSIONS.has(ext); + return TEXT_EXTENSIONS.has(ext) || isDotEnvPath(path); } async readPreview(path) { @@ -121,6 +160,7 @@ export class FilesystemWatcher { let preview = null; if (exists && this.isTextFile(absPath)) { preview = await this.readPreview(absPath); + if (preview !== null) preview = redactSensitivePreview(preview); } const truncated = exists && size > MAX_PREVIEW_BYTES; const payload = { diff --git a/test/fs-watcher.test.ts b/test/fs-watcher.test.ts index 21e260ee..b58ab023 100644 --- a/test/fs-watcher.test.ts +++ b/test/fs-watcher.test.ts @@ -145,6 +145,73 @@ describe("FilesystemWatcher", () => { } }); + it("redacts sensitive dotenv preview values before sending observations", async () => { + writeFileSync( + join(root, ".env"), + [ + "OPENAI_API_KEY=sk-test-secret-value", + "PUBLIC_FLAG=enabled", + "AUTHORIZATION=Bearer live-token-value", + ].join("\n"), + ); + const w = new FilesystemWatcher({ + roots: [root], + baseUrl: "http://localhost:3111", + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + + await w.flush(root, ".env"); + + expect(captured).toHaveLength(1); + const content = (captured[0].body as { data: { content: string } }).data.content; + expect(content).toContain("OPENAI_API_KEY=[REDACTED]"); + expect(content).toContain("PUBLIC_FLAG=enabled"); + expect(content).toContain("AUTHORIZATION=[REDACTED]"); + expect(content).not.toContain("sk-test-secret-value"); + expect(content).not.toContain("live-token-value"); + }); + + it("redacts quoted JSON-style sensitive keys before sending observations", async () => { + writeFileSync( + join(root, "settings.json"), + [ + '{', + ' "api_key": "json-preview-secret",', + ' "public_flag": "enabled"', + '}', + ].join("\n"), + ); + const w = new FilesystemWatcher({ + roots: [root], + baseUrl: "http://localhost:3111", + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + + await w.flush(root, "settings.json"); + + expect(captured).toHaveLength(1); + const content = (captured[0].body as { data: { content: string } }).data.content; + expect(content).toContain('"api_key": [REDACTED]'); + expect(content).toContain('"public_flag": "enabled"'); + expect(content).not.toContain("json-preview-secret"); + }); + + it("redacts bearer tokens from regular text previews before sending observations", async () => { + writeFileSync(join(root, "request.txt"), "Authorization: Bearer plaintext-token-value\n"); + const w = new FilesystemWatcher({ + roots: [root], + baseUrl: "http://localhost:3111", + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + + await w.flush(root, "request.txt"); + + expect(captured).toHaveLength(1); + const content = (captured[0].body as { data: { content: string } }).data.content; + expect(content).toContain("Authorization: Bearer [REDACTED]"); + expect(content).not.toContain("plaintext-token-value"); + }); + it("debounces rapid writes to a single observation", async () => { const w = new FilesystemWatcher({ roots: [root],