Skip to content

Commit 9a3e46b

Browse files
authored
bridge: sanitize outbound /proc and env leaks (#142)
1 parent 505631f commit 9a3e46b

3 files changed

Lines changed: 104 additions & 2 deletions

File tree

slack-bridge/broker-bridge.mjs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
validateSendParams,
2222
validateReactParams,
2323
createRateLimiter,
24+
sanitizeOutboundText,
2425
} from "./security.mjs";
2526
import {
2627
canonicalizeEnvelope,
@@ -575,6 +576,16 @@ async function _react(channel, threadTs, emoji) {
575576
});
576577
}
577578

579+
function sanitizeOutboundMessage(text, contextLabel) {
580+
const sanitized = sanitizeOutboundText(text);
581+
if (sanitized.blocked) {
582+
logWarn(`🛡️ outbound message blocked (${contextLabel}): ${sanitized.reasons.join(", ")}`);
583+
} else if (sanitized.redacted) {
584+
logWarn(`🧼 outbound message redacted (${contextLabel}): ${sanitized.reasons.join(", ")}`);
585+
}
586+
return sanitized.text;
587+
}
588+
578589
async function handleUserMessage(userMessage, event) {
579590
logInfo(`👤 message from <@${event.user}> in ${event.channel} (type: ${event.type}, ts: ${event.ts})`);
580591

@@ -849,11 +860,12 @@ function startApiServer() {
849860
}
850861

851862
const { channel, text, thread_ts } = apiRequestBody;
863+
const safeText = sanitizeOutboundMessage(text, "/send");
852864

853865
const result = await sendViaBroker({
854866
action: "chat.postMessage",
855867
routing: { channel, ...(thread_ts ? { thread_ts } : {}) },
856-
actionRequestBody: { text },
868+
actionRequestBody: { text: safeText },
857869
});
858870

859871
res.writeHead(200, { "Content-Type": "application/json" });
@@ -881,10 +893,11 @@ function startApiServer() {
881893
return;
882894
}
883895

896+
const safeText = sanitizeOutboundMessage(text, "/reply");
884897
const result = await sendViaBroker({
885898
action: "chat.postMessage",
886899
routing: { channel: thread.channel, thread_ts: thread.thread_ts },
887-
actionRequestBody: { text },
900+
actionRequestBody: { text: safeText },
888901
});
889902

890903
res.writeHead(200, { "Content-Type": "application/json" });

slack-bridge/security.mjs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,57 @@ export function wrapExternalContent({ text, source, user, channel, threadTs }) {
161161
].join("\n");
162162
}
163163

164+
// ── Outbound Redaction / Leak Prevention ───────────────────────────────────
165+
166+
const OUTBOUND_BLOCK_PATTERNS = [
167+
{ pattern: /\/proc\/(self|\d+)\/environ\b/i, reason: "proc-environ-path" },
168+
{ pattern: /\/proc\/(self|\d+)\/cmdline\b/i, reason: "proc-cmdline-path" },
169+
{ pattern: /\0[A-Za-z_][A-Za-z0-9_]*=[^\0]{0,200}\0/, reason: "nul-delimited-env-dump" },
170+
];
171+
172+
const OUTBOUND_REDACT_PATTERNS = [
173+
{ pattern: /\bxox[baprs]-[0-9A-Za-z-]{12,}\b/g, replacement: "[REDACTED_SLACK_TOKEN]", reason: "slack-token" },
174+
{ pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]", reason: "github-token" },
175+
{ pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]", reason: "github-token" },
176+
{ pattern: /\bsk-[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_API_KEY]", reason: "openai-key" },
177+
{ pattern: /\bAKIA[A-Z0-9]{16}\b/g, replacement: "[REDACTED_AWS_KEY]", reason: "aws-access-key" },
178+
{ pattern: /\b((?:SECRET|TOKEN|PASSWORD|PASS|API(?:_|-)?KEY|ACCESS(?:_|-)?KEY|PRIVATE(?:_|-)?KEY|SESSION|COOKIE|BEARER|SLACK|GITHUB|OPENAI|ANTHROPIC|GEMINI|AWS)[A-Z0-9_-]*)=[^\s\n\r\0]{1,400}/gi, replacement: "$1=[REDACTED_ENV]", reason: "sensitive-env-assignment" },
179+
];
180+
181+
const OUTBOUND_BLOCK_FALLBACK = "I found potentially sensitive runtime data and omitted it. I can still help with the task if you share only the non-sensitive details.";
182+
183+
export function sanitizeOutboundText(input) {
184+
let text = typeof input === "string" ? input : String(input);
185+
const reasons = [];
186+
187+
for (const rule of OUTBOUND_BLOCK_PATTERNS) {
188+
if (rule.pattern.test(text)) {
189+
reasons.push(rule.reason);
190+
}
191+
}
192+
193+
if (reasons.length > 0) {
194+
return {
195+
text: OUTBOUND_BLOCK_FALLBACK,
196+
redacted: true,
197+
blocked: true,
198+
reasons,
199+
};
200+
}
201+
202+
let redacted = false;
203+
for (const rule of OUTBOUND_REDACT_PATTERNS) {
204+
const next = text.replace(rule.pattern, rule.replacement);
205+
if (next !== text) {
206+
redacted = true;
207+
reasons.push(rule.reason);
208+
text = next;
209+
}
210+
}
211+
212+
return { text, redacted, blocked: false, reasons };
213+
}
214+
164215
// ── Access Control ──────────────────────────────────────────────────────────
165216

166217
/**

slack-bridge/security.test.mjs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
validateReactParams,
1919
safeEqualSecret,
2020
createRateLimiter,
21+
sanitizeOutboundText,
2122
} from "./security.mjs";
2223

2324
// ── detectSuspiciousPatterns ────────────────────────────────────────────────
@@ -315,6 +316,43 @@ describe("formatForSlack", () => {
315316
});
316317
});
317318

319+
// ── sanitizeOutboundText ────────────────────────────────────────────────────
320+
321+
describe("sanitizeOutboundText", () => {
322+
it("passes through clean text", () => {
323+
const result = sanitizeOutboundText("All good here.");
324+
assert.equal(result.text, "All good here.");
325+
assert.equal(result.redacted, false);
326+
assert.equal(result.blocked, false);
327+
assert.deepEqual(result.reasons, []);
328+
});
329+
330+
it("blocks /proc environ references", () => {
331+
const result = sanitizeOutboundText("Saw this in /proc/self/environ just now");
332+
assert.equal(result.blocked, true);
333+
assert.equal(result.redacted, true);
334+
assert.ok(result.text.includes("omitted"));
335+
assert.ok(result.reasons.includes("proc-environ-path"));
336+
});
337+
338+
it("redacts sensitive env assignments", () => {
339+
const result = sanitizeOutboundText("OPENAI_API_KEY=sk-abcdefghijklmnopqrstuvwxyz123456");
340+
assert.equal(result.blocked, false);
341+
assert.equal(result.redacted, true);
342+
assert.equal(result.text, "OPENAI_API_KEY=[REDACTED_ENV]");
343+
assert.ok(result.reasons.includes("sensitive-env-assignment"));
344+
});
345+
346+
it("redacts known token formats", () => {
347+
const syntheticSlackToken = `xox${"b"}-123456789012-abcdefghijklmno`;
348+
const result = sanitizeOutboundText(`token ${syntheticSlackToken}`);
349+
assert.equal(result.blocked, false);
350+
assert.equal(result.redacted, true);
351+
assert.ok(result.text.includes("[REDACTED_SLACK_TOKEN]"));
352+
assert.ok(result.reasons.includes("slack-token"));
353+
});
354+
});
355+
318356
// ── validateSendParams ──────────────────────────────────────────────────────
319357

320358
describe("validateSendParams", () => {

0 commit comments

Comments
 (0)