@vaultcompass/vault-guard-core 1.3.0 → 1.4.1

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';
12
+ export { isPlaceholderSecret, isNonSecretConnectionString, isSampleJwt, isRedactedTemplateValue, isEnvVarNameToken, isCodeIdentifierReference, isPasswordHash, isPemHeaderWithoutBody } from './utils/placeholder';
13
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.readGitIndexFile = 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,6 +35,9 @@ 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; } });
40
43
  Object.defineProperty(exports, "readGitIndexFile", { enumerable: true, get: function () { return git_utils_1.readGitIndexFile; } });
@@ -53,3 +56,11 @@ Object.defineProperty(exports, "scanTextFileSync", { enumerable: true, get: func
53
56
  var path_severity_1 = require("./utils/path-severity");
54
57
  Object.defineProperty(exports, "applyPathAwareSeverity", { enumerable: true, get: function () { return path_severity_1.applyPathAwareSeverity; } });
55
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;
@@ -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)"
@@ -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 &&
@@ -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,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.3.0",
3
+ "version": "1.4.1",
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",