fullcourtdefense-cli 1.18.6 → 1.18.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52,6 +52,105 @@ const SECRET_PATTERNS = [
52
52
  { itemId: 'huggingface_token', label: 'HuggingFace token', re: /\bhf_[A-Za-z0-9]{30,}\b/ },
53
53
  { itemId: 'database_url_password', label: 'database URL with password', re: /\b(?:postgres(?:ql)?|mongodb(?:\+srv)?|mysql):\/\/[^/\s'":]+:[^@\s'"]+@/i },
54
54
  ];
55
+ // Contextual secret detection: bare high-entropy strings (hex app secrets,
56
+ // random tokens) have no recognizable prefix, so SECRET_PATTERNS misses them.
57
+ // When one sits next to a credential keyword ("secret", "password", "api key"
58
+ // ...), treat it as secret material. Keyword proximity keeps git SHAs, UUIDs
59
+ // in URLs, and hashes in ordinary output from false-positiving.
60
+ // English terms use \b word boundaries (so "tokenizer" does not match "token").
61
+ // Deliberately canonical words only — no typo/shorthand variants (pswd, scrt,
62
+ // ...). A deterministic guard must stay predictable; typo evasion is handled
63
+ // by the cloud tier (meta-safety specialist is typo-augmented).
64
+ const CONTEXT_SECRET_KEYWORD_EN = /\b(?:secret|password|passwd|pwd|credential|passphrase|api[ _-]?key|apikey|access[ _-]?key|private[ _-]?key|client[ _-]?secret|app[ _-]?secret|signing[ _-]?key|auth[ _-]?token|bearer|token)\b/i;
65
+ // Language-agnostic pack: password/secret/key words across common languages.
66
+ // No \b — JS word boundaries break on diacritics and non-Latin scripts. Broad
67
+ // everyday words ("clave", "chiave", "ключ") are safe to include because a
68
+ // keyword NEVER blocks alone: a high-entropy credential-looking token must
69
+ // also sit within the context window.
70
+ const CONTEXT_SECRET_KEYWORD_MULTI = new RegExp([
71
+ // Romance: Romanian (first customer — parola/parolă, cheie/cheia/cheile,
72
+ // credențial; "secret"/"token" are identical to English and covered there),
73
+ // Spanish, French, Italian, Portuguese
74
+ 'parol', 'chei', 'credenția', 'credentia', 'contraseñ', 'clave', 'mot de passe', 'clé', 'jeton', 'chiave', 'segret', 'senha', 'chave', 'segredo',
75
+ // Germanic: German, Dutch, Swedish, Norwegian, Danish
76
+ 'passwort', 'kennwort', 'schlüssel', 'geheim', 'wachtwoord', 'sleutel', 'lösenord', 'løsenord', 'passord', 'adgangskode', 'nøkkel', 'hemlig',
77
+ // Slavic (Latin + Cyrillic): Polish, Czech, Russian, Ukrainian, Bulgarian, Serbian
78
+ 'hasło', 'haslo', 'klucz', 'sekret', 'heslo', 'klíč', 'geslo', 'пароль', 'парол', 'ключ', 'секрет', 'токен',
79
+ // Turkish, Hungarian, Finnish, Greek
80
+ 'şifre', 'sifre', 'anahtar', 'gizli', 'jelszó', 'jelszo', 'kulcs', 'titok', 'salasana', 'avain', 'salainen', 'κωδικ', 'μυστικ', 'κλειδ',
81
+ // Hebrew, Arabic, Persian
82
+ 'סיסמה', 'סיסמא', 'מפתח', 'סוד', 'كلمة المرور', 'كلمة السر', 'مفتاح', 'رمز سري', 'رمز', 'گذرواژه', 'رمز عبور',
83
+ // Indic: Hindi, Bengali, Tamil
84
+ 'पासवर्ड', 'कुंजी', 'गुप्त', 'পাসওয়ার্ড', 'கடவுச்சொல்',
85
+ // CJK: Chinese (simplified + traditional), Japanese, Korean
86
+ '密码', '密碼', '密钥', '密鑰', '口令', '令牌', '秘钥', 'パスワード', 'トークン', '秘密鍵', '비밀번호', '암호', '토큰', '비밀 키',
87
+ // Vietnamese, Indonesian/Malay, Thai
88
+ 'mật khẩu', 'bí mật', 'kata sandi', 'kata laluan', 'rahasia', 'rahsia', 'kunci', 'รหัสผ่าน', 'กุญแจ', 'ความลับ',
89
+ ].join('|'), 'iu');
90
+ function hasSecretKeyword(text) {
91
+ return CONTEXT_SECRET_KEYWORD_EN.test(text) || CONTEXT_SECRET_KEYWORD_MULTI.test(text);
92
+ }
93
+ const CONTEXT_WINDOW_CHARS = 120;
94
+ function shannonEntropy(value) {
95
+ const counts = new Map();
96
+ for (const ch of value)
97
+ counts.set(ch, (counts.get(ch) || 0) + 1);
98
+ let entropy = 0;
99
+ for (const count of counts.values()) {
100
+ const p = count / value.length;
101
+ entropy -= p * Math.log2(p);
102
+ }
103
+ return entropy;
104
+ }
105
+ function isHighEntropyToken(token) {
106
+ // Pure hex (app secrets, HMAC keys): hex charset caps entropy at 4 bits and
107
+ // excludes most English letters, so a modest bar is already selective.
108
+ if (/^[A-Fa-f0-9]+$/.test(token)) {
109
+ return token.length >= 24 && shannonEntropy(token) >= 3.0;
110
+ }
111
+ // Mixed charset: the 3.8-bit bar keeps camelCase identifiers and English-y
112
+ // names out (they sit ~3.0-3.6) while random tokens land at ~4.1+.
113
+ if (token.length >= 20 && /\d/.test(token) && /[A-Za-z]/.test(token) && shannonEntropy(token) >= 3.8)
114
+ return true;
115
+ // KEY=value style: evaluate the assigned value on its own.
116
+ const assigned = token.includes('=') ? token.split('=').pop() : undefined;
117
+ if (assigned && assigned !== token && assigned.length >= 20)
118
+ return isHighEntropyToken(assigned);
119
+ return false;
120
+ }
121
+ function maskSecretValue(value) {
122
+ return value.length <= 10 ? '***' : `${value.slice(0, 6)}…${value.slice(-4)} (${value.length} chars)`;
123
+ }
124
+ function findContextualSecret(text) {
125
+ if (!hasSecretKeyword(text))
126
+ return undefined;
127
+ for (const match of text.matchAll(/[A-Za-z0-9_\-+/=]{20,}/g)) {
128
+ const token = match[0];
129
+ const index = match.index ?? 0;
130
+ // Skip tokens inside URLs / file paths — path segments and query params
131
+ // routinely look random but are not pasteable credentials.
132
+ const before = text.slice(Math.max(0, index - 2), index);
133
+ if (/[/\\.]/.test(before))
134
+ continue;
135
+ const windowText = text.slice(Math.max(0, index - CONTEXT_WINDOW_CHARS), Math.min(text.length, index + token.length + CONTEXT_WINDOW_CHARS));
136
+ if (!hasSecretKeyword(windowText.replace(token, '')))
137
+ continue;
138
+ if (!isHighEntropyToken(token))
139
+ continue;
140
+ return { itemId: 'contextual_secret', label: 'credential-like string near a secret keyword', value: maskSecretValue(token) };
141
+ }
142
+ return undefined;
143
+ }
144
+ /** toolArgs like { password: "<random>" } — the keyword lives in the key, not the value. */
145
+ function contextualSecretFromKeyPath(keyPath, value) {
146
+ const lastKey = keyPath.split('.').pop() || '';
147
+ if (!hasSecretKeyword(lastKey.replace(/[_-]/g, ' ')))
148
+ return undefined;
149
+ const trimmed = value.trim();
150
+ if (!/^[A-Za-z0-9_\-+/=]+$/.test(trimmed) || !isHighEntropyToken(trimmed))
151
+ return undefined;
152
+ return { itemId: 'contextual_secret', label: `credential-like value in "${lastKey}"`, value: maskSecretValue(trimmed) };
153
+ }
55
154
  const METADATA_ENDPOINTS = [
56
155
  { itemId: 'aws_gcp_metadata_ip', value: '169.254.169.254', label: 'cloud metadata endpoint' },
57
156
  { itemId: 'gcp_metadata_host', value: 'metadata.google.internal', label: 'GCP metadata endpoint' },
@@ -358,7 +457,9 @@ function scanDeterministicToolCall(toolName, toolArgs, options) {
358
457
  }
359
458
  if (outbound) {
360
459
  for (const candidate of candidates) {
361
- const secret = findSecret(candidate.value);
460
+ const secret = findSecret(candidate.value)
461
+ || contextualSecretFromKeyPath(candidate.keyPath, candidate.value)
462
+ || findContextualSecret(candidate.value);
362
463
  if (secret) {
363
464
  return builtIn(secret.itemId, 'secret_exfiltration', 'secret_exfiltration', 'local-secret-exfiltration', `Blocked outbound data containing ${secret.label}.`, secret.value, secret.label, options);
364
465
  }
@@ -372,7 +473,7 @@ function scanDeterministicToolCall(toolName, toolArgs, options) {
372
473
  return undefined;
373
474
  }
374
475
  function scanDeterministicTextResponse(text, options) {
375
- const secret = findSecret(text);
476
+ const secret = findSecret(text) || findContextualSecret(text);
376
477
  if (!secret)
377
478
  return undefined;
378
479
  return builtIn(secret.itemId, 'secret_exfiltration', 'secret_exfiltration', 'local-secret-in-tool-response', `Blocked tool response containing ${secret.label}.`, secret.value, secret.label, options);
@@ -397,6 +498,14 @@ function scanDeterministicPrompt(text, options) {
397
498
  if (secret && OUTBOUND_TOOL_HINT.test(trimmed)) {
398
499
  return builtIn(secret.itemId, 'secret_exfiltration', 'secret_exfiltration', 'local-prompt-secret-exfiltration', `Blocked prompt asking to send ${secret.label}.`, secret.value, secret.label, options);
399
500
  }
501
+ // Pasting a credential next to "secret"/"password"/"api key" wording blocks
502
+ // even without send intent — once it is in the agent context it can leak
503
+ // through any later tool call. Known-prefix secrets without such wording
504
+ // keep the outbound-intent requirement above (devs paste keys to debug).
505
+ const contextualSecret = findContextualSecret(trimmed);
506
+ if (contextualSecret) {
507
+ return builtIn(contextualSecret.itemId, 'secret_exfiltration', 'secret_exfiltration', 'local-prompt-contextual-secret', `Blocked prompt containing ${contextualSecret.label}.`, contextualSecret.value, contextualSecret.label, options);
508
+ }
400
509
  const sensitivePath = containsSensitiveCredentialPath(trimmed);
401
510
  if (sensitivePath && bareOrActionIntent(trimmed, /\b(?:read|open|cat|show|print|copy|upload|send|exfiltrate)\b/i)) {
402
511
  return builtIn(sensitivePath.itemId, 'sensitive_files', 'sensitive_file', 'local-prompt-sensitive-credential-path', `Blocked prompt targeting ${sensitivePath.label}.`, trimmed, sensitivePath.label, options);
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.18.6"
2
+ "version": "1.18.7"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.18.6",
3
+ "version": "1.18.7",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {