@vaultcompass/vault-guard-core 1.2.3 → 1.4.0

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.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.validateVaultGuardConfig = validateVaultGuardConfig;
4
+ const fail_on_1 = require("./utils/fail-on");
4
5
  const SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'off']);
5
6
  /**
6
7
  * Structural validation for parsed `.vault-guard.json` (no file I/O).
@@ -19,6 +20,7 @@ function validateVaultGuardConfig(value) {
19
20
  'extra_patterns',
20
21
  'extra_patterns_unsafe',
21
22
  'entropy_threshold',
23
+ 'fail_on',
22
24
  ]);
23
25
  if (!allowed.has(key)) {
24
26
  errors.push(`unknown top-level key: ${JSON.stringify(key)}`);
@@ -67,6 +69,9 @@ function validateVaultGuardConfig(value) {
67
69
  errors.push('entropy_threshold must be a finite number');
68
70
  }
69
71
  }
72
+ if (o.fail_on !== undefined && !(0, fail_on_1.isFailOnThreshold)(o.fail_on)) {
73
+ errors.push('fail_on must be critical|high|medium|low|none');
74
+ }
70
75
  if (o.extra_patterns !== undefined) {
71
76
  if (!Array.isArray(o.extra_patterns)) {
72
77
  errors.push('extra_patterns must be an array');
package/dist/config.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { SecretMatch } from './types';
2
+ import type { FailOnThreshold } from './utils/fail-on';
2
3
  /**
3
4
  * Shape of .vault-guard.json in a repository root.
4
5
  *
@@ -40,6 +41,13 @@ export interface VaultGuardConfig {
40
41
  * generic catch-all patterns. Lower = more matches but more false positives.
41
42
  */
42
43
  entropy_threshold?: number;
44
+ /**
45
+ * Minimum severity that fails the scan (non-zero exit). Findings below the
46
+ * threshold are still reported in text/JSON/SARIF, they just do not break
47
+ * the build. `"none"` reports everything and never fails, for advisory
48
+ * rollouts. Defaults to `"medium"`; overridden by `--fail-on`.
49
+ */
50
+ fail_on?: FailOnThreshold;
43
51
  }
44
52
  /**
45
53
  * Directories searched for `.vault-guard.json` / baseline files, in order
package/dist/index.d.ts CHANGED
@@ -9,8 +9,9 @@ export { fingerprintForMatch } from './match-fingerprint';
9
9
  export * from './scan-output';
10
10
  export * from './diagnostics';
11
11
  export { shannonEntropy, DEFAULT_ENTROPY_THRESHOLD } from './utils/entropy';
12
- export { isPlaceholderSecret, isNonSecretConnectionString, isSampleJwt, isRedactedTemplateValue, isEnvVarNameToken } from './utils/placeholder';
13
- export { getGitStagedFilePaths, isInsideGitWorkTree } from './utils/git-utils';
12
+ export { isPlaceholderSecret, isNonSecretConnectionString, isSampleJwt, isRedactedTemplateValue, isEnvVarNameToken, isCodeIdentifierReference, isPasswordHash, isPemHeaderWithoutBody } from './utils/placeholder';
13
+ export { getGitStagedFilePaths, readGitIndexFile, isInsideGitWorkTree } from './utils/git-utils';
14
14
  export { validateRegexSafety, validateRegexLength, mapRegexSafetyReasonToDiagnosticCode, mapPatternRejectionReasonToDiagnosticCode, REGEX_REASON_TO_DIAGNOSTIC_CODE, REGEX_MAX_LENGTH, REGEX_MAX_QUANTIFIERS, } from './utils/regex-safety';
15
15
  export { scanTextFileAsync, scanTextFileSync } from './utils/scan-file';
16
- export { applyPathAwareSeverity, isTestFilePath } from './utils/path-severity';
16
+ export { applyPathAwareSeverity, isTestFilePath, isLocalePath } from './utils/path-severity';
17
+ export { DEFAULT_FAIL_ON, FAIL_ON_VALUES, isFailOnThreshold, meetsFailThreshold, countBlockingMatches, resolveFailOn, type FailOnThreshold, } from './utils/fail-on';
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.isTestFilePath = exports.applyPathAwareSeverity = exports.scanTextFileSync = exports.scanTextFileAsync = exports.REGEX_MAX_QUANTIFIERS = exports.REGEX_MAX_LENGTH = exports.REGEX_REASON_TO_DIAGNOSTIC_CODE = exports.mapPatternRejectionReasonToDiagnosticCode = exports.mapRegexSafetyReasonToDiagnosticCode = exports.validateRegexLength = exports.validateRegexSafety = exports.isInsideGitWorkTree = exports.getGitStagedFilePaths = exports.isEnvVarNameToken = exports.isRedactedTemplateValue = exports.isSampleJwt = exports.isNonSecretConnectionString = exports.isPlaceholderSecret = exports.DEFAULT_ENTROPY_THRESHOLD = exports.shannonEntropy = exports.fingerprintForMatch = void 0;
17
+ exports.resolveFailOn = exports.countBlockingMatches = exports.meetsFailThreshold = exports.isFailOnThreshold = exports.FAIL_ON_VALUES = exports.DEFAULT_FAIL_ON = exports.isLocalePath = exports.isTestFilePath = exports.applyPathAwareSeverity = exports.scanTextFileSync = exports.scanTextFileAsync = exports.REGEX_MAX_QUANTIFIERS = exports.REGEX_MAX_LENGTH = exports.REGEX_REASON_TO_DIAGNOSTIC_CODE = exports.mapPatternRejectionReasonToDiagnosticCode = exports.mapRegexSafetyReasonToDiagnosticCode = exports.validateRegexLength = exports.validateRegexSafety = exports.isInsideGitWorkTree = exports.readGitIndexFile = exports.getGitStagedFilePaths = exports.isPemHeaderWithoutBody = exports.isPasswordHash = exports.isCodeIdentifierReference = exports.isEnvVarNameToken = exports.isRedactedTemplateValue = exports.isSampleJwt = exports.isNonSecretConnectionString = exports.isPlaceholderSecret = exports.DEFAULT_ENTROPY_THRESHOLD = exports.shannonEntropy = exports.fingerprintForMatch = void 0;
18
18
  __exportStar(require("./types"), exports);
19
19
  __exportStar(require("./errors"), exports);
20
20
  __exportStar(require("./scanners"), exports);
@@ -35,8 +35,12 @@ Object.defineProperty(exports, "isNonSecretConnectionString", { enumerable: true
35
35
  Object.defineProperty(exports, "isSampleJwt", { enumerable: true, get: function () { return placeholder_1.isSampleJwt; } });
36
36
  Object.defineProperty(exports, "isRedactedTemplateValue", { enumerable: true, get: function () { return placeholder_1.isRedactedTemplateValue; } });
37
37
  Object.defineProperty(exports, "isEnvVarNameToken", { enumerable: true, get: function () { return placeholder_1.isEnvVarNameToken; } });
38
+ Object.defineProperty(exports, "isCodeIdentifierReference", { enumerable: true, get: function () { return placeholder_1.isCodeIdentifierReference; } });
39
+ Object.defineProperty(exports, "isPasswordHash", { enumerable: true, get: function () { return placeholder_1.isPasswordHash; } });
40
+ Object.defineProperty(exports, "isPemHeaderWithoutBody", { enumerable: true, get: function () { return placeholder_1.isPemHeaderWithoutBody; } });
38
41
  var git_utils_1 = require("./utils/git-utils");
39
42
  Object.defineProperty(exports, "getGitStagedFilePaths", { enumerable: true, get: function () { return git_utils_1.getGitStagedFilePaths; } });
43
+ Object.defineProperty(exports, "readGitIndexFile", { enumerable: true, get: function () { return git_utils_1.readGitIndexFile; } });
40
44
  Object.defineProperty(exports, "isInsideGitWorkTree", { enumerable: true, get: function () { return git_utils_1.isInsideGitWorkTree; } });
41
45
  var regex_safety_1 = require("./utils/regex-safety");
42
46
  Object.defineProperty(exports, "validateRegexSafety", { enumerable: true, get: function () { return regex_safety_1.validateRegexSafety; } });
@@ -52,3 +56,11 @@ Object.defineProperty(exports, "scanTextFileSync", { enumerable: true, get: func
52
56
  var path_severity_1 = require("./utils/path-severity");
53
57
  Object.defineProperty(exports, "applyPathAwareSeverity", { enumerable: true, get: function () { return path_severity_1.applyPathAwareSeverity; } });
54
58
  Object.defineProperty(exports, "isTestFilePath", { enumerable: true, get: function () { return path_severity_1.isTestFilePath; } });
59
+ Object.defineProperty(exports, "isLocalePath", { enumerable: true, get: function () { return path_severity_1.isLocalePath; } });
60
+ var fail_on_1 = require("./utils/fail-on");
61
+ Object.defineProperty(exports, "DEFAULT_FAIL_ON", { enumerable: true, get: function () { return fail_on_1.DEFAULT_FAIL_ON; } });
62
+ Object.defineProperty(exports, "FAIL_ON_VALUES", { enumerable: true, get: function () { return fail_on_1.FAIL_ON_VALUES; } });
63
+ Object.defineProperty(exports, "isFailOnThreshold", { enumerable: true, get: function () { return fail_on_1.isFailOnThreshold; } });
64
+ Object.defineProperty(exports, "meetsFailThreshold", { enumerable: true, get: function () { return fail_on_1.meetsFailThreshold; } });
65
+ Object.defineProperty(exports, "countBlockingMatches", { enumerable: true, get: function () { return fail_on_1.countBlockingMatches; } });
66
+ Object.defineProperty(exports, "resolveFailOn", { enumerable: true, get: function () { return fail_on_1.resolveFailOn; } });
@@ -17,6 +17,14 @@ export interface JsonRunMetadata {
17
17
  diagnostics_count?: number;
18
18
  /** Matches removed because they appeared in `.vault-guard.baseline.json`. */
19
19
  baseline_suppressed?: number;
20
+ /** Effective gate threshold for this run (`--fail-on` / `fail_on` / default). */
21
+ fail_on?: string;
22
+ /**
23
+ * Matches at or above {@link fail_on}. This, not the total match count, is
24
+ * what drives the process exit code; integrators gating a build should read
25
+ * this field rather than `summary.secrets`.
26
+ */
27
+ blocking_matches?: number;
20
28
  }
21
29
  export interface JsonOutput {
22
30
  version: string;
@@ -18,6 +18,10 @@ export declare class PreCommitHook {
18
18
  * Absolute path to the \`pre-commit\` hook file for the given manager.
19
19
  */
20
20
  getPreCommitHookPath(cwd: string, manager?: HookManager): string;
21
+ /**
22
+ * Absolute path to the Windows \`pre-commit.cmd\` companion (native manager only).
23
+ */
24
+ getPreCommitCmdPath(cwd: string): string;
21
25
  install(options?: InstallHookOptions): {
22
26
  success: boolean;
23
27
  message: string;
@@ -28,6 +32,12 @@ export declare class PreCommitHook {
28
32
  message: string;
29
33
  };
30
34
  isInstalled(options?: InstallHookOptions): boolean;
35
+ /**
36
+ * Write or refresh our `pre-commit.cmd`. Never overwrites a foreign file.
37
+ * @returns whether our companion is present afterwards
38
+ */
39
+ private writeNativeCmdCompanion;
40
+ private removeNativeCmdCompanion;
31
41
  private installNative;
32
42
  private uninstallNative;
33
43
  private installHusky;
@@ -16,8 +16,16 @@ const NATIVE_HOOK_SCRIPT = `#!/bin/sh
16
16
  # vault-guard pre-commit (installed by @vaultcompass/vault-guard)
17
17
  set -e
18
18
 
19
- # Re-attach stdin for GUI git clients.
20
- if [ -t 0 ]; then :; else exec </dev/tty 2>/dev/null || true; fi
19
+ # Re-attach stdin for GUI git clients when possible.
20
+ # dash (Ubuntu /bin/sh) exits the whole shell on a failed \`exec </dev/tty\`
21
+ # even with \`|| true\` / \`set +e\` — exit status 2. Probe in a subshell first;
22
+ # only \`exec\` in the current shell when that open succeeds. \`[ -r /dev/tty ]\`
23
+ # is not a usable guard: the node can exist and still fail open with ENXIO.
24
+ if [ ! -t 0 ]; then
25
+ if (exec </dev/tty) 2>/dev/null; then
26
+ exec </dev/tty
27
+ fi
28
+ fi
21
29
 
22
30
  if ! command -v vault-guard >/dev/null 2>&1; then
23
31
  echo "❌ vault-guard: command not found (install: npm i -g @vaultcompass/vault-guard)"
@@ -35,6 +43,30 @@ echo "❌ COMMIT BLOCKED: secrets detected in staged files"
35
43
  echo "💡 Fix or unstage, then retry. Emergency bypass (discouraged): git commit --no-verify"
36
44
  exit 1
37
45
  `;
46
+ /**
47
+ * Optional `pre-commit.cmd` beside the POSIX hook. Git for Windows runs the
48
+ * extensionless `pre-commit` via sh; a few clients may invoke `.cmd` directly.
49
+ */
50
+ const NATIVE_HOOK_CMD = `@echo off
51
+ REM vault-guard pre-commit (installed by @vaultcompass/vault-guard)
52
+ where vault-guard >nul 2>&1
53
+ if errorlevel 1 (
54
+ echo ❌ vault-guard: command not found ^(install: npm i -g @vaultcompass/vault-guard^)
55
+ exit /b 1
56
+ )
57
+
58
+ echo 🔍 vault-guard: scanning staged files...
59
+ call vault-guard scan --staged
60
+ if errorlevel 1 (
61
+ echo.
62
+ echo ❌ COMMIT BLOCKED: secrets detected in staged files
63
+ echo 💡 Fix or unstage, then retry. Emergency bypass ^(discouraged^): git commit --no-verify
64
+ exit /b 1
65
+ )
66
+
67
+ echo ✅ vault-guard: no secrets in staged files
68
+ exit /b 0
69
+ `;
38
70
  /** Husky-friendly hook (sources \`_/husky.sh\` when present). */
39
71
  const HUSKY_HOOK_SCRIPT = `#!/usr/bin/env sh
40
72
  if [ -f "$(dirname "$0")/_/husky.sh" ]; then
@@ -110,6 +142,12 @@ class PreCommitHook {
110
142
  }
111
143
  return path_1.default.join(this.getEffectiveHooksDir(cwd).hooksDir, 'pre-commit');
112
144
  }
145
+ /**
146
+ * Absolute path to the Windows \`pre-commit.cmd\` companion (native manager only).
147
+ */
148
+ getPreCommitCmdPath(cwd) {
149
+ return path_1.default.join(this.getEffectiveHooksDir(cwd).hooksDir, 'pre-commit.cmd');
150
+ }
113
151
  install(options = {}) {
114
152
  const cwd = options.cwd ?? process.cwd();
115
153
  const manager = options.manager ?? 'native';
@@ -160,9 +198,37 @@ class PreCommitHook {
160
198
  // -------------------------------------------------------------------------
161
199
  // native (Git hooks / core.hooksPath)
162
200
  // -------------------------------------------------------------------------
201
+ /**
202
+ * Write or refresh our `pre-commit.cmd`. Never overwrites a foreign file.
203
+ * @returns whether our companion is present afterwards
204
+ */
205
+ writeNativeCmdCompanion(hooksDir) {
206
+ const cmdPath = path_1.default.join(hooksDir, 'pre-commit.cmd');
207
+ if (fs_1.default.existsSync(cmdPath)) {
208
+ const existing = fs_1.default.readFileSync(cmdPath, 'utf-8');
209
+ const isOurs = existing.includes('vault-guard') && existing.includes('scan --staged');
210
+ if (!isOurs) {
211
+ return false;
212
+ }
213
+ }
214
+ fs_1.default.writeFileSync(cmdPath, NATIVE_HOOK_CMD, { encoding: 'utf-8' });
215
+ return true;
216
+ }
217
+ removeNativeCmdCompanion(hooksDir) {
218
+ const cmdPath = path_1.default.join(hooksDir, 'pre-commit.cmd');
219
+ if (!fs_1.default.existsSync(cmdPath))
220
+ return false;
221
+ const content = fs_1.default.readFileSync(cmdPath, 'utf-8');
222
+ if (!content.includes('vault-guard') || !content.includes('scan --staged')) {
223
+ return false;
224
+ }
225
+ fs_1.default.unlinkSync(cmdPath);
226
+ return true;
227
+ }
163
228
  installNative(cwd) {
164
229
  const { hooksDir, viaHooksPath } = this.getEffectiveHooksDir(cwd);
165
230
  const hookPath = path_1.default.join(hooksDir, 'pre-commit');
231
+ const cmdPath = path_1.default.join(hooksDir, 'pre-commit.cmd');
166
232
  try {
167
233
  if (!fs_1.default.existsSync(hooksDir)) {
168
234
  fs_1.default.mkdirSync(hooksDir, { recursive: true });
@@ -170,18 +236,31 @@ class PreCommitHook {
170
236
  if (fs_1.default.existsSync(hookPath)) {
171
237
  const existing = fs_1.default.readFileSync(hookPath, 'utf-8');
172
238
  if (existing.includes('vault-guard') && existing.includes('scan --staged')) {
239
+ // Refresh the Windows companion if missing or stale (never clobber foreign).
240
+ const cmdOk = this.writeNativeCmdCompanion(hooksDir);
173
241
  return {
174
242
  success: true,
175
- message: 'Hook already installed',
243
+ message: cmdOk
244
+ ? 'Hook already installed (POSIX + Windows .cmd companion)'
245
+ : fs_1.default.existsSync(cmdPath)
246
+ ? 'Hook already installed (left foreign pre-commit.cmd untouched)'
247
+ : 'Hook already installed',
176
248
  hookPath,
177
249
  };
178
250
  }
179
251
  }
180
252
  fs_1.default.writeFileSync(hookPath, NATIVE_HOOK_SCRIPT, { mode: 0o755 });
253
+ const cmdOk = this.writeNativeCmdCompanion(hooksDir);
181
254
  const hint = viaHooksPath
182
255
  ? `Installed to hooksPath: ${hooksDir}`
183
- : 'Installed to .git/hooks/pre-commit';
184
- return { success: true, message: `Pre-commit hook installed (${hint})`, hookPath };
256
+ : cmdOk
257
+ ? 'Installed to .git/hooks/pre-commit (+ pre-commit.cmd)'
258
+ : 'Installed to .git/hooks/pre-commit';
259
+ return {
260
+ success: true,
261
+ message: `Pre-commit hook installed (${hint})`,
262
+ hookPath,
263
+ };
185
264
  }
186
265
  catch (error) {
187
266
  const hookError = new errors_1.HookError(`Failed to install hook: ${error}`, 'install');
@@ -189,17 +268,34 @@ class PreCommitHook {
189
268
  }
190
269
  }
191
270
  uninstallNative(cwd) {
192
- const hookPath = this.getPreCommitHookPath(cwd, 'native');
271
+ const { hooksDir } = this.getEffectiveHooksDir(cwd);
272
+ const hookPath = path_1.default.join(hooksDir, 'pre-commit');
273
+ const cmdRemoved = this.removeNativeCmdCompanion(hooksDir);
193
274
  if (!fs_1.default.existsSync(hookPath)) {
194
- return { success: true, message: 'No hook to remove' };
275
+ return {
276
+ success: true,
277
+ message: cmdRemoved
278
+ ? 'Removed Windows pre-commit.cmd companion'
279
+ : 'No hook to remove',
280
+ };
195
281
  }
196
282
  const content = fs_1.default.readFileSync(hookPath, 'utf-8');
197
283
  if (!content.includes('vault-guard')) {
198
- return { success: true, message: 'No vault-guard hook to remove' };
284
+ return {
285
+ success: true,
286
+ message: cmdRemoved
287
+ ? 'Removed Windows pre-commit.cmd companion'
288
+ : 'No vault-guard hook to remove',
289
+ };
199
290
  }
200
291
  try {
201
292
  fs_1.default.unlinkSync(hookPath);
202
- return { success: true, message: 'Pre-commit hook removed' };
293
+ return {
294
+ success: true,
295
+ message: cmdRemoved
296
+ ? 'Pre-commit hook and Windows .cmd companion removed'
297
+ : 'Pre-commit hook removed',
298
+ };
203
299
  }
204
300
  catch (error) {
205
301
  const hookError = new errors_1.HookError(`Failed to remove hook: ${error}`, 'uninstall');
@@ -47,7 +47,26 @@ const BUILTIN_PATTERNS = new Map([
47
47
  ['openai', { regex: /(?<![A-Za-z0-9_-])sk-[a-zA-Z0-9]{20}T3BlbkFJ[a-zA-Z0-9]{20,}/g, severity: 'critical' }],
48
48
  ['huggingface', { regex: /hf_[a-zA-Z0-9]{34,}/g, severity: 'critical' }],
49
49
  ['replicate', { regex: /r8_[a-zA-Z0-9]{32}/g, severity: 'critical' }],
50
+ // Post-2023 AI provider keys. The product's stated wedge is AI-assisted
51
+ // coding, so these carry the same weight as the OpenAI/Anthropic rules.
52
+ // Every one is prefix-anchored with a fixed length, so no entropy gate is
53
+ // needed (same precision profile as `ghp_` / `hf_`).
54
+ ['groq', { regex: /(?<![A-Za-z0-9_-])gsk_[a-zA-Z0-9]{52}/g, severity: 'critical' }],
55
+ ['openrouter', { regex: /sk-or-v1-[a-f0-9]{64}/g, severity: 'critical' }],
56
+ ['xai', { regex: /(?<![A-Za-z0-9_-])xai-[a-zA-Z0-9]{80}/g, severity: 'critical' }],
57
+ ['perplexity', { regex: /(?<![A-Za-z0-9_-])pplx-[a-zA-Z0-9]{40,}/g, severity: 'critical' }],
58
+ ['mistral', { regex: /(?:mistral_api_key|MISTRAL_API_KEY)\s*[=:]\s*["']?([a-zA-Z0-9]{32})/g, severity: 'critical' }],
59
+ ['together-ai', { regex: /(?:together_api_key|TOGETHER_API_KEY)\s*[=:]\s*["']?([a-f0-9]{64})/g, severity: 'critical' }],
60
+ ['fireworks-ai', { regex: /(?<![A-Za-z0-9_-])fw_[a-zA-Z0-9]{24,}/g, severity: 'critical' }],
61
+ ['langsmith', { regex: /lsv2_(?:pt|sk)_[a-f0-9]{32}_[a-f0-9]{10}/g, severity: 'critical' }],
62
+ ['deepseek', { regex: /(?:deepseek_api_key|DEEPSEEK_API_KEY)\s*[=:]\s*["']?(sk-[a-f0-9]{32})/g, severity: 'critical' }],
50
63
  // --- Payment processors ---
64
+ // NOTE: `sk_live_` / `sk_test_` are not unique to Stripe — Clerk uses the
65
+ // same prefixes and there is no reliable discriminator in the key body, so a
66
+ // Clerk secret key is reported under the `stripe` rule id. The finding is
67
+ // correct (it IS a live secret key); only the vendor label may be wrong. The
68
+ // id is kept as-is because baseline fingerprints include the rule id and
69
+ // renaming it would silently invalidate every existing baseline entry.
51
70
  ['stripe', { regex: /sk_live_[a-zA-Z0-9]{24,}/g, severity: 'critical' }],
52
71
  ['stripe-test', { regex: /sk_test_[a-zA-Z0-9]{24,}/g, severity: 'high' }],
53
72
  ['paypal', { regex: /access_token\$production\$[a-zA-Z0-9]{20,}/g, severity: 'critical' }],
@@ -87,12 +106,34 @@ const BUILTIN_PATTERNS = new Map([
87
106
  ['mailgun-api', { regex: /key-[a-zA-Z0-9]{32}/g, severity: 'critical', minEntropy: 3.5 }],
88
107
  // --- Package managers ---
89
108
  ['npm-token', { regex: /npm_[a-zA-Z0-9]{36}/g, severity: 'critical' }],
109
+ // --- Backend / infra platforms ---
110
+ ['supabase-token', { regex: /(?<![A-Za-z0-9_-])sbp_[a-f0-9]{40}/g, severity: 'critical' }],
111
+ ['supabase-secret', { regex: /(?<![A-Za-z0-9_-])sb_secret_[a-zA-Z0-9_-]{20,}/g, severity: 'critical' }],
112
+ ['vercel-blob', { regex: /vercel_blob_rw_[a-zA-Z0-9]{20,}_[a-zA-Z0-9]{20,}/g, severity: 'critical' }],
113
+ ['planetscale', { regex: /pscale_(?:tkn|pw)_[a-zA-Z0-9_-]{32,}/g, severity: 'critical' }],
114
+ ['doppler-token', { regex: /dp\.(?:pt|st|sa|scim|audit)\.[a-zA-Z0-9]{40,}/g, severity: 'critical' }],
115
+ ['databricks-token', { regex: /(?<![A-Za-z0-9_-])dapi[a-f0-9]{32}/g, severity: 'critical' }],
116
+ ['cloudflare-token', { regex: /(?:cloudflare_api_token|CLOUDFLARE_API_TOKEN)\s*[=:]\s*["']?([a-zA-Z0-9_-]{40})/g, severity: 'critical' }],
117
+ // --- SaaS / productivity ---
118
+ ['notion-token', { regex: /(?<![A-Za-z0-9_-])(?:ntn_[a-zA-Z0-9]{40,}|secret_[a-zA-Z0-9]{43})/g, severity: 'critical' }],
119
+ ['airtable-pat', { regex: /(?<![A-Za-z0-9_-])pat[a-zA-Z0-9]{14}\.[a-f0-9]{64}/g, severity: 'critical' }],
120
+ ['figma-token', { regex: /(?<![A-Za-z0-9_-])figd_[a-zA-Z0-9_-]{40,}/g, severity: 'critical' }],
90
121
  // --- Monitoring ---
91
122
  ['newrelic-api', { regex: /NRAK-[a-zA-Z0-9]{26}/g, severity: 'critical' }],
123
+ // A Sentry DSN is designed to be embedded in client-side bundles — the
124
+ // public key it carries only permits event ingestion, not data read. Kept at
125
+ // `low` for visibility under the same policy as `gcp-oauth`: real, but not a
126
+ // credential leak worth blocking a commit over.
127
+ ['sentry-dsn', { regex: /https:\/\/[a-f0-9]{32}@o\d+\.ingest\.(?:[a-z]{2}\.)?sentry\.io\/\d+/g, severity: 'low' }],
92
128
  // --- E-commerce ---
93
129
  ['shopify-admin', { regex: /shp(?:ss|at|ca)_[a-zA-Z0-9]{32}/g, severity: 'critical' }],
94
130
  // --- Keys and auth tokens ---
95
- ['ssh-private-key', { regex: /-----BEGIN [A-Z ]+ PRIVATE KEY-----/g, severity: 'critical' }],
131
+ // The algorithm prefix is optional. `-----BEGIN PRIVATE KEY-----` (PKCS#8)
132
+ // has no prefix at all, and it is what modern OpenSSL emits by default and
133
+ // what GCP service-account JSON embeds — i.e. the most common private key
134
+ // form in circulation. Requiring `[A-Z ]+` between BEGIN and PRIVATE meant
135
+ // the scanner printed "No secrets found" on a bare PKCS#8 key file.
136
+ ['ssh-private-key', { regex: /-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/g, severity: 'critical' }],
96
137
  ['jwt-token', { regex: /eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g, severity: 'high' }],
97
138
  // Generic patterns — entropy-gated AND placeholder-filtered (aggressive) to
98
139
  // suppress false positives on documentation samples and unit-test fixtures.
@@ -292,6 +333,17 @@ class SecretScanner {
292
333
  if (type === 'jwt-token' && (0, placeholder_1.isSampleJwt)(fullMatch)) {
293
334
  continue;
294
335
  }
336
+ // A password hash is the safe-at-rest form, not a usable credential.
337
+ // Seed data and fixtures are full of bcrypt/argon2 digests.
338
+ if (type === 'password-in-code' && (0, placeholder_1.isPasswordHash)(rawValue)) {
339
+ continue;
340
+ }
341
+ // A bare PEM header with no key material after it is a UI label or an
342
+ // input placeholder, not a leaked key.
343
+ if (type === 'ssh-private-key' &&
344
+ (0, placeholder_1.isPemHeaderWithoutBody)(content, match.index + fullMatch.length)) {
345
+ continue;
346
+ }
295
347
  // Suppress unquoted assignments whose "value" is actually a function
296
348
  // call — e.g. `csrf_secret = _add_new_csrf_cookie(request)`. The value
297
349
  // capture group stops at `(`, so a `(` immediately following the match
@@ -303,6 +355,19 @@ class SecretScanner {
303
355
  content[match.index + fullMatch.length] === '(') {
304
356
  continue;
305
357
  }
358
+ // Suppress unquoted assignments whose "value" is a bare reference to
359
+ // another identifier — e.g. `'x-api-key': scheduledIngestApiKey`. Only
360
+ // applies when the captured value was NOT wrapped in quotes: a quoted
361
+ // string is a literal, and literals are what we are hunting. Scoped to
362
+ // the low-precision generic assignment patterns.
363
+ if (GENERIC_ASSIGNMENT_IDS.has(type) && match[1] !== undefined) {
364
+ const valueStart = fullMatch.lastIndexOf(rawValue);
365
+ const charBefore = valueStart > 0 ? fullMatch[valueStart - 1] : '';
366
+ const quoted = charBefore === '"' || charBefore === "'";
367
+ if (!quoted && (0, placeholder_1.isCodeIdentifierReference)(rawValue)) {
368
+ continue;
369
+ }
370
+ }
306
371
  const line = this.lineFromIndex(lineIndex, match.index);
307
372
  const lineContent = this.lineContentAt(content, lineIndex, line);
308
373
  if (opts?.filePath &&
@@ -96,8 +96,9 @@ class TokenCounter {
96
96
  return ['.png', '.jpg', '.jpeg', '.gif', '.pdf', '.zip', '.lock', '.log'].includes(ext);
97
97
  }
98
98
  getExtension(filePath) {
99
- const parts = filePath.split('.');
100
- return parts.length > 1 ? `.${parts[parts.length - 1]}` : '(no ext)';
99
+ // Use basename so parent dirs with '.' (e.g. macOS temp paths) are ignored.
100
+ const ext = path_1.default.extname(path_1.default.basename(filePath));
101
+ return ext.length > 0 ? ext : '(no ext)';
101
102
  }
102
103
  }
103
104
  exports.TokenCounter = TokenCounter;
@@ -0,0 +1,47 @@
1
+ import type { SecretMatch } from '../types';
2
+ import type { FileScanResult } from '../scan-output';
3
+ /**
4
+ * Severity threshold at or above which a finding makes the scan **fail**
5
+ * (non-zero exit). `'none'` never fails the gate — findings are still
6
+ * reported, which is the right mode for an advisory / observe-only rollout.
7
+ */
8
+ export type FailOnThreshold = SecretMatch['severity'] | 'none';
9
+ /**
10
+ * Default gate threshold.
11
+ *
12
+ * `medium` (not `low`) because the scanner deliberately downgrades findings to
13
+ * `low` in exactly the places where they are not real leaks:
14
+ *
15
+ * - `path-severity.ts` downgrades generic patterns inside test / fixture /
16
+ * docs / `*.example` paths;
17
+ * - public identifiers that are documented as safe to embed (`gcp-oauth`)
18
+ * are `low` by definition.
19
+ *
20
+ * Blocking a commit on those was the dominant real-world complaint: the
21
+ * downgrade existed but bought the user nothing because any match at all
22
+ * returned exit 1. Findings below the threshold are still printed and still
23
+ * appear in JSON / SARIF; they just do not fail the build.
24
+ */
25
+ export declare const DEFAULT_FAIL_ON: FailOnThreshold;
26
+ /** Accepted `--fail-on` / `fail_on` values, in descending strictness. */
27
+ export declare const FAIL_ON_VALUES: readonly FailOnThreshold[];
28
+ export declare function isFailOnThreshold(v: unknown): v is FailOnThreshold;
29
+ /** True when `severity` is at or above the gate `threshold`. */
30
+ export declare function meetsFailThreshold(severity: SecretMatch['severity'], threshold: FailOnThreshold): boolean;
31
+ /**
32
+ * Count findings at or above `threshold` across scan results. A non-zero
33
+ * count is what drives the CLI exit code; the full result set is still what
34
+ * gets displayed and serialized.
35
+ */
36
+ export declare function countBlockingMatches(results: FileScanResult[], threshold: FailOnThreshold): number;
37
+ /**
38
+ * Resolve the effective threshold from (highest priority first) the CLI flag,
39
+ * the repo config, then {@link DEFAULT_FAIL_ON}.
40
+ */
41
+ export declare function resolveFailOn(flagValue: string | undefined, configValue: unknown): {
42
+ ok: true;
43
+ threshold: FailOnThreshold;
44
+ } | {
45
+ ok: false;
46
+ invalid: string;
47
+ };
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FAIL_ON_VALUES = exports.DEFAULT_FAIL_ON = void 0;
4
+ exports.isFailOnThreshold = isFailOnThreshold;
5
+ exports.meetsFailThreshold = meetsFailThreshold;
6
+ exports.countBlockingMatches = countBlockingMatches;
7
+ exports.resolveFailOn = resolveFailOn;
8
+ /**
9
+ * Default gate threshold.
10
+ *
11
+ * `medium` (not `low`) because the scanner deliberately downgrades findings to
12
+ * `low` in exactly the places where they are not real leaks:
13
+ *
14
+ * - `path-severity.ts` downgrades generic patterns inside test / fixture /
15
+ * docs / `*.example` paths;
16
+ * - public identifiers that are documented as safe to embed (`gcp-oauth`)
17
+ * are `low` by definition.
18
+ *
19
+ * Blocking a commit on those was the dominant real-world complaint: the
20
+ * downgrade existed but bought the user nothing because any match at all
21
+ * returned exit 1. Findings below the threshold are still printed and still
22
+ * appear in JSON / SARIF; they just do not fail the build.
23
+ */
24
+ exports.DEFAULT_FAIL_ON = 'medium';
25
+ const SEVERITY_RANK = {
26
+ critical: 4,
27
+ high: 3,
28
+ medium: 2,
29
+ low: 1,
30
+ };
31
+ /** Accepted `--fail-on` / `fail_on` values, in descending strictness. */
32
+ exports.FAIL_ON_VALUES = [
33
+ 'low',
34
+ 'medium',
35
+ 'high',
36
+ 'critical',
37
+ 'none',
38
+ ];
39
+ function isFailOnThreshold(v) {
40
+ return typeof v === 'string' && exports.FAIL_ON_VALUES.includes(v);
41
+ }
42
+ /** True when `severity` is at or above the gate `threshold`. */
43
+ function meetsFailThreshold(severity, threshold) {
44
+ if (threshold === 'none')
45
+ return false;
46
+ return SEVERITY_RANK[severity] >= SEVERITY_RANK[threshold];
47
+ }
48
+ /**
49
+ * Count findings at or above `threshold` across scan results. A non-zero
50
+ * count is what drives the CLI exit code; the full result set is still what
51
+ * gets displayed and serialized.
52
+ */
53
+ function countBlockingMatches(results, threshold) {
54
+ let n = 0;
55
+ for (const r of results) {
56
+ for (const m of r.matches) {
57
+ if (meetsFailThreshold(m.severity, threshold))
58
+ n++;
59
+ }
60
+ }
61
+ return n;
62
+ }
63
+ /**
64
+ * Resolve the effective threshold from (highest priority first) the CLI flag,
65
+ * the repo config, then {@link DEFAULT_FAIL_ON}.
66
+ */
67
+ function resolveFailOn(flagValue, configValue) {
68
+ if (flagValue !== undefined) {
69
+ if (!isFailOnThreshold(flagValue))
70
+ return { ok: false, invalid: flagValue };
71
+ return { ok: true, threshold: flagValue };
72
+ }
73
+ if (configValue !== undefined) {
74
+ if (!isFailOnThreshold(configValue))
75
+ return { ok: false, invalid: String(configValue) };
76
+ return { ok: true, threshold: configValue };
77
+ }
78
+ return { ok: true, threshold: exports.DEFAULT_FAIL_ON };
79
+ }
@@ -1,11 +1,20 @@
1
1
  /**
2
2
  * Return absolute paths of files staged for commit (cached index vs HEAD).
3
- * Excludes deleted paths; only returns paths that still exist on disk.
3
+ *
4
+ * Uses `--diff-filter=ACMRT` so deleted index entries are excluded, but
5
+ * **does not** require the path to exist in the worktree. A staged add that
6
+ * was later deleted from disk (`AD` in `git status`) still appears — that
7
+ * blob will be committed and must be scanned.
4
8
  *
5
9
  * Throws `GitError` on git failure rather than returning an empty list.
6
10
  * Returning `[]` silently on git failure would produce a false "✅ nothing
7
11
  * staged" result in pre-commit, letting secrets through undetected.
8
12
  */
9
13
  export declare function getGitStagedFilePaths(cwd?: string): string[];
14
+ /**
15
+ * Read a staged blob from the index (`git show :path`), not the worktree.
16
+ * `relativePath` may use OS separators; it is normalized to git's `/` form.
17
+ */
18
+ export declare function readGitIndexFile(cwd: string, relativePath: string): string;
10
19
  /** True when `cwd` is inside a work tree with a `.git` directory or file. */
11
20
  export declare function isInsideGitWorkTree(cwd?: string): boolean;
@@ -4,24 +4,28 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.getGitStagedFilePaths = getGitStagedFilePaths;
7
+ exports.readGitIndexFile = readGitIndexFile;
7
8
  exports.isInsideGitWorkTree = isInsideGitWorkTree;
8
9
  const child_process_1 = require("child_process");
9
- const fs_1 = __importDefault(require("fs"));
10
10
  const path_1 = __importDefault(require("path"));
11
11
  const errors_1 = require("../errors");
12
12
  /**
13
13
  * Return absolute paths of files staged for commit (cached index vs HEAD).
14
- * Excludes deleted paths; only returns paths that still exist on disk.
14
+ *
15
+ * Uses `--diff-filter=ACMRT` so deleted index entries are excluded, but
16
+ * **does not** require the path to exist in the worktree. A staged add that
17
+ * was later deleted from disk (`AD` in `git status`) still appears — that
18
+ * blob will be committed and must be scanned.
15
19
  *
16
20
  * Throws `GitError` on git failure rather than returning an empty list.
17
21
  * Returning `[]` silently on git failure would produce a false "✅ nothing
18
22
  * staged" result in pre-commit, letting secrets through undetected.
19
23
  */
20
24
  function getGitStagedFilePaths(cwd = process.cwd()) {
21
- const cmd = 'git diff --cached --name-only --diff-filter=ACMRT';
25
+ const args = ['diff', '--cached', '--name-only', '--diff-filter=ACMRT', '-z'];
22
26
  let out;
23
27
  try {
24
- out = (0, child_process_1.execSync)(cmd, {
28
+ out = (0, child_process_1.execFileSync)('git', args, {
25
29
  cwd,
26
30
  encoding: 'utf-8',
27
31
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -29,19 +33,37 @@ function getGitStagedFilePaths(cwd = process.cwd()) {
29
33
  }
30
34
  catch (err) {
31
35
  throw new errors_1.GitError(`Failed to list staged files — is this a git repository? (cwd: ${cwd})\n` +
32
- `Run 'git status' to verify.\nUnderlying error: ${String(err)}`, cmd, err);
36
+ `Run 'git status' to verify.\nUnderlying error: ${String(err)}`, `git ${args.join(' ')}`, err);
33
37
  }
34
38
  return out
35
- .split('\n')
39
+ .split('\0')
36
40
  .map((line) => line.trim())
37
41
  .filter(Boolean)
38
- .map((rel) => path_1.default.resolve(cwd, rel))
39
- .filter((abs) => fs_1.default.existsSync(abs) && fs_1.default.statSync(abs).isFile());
42
+ .map((rel) => path_1.default.resolve(cwd, rel));
43
+ }
44
+ /**
45
+ * Read a staged blob from the index (`git show :path`), not the worktree.
46
+ * `relativePath` may use OS separators; it is normalized to git's `/` form.
47
+ */
48
+ function readGitIndexFile(cwd, relativePath) {
49
+ const normalized = relativePath.split(path_1.default.sep).join('/');
50
+ const args = ['show', `:${normalized}`];
51
+ try {
52
+ return (0, child_process_1.execFileSync)('git', args, {
53
+ cwd,
54
+ encoding: 'utf-8',
55
+ stdio: ['ignore', 'pipe', 'pipe'],
56
+ maxBuffer: 32 * 1024 * 1024,
57
+ });
58
+ }
59
+ catch (err) {
60
+ throw new errors_1.GitError(`Failed to read staged blob for ${normalized}\nUnderlying error: ${String(err)}`, `git ${args.join(' ')}`, err);
61
+ }
40
62
  }
41
63
  /** True when `cwd` is inside a work tree with a `.git` directory or file. */
42
64
  function isInsideGitWorkTree(cwd = process.cwd()) {
43
65
  try {
44
- (0, child_process_1.execSync)('git rev-parse --is-inside-work-tree', {
66
+ (0, child_process_1.execFileSync)('git', ['rev-parse', '--is-inside-work-tree'], {
45
67
  cwd,
46
68
  encoding: 'utf-8',
47
69
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -1,4 +1,14 @@
1
1
  import type { SecretMatch } from '../types';
2
+ /**
3
+ * True when a file is a translation catalogue.
4
+ *
5
+ * These are entirely natural-language strings keyed by identifiers, so a key
6
+ * such as `tfa_secret` or `api_key_label` puts a translated *label* where the
7
+ * generic assignment patterns expect a value — `tfa_secret: Zwei-Faktor-
8
+ * Authentifizierung` reads as a 29-character high-entropy secret. Translation
9
+ * files never hold real credentials.
10
+ */
11
+ export declare function isLocalePath(filePath: string): boolean;
2
12
  /**
3
13
  * Return `true` when `filePath` looks like a test or fixture file.
4
14
  */
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.isLocalePath = isLocalePath;
6
7
  exports.isTestFilePath = isTestFilePath;
7
8
  exports.applyPathAwareSeverity = applyPathAwareSeverity;
8
9
  const path_1 = __importDefault(require("path"));
@@ -37,7 +38,11 @@ const TEST_DIR_SEGMENTS = new Set([
37
38
  'tests',
38
39
  'test',
39
40
  'fixtures',
41
+ 'fixture',
40
42
  'testdata',
43
+ 'test-data',
44
+ 'test_data',
45
+ 'testfixtures',
41
46
  'spec',
42
47
  'e2e',
43
48
  'examples',
@@ -75,6 +80,35 @@ function isTestDirectorySegment(seg) {
75
80
  seg.length >= 7 &&
76
81
  !NON_TEST_TEST_SUFFIX_DIRS.has(seg));
77
82
  }
83
+ /** Directory segments holding UI translation catalogues. */
84
+ const LOCALE_DIR_SEGMENTS = new Set([
85
+ 'locales',
86
+ 'locale',
87
+ 'translations',
88
+ 'translation',
89
+ 'i18n',
90
+ 'lang',
91
+ 'langs',
92
+ ]);
93
+ /**
94
+ * Locale file basenames: `en.json`, `de-DE.yaml`, `pt_BR.yml`, `zh-Hant.json`.
95
+ */
96
+ const LOCALE_BASENAME = /^[a-z]{2}(?:[-_][A-Za-z]{2,4})?\.(json|ya?ml|ts|js|po|properties)$/;
97
+ /**
98
+ * True when a file is a translation catalogue.
99
+ *
100
+ * These are entirely natural-language strings keyed by identifiers, so a key
101
+ * such as `tfa_secret` or `api_key_label` puts a translated *label* where the
102
+ * generic assignment patterns expect a value — `tfa_secret: Zwei-Faktor-
103
+ * Authentifizierung` reads as a 29-character high-entropy secret. Translation
104
+ * files never hold real credentials.
105
+ */
106
+ function isLocalePath(filePath) {
107
+ const parts = (0, path_parts_1.splitPathParts)(filePath);
108
+ if (parts.some(p => LOCALE_DIR_SEGMENTS.has(p.toLowerCase())))
109
+ return true;
110
+ return LOCALE_BASENAME.test(path_1.default.basename(filePath));
111
+ }
78
112
  /**
79
113
  * Celery / Perl-style test root: `t/unit/…`, `t/integration/…`.
80
114
  */
@@ -112,7 +146,7 @@ function isTestFilePath(filePath) {
112
146
  * file is still worth a `critical` alert.
113
147
  */
114
148
  function isLowPrecisionContextPath(filePath) {
115
- return isTestFilePath(filePath) || (0, doc_context_1.isDocumentationPath)(filePath);
149
+ return isTestFilePath(filePath) || (0, doc_context_1.isDocumentationPath)(filePath) || isLocalePath(filePath);
116
150
  }
117
151
  function applyPathAwareSeverity(matches, filePath) {
118
152
  if (matches.length === 0)
@@ -20,11 +20,61 @@
20
20
  * by chance.
21
21
  */
22
22
  export declare function isRedactedTemplateValue(value: string): boolean;
23
+ /**
24
+ * A stored password *hash* is the safe-at-rest form of a credential, not a
25
+ * credential. Seed data, fixtures, and migration files are full of them, and
26
+ * flagging them as `password-in-code` is noise: rotating them is meaningless
27
+ * and they cannot be used to authenticate.
28
+ *
29
+ * Covers modular crypt format (bcrypt `$2a/2b/2y$`, sha-crypt `$1/5/6$`,
30
+ * yescrypt `$y$`, `$argon2i/d/id$`, `$pbkdf2-*$`) and the Django/Passlib
31
+ * `algo$iterations$salt$hash` convention.
32
+ */
33
+ export declare function isPasswordHash(value: string): boolean;
34
+ /**
35
+ * True when a PEM `-----BEGIN … PRIVATE KEY-----` header is not followed by
36
+ * any key material.
37
+ *
38
+ * UI code and documentation carry the header on its own as a label or an
39
+ * input placeholder (`const privateKeyBeginsWith = '-----BEGIN RSA PRIVATE
40
+ * KEY-----'`). A header with no body leaks nothing. Real PEM files wrap their
41
+ * base64 at 64 characters per line, so requiring a single long base64 run
42
+ * right after the header separates the two cleanly, and still works when the
43
+ * key is embedded in JSON with escaped newlines.
44
+ */
45
+ export declare function isPemHeaderWithoutBody(content: string, headerEndOffset: number): boolean;
23
46
  /**
24
47
  * ALL_CAPS identifiers (e.g. `PLAID_TOKEN_ENCRYPTION_KEY`) are env-var names,
25
48
  * not secret values — common in GitHub Actions `secret:NAME` checks.
26
49
  */
27
50
  export declare function isEnvVarNameToken(value: string): boolean;
51
+ /**
52
+ * True when an **unquoted** captured value is a reference to a code
53
+ * identifier rather than a literal credential — e.g.
54
+ *
55
+ * headers: { 'x-api-key': scheduledIngestApiKey }
56
+ * api_key = defaultServiceCredential
57
+ *
58
+ * The generic assignment patterns capture whatever follows `:`/`=`, and in
59
+ * real code that is very often a variable, not a secret. An existing check
60
+ * covers the function-call case (`= makeKey(...)`); this covers the far more
61
+ * common bare-reference case.
62
+ *
63
+ * Discriminator: generated credentials are random, so they mix digits into the
64
+ * alphabet and do not decompose into word-shaped segments. We require the
65
+ * value to split (on `_` and camelCase boundaries) into **two or more**
66
+ * segments that are each purely alphabetic and at least two characters long.
67
+ *
68
+ * `scheduledIngestApiKey` → scheduled | Ingest | Api | Key → identifier
69
+ * `default_service_token` → default | service | token → identifier
70
+ * `x7Kf9mQ2pL8vB3nR5wT1` → contains digits → NOT identifier
71
+ * `qwertyuiopasdfghjklz` → one segment → NOT identifier
72
+ *
73
+ * Callers must only apply this to unquoted values on low-precision generic
74
+ * patterns; a quoted string literal is a literal, and vendor-anchored rules
75
+ * must never be weakened by it.
76
+ */
77
+ export declare function isCodeIdentifierReference(value: string): boolean;
28
78
  /**
29
79
  * Return `true` when a database/Redis connection string is **not** a real
30
80
  * credential leak — i.e. it targets a local/dev/docker/example host, or uses
@@ -22,7 +22,10 @@
22
22
  */
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
24
  exports.isRedactedTemplateValue = isRedactedTemplateValue;
25
+ exports.isPasswordHash = isPasswordHash;
26
+ exports.isPemHeaderWithoutBody = isPemHeaderWithoutBody;
25
27
  exports.isEnvVarNameToken = isEnvVarNameToken;
28
+ exports.isCodeIdentifierReference = isCodeIdentifierReference;
26
29
  exports.isSampleJwt = isSampleJwt;
27
30
  exports.isNonSecretConnectionString = isNonSecretConnectionString;
28
31
  exports.isPlaceholderSecret = isPlaceholderSecret;
@@ -73,6 +76,7 @@ const AGGRESSIVE_MARKERS = [
73
76
  'qwerty',
74
77
  'letmein',
75
78
  'your_', // your_google_places_key, your_api_key_here
79
+ 'your-', // your-anthropic-api-key — hyphen form is just as common in docs
76
80
  ];
77
81
  /** Known vendor key prefixes whose remainder is often redacted with X/* in docs. */
78
82
  const REDACTED_PREFIXES = [
@@ -109,6 +113,52 @@ function isRedactedTemplateValue(value) {
109
113
  }
110
114
  return false;
111
115
  }
116
+ /**
117
+ * A stored password *hash* is the safe-at-rest form of a credential, not a
118
+ * credential. Seed data, fixtures, and migration files are full of them, and
119
+ * flagging them as `password-in-code` is noise: rotating them is meaningless
120
+ * and they cannot be used to authenticate.
121
+ *
122
+ * Covers modular crypt format (bcrypt `$2a/2b/2y$`, sha-crypt `$1/5/6$`,
123
+ * yescrypt `$y$`, `$argon2i/d/id$`, `$pbkdf2-*$`) and the Django/Passlib
124
+ * `algo$iterations$salt$hash` convention.
125
+ */
126
+ function isPasswordHash(value) {
127
+ if (/^\$(?:2[abxy]?|1|5|6|7|y|gy|argon2(?:i|d|id)?|scrypt|pbkdf2(?:-[a-z0-9]+)?|sha1|md5|apr1|bcrypt)\$/i.test(value)) {
128
+ return true;
129
+ }
130
+ // Django: pbkdf2_sha256$390000$<salt>$<hash>, argon2$..., bcrypt_sha256$...
131
+ return /^(?:pbkdf2_[a-z0-9]+|argon2[a-z]*|bcrypt(?:_sha256)?|scrypt|sha1|md5|crypt|unsalted_[a-z0-9]+)\$\d*\$?/i.test(value);
132
+ }
133
+ /**
134
+ * True when a PEM `-----BEGIN … PRIVATE KEY-----` header is not followed by
135
+ * any key material.
136
+ *
137
+ * UI code and documentation carry the header on its own as a label or an
138
+ * input placeholder (`const privateKeyBeginsWith = '-----BEGIN RSA PRIVATE
139
+ * KEY-----'`). A header with no body leaks nothing. Real PEM files wrap their
140
+ * base64 at 64 characters per line, so requiring a single long base64 run
141
+ * right after the header separates the two cleanly, and still works when the
142
+ * key is embedded in JSON with escaped newlines.
143
+ */
144
+ function isPemHeaderWithoutBody(content, headerEndOffset) {
145
+ const window = content.slice(headerEndOffset, headerEndOffset + 400);
146
+ // Split on real newlines and on the escaped `\n` used when a key is embedded
147
+ // in JSON or YAML, then strip the quoting that survives that embedding.
148
+ const lines = window.split(/\r?\n|\\r\\n|\\n/);
149
+ for (const line of lines) {
150
+ const token = line.replace(/["'`\\\s]/g, '');
151
+ // A PEM body wraps base64 at 64 characters, so a body line is base64 and
152
+ // nothing else. Requiring the *whole* line to match is what separates it
153
+ // from surrounding code: a long camelCase identifier such as
154
+ // `onUpdateDatasourceSecureJsonDataOption` is a valid base64 substring,
155
+ // but the line it sits on never is.
156
+ if (token.length >= 32 && /^[A-Za-z0-9+/]+={0,2}$/.test(token)) {
157
+ return false;
158
+ }
159
+ }
160
+ return true;
161
+ }
112
162
  /**
113
163
  * ALL_CAPS identifiers (e.g. `PLAID_TOKEN_ENCRYPTION_KEY`) are env-var names,
114
164
  * not secret values — common in GitHub Actions `secret:NAME` checks.
@@ -116,6 +166,58 @@ function isRedactedTemplateValue(value) {
116
166
  function isEnvVarNameToken(value) {
117
167
  return /^[A-Z][A-Z0-9_]{7,}$/.test(value);
118
168
  }
169
+ /**
170
+ * True when an **unquoted** captured value is a reference to a code
171
+ * identifier rather than a literal credential — e.g.
172
+ *
173
+ * headers: { 'x-api-key': scheduledIngestApiKey }
174
+ * api_key = defaultServiceCredential
175
+ *
176
+ * The generic assignment patterns capture whatever follows `:`/`=`, and in
177
+ * real code that is very often a variable, not a secret. An existing check
178
+ * covers the function-call case (`= makeKey(...)`); this covers the far more
179
+ * common bare-reference case.
180
+ *
181
+ * Discriminator: generated credentials are random, so they mix digits into the
182
+ * alphabet and do not decompose into word-shaped segments. We require the
183
+ * value to split (on `_` and camelCase boundaries) into **two or more**
184
+ * segments that are each purely alphabetic and at least two characters long.
185
+ *
186
+ * `scheduledIngestApiKey` → scheduled | Ingest | Api | Key → identifier
187
+ * `default_service_token` → default | service | token → identifier
188
+ * `x7Kf9mQ2pL8vB3nR5wT1` → contains digits → NOT identifier
189
+ * `qwertyuiopasdfghjklz` → one segment → NOT identifier
190
+ *
191
+ * Callers must only apply this to unquoted values on low-precision generic
192
+ * patterns; a quoted string literal is a literal, and vendor-anchored rules
193
+ * must never be weakened by it.
194
+ */
195
+ function isCodeIdentifierReference(value) {
196
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value))
197
+ return false;
198
+ if (/\d/.test(value))
199
+ return false;
200
+ const segments = value
201
+ .replace(/\$/g, '_')
202
+ .split('_')
203
+ .filter(s => s.length > 0)
204
+ .flatMap(part => part.split(/(?=[A-Z])/));
205
+ if (segments.length < 2)
206
+ return false;
207
+ if (!segments.every(s => s.length >= 2 && /^[A-Za-z]+$/.test(s)))
208
+ return false;
209
+ // Guard against alpha-only random keys with alternating capitals
210
+ // (`PmZkQvXtLdRwNbGhYuJcEaSf` splits into twelve 2-char "segments"). Real
211
+ // identifiers are made of words, so beyond a handful of segments the mean
212
+ // segment length stays word-like. Values of three segments or fewer are
213
+ // exempt: `myApiKey` is a legitimate identifier with short parts.
214
+ if (segments.length > 3) {
215
+ const mean = segments.reduce((n, s) => n + s.length, 0) / segments.length;
216
+ if (mean < 3)
217
+ return false;
218
+ }
219
+ return true;
220
+ }
119
221
  /**
120
222
  * A value made of one or two distinct characters (e.g. `xxxxxxxx`, `00000000`)
121
223
  * is padding, never a real secret.
@@ -189,7 +291,12 @@ function isSampleJwt(token) {
189
291
  }
190
292
  return (/"sub"\s*:\s*"1234567890"/.test(payload) ||
191
293
  /"name"\s*:\s*"John Doe"/.test(payload) ||
192
- /\b1516239022\b/.test(payload));
294
+ /\b1516239022\b/.test(payload) ||
295
+ // Supabase ships fixed anon / service_role keys for local development.
296
+ // They are printed by `supabase start`, published in Supabase's own docs,
297
+ // and signed with a well-known secret, so they appear verbatim in a large
298
+ // share of Supabase projects. Same category as the jwt.io sample.
299
+ /"iss"\s*:\s*"supabase-demo"/.test(payload));
193
300
  }
194
301
  function isNonSecretConnectionString(url) {
195
302
  const m = /^[a-z][a-z0-9+.-]*:\/\/([^:@/\s]+):([^@/\s]+)@([^:/?\s]+)/i.exec(url);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vaultcompass/vault-guard-core",
3
- "version": "1.2.3",
3
+ "version": "1.4.0",
4
4
  "description": "Secret-scanning engine: vendor-anchored patterns, entropy gating, baselines, SARIF/JSON, hook helpers.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",