Skip to content

Commit 00df540

Browse files
authored
fix(fs-watcher): redact PEM private-key blocks and standalone JWTs in previews (#448) (#450)
PR #332 covered KEY=value and Bearer-prefixed tokens. This extends the filesystem watcher's preview pipeline to also collapse multi-line and inline PEM private-key blocks (RSA / DSA / EC / OPENSSH / ENCRYPTED / generic) into a single REDACTED line bracketed by the BEGIN/END markers, and to redact standalone JWT-looking strings outside Bearer context. JWT redaction requires a three-segment dot pattern AND total length >= 100 chars to avoid matching incidental base64-shaped words. Closes #448
1 parent 92e5c88 commit 00df540

2 files changed

Lines changed: 166 additions & 2 deletions

File tree

integrations/filesystem-watcher/watcher.mjs

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ const DEFAULT_IGNORE = [
2929
const MAX_PREVIEW_BYTES = 4096;
3030
const DEBOUNCE_MS = 500;
3131
const REDACTED = "[REDACTED]";
32+
const PEM_BEGIN_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
33+
const PEM_END_RE = /-----END [A-Z ]*PRIVATE KEY-----/;
34+
const JWT_RE = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g;
35+
const JWT_MIN_LEN = 100;
3236

3337
function isDotEnvPath(path) {
3438
const name = basename(path).toLowerCase();
@@ -53,19 +57,63 @@ function isSensitiveKey(key) {
5357
].some((needle) => normalized.includes(needle));
5458
}
5559

60+
function redactJwtTokens(line) {
61+
return line.replace(JWT_RE, (match) => (match.length >= JWT_MIN_LEN ? REDACTED : match));
62+
}
63+
5664
function redactSensitiveLine(line) {
65+
if (PEM_BEGIN_RE.test(line) || PEM_END_RE.test(line)) {
66+
return line;
67+
}
5768
const assignment = line.match(
5869
/^(\s*(?:export\s+)?["']?([A-Za-z_][A-Za-z0-9_.-]*)["']?\s*([=:])\s*)(.*)$/,
5970
);
6071
if (assignment && isSensitiveKey(assignment[2])) {
6172
const bearer = assignment[3] === ":" ? assignment[4].match(/^(Bearer\s+).+/i) : null;
6273
return `${assignment[1]}${bearer ? bearer[1] : ""}${REDACTED}`;
6374
}
64-
return line.replace(/\b(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}\b/gi, `$1${REDACTED}`);
75+
const bearerRedacted = line.replace(
76+
/\b(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}\b/gi,
77+
`$1${REDACTED}`,
78+
);
79+
return redactJwtTokens(bearerRedacted);
80+
}
81+
82+
function redactPemBlocks(preview) {
83+
const lines = preview.split("\n");
84+
const out = [];
85+
let inBlock = false;
86+
for (const line of lines) {
87+
if (!inBlock) {
88+
const beginMatch = line.match(PEM_BEGIN_RE);
89+
if (!beginMatch) {
90+
out.push(line);
91+
continue;
92+
}
93+
const beginIdx = beginMatch.index;
94+
const endMatch = line.match(PEM_END_RE);
95+
if (endMatch && endMatch.index > beginIdx) {
96+
const before = line.slice(0, beginIdx);
97+
const after = line.slice(endMatch.index + endMatch[0].length);
98+
out.push(`${before}${beginMatch[0]}${REDACTED}${endMatch[0]}${after}`);
99+
} else {
100+
out.push(`${line.slice(0, beginIdx)}${beginMatch[0]}`);
101+
out.push(REDACTED);
102+
inBlock = true;
103+
}
104+
} else {
105+
const endMatch = line.match(PEM_END_RE);
106+
if (endMatch) {
107+
out.push(`${endMatch[0]}${line.slice(endMatch.index + endMatch[0].length)}`);
108+
inBlock = false;
109+
}
110+
}
111+
}
112+
return out.join("\n");
65113
}
66114

67115
function redactSensitivePreview(preview) {
68-
return preview.split("\n").map(redactSensitiveLine).join("\n");
116+
return redactPemBlocks(preview).split("\n").map(redactSensitiveLine).join("\n");
69117
}
70118

71119
export class FilesystemWatcher {

test/fs-watcher.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,122 @@ describe("FilesystemWatcher", () => {
212212
expect(content).not.toContain("plaintext-token-value");
213213
});
214214

215+
it("collapses multi-line PEM private-key blocks while keeping BEGIN/END markers", async () => {
216+
const dashes = "-".repeat(5);
217+
const rsaBegin = `${dashes}BEGIN RSA PRIVATE KEY${dashes}`;
218+
const rsaEnd = `${dashes}END RSA PRIVATE KEY${dashes}`;
219+
const sshBegin = `${dashes}BEGIN OPENSSH PRIVATE KEY${dashes}`;
220+
const sshEnd = `${dashes}END OPENSSH PRIVATE KEY${dashes}`;
221+
writeFileSync(
222+
join(root, "id_rsa.txt"),
223+
[
224+
rsaBegin,
225+
"MIIEowIBAAKCAQEAuRFakeRsaBodyLine1ShouldNeverLeakToObservationPipeline",
226+
"MoreFakeBase64BodyForRsaKeyMaterialThatMustStayRedacted",
227+
"YetAnotherSecretLineOfBase64KeyContentNoOneShouldRead",
228+
rsaEnd,
229+
"",
230+
sshBegin,
231+
"b3BlbnNzaC1mYWtlLWtleS1ib2R5LWxpbmUtb25l",
232+
"b3BlbnNzaC1mYWtlLWtleS1ib2R5LWxpbmUtdHdv",
233+
sshEnd,
234+
"",
235+
].join("\n"),
236+
);
237+
const w = new FilesystemWatcher({
238+
roots: [root],
239+
baseUrl: "http://localhost:3111",
240+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
241+
});
242+
243+
await w.flush(root, "id_rsa.txt");
244+
245+
expect(captured).toHaveLength(1);
246+
const content = (captured[0].body as { data: { content: string } }).data.content;
247+
expect(content).toContain(rsaBegin);
248+
expect(content).toContain(rsaEnd);
249+
expect(content).toContain(sshBegin);
250+
expect(content).toContain(sshEnd);
251+
expect(content).toContain("[REDACTED]");
252+
expect(content).not.toContain("MIIEowIBAAKCAQEAuRFakeRsaBodyLine1");
253+
expect(content).not.toContain("MoreFakeBase64BodyForRsaKeyMaterial");
254+
expect(content).not.toContain("YetAnotherSecretLineOfBase64KeyContent");
255+
expect(content).not.toContain("b3BlbnNzaC1mYWtlLWtleS1ib2R5LWxpbmUtb25l");
256+
expect(content).not.toContain("b3BlbnNzaC1mYWtlLWtleS1ib2R5LWxpbmUtdHdv");
257+
});
258+
259+
it("redacts inline PEM blocks embedded in single-line JSON values", async () => {
260+
const dashes = "-".repeat(5);
261+
const pemBegin = `${dashes}BEGIN PRIVATE KEY${dashes}`;
262+
const pemEnd = `${dashes}END PRIVATE KEY${dashes}`;
263+
const inlinePem = `${pemBegin}\\nMIIEvgIBADANBgkqhkiG9w0FakeServiceAccountBody\\n${pemEnd}`;
264+
writeFileSync(
265+
join(root, "service-account.json"),
266+
`{\n "type": "service_account",\n "private_key": "${inlinePem}",\n "client_email": "demo@example.com"\n}\n`,
267+
);
268+
const w = new FilesystemWatcher({
269+
roots: [root],
270+
baseUrl: "http://localhost:3111",
271+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
272+
});
273+
274+
await w.flush(root, "service-account.json");
275+
276+
expect(captured).toHaveLength(1);
277+
const content = (captured[0].body as { data: { content: string } }).data.content;
278+
expect(content).toContain(pemBegin);
279+
expect(content).toContain(pemEnd);
280+
expect(content).toContain("[REDACTED]");
281+
expect(content).not.toContain("MIIEvgIBADANBgkqhkiG9w0FakeServiceAccountBody");
282+
expect(content).toContain('"client_email": "demo@example.com"');
283+
});
284+
285+
it("redacts standalone JWT-looking strings outside Bearer context", async () => {
286+
const jwt =
287+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
288+
writeFileSync(
289+
join(root, "notes.txt"),
290+
["session token below:", jwt, "end of token"].join("\n"),
291+
);
292+
const w = new FilesystemWatcher({
293+
roots: [root],
294+
baseUrl: "http://localhost:3111",
295+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
296+
});
297+
298+
await w.flush(root, "notes.txt");
299+
300+
expect(captured).toHaveLength(1);
301+
const content = (captured[0].body as { data: { content: string } }).data.content;
302+
expect(content).toContain("[REDACTED]");
303+
expect(content).not.toContain(jwt);
304+
expect(content).toContain("end of token");
305+
});
306+
307+
it("does not redact base64-looking words that are not three-segment JWTs of sufficient length", async () => {
308+
const notJwt = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
309+
const shortThreeSegment = "eyJabc.def.ghi";
310+
expect(notJwt.length).toBe(62);
311+
expect(shortThreeSegment.length).toBeLessThan(100);
312+
writeFileSync(
313+
join(root, "fixture.txt"),
314+
["random base64-ish word:", notJwt, "tiny segmented thing:", shortThreeSegment].join("\n"),
315+
);
316+
const w = new FilesystemWatcher({
317+
roots: [root],
318+
baseUrl: "http://localhost:3111",
319+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
320+
});
321+
322+
await w.flush(root, "fixture.txt");
323+
324+
expect(captured).toHaveLength(1);
325+
const content = (captured[0].body as { data: { content: string } }).data.content;
326+
expect(content).toContain(notJwt);
327+
expect(content).toContain(shortThreeSegment);
328+
expect(content).not.toContain("[REDACTED]");
329+
});
330+
215331
it("debounces rapid writes to a single observation", async () => {
216332
const w = new FilesystemWatcher({
217333
roots: [root],

0 commit comments

Comments
 (0)