@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.
- package/dist/config-validate.js +5 -0
- package/dist/config.d.ts +8 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +13 -1
- package/dist/scan-output.d.ts +8 -0
- package/dist/scanners/pre-commit-hook.d.ts +10 -0
- package/dist/scanners/pre-commit-hook.js +105 -9
- package/dist/scanners/secret-scanner.js +66 -1
- package/dist/scanners/token-counter.js +3 -2
- package/dist/utils/fail-on.d.ts +47 -0
- package/dist/utils/fail-on.js +79 -0
- package/dist/utils/git-utils.d.ts +10 -1
- package/dist/utils/git-utils.js +31 -9
- package/dist/utils/path-severity.d.ts +10 -0
- package/dist/utils/path-severity.js +35 -1
- package/dist/utils/placeholder.d.ts +50 -0
- package/dist/utils/placeholder.js +108 -1
- package/package.json +1 -1
package/dist/config-validate.js
CHANGED
|
@@ -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; } });
|
package/dist/scan-output.d.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
:
|
|
184
|
-
|
|
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
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
*
|
|
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;
|
package/dist/utils/git-utils.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
25
|
+
const args = ['diff', '--cached', '--name-only', '--diff-filter=ACMRT', '-z'];
|
|
22
26
|
let out;
|
|
23
27
|
try {
|
|
24
|
-
out = (0, child_process_1.
|
|
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)}`,
|
|
36
|
+
`Run 'git status' to verify.\nUnderlying error: ${String(err)}`, `git ${args.join(' ')}`, err);
|
|
33
37
|
}
|
|
34
38
|
return out
|
|
35
|
-
.split('\
|
|
39
|
+
.split('\0')
|
|
36
40
|
.map((line) => line.trim())
|
|
37
41
|
.filter(Boolean)
|
|
38
|
-
.map((rel) => path_1.default.resolve(cwd, rel))
|
|
39
|
-
|
|
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.
|
|
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.
|
|
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",
|