src/log-safety.js
Rama master · réplica de lectura del repositorio original.
import { createHash } from 'crypto';
export function logHash(value, len = 12) {
return createHash('sha256').update(String(value || '')).digest('hex').slice(0, len);
}
export function safeAccountRef(accountOrId, label = '') {
const id = typeof accountOrId === 'object'
? (accountOrId?.id || 'unknown')
: (accountOrId || 'unknown');
const rawLabel = typeof accountOrId === 'object'
? (accountOrId?.email || accountOrId?.name || label || '')
: (label || '');
const out = `account=${id}`;
return rawLabel ? `${out} labelHash=${logHash(rawLabel)}` : out;
}
export function accountRef(id) {
return `acct-${logHash(id, 8)}`;
}
export function safeEmailRef(email) {
return `emailHash=${logHash(email)}`;
}
export function safeKeyRef(key, prefix = 'key') {
return `${prefix}Hash=${logHash(key)}`;
}
// Operator-facing errors (dashboard toast, RegisterUser wrap) used to echo
// upstream bodies that included the pasted JWT / session string (#257).
export function redactCredentialFragments(text) {
return String(text ?? '')
.replace(/devin-session-token\$[A-Za-z0-9._-]+/g, 'devin-session-token$[redacted]')
.replace(/auth1_[A-Za-z0-9_-]{8,}/g, 'auth1_[redacted]')
.replace(/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[jwt-redacted]');
}
// Slice AFTER redaction. RegisterUser used to slice(0,120) first, which
// split a JWT at the first dot so the regex missed it. Same trap is in
// windsurf-login PostAuth / OneTimeToken error wrapping.
export function sliceRedactedJson(value, n = 120) {
const raw = typeof value === 'string' ? value : JSON.stringify(value ?? '');
return redactCredentialFragments(raw).slice(0, n);
}
// Client-supplied strings (model names, selectors) are interpolated straight into
// log lines. Raw control characters let an authenticated caller forge log records
// or inject ANSI escape sequences into an operator's terminal, so strip them at
// the log boundary. Only C0/C1 controls and DEL are replaced (the value stays
// readable), and the length is bounded so one request cannot flood a line.
const LOG_CONTROL_CHARS = /[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g;
export function safeLogValue(value, max = 120) {
const cleaned = String(value ?? '').replace(LOG_CONTROL_CHARS, '\u00b7');
return cleaned.length > max ? `${cleaned.slice(0, max)}\u2026` : cleaned;
}
// The Gemini surface accepts the API key as `?key=` — a verbatim request-URL
// log line (https-proxy.js prints `${method} ${url}`) writes a live credential
// into the journal. Replace the value; keep the param name so the line still
// explains which carrier the client used. Applied on the raw string so a
// malformed URL cannot sneak the key past a parser.
export function redactUrlForLog(url) {
const raw = String(url ?? '');
return raw
.replace(/([?&]key=)[^&\s]*/gi, '$1[redacted]')
.replace(LOG_CONTROL_CHARS, '·');
}
// ── Subprocess environment hygiene (L3b env-leak fix) ─────────────────────
// Every spawn/exec site used to pass the full process.env (or an implicit
// inherit) to its child, forwarding the credential-store KEK
// (DEVIN_CONNECT_CRED_KEY), the proxy's own API_KEY, the dashboard password
// and any ambient CI token to binaries that never asked for them — the L3b
// audit measured 15/15 sentinel keys reaching spawned subprocesses.
//
// The language server solved the same problem with an ALLOWLIST
// (buildLanguageServerEnv in langserver.js) because the LS binary only needs
// ~8 vars. The children covered here are different: the Devin CLI and
// install-ls.sh legitimately want the operator's general environment (PATH,
// HOME, proxy/CA-trust vars, DEVIN_* CLI config, WINDSURFAPI_LS_* installer
// overrides), so these builders take a DENYLIST approach — copy everything
// that does not look like a credential, then let each call site re-add
// exactly the secrets it deliberately channels (`keep`).
// Substring layer: words that mark a credential wherever they appear,
// including fused names with no separator (PGPASSWORD, AWSSECRETACCESSKEY,
// npm_config__authToken). Proxy-internal config that trips these
// (MAX_TOKENS, *_ALLOW_*_AUTH flags) is still stripped — none of it is
// consumed by spawned children, and over-stripping is the safe side.
const SECRET_NAME_SUBSTRING =
/SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|BEARER|COOKIE/i;
// Boundary layer: short marker words that also appear inside NON-secret
// names (SSH_ASKPASS/GIT_ASKPASS hold program paths, MONKEY ends in "key"),
// so they only match at a `_` or string boundary — never mid-word.
// stripped: SSH_AUTH_SOCK, MYSQL_PWD, npm_config__auth, BASIC_AUTH_PASS
// kept: SSH_ASKPASS, MONKEY, OLDPWD, DBUS_SESSION_BUS_ADDRESS
const SECRET_NAME_WORD =
/(?:^|_)(?:KEY|AUTH|AUTHENTICATION|AUTHORIZATION|PWD|PASS|CRED)(?:_|$)/i;
// Explicit name denylist: this repo's own credentials, pinned by name so a
// future regex tune can never silently re-admit them. All of them are also
// caught by the shape tests above; the list is the audit record.
const SUBPROCESS_ENV_DENYLIST = new Set([
'API_KEY', // this proxy's own inbound credential
'DASHBOARD_PASSWORD', // dashboard auth (runtime-rotatable)
'DASHBOARD_PASSWORD_HASH',
'DEVIN_CONNECT_CRED_KEY', // KEK for the on-disk credential store
'DEVIN_CONNECT_ADMIN_TOKEN',
'DEVIN_CONNECT_TOKEN', // devin-connect upstream bearer
'DEVIN_BACKEND_TOKEN',
'DEVIN_API_KEY',
'DEVIN_CLI_API_KEY',
'WINDSURF_API_KEY', // upstream Windsurf credential (re-added by
// the devin -p site as the deliberate channel)
'CODEIUM_API_KEY',
'CODEIUM_AUTH_TOKEN',
'CODEIUM_PASSWORD',
'ASTRAFLOW_API_KEY',
'ASTRAFLOW_CN_API_KEY',
'GMAIL_IMAP_PASSWORD', // email-otp dashboard login
]);
function isSecretEnvName(name) {
return SUBPROCESS_ENV_DENYLIST.has(name)
|| SECRET_NAME_SUBSTRING.test(name)
|| SECRET_NAME_WORD.test(name);
}
// Filtered copy of `source`: every var except secret-shaped names. `keep`
// re-admits specific names the call site deliberately channels to the child
// (e.g. the devin -p pool-credential var, SSH_AUTH_SOCK for agent git work).
// Entries with null/undefined values are never copied.
export function buildSubprocessEnv(source = process.env, { keep = [] } = {}) {
const keepSet = new Set(keep);
const env = {};
for (const key of Object.keys(source)) {
if (source[key] == null) continue;
if (!keepSet.has(key) && isSecretEnvName(key)) continue;
env[key] = source[key];
}
return env;
}
// Strict allowlist for the STRICTEST children — a PATH-resolved probe like
// `devin --version` where a hijacked binary is the threat model. The child
// gets only what it needs to load and run; no proxy vars, no HOME secrets
// beyond the directory itself, nothing an OS needs beyond the platform set.
const MINIMAL_ENV_ALLOWLIST = new Set([
'HOME', 'PATH', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TMPDIR', 'TMP', 'TEMP',
'USER', 'LOGNAME', 'SHELL',
]);
const MINIMAL_ENV_ALLOWLIST_WIN32 = new Set([
// Windows env keys are case-insensitive (`Path`, `ComSpec`), so the win32
// build compares uppercased names and preserves the source spelling.
'APPDATA', 'LOCALAPPDATA', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH',
'SYSTEMROOT', 'WINDIR', 'SYSTEMDRIVE', 'COMSPEC', 'PATHEXT', 'PROGRAMDATA',
'PUBLIC', 'PROCESSOR_ARCHITECTURE', 'OS', 'NUMBER_OF_PROCESSORS',
'PSMODULEPATH', 'USERNAME', 'USERDOMAIN',
]);
export function buildMinimalSubprocessEnv(source = process.env, { platform = process.platform } = {}) {
const env = {};
if (platform === 'win32') {
for (const key of Object.keys(source)) {
const upper = key.toUpperCase();
if (source[key] == null) continue;
if (MINIMAL_ENV_ALLOWLIST.has(upper) || MINIMAL_ENV_ALLOWLIST_WIN32.has(upper)) {
env[key] = source[key];
}
}
return env;
}
for (const key of MINIMAL_ENV_ALLOWLIST) {
if (source[key] != null && source[key] !== '') env[key] = source[key];
}
return env;
}