fix(redact): keep the credential heuristics off ordinary files
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).
This commit is contained in:
+30
-14
@@ -8,8 +8,8 @@
|
||||
* *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/token/password assignments, sk-/AKIA/ghp_/
|
||||
* xox prefixes, Bearer headers, PEM private-key block headers).
|
||||
* 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
|
||||
@@ -27,20 +27,33 @@ export interface RedactionOutcome {
|
||||
readonly hit: boolean
|
||||
}
|
||||
|
||||
/** Lowercased filename substrings that mark a whole file as sensitive. */
|
||||
const SENSITIVE_PATH_PATTERNS: readonly string[] = [
|
||||
'.env', 'secret', 'credential', 'token', 'api-key', 'apikey', 'password',
|
||||
'passwd', 'private_key', 'privatekey', 'id_rsa', 'id_ed25519',
|
||||
/**
|
||||
* 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 {
|
||||
const lower = path.toLowerCase()
|
||||
return SENSITIVE_PATH_PATTERNS.some((pattern) => lower.includes(pattern))
|
||||
return SENSITIVE_PATH_RES.some((re) => re.test(path))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,15 +66,18 @@ 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.
|
||||
* "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', 'key', 'secret', 'secretkey',
|
||||
'token', 'apitoken', 'accesstoken', 'refreshtoken', 'idtoken', 'auth',
|
||||
'password', 'passwd', 'pass', 'pwd',
|
||||
'apikey', 'secret', 'secretkey',
|
||||
'apitoken', 'accesstoken', 'refreshtoken', 'idtoken',
|
||||
'password', 'passwd', 'pwd',
|
||||
'clientsecret', 'privatekey',
|
||||
'accesskey', 'accesskeyid', 'secretaccesskey',
|
||||
'bearer', 'authorization',
|
||||
'authorization',
|
||||
])
|
||||
|
||||
/** Bearer-scheme token in free text (covers HTTP headers in logs/configs). */
|
||||
|
||||
+22
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isSensitivePath, redactContentText, redactText, REDACTED } from '../src/client/redact.js'
|
||||
import { isSensitivePath, redactContentText, redactText, REDACTED } from '../src/client/redact.ts'
|
||||
|
||||
describe('isSensitivePath', () => {
|
||||
it('marks credential-shaped filenames', () => {
|
||||
@@ -15,6 +15,17 @@ describe('isSensitivePath', () => {
|
||||
expect(isSensitivePath('docs/readme.md')).toBe(false)
|
||||
expect(isSensitivePath('package.json')).toBe(false)
|
||||
})
|
||||
it('does not mask files whose names merely CONTAIN a pattern inside a longer word', () => {
|
||||
expect(isSensitivePath('src/tokenizer.ts')).toBe(false)
|
||||
expect(isSensitivePath('config/dev.environment.ts')).toBe(false)
|
||||
expect(isSensitivePath('src/style.keys.ts')).toBe(false)
|
||||
expect(isSensitivePath('docs/api-tokens.md')).toBe(false)
|
||||
})
|
||||
it('still masks pattern matches at a word boundary', () => {
|
||||
expect(isSensitivePath('deploy/.env.local')).toBe(true)
|
||||
expect(isSensitivePath('app/mytoken.yaml')).toBe(true)
|
||||
expect(isSensitivePath('config/passwords.yml')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('redactContentText', () => {
|
||||
@@ -43,6 +54,16 @@ describe('redactContentText', () => {
|
||||
const code = 'const x = 42\nconsole.log("count", x)'
|
||||
expect(redactContentText(code)).toBe(code)
|
||||
})
|
||||
it('leaves generic bare-word assignments alone (key/auth/token/pass label ordinary content too)', () => {
|
||||
expect(redactContentText("key: 'ArrowUp'")).toBe("key: 'ArrowUp'")
|
||||
expect(redactContentText('--key: red')).toBe('--key: red')
|
||||
expect(redactContentText('token: a lexical unit')).toBe('token: a lexical unit')
|
||||
expect(redactContentText('auth: none')).toBe('auth: none')
|
||||
})
|
||||
it('still masks qualified secret field names', () => {
|
||||
expect(redactContentText('access_token: ghp_abcdefghijklmnopqrst')).toBe('access_token: ' + REDACTED)
|
||||
expect(redactContentText('client_secret: shhh-1234567890')).toBe('client_secret: ' + REDACTED)
|
||||
})
|
||||
})
|
||||
|
||||
describe('redactText', () => {
|
||||
|
||||
Reference in New Issue
Block a user