feat: claude-code tokenization proxy with ggshield DLP
Local DLP proxy between claude-code and the Anthropic API. Scans outgoing /v1/messages bodies with ggshield, swaps detected secrets for opaque placeholder tokens, and restores them in the streamed SSE response on the fly. Licensed under AGPL-3.0-or-later.
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
// Copyright (C) 2026 Rootiest
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import { Transform, type TransformCallback } from "node:stream";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolveToken as storeResolve } from "./store.ts";
|
||||
|
||||
// Token shape: <<<gg_token_[0-9a-f]+>>> (hex, because that's what we generate).
|
||||
const PREFIX = "<<<gg_token_";
|
||||
const SUFFIX = ">>>";
|
||||
|
||||
const COMPLETE_RE = /^<<<gg_token_[0-9a-f]+>>>/;
|
||||
|
||||
// Could `s` (which starts with '<') still grow into a token with more input?
|
||||
// True for any strict prefix of a well-formed token.
|
||||
function isPartialPrefix(s: string): boolean {
|
||||
if (PREFIX.startsWith(s)) return true; // still building the literal "<<<gg_token_"
|
||||
if (!s.startsWith(PREFIX)) return false;
|
||||
const rest = s.slice(PREFIX.length);
|
||||
// hex chars, then an optional partial of the ">>>" closer
|
||||
return /^[0-9a-f]*>{0,2}$/.test(rest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful SSE detokenizer. Scans the raw upstream byte stream for our
|
||||
* placeholder tokens and swaps them back to the original secrets on the fly,
|
||||
* holding back only the few characters that could still become a token so
|
||||
* tokens split across chunk boundaries are reassembled correctly.
|
||||
*/
|
||||
export class DetokenizeStream extends Transform {
|
||||
private buf = "";
|
||||
private decoder = new StringDecoder("utf8");
|
||||
private resolve: (token: string) => string | undefined;
|
||||
|
||||
constructor(resolve: (token: string) => string | undefined = storeResolve) {
|
||||
super();
|
||||
this.resolve = resolve;
|
||||
}
|
||||
|
||||
private emitFor(token: string): string {
|
||||
const secret = this.resolve(token);
|
||||
if (secret === undefined) return token; // unknown token: leave it untouched
|
||||
// ponytail: token appears inside a JSON string in the SSE `data:` line, so
|
||||
// re-escape. Assumes secrets are escape-neutral (typical API keys/tokens);
|
||||
// a secret literally containing a backslash or quote would double-escape —
|
||||
// upgrade to context-aware splicing if that ever bites.
|
||||
return JSON.stringify(secret).slice(1, -1);
|
||||
}
|
||||
|
||||
override _transform(chunk: Buffer, _enc: BufferEncoding, cb: TransformCallback): void {
|
||||
this.buf += this.decoder.write(chunk);
|
||||
let out = "";
|
||||
while (this.buf.length > 0) {
|
||||
const lt = this.buf.indexOf("<");
|
||||
if (lt === -1) {
|
||||
out += this.buf;
|
||||
this.buf = "";
|
||||
break;
|
||||
}
|
||||
out += this.buf.slice(0, lt); // everything before '<' is safe to emit
|
||||
this.buf = this.buf.slice(lt); // buf now starts at the '<'
|
||||
|
||||
const complete = COMPLETE_RE.exec(this.buf);
|
||||
if (complete) {
|
||||
out += this.emitFor(complete[0]);
|
||||
this.buf = this.buf.slice(complete[0].length);
|
||||
continue;
|
||||
}
|
||||
if (isPartialPrefix(this.buf)) break; // might finish next chunk; hold it
|
||||
// This '<' can't begin a token. Emit it and keep scanning past it.
|
||||
out += "<";
|
||||
this.buf = this.buf.slice(1);
|
||||
}
|
||||
cb(null, out);
|
||||
}
|
||||
|
||||
override _flush(cb: TransformCallback): void {
|
||||
const out = this.buf + this.decoder.end();
|
||||
this.buf = "";
|
||||
cb(null, out);
|
||||
}
|
||||
}
|
||||
|
||||
// --- self-check: `node src/detokenize.ts --test` -------------------------
|
||||
function runSelfTest(): void {
|
||||
const assert = (cond: boolean, msg: string) => {
|
||||
if (!cond) throw new Error("FAIL: " + msg);
|
||||
};
|
||||
const map = new Map([
|
||||
["<<<gg_token_abc123>>>", "SUPER-SECRET-KEY"],
|
||||
["<<<gg_token_dead>>>", "hunter2"],
|
||||
]);
|
||||
const resolve = (t: string) => map.get(t);
|
||||
|
||||
// Feed input split into arbitrary chunks, collect output.
|
||||
const run = (chunks: string[]): string => {
|
||||
const s = new DetokenizeStream(resolve);
|
||||
let out = "";
|
||||
s.on("data", (d) => (out += d.toString()));
|
||||
for (const c of chunks) s.write(Buffer.from(c, "utf8"));
|
||||
s.end();
|
||||
return out;
|
||||
};
|
||||
|
||||
// 1. Token split across a chunk boundary (the critical case).
|
||||
assert(
|
||||
run(["password is <<<gg_to", "ken_abc123>>> done"]) ===
|
||||
"password is SUPER-SECRET-KEY done",
|
||||
"split token reassembled",
|
||||
);
|
||||
|
||||
// 2. Token split one char at a time.
|
||||
assert(
|
||||
run("x <<<gg_token_dead>>> y".split("")) === "x hunter2 y",
|
||||
"char-by-char token",
|
||||
);
|
||||
|
||||
// 3. Plain text with stray '<' and '<<<' that is NOT a token flushes promptly.
|
||||
assert(run(["a < b <<< c <<<not"]) === "a < b <<< c <<<not", "non-token < flushed");
|
||||
|
||||
// 4. Unknown token passes through unchanged.
|
||||
assert(run(["see <<<gg_token_ffff>>>!"]) === "see <<<gg_token_ffff>>>!", "unknown token kept");
|
||||
|
||||
// 5. Two tokens back to back.
|
||||
assert(
|
||||
run(["<<<gg_token_abc123>>><<<gg_token_dead>>>"]) === "SUPER-SECRET-KEYhunter2",
|
||||
"adjacent tokens",
|
||||
);
|
||||
|
||||
// 6. Incomplete token at end of stream flushed verbatim.
|
||||
assert(run(["tail <<<gg_token_ab"]) === "tail <<<gg_token_ab", "incomplete token flushed on end");
|
||||
|
||||
// 7. Overlapping '<' runs.
|
||||
assert(run(["<<<<gg_token_dead>>>"]) === "<hunter2", "leading extra angle bracket");
|
||||
|
||||
console.log("detokenize self-test: all assertions passed");
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
runSelfTest();
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright (C) 2026 Rootiest
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import { tokenizeBody } from "./tokenize.ts";
|
||||
import { DetokenizeStream } from "./detokenize.ts";
|
||||
|
||||
const PORT = Number(process.env.PORT) || 8080;
|
||||
const UPSTREAM_HOST = process.env.ANTHROPIC_UPSTREAM_HOST || "api.anthropic.com";
|
||||
|
||||
// Headers we must not forward verbatim (they describe the OLD body/transport).
|
||||
const STRIP_REQ = new Set(["host", "content-length", "accept-encoding"]);
|
||||
const STRIP_RES = new Set(["content-length", "content-encoding", "transfer-encoding", "connection"]);
|
||||
|
||||
function readBody(req: http.IncomingMessage): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (c) => chunks.push(c));
|
||||
req.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function pick(headers: http.IncomingHttpHeaders, strip: Set<string>): http.OutgoingHttpHeaders {
|
||||
const out: http.OutgoingHttpHeaders = {};
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
if (v !== undefined && !strip.has(k.toLowerCase())) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const isTarget = req.method === "POST" && (req.url ?? "").startsWith("/v1/messages");
|
||||
const rawBody = await readBody(req);
|
||||
|
||||
// Only the messages endpoint carries user prompts worth scanning.
|
||||
const outBody = isTarget ? Buffer.from(await tokenizeBody(rawBody.toString("utf8")), "utf8") : rawBody;
|
||||
|
||||
const headers = pick(req.headers, STRIP_REQ);
|
||||
headers["content-length"] = Buffer.byteLength(outBody);
|
||||
// Force identity so the SSE stream reaches the detokenizer uncompressed.
|
||||
headers["accept-encoding"] = "identity";
|
||||
|
||||
const upstream = https.request(
|
||||
{ host: UPSTREAM_HOST, port: 443, method: req.method, path: req.url, headers },
|
||||
(upRes) => {
|
||||
res.writeHead(upRes.statusCode ?? 502, pick(upRes.headers, STRIP_RES));
|
||||
if (isTarget) {
|
||||
// Detokenize the response body (SSE or JSON) on the way back.
|
||||
upRes.pipe(new DetokenizeStream()).pipe(res);
|
||||
} else {
|
||||
upRes.pipe(res);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
upstream.on("error", (err) => {
|
||||
console.error("[proxy] upstream error:", err.message);
|
||||
if (!res.headersSent) res.writeHead(502, { "content-type": "text/plain" });
|
||||
res.end("Upstream request failed");
|
||||
});
|
||||
|
||||
upstream.end(outBody);
|
||||
} catch (err) {
|
||||
console.error("[proxy] request error:", (err as Error).message);
|
||||
if (!res.headersSent) res.writeHead(500, { "content-type": "text/plain" });
|
||||
res.end("Proxy error");
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, "127.0.0.1", () => {
|
||||
console.log(`[proxy] DLP tokenization proxy listening on http://127.0.0.1:${PORT}`);
|
||||
console.log(`[proxy] forwarding to https://${UPSTREAM_HOST}`);
|
||||
console.log(`[proxy] point claude-code at it: export ANTHROPIC_BASE_URL=http://127.0.0.1:${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (C) 2026 Rootiest
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
// In-memory token <-> secret map. ponytail: a Map is enough; secrets live only
|
||||
// for the lifetime of the proxy process. Swap for SQLite only if you need
|
||||
// persistence across restarts (you almost certainly don't — secrets shouldn't
|
||||
// outlive the session).
|
||||
export const secrets = new Map<string, string>();
|
||||
|
||||
export function resolveToken(token: string): string | undefined {
|
||||
return secrets.get(token);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright (C) 2026 Rootiest
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { secrets } from "./store.ts";
|
||||
|
||||
let ggshieldWarned = false;
|
||||
|
||||
function newToken(): string {
|
||||
return `<<<gg_token_${randomBytes(8).toString("hex")}>>>`;
|
||||
}
|
||||
|
||||
// Run ggshield over `text` and return the list of plaintext secret strings it
|
||||
// found. Returns [] (and warns once) if ggshield is missing or errors — fail
|
||||
// open so the proxy never blocks traffic, per the brief.
|
||||
async function scanSecrets(text: string): Promise<string[]> {
|
||||
let dir: string | undefined;
|
||||
try {
|
||||
dir = await mkdtemp(join(tmpdir(), "ggscan-"));
|
||||
const file = join(dir, "payload.txt");
|
||||
await writeFile(file, text, "utf8");
|
||||
|
||||
const stdout = await new Promise<string>((resolve, reject) => {
|
||||
// ggshield 1.45 has no stdin scan mode, so we scan a temp file.
|
||||
// --show-secrets puts the plaintext in `match`; --all-secrets stops it
|
||||
// filtering example/known keys; exit code 1 just means "found secrets".
|
||||
const proc = spawn(
|
||||
"ggshield",
|
||||
["secret", "scan", "--json", "--show-secrets", "--all-secrets", "path", file],
|
||||
{ stdio: ["ignore", "pipe", "ignore"] },
|
||||
);
|
||||
let out = "";
|
||||
proc.stdout.on("data", (d) => (out += d));
|
||||
proc.on("error", reject); // ENOENT etc.
|
||||
proc.on("close", () => resolve(out));
|
||||
});
|
||||
|
||||
const report = JSON.parse(stdout);
|
||||
const found = new Set<string>();
|
||||
for (const entity of report.entities_with_incidents ?? []) {
|
||||
for (const incident of entity.incidents ?? []) {
|
||||
for (const occ of incident.occurrences ?? []) {
|
||||
if (typeof occ.match === "string" && occ.match.length > 0) found.add(occ.match);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...found];
|
||||
} catch (err) {
|
||||
if (!ggshieldWarned) {
|
||||
ggshieldWarned = true;
|
||||
const reason = (err as NodeJS.ErrnoException).code === "ENOENT"
|
||||
? "ggshield not found on PATH"
|
||||
: `ggshield scan failed: ${(err as Error).message}`;
|
||||
console.warn(`[proxy] WARNING: ${reason} — forwarding traffic WITHOUT secret redaction`);
|
||||
}
|
||||
return [];
|
||||
} finally {
|
||||
if (dir) await rm(dir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a raw request body for secrets, replace each with a placeholder token,
|
||||
* record the token->secret mapping, and return the redacted body. Longest
|
||||
* secrets first so a secret that contains another is replaced whole.
|
||||
*/
|
||||
export async function tokenizeBody(body: string): Promise<string> {
|
||||
const found = await scanSecrets(body);
|
||||
if (found.length === 0) return body;
|
||||
|
||||
let redacted = body;
|
||||
for (const secret of found.sort((a, b) => b.length - a.length)) {
|
||||
if (!redacted.includes(secret)) continue;
|
||||
const token = newToken();
|
||||
secrets.set(token, secret);
|
||||
redacted = redacted.split(secret).join(token);
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
Reference in New Issue
Block a user