UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

197 lines (196 loc) • 10.4 kB
/** * Credential detection for anything phren is about to persist or hand back to * an agent. * * Deliberately a dependency-free leaf module. It used to live inside * content/dedup.ts, whose import graph pulls in data/access, finding/lifecycle * and phren-core — far too heavy for hooks.ts, which runs in a subprocess on * every PostToolUse and is explicitly budgeted for cold-start time. dedup.ts * re-exports everything here, so existing importers are unchanged. */ /** * Whether a value captured by one of the *name-based* rules below is obviously * a placeholder rather than a credential. * * This exists to raise precision, not recall. A hit here does not scrub the * finding — it silently throws the whole finding away, so a false positive * costs the user real knowledge and leaves no trace that it happened. The * name-based rules are the ones that fire on shape alone ("something called * token was assigned something long"), and source code is full of exactly that * shape with nothing secret in it: this store already contains an * auto-captured finding quoting `_authToken = '__PHRE…'` from phren's own * web UI, which is a template marker, not a credential. * * Only whole values count as placeholders. `TESTONLYFAKEKEY0000001` contains * "FAKE" but is not itself a placeholder word, so it is still treated as a * secret. The high-confidence rules (AKIA…, ghp_…, sk-ant-…, PEM headers) * never consult this — those shapes are unambiguous and a documented example * key is still a key. */ export function looksLikePlaceholderSecret(value) { const v = value.trim().replace(/^['"`]+|['"`]+$/g, "").trim(); if (!v) return true; // Template syntax: <TOKEN>, {{token}}, ${TOKEN}, %TOKEN%, __TOKEN__, and // bare shell interpolation ($TOKEN), which is how captured command output // usually spells a credential it did not actually expand. if (/^(?:<[^<>]*>|\{\{[^{}]*\}\}|\$\{[^{}]*\}|%[^%]*%|__.+__|\$[A-Za-z_][A-Za-z0-9_]*)$/.test(v)) return true; // Masked or elided values: ****, xxxxxxxx, ......, 00000000. if (/^(?:[*.]{3,}|[xX]{4,}|0{8,})$/.test(v)) return true; // SCREAMING_SNAKE identifiers — YOUR_API_KEY, PHREN_AUTH_TOKEN. Real tokens // are mixed-case or mixed-charset; an all-caps underscore identifier is a // variable name standing in for one. if (/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/.test(v)) return true; // Whole-value placeholder words, and `your_*` / `my_*` template names. if (/^(?:changeme|change[_-]?me|placeholder|redacted|example|dummy|sample|fake|todo|fixme|none|null|undefined|secret|pass|passwd|password|token|apikey|api[_-]?key)$/i.test(v)) return true; if (/^(?:your|my|the|some)[_-][a-z0-9_-]*$/i.test(v)) return true; if (/[_-]?goes[_-]?here$/i.test(v)) return true; return false; } /** * Return the first non-placeholder value captured by `re` (capture group 1), * or null when every match looks like a template marker. */ function firstRealSecretValue(text, re) { for (const match of text.matchAll(re)) { const value = match[1]; if (value && !looksLikePlaceholderSecret(value)) return value; } return null; } /** Something called api_key/secret/token/password assigned a long literal. */ const NAMED_SECRET_RE = /['"]?(?:api_?key|secret|token|password)['"]?\s*[=:]\s*['"]?([a-zA-Z0-9_\-.]{20,})/gi; /** `Authorization: Bearer <token>` as captured from command output or code. */ const BEARER_RE = /\bauthorization['"]?\s*[=:]\s*['"]?\s*bearer\s+([A-Za-z0-9\-._~+/]{20,}=*)/gi; /** npm/yarn registry auth line, e.g. `//registry.example.com/:_authToken=…`. */ const NPM_AUTH_TOKEN_RE = /_auth(?:Token)?\s*=\s*['"]?([A-Za-z0-9\-._~+/]{16,}=*)/gi; /** Any URL carrying inline credentials, not just the four db schemes below. */ const URL_CREDENTIALS_RE = /\bhttps?:\/\/[^/\s:@]+:([^/\s:@]+)@[^\s]+/gi; /** * Scan text for secrets and PII patterns. Returns the type of secret found, or null if clean. */ export function scanForSecrets(text) { // AWS Access Key if (/AKIA[0-9A-Z]{16}/.test(text)) return 'AWS access key'; // AWS Secret Access Key (variable assignment pattern) if (/(?:aws[_-]?secret|AWS_SECRET)[_-]?(?:access[_-]?)?key[_-]?(?:id)?['":\s]+[A-Za-z0-9/+=]{40}/i.test(text)) return 'AWS secret access key'; // JWT token if (/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/.test(text)) return 'JWT token'; // Fixed-prefix, fixed-shape credentials. These sit above the generic base64 // rule below so the rejection message names the actual credential — "Slack // webhook URL" tells the user what to strip, "long base64 secret" does not. // GitHub fine-grained PAT: different prefix and length from the classic // ghp_ form, so none of the existing rules saw it. if (/github_pat_[A-Za-z0-9_]{22,}/.test(text)) return 'GitHub fine-grained token'; // Google / Firebase API key. Fixed prefix, fixed length, no FP surface. if (/\bAIza[0-9A-Za-z_-]{35}\b/.test(text)) return 'Google API key'; // Slack incoming-webhook URL — possession of the URL is the credential. if (/https:\/\/hooks\.slack\.com\/services\/T[A-Za-z0-9]+\/B[A-Za-z0-9]+\/[A-Za-z0-9]+/.test(text)) return 'Slack webhook URL'; // Long base64-encoded secret-like blob (requires base64 chars including +/= and must not be // a plain hex digest like a git commit SHA — 40-char lowercase hex is explicitly exempt). // Also requires a digit: 40 random base64 chars lack one ~0.1% of the time, while // slash-joined camelCase identifier chains ("addFooToBar/addFoosToBar/upsertBaz") never // have one — that prose shape was being rejected as a credential. if (!/^[0-9a-f]{40}$/.test(text) && /(?=[A-Za-z0-9+/]*[+/])(?=[A-Za-z+/]*[0-9])[A-Za-z0-9+/]{40,}={0,2}/.test(text.replace(/[0-9a-f]{40}/g, ""))) return 'long base64 secret'; // Connection string with credentials if (/(mongodb|postgres|mysql|redis):\/\/[^@\s]+:[^@\s]+@/i.test(text)) return 'connection string with credentials'; // Private key PEM block. The alternation used to be RSA|EC|OPENSSH only, // which misses the most common header of all — PKCS#8 keys are written as a // bare `-----BEGIN PRIVATE KEY-----`, and that is what `openssl genpkey`, // every GCP service-account .pem and most Java tooling emit. if (/-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/.test(text)) return 'SSH private key'; // Anthropic API key if (/sk-ant-api\d{2}-[A-Za-z0-9_\-]{10,}/.test(text)) return 'Anthropic API key'; // OpenAI API key if (/sk-proj-[A-Za-z0-9_\-]{30,}/.test(text)) return 'OpenAI API key'; // GitHub PAT classic if (/ghp_[A-Za-z0-9]{36}/.test(text)) return 'GitHub personal access token'; // GitHub OAuth token if (/gho_[A-Za-z0-9]{36}/.test(text)) return 'GitHub OAuth token'; // GitHub tokens (classic, OAuth, user, org, server) if (/gh[pousr]_[A-Za-z0-9]{36}/.test(text)) return 'GitHub token'; // Slack bot token if (/xoxb-[0-9]+-[A-Za-z0-9-]+/.test(text)) return 'Slack bot token'; // Slack user token if (/xoxp-[0-9]+-[A-Za-z0-9-]+/.test(text)) return 'Slack user token'; // Stripe secret key if (/sk_live_[A-Za-z0-9]{24,}/.test(text)) return 'Stripe secret key'; // Stripe publishable key if (/pk_live_[A-Za-z0-9]{24,}/.test(text)) return 'Stripe publishable key'; // npm access token if (/npm_[A-Za-z0-9]{36}/.test(text)) return 'npm access token'; // GCP service account if (/"private_key_id"\s*:\s*"[^"]{20,}"/.test(text)) return 'GCP service account key'; // ── Name-based rules ────────────────────────────────────────────────────── // Below here a match is inferred from a *name* plus a long value, not from // an unambiguous credential shape, so each one skips values that are plainly // template markers. See looksLikePlaceholderSecret for why that matters: // a hit discards the user's finding outright rather than redacting it. // Any URL carrying inline credentials. The connection-string rule above only // covers mongodb/postgres/mysql/redis, so `https://user:pat@github.com/…` — // the form that actually shows up in captured command output and in remote // URLs — went straight through. if (firstRealSecretValue(text, URL_CREDENTIALS_RE)) return 'URL with embedded credentials'; // `Authorization: Bearer …`, as copied out of curl invocations and logs. if (firstRealSecretValue(text, BEARER_RE)) return 'bearer token'; // npm/yarn registry auth line. `npm_…` tokens are caught above, but a // self-hosted or GitHub Packages registry uses an arbitrary token string. if (firstRealSecretValue(text, NPM_AUTH_TOKEN_RE)) return 'registry auth token'; // Generic API key (only when the variable name suggests it). if (firstRealSecretValue(text, NAMED_SECRET_RE)) return 'API key or secret'; return null; } /** * Make a diagnostic string safe to write to a log or hand back to an agent. * * Custom hook failures compose their message from the hook's own command plus * the child's stderr — `${event}: ${hook.command}: ${errorMessage(err)}` — and * that string goes to two places: `.runtime/hook-errors.log`, and the MCP * response, which lands in the agent's context and its transcript. Hook * commands legitimately carry inline credentials (`curl -H "Authorization: * Bearer …"` passes validateCustomHookCommand — it contains none of the * blocked shell metacharacters), and a failing child prints whatever it likes * to stderr. * * Fails closed rather than trying to excise the secret in place: if the text * trips the detector at all, none of it is emitted. A partially-redacted * diagnostic is worth less than an honest statement that it was withheld, and * substring surgery on an unknown format is how redactors leak. */ export function redactSecretsForLog(text) { const secretType = scanForSecrets(text); if (!secretType) return text; return `[redacted: withheld, contained ${secretType}]`; }