Path layer: patterns now match with a right boundary (must not continue into [a-z0-9]) and list only store-plural forms (credentials/secrets/ passwords, not tokens), so tokenizer.ts / dev.environment.ts / style.keys.ts / api-tokens.md are no longer masked whole while .env.local / credentials.json / mytoken.yaml still are. Content layer: drop the bare key/auth/token/pass/bearer field names — they label far more ordinary content (keyboard keys, CSS custom properties, lexical tokens) than secrets; qualified names stay and Bearer schemes remain covered by BEARER_RE. Adds the over-masking regression cases; fixes the spec's source import extension to .ts (repo convention).
149 lines
5.9 KiB
TypeScript
149 lines
5.9 KiB
TypeScript
/**
|
|
* Secret redaction for rendered file content. The tracer replays read/write
|
|
* tool results into the DOM (diff, read view, markdown mode) — when the
|
|
* model reads a credentials file, that plaintext must not land on screen or
|
|
* in inspectable DOM. Two layers, both pure and unit-tested:
|
|
*
|
|
* 1. Path layer: files whose NAME marks them as secrets (.env, *secret*,
|
|
* *credential*, *token*, *api-key*, private keys, …) are masked whole —
|
|
* every non-empty line becomes [REDACTED].
|
|
* 2. Content layer: for ordinary files, secret-SHAPED lines and tokens are
|
|
* masked in place (api_key/access_token/password assignments, sk-/AKIA/
|
|
* ghp_/xox prefixes, Bearer headers, PEM private-key block headers).
|
|
*
|
|
* Redaction is display-only: the session log and the tool results keep their
|
|
* original bytes; this layer guarantees only that this plugin never renders
|
|
* the secret. It is NOT a security boundary against a same-context malicious
|
|
* plugin (which can read anything on the page or call host APIs itself) —
|
|
* it removes the tracer's own contribution to secret exposure.
|
|
*/
|
|
|
|
/** Placeholder substituted for every secret found. */
|
|
export const REDACTED = '[REDACTED]'
|
|
|
|
/** Result of redacting one text: the masked text + whether anything was hit. */
|
|
export interface RedactionOutcome {
|
|
readonly text: string
|
|
readonly hit: boolean
|
|
}
|
|
|
|
/**
|
|
* Filename substrings that mark a whole file as sensitive. Each pattern is
|
|
* matched case-insensitively against the full path with a RIGHT boundary
|
|
* (the pattern must not continue into `[a-z0-9]`) so `tokenizer.ts`,
|
|
* `dev.environment.ts`, and `style.keys.ts` stay ordinary files while
|
|
* `.env.local`, `credentials.json`, and `id_rsa` still mask whole. Plurals
|
|
* are listed only where the plural names a secret STORE (credentials /
|
|
* secrets / passwords); a bare `tokens` is usually documentation
|
|
* (api-tokens.md) and is deliberately absent.
|
|
*/
|
|
const SENSITIVE_PATH_RES: readonly RegExp[] = [
|
|
'.env', 'secret', 'secrets', 'credential', 'credentials', 'token', 'api-key', 'apikey',
|
|
'password', 'passwords', 'passwd', 'private_key', 'privatekey', 'id_rsa', 'id_ed25519',
|
|
'.pem', '.key', '.p12', '.pfx',
|
|
].map((pattern) => new RegExp(`${escapeRe(pattern)}(?![a-z0-9])`, 'i'))
|
|
|
|
/** Escape literal text for embedding into a RegExp. */
|
|
function escapeRe(text: string): string {
|
|
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
}
|
|
|
|
/**
|
|
* Whether a path's own name marks the file as sensitive (case-insensitive,
|
|
* matched against the full path so .env in any directory counts).
|
|
*/
|
|
export function isSensitivePath(path: string): boolean {
|
|
return SENSITIVE_PATH_RES.some((re) => re.test(path))
|
|
}
|
|
|
|
/**
|
|
* Assignment-shaped secret lines: "api_key: …", "TOKEN = …", "password": ….
|
|
* The key name survives; the value is masked so the user still sees WHICH
|
|
* field was secret.
|
|
*/
|
|
const ASSIGNMENT_RE = /^(\s*["']?[A-Za-z0-9_.-]*["']?\s*[:=]\s*)(\S.*)$/
|
|
|
|
/**
|
|
* Field names whose assigned value is a secret. Stored in NORMALIZED form
|
|
* (lower-case, separators stripped — see normalizeField) so api_key/API-KEY/
|
|
* "api-key" all match one entry. Generic bare words (key / token / auth /
|
|
* pass) are deliberately EXCLUDED: they label far more ordinary content
|
|
* (keyboard keys, CSS custom properties, lexical tokens) than secrets —
|
|
* Bearer-scheme headers stay covered by BEARER_RE below.
|
|
*/
|
|
const SECRET_FIELDS = new Set([
|
|
'apikey', 'secret', 'secretkey',
|
|
'apitoken', 'accesstoken', 'refreshtoken', 'idtoken',
|
|
'password', 'passwd', 'pwd',
|
|
'clientsecret', 'privatekey',
|
|
'accesskey', 'accesskeyid', 'secretaccesskey',
|
|
'authorization',
|
|
])
|
|
|
|
/** Bearer-scheme token in free text (covers HTTP headers in logs/configs). */
|
|
const BEARER_RE = /(\bBearer\s+)[A-Za-z0-9\-_.+/]{16,}/gi
|
|
|
|
/** Well-known credential token prefixes (OpenAI/AWS/GitHub/Slack/Google). */
|
|
const TOKEN_RES: readonly RegExp[] = [
|
|
/\bsk-[A-Za-z0-9_-]{12,}/g,
|
|
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
/\bghp_[A-Za-z0-9]{20,}\b/g,
|
|
/\bgho_[A-Za-z0-9]{20,}\b/g,
|
|
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
|
|
/\bya29\.[A-Za-z0-9_-]{20,}\b/g,
|
|
]
|
|
|
|
/** Mask an entire non-empty line, keeping whitespace shape. */
|
|
function maskLine(line: string): string {
|
|
return line.trim().length === 0 ? line : REDACTED
|
|
}
|
|
|
|
/** Normalize a field name for SECRET_FIELDS lookup. */
|
|
function normalizeField(raw: string): string {
|
|
return raw.replace(/["'\s:=]/g, '').toLowerCase().replace(/[^a-z0-9]/g, '')
|
|
}
|
|
|
|
/** Redact one possibly-secret assignment line; undefined when not a secret field. */
|
|
function redactAssignment(line: string): string | undefined {
|
|
const match = ASSIGNMENT_RE.exec(line)
|
|
const prefix = match?.[1]
|
|
if (prefix === undefined || match?.[2] === undefined) return undefined
|
|
if (!SECRET_FIELDS.has(normalizeField(prefix))) return undefined
|
|
return prefix + REDACTED
|
|
}
|
|
|
|
/** Whether a line is the header of a PEM private-key block. */
|
|
function isPemPrivateKeyHeader(line: string): boolean {
|
|
return line.includes('-----BEGIN') && line.includes('PRIVATE KEY-----')
|
|
}
|
|
|
|
/**
|
|
* Redact secret-shaped content from an ordinary file's text. Only the secret
|
|
* itself is masked; surrounding code/config stays readable.
|
|
*/
|
|
export function redactContentText(text: string): string {
|
|
return text
|
|
.split('\n')
|
|
.map((line) => {
|
|
if (isPemPrivateKeyHeader(line)) return REDACTED
|
|
const assignment = redactAssignment(line)
|
|
if (assignment !== undefined) return assignment
|
|
let masked = line.replace(BEARER_RE, (_m, prefix) => prefix + REDACTED)
|
|
for (const re of TOKEN_RES) masked = masked.replace(re, () => REDACTED)
|
|
return masked
|
|
})
|
|
.join('\n')
|
|
}
|
|
|
|
/**
|
|
* Apply the full two-layer redaction for one file's text. Sensitive paths
|
|
* mask whole lines; ordinary files mask only secret-shaped spans.
|
|
*/
|
|
export function redactText(path: string, text: string): RedactionOutcome {
|
|
if (isSensitivePath(path)) {
|
|
return { text: text.split('\n').map(maskLine).join('\n'), hit: text.trim().length > 0 }
|
|
}
|
|
const masked = redactContentText(text)
|
|
return { text: masked, hit: masked !== text }
|
|
}
|