@jovan158/vantage
Version:
See and control what your coding agent does
97 lines (96 loc) • 4.67 kB
JavaScript
// Warns when something that looks like a secret is sent to the API.
//
// Every request Claude Code sends carries the conversation so far: your
// messages, Claude's replies, and the output of every tool it ran. When
// Claude reads a .env file or runs a command that prints a token, that
// secret goes to Anthropic with the next request. Vantage cannot and does
// not stop that (it never changes what the agent sends), but it can tell
// you right away, name where it came from, so you can decide whether to
// rotate it.
//
// Only patterns with a low false-positive rate: well-known key formats,
// private key blocks, and .env-style assignments to names like PASSWORD or
// API_KEY. The secret itself is never stored — only its kind, a masked
// prefix and where it appeared. A request repeats the whole history, so the
// caller deduplicates by fingerprint.
import { createHash } from "node:crypto";
import { anthropicParts } from "./formats/parts.js";
const PATTERNS = [
{ kind: "private key", re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY(?: BLOCK)?-----/g },
{ kind: "Anthropic API key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
{ kind: "OpenAI API key", re: /\bsk-(?:proj-)?(?!ant-)[A-Za-z0-9_-]{32,}/g },
{ kind: "AWS access key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
{ kind: "GitHub token", re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})/g },
{ kind: "Slack token", re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
{ kind: "Stripe live key", re: /\b(?:sk|rk)_live_[A-Za-z0-9]{20,}/g },
{ kind: "Google API key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
// KEY=value lines as in .env files: the name says what it is. The value
// must be long enough and not a reference or placeholder. Claude Code's
// Read shows files with line numbers (" 2\tDB_PASSWORD=…"), so an
// optional number prefix is allowed.
{
kind: "secret in an assignment",
re: /(?:^|\n)\s*(?:\d+\s*[\t→|:]\s*)?(?:export\s+)?([A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|API_KEY|APIKEY|ACCESS_KEY|AUTH_TOKEN|TOKEN|PRIVATE_KEY)[A-Z0-9_]*)\s*=\s*["']?([^\s"'$<>{}]{8,})/g,
group: 2,
nameGroup: 1,
},
];
// Obvious placeholders are not secrets.
const PLACEHOLDER = /^(?:x+|\*+|changeme|change_me|your[_-].*|example.*|placeholder.*|dummy.*|test.*|<.*>|\.\.\.+)$/i;
function mask(value) {
return `${value.slice(0, 4)}…(${value.length} chars)`;
}
// A UTF-16 file — what `>` and Out-File write in Windows PowerShell 5.1 —
// reaches the API as text with a NUL after every character and its
// byte-order mark as replacement characters: "��D\0B\0_\0P\0…". Neither can
// be part of a secret, so both are dropped before matching.
const NOT_TEXT = /[\u0000\uFEFF\uFFFD]/g;
export function scanText(raw, source) {
const text = raw.replace(NOT_TEXT, "");
const out = [];
for (const p of PATTERNS) {
p.re.lastIndex = 0;
for (const m of text.matchAll(p.re)) {
const value = m[p.group ?? 0] ?? m[0];
if (PLACEHOLDER.test(value))
continue;
const name = p.nameGroup ? m[p.nameGroup] : undefined;
const fingerprint = createHash("sha256").update(value).digest("hex").slice(0, 16);
if (out.some((f) => f.fingerprint === fingerprint))
continue;
out.push({
kind: name ? `value of ${name}` : p.kind,
masked: p.kind === "private key" ? m[0] : mask(value),
source,
fingerprint,
});
}
}
return out;
}
// Scans a request's conversation parts (src/formats/parts.ts) and attributes
// each finding: the tool call whose output carried it, your message, the
// agent's reply, or the system prompt (where CLAUDE.md and injected memory
// live).
//
// `seen` makes a long session cheap: every request repeats the whole
// history, so parts already scanned (recognized by a hash of their text) are
// skipped. The tool calls in them are still read, to name the source of a
// later tool result.
export function scanParts(parts, seen) {
const found = [];
for (const p of parts) {
if (seen && p.text) {
const key = createHash("sha1").update(p.key).update("\0").update(p.text).digest("hex");
if (seen.has(key))
continue;
seen.add(key);
}
found.push(...scanText(p.text, p.source));
}
return found;
}
// A Messages API request, as Claude Code sends it.
export function scanRequest(body, seen, agent = "Claude") {
return scanParts(anthropicParts(body, agent), seen);
}