@vaultcompass/vault-guard-core 1.4.4 → 1.4.6
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/README.md +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/scan-output.d.ts +30 -1
- package/dist/scan-output.js +97 -10
- package/dist/scanners/pre-commit-hook.js +40 -3
- package/dist/utils/git-utils.d.ts +48 -2
- package/dist/utils/git-utils.js +154 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,6 +68,7 @@ Full rule reference: [docs/RULES.md](https://github.com/vaultcompasshq/vault-gua
|
|
|
68
68
|
| `fingerprintForMatch` | Baseline fingerprinting (no raw secrets stored) |
|
|
69
69
|
| `scanTextFileAsync` / `scanTextFileSync` | Stream-aware file scanning |
|
|
70
70
|
| `getGitStagedFilePaths` | Staged-file enumeration for hooks |
|
|
71
|
+
| `getGitWorkTreeRoot` | Repository root for a directory, the base every staged path resolves against |
|
|
71
72
|
|
|
72
73
|
## Prefer the CLI?
|
|
73
74
|
|
package/dist/index.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export * from './scan-output';
|
|
|
10
10
|
export * from './diagnostics';
|
|
11
11
|
export { shannonEntropy, DEFAULT_ENTROPY_THRESHOLD } from './utils/entropy';
|
|
12
12
|
export { isPlaceholderSecret, isNonSecretConnectionString, isSampleJwt, isRedactedTemplateValue, isEnvVarNameToken, isCodeIdentifierReference, isPasswordHash, isPemHeaderWithoutBody } from './utils/placeholder';
|
|
13
|
-
export { getGitStagedFilePaths, readGitIndexFile, isInsideGitWorkTree } from './utils/git-utils';
|
|
13
|
+
export { getGitStagedFilePaths, readGitIndexFile, isInsideGitWorkTree, getGitWorkTreeRoot, } 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
16
|
export { applyPathAwareSeverity, isTestFilePath, isLocalePath } from './utils/path-severity';
|
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.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;
|
|
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.getGitWorkTreeRoot = 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);
|
|
@@ -42,6 +42,7 @@ var git_utils_1 = require("./utils/git-utils");
|
|
|
42
42
|
Object.defineProperty(exports, "getGitStagedFilePaths", { enumerable: true, get: function () { return git_utils_1.getGitStagedFilePaths; } });
|
|
43
43
|
Object.defineProperty(exports, "readGitIndexFile", { enumerable: true, get: function () { return git_utils_1.readGitIndexFile; } });
|
|
44
44
|
Object.defineProperty(exports, "isInsideGitWorkTree", { enumerable: true, get: function () { return git_utils_1.isInsideGitWorkTree; } });
|
|
45
|
+
Object.defineProperty(exports, "getGitWorkTreeRoot", { enumerable: true, get: function () { return git_utils_1.getGitWorkTreeRoot; } });
|
|
45
46
|
var regex_safety_1 = require("./utils/regex-safety");
|
|
46
47
|
Object.defineProperty(exports, "validateRegexSafety", { enumerable: true, get: function () { return regex_safety_1.validateRegexSafety; } });
|
|
47
48
|
Object.defineProperty(exports, "validateRegexLength", { enumerable: true, get: function () { return regex_safety_1.validateRegexLength; } });
|
package/dist/scan-output.d.ts
CHANGED
|
@@ -25,6 +25,20 @@ export interface JsonRunMetadata {
|
|
|
25
25
|
* this field rather than `summary.secrets`.
|
|
26
26
|
*/
|
|
27
27
|
blocking_matches?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Files the scan opened but could not read, so their contents were never
|
|
30
|
+
* examined. Omitted when zero.
|
|
31
|
+
*
|
|
32
|
+
* A non-zero value means the run is INCOMPLETE: `blocking_matches` counts
|
|
33
|
+
* only what the scanner actually looked at, so a clean gate result sits on
|
|
34
|
+
* top of files nobody checked. On the `--staged` path this is fatal on its
|
|
35
|
+
* own (exit 2) because that file list is exactly what is about to be
|
|
36
|
+
* committed; on a directory scan it is reported and counted but does not
|
|
37
|
+
* fail the gate, since an unreadable file inside a walked tree is an
|
|
38
|
+
* ordinary occurrence. Unreadable DIRECTORIES are not counted here -- they
|
|
39
|
+
* surface as `fs.permission_denied` diagnostics instead.
|
|
40
|
+
*/
|
|
41
|
+
unscannable_files?: number;
|
|
28
42
|
}
|
|
29
43
|
export interface JsonOutput {
|
|
30
44
|
version: string;
|
|
@@ -62,9 +76,24 @@ export interface FormatOptions {
|
|
|
62
76
|
/**
|
|
63
77
|
* Base directory to render `file` paths relative to.
|
|
64
78
|
* Defaults to `process.cwd()`. Files outside this root are kept absolute.
|
|
65
|
-
* Pass `null` to skip relativization
|
|
79
|
+
* Pass `null` to skip relativization for `formatJson`'s `file` paths and
|
|
80
|
+
* for SARIF when no {@link scanRoot} is given either. A `scanRoot` still
|
|
81
|
+
* relativizes SARIF `artifactLocation.uri` (and diagnostic ctx) against
|
|
82
|
+
* itself in that case, so `cwd: null` does not skip SARIF relativization
|
|
83
|
+
* on its own.
|
|
66
84
|
*/
|
|
67
85
|
cwd?: string | null;
|
|
86
|
+
/**
|
|
87
|
+
* Directory actually being scanned (the scan target), when it differs from
|
|
88
|
+
* {@link cwd}. SARIF only: `artifactLocation.uri` is relativized against
|
|
89
|
+
* this instead of `cwd`, so a finding outside the process cwd but inside the
|
|
90
|
+
* scan target still gets a relative uri. Defaults to `cwd`.
|
|
91
|
+
*
|
|
92
|
+
* `formatJson` ignores this field. Its `file` paths stay cwd-relative,
|
|
93
|
+
* because they are what the terminal output and the baseline fingerprints
|
|
94
|
+
* are keyed on.
|
|
95
|
+
*/
|
|
96
|
+
scanRoot?: string;
|
|
68
97
|
/** Non-fatal diagnostics to include in structured output. */
|
|
69
98
|
diagnostics?: Diagnostic[];
|
|
70
99
|
/** Scan timing / coverage stats for JSON and SARIF `runs[].properties`. */
|
package/dist/scan-output.js
CHANGED
|
@@ -37,24 +37,103 @@ function isWindowsStylePath(p) {
|
|
|
37
37
|
* SARIF spec requires this even when the scanner itself runs on Windows,
|
|
38
38
|
* where `path.relative` returns backslash-separated paths).
|
|
39
39
|
*
|
|
40
|
+
* The base is the scan root (the directory actually being scanned), not the
|
|
41
|
+
* process cwd. Scanning an out-of-tree target from somewhere else used to
|
|
42
|
+
* leave every uri absolute, which published the developer's home directory
|
|
43
|
+
* and OS username to whoever reads the Code Scanning upload.
|
|
44
|
+
*
|
|
45
|
+
* A file genuinely outside the scan root still stays absolute rather than
|
|
46
|
+
* becoming a `../..` traversal: SARIF relative references are resolved
|
|
47
|
+
* against `%SRCROOT%`, so a traversal out of it is not a legal uri, and there
|
|
48
|
+
* is no other root to express such a path against. In practice this only
|
|
49
|
+
* happens for a path a caller injected from outside the scan, since every
|
|
50
|
+
* file the scanner itself walks is under the target it was given -- and a
|
|
51
|
+
* non-absolute `file` is resolved against cwd below before that check runs,
|
|
52
|
+
* so a literal `..` traversal segment never reaches the returned uri either.
|
|
53
|
+
*
|
|
40
54
|
* Picks `path.win32` when either side of the comparison looks like a
|
|
41
55
|
* Windows-style path, so this is correct both when the process itself runs
|
|
42
56
|
* on Windows (native `path` is already `path.win32`) and when a
|
|
43
57
|
* Windows-style path is normalized on a POSIX host (tests, or a SARIF file
|
|
44
58
|
* produced elsewhere and re-normalized).
|
|
45
59
|
*/
|
|
46
|
-
function toSarifArtifactUri(file, cwd) {
|
|
47
|
-
if (cwd === null)
|
|
60
|
+
function toSarifArtifactUri(file, cwd, scanRoot) {
|
|
61
|
+
if (cwd === null && scanRoot === undefined)
|
|
48
62
|
return file.split('\\').join('/');
|
|
49
|
-
const
|
|
50
|
-
const impl = isWindowsStylePath(file) || isWindowsStylePath(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const rel = impl.relative(base,
|
|
63
|
+
const anchor = cwd ?? process.cwd();
|
|
64
|
+
const impl = isWindowsStylePath(file) || isWindowsStylePath(anchor) ? path_1.default.win32 : path_1.default;
|
|
65
|
+
const abs = impl.isAbsolute(file) ? file : impl.resolve(anchor, file);
|
|
66
|
+
const base = resolveSarifBase(anchor, scanRoot, impl);
|
|
67
|
+
const rel = impl.relative(base, abs);
|
|
54
68
|
if (rel.startsWith('..') || impl.isAbsolute(rel))
|
|
55
|
-
return
|
|
69
|
+
return abs.split('\\').join('/');
|
|
56
70
|
return (rel || '.').split('\\').join('/');
|
|
57
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* The effective SARIF `%SRCROOT%` base. `scanRoot` is honored only when it
|
|
74
|
+
* is genuinely outside `cwd`: a scan root nested inside cwd (or equal to
|
|
75
|
+
* it) must not narrow `%SRCROOT%` to a subdirectory, because GitHub Code
|
|
76
|
+
* Scanning (and any other SARIF consumer) resolves `%SRCROOT%` from its own
|
|
77
|
+
* knowledge of the checkout, not from this uri -- a uri relative to a
|
|
78
|
+
* subdirectory would then name a different file entirely. Mirrors
|
|
79
|
+
* `resolveScanRoot` in `packages/cli/src/utils/scan-utils.ts`, which applies
|
|
80
|
+
* the same rule when it derives `scanRoot` from the CLI's scan target in the
|
|
81
|
+
* first place.
|
|
82
|
+
*/
|
|
83
|
+
function resolveSarifBase(cwd, scanRoot, impl) {
|
|
84
|
+
if (scanRoot === undefined)
|
|
85
|
+
return cwd;
|
|
86
|
+
const rel = impl.relative(cwd, scanRoot);
|
|
87
|
+
if (rel === '' || (!rel.startsWith('..') && !impl.isAbsolute(rel)))
|
|
88
|
+
return cwd;
|
|
89
|
+
return scanRoot;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Diagnostic `ctx` reaches SARIF notifications as `JSON.stringify(d.ctx)`
|
|
93
|
+
* (see `formatSarif` above), and some diagnostic sources (`fs.permission_denied`,
|
|
94
|
+
* `file.read_error`) carry the scanned directory or file as an absolute path,
|
|
95
|
+
* plus a `detail` field that is `String(error)` -- Node's own fs error text
|
|
96
|
+
* often bakes that same absolute path in (e.g. "EACCES: permission denied,
|
|
97
|
+
* scandir '/abs/dir'"). Both leak exactly what `artifactLocation.uri`
|
|
98
|
+
* exists to avoid leaking, so ctx gets the same relativize-or-keep-absolute
|
|
99
|
+
* treatment before it is stringified into a notification: any absolute-path
|
|
100
|
+
* ctx value (`dir`, `path`, `file`, or any other field shaped that way) is
|
|
101
|
+
* run through {@link toSarifArtifactUri}, and any other string value has
|
|
102
|
+
* literal occurrences of the base directory stripped out.
|
|
103
|
+
*/
|
|
104
|
+
function sanitizeDiagnosticCtxForSarif(ctx, cwd, scanRoot) {
|
|
105
|
+
if (cwd === null && scanRoot === undefined)
|
|
106
|
+
return ctx;
|
|
107
|
+
const anchor = cwd ?? process.cwd();
|
|
108
|
+
const base = resolveSarifBase(anchor, scanRoot, path_1.default);
|
|
109
|
+
const out = {};
|
|
110
|
+
for (const [key, value] of Object.entries(ctx)) {
|
|
111
|
+
if (typeof value !== 'string') {
|
|
112
|
+
out[key] = value;
|
|
113
|
+
}
|
|
114
|
+
else if (path_1.default.isAbsolute(value)) {
|
|
115
|
+
out[key] = toSarifArtifactUri(value, cwd, scanRoot);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
out[key] = stripSarifBasePath(value, base);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Removes literal occurrences of the SARIF base directory from a free-form
|
|
125
|
+
* string (diagnostic `detail`, which is `String(error)` and can embed the
|
|
126
|
+
* scanned path inside Node's own message text). This is not a full
|
|
127
|
+
* relative-path rewrite of the string, just enough to keep the base
|
|
128
|
+
* directory out of the document, matching what `artifactLocation.uri` does
|
|
129
|
+
* for the path fields themselves.
|
|
130
|
+
*/
|
|
131
|
+
function stripSarifBasePath(text, base) {
|
|
132
|
+
if (!text.includes(base))
|
|
133
|
+
return text;
|
|
134
|
+
const withTrailingSep = base.endsWith(path_1.default.sep) ? base : base + path_1.default.sep;
|
|
135
|
+
return text.split(withTrailingSep).join('').split(base).join('.');
|
|
136
|
+
}
|
|
58
137
|
function formatJson(results, opts = {}) {
|
|
59
138
|
const fpCwd = opts.cwd === undefined ? process.cwd() : opts.cwd;
|
|
60
139
|
const output = {
|
|
@@ -112,7 +191,10 @@ function formatSarif(results, opts = {}) {
|
|
|
112
191
|
locations: [
|
|
113
192
|
{
|
|
114
193
|
physicalLocation: {
|
|
115
|
-
artifactLocation: {
|
|
194
|
+
artifactLocation: {
|
|
195
|
+
uri: toSarifArtifactUri(file, opts.cwd, opts.scanRoot),
|
|
196
|
+
uriBaseId: '%SRCROOT%',
|
|
197
|
+
},
|
|
116
198
|
region: {
|
|
117
199
|
startLine: m.line,
|
|
118
200
|
startColumn: m.column + 1,
|
|
@@ -133,7 +215,9 @@ function formatSarif(results, opts = {}) {
|
|
|
133
215
|
? opts.diagnostics.map(d => ({
|
|
134
216
|
id: d.code,
|
|
135
217
|
level: d.severity === 'error' ? 'error' : 'warning',
|
|
136
|
-
message: {
|
|
218
|
+
message: {
|
|
219
|
+
text: `${d.code}: ${JSON.stringify(sanitizeDiagnosticCtxForSarif(d.ctx, opts.cwd, opts.scanRoot))}`,
|
|
220
|
+
},
|
|
137
221
|
}))
|
|
138
222
|
: undefined;
|
|
139
223
|
const runProps = opts.run !== undefined
|
|
@@ -149,6 +233,9 @@ function formatSarif(results, opts = {}) {
|
|
|
149
233
|
...(opts.run.baseline_suppressed !== undefined
|
|
150
234
|
? { baseline_suppressed: opts.run.baseline_suppressed }
|
|
151
235
|
: {}),
|
|
236
|
+
...(opts.run.unscannable_files !== undefined
|
|
237
|
+
? { unscannable_files: opts.run.unscannable_files }
|
|
238
|
+
: {}),
|
|
152
239
|
},
|
|
153
240
|
}
|
|
154
241
|
: undefined;
|
|
@@ -44,12 +44,28 @@ if ! command -v vault-guard >/dev/null 2>&1; then
|
|
|
44
44
|
fi
|
|
45
45
|
|
|
46
46
|
echo "🔍 vault-guard: scanning staged files..."
|
|
47
|
-
if vault-guard
|
|
47
|
+
# Capture the status rather than branching on \`if vault-guard ...\`: after a
|
|
48
|
+
# bare \`if\` with no else branch, \`$?\` is the IF STATEMENT's status (0), not
|
|
49
|
+
# the command's, so the exit code would be lost. \`|| status=$?\` also keeps
|
|
50
|
+
# \`set -e\` from aborting here, because the failure is handled.
|
|
51
|
+
status=0
|
|
52
|
+
vault-guard scan --staged || status=$?
|
|
53
|
+
|
|
54
|
+
if [ "$status" -eq 0 ]; then
|
|
48
55
|
echo "✅ vault-guard: no secrets in staged files"
|
|
49
56
|
exit 0
|
|
50
57
|
fi
|
|
51
58
|
|
|
52
59
|
echo ""
|
|
60
|
+
if [ "$status" -eq 2 ]; then
|
|
61
|
+
# Exit 2 means vault-guard could not finish the scan, not that it found
|
|
62
|
+
# something. Claiming secrets were detected would be false, and offering
|
|
63
|
+
# --no-verify beside it would recommend skipping a check that never ran.
|
|
64
|
+
echo "❌ COMMIT BLOCKED: vault-guard could not complete the scan"
|
|
65
|
+
echo " See the message above for what went unexamined. This commit has not been checked."
|
|
66
|
+
exit 1
|
|
67
|
+
fi
|
|
68
|
+
|
|
53
69
|
echo "❌ COMMIT BLOCKED: secrets detected in staged files"
|
|
54
70
|
echo "💡 Fix or unstage, then retry. Emergency bypass (discouraged): git commit --no-verify"
|
|
55
71
|
exit 1
|
|
@@ -68,6 +84,14 @@ if errorlevel 1 (
|
|
|
68
84
|
|
|
69
85
|
echo 🔍 vault-guard: scanning staged files...
|
|
70
86
|
call vault-guard scan --staged
|
|
87
|
+
REM \`if errorlevel N\` is true for anything >= N, so 2 must be tested BEFORE
|
|
88
|
+
REM 1 or an incomplete scan would fall into the secrets-detected branch.
|
|
89
|
+
if errorlevel 2 (
|
|
90
|
+
echo.
|
|
91
|
+
echo ❌ COMMIT BLOCKED: vault-guard could not complete the scan
|
|
92
|
+
echo See the message above for what went unexamined. This commit has not been checked.
|
|
93
|
+
exit /b 1
|
|
94
|
+
)
|
|
71
95
|
if errorlevel 1 (
|
|
72
96
|
echo.
|
|
73
97
|
echo ❌ COMMIT BLOCKED: secrets detected in staged files
|
|
@@ -91,12 +115,25 @@ if ! command -v vault-guard >/dev/null 2>&1; then
|
|
|
91
115
|
fi
|
|
92
116
|
|
|
93
117
|
echo "🔍 vault-guard: scanning staged files..."
|
|
94
|
-
|
|
118
|
+
status=0
|
|
119
|
+
vault-guard scan --staged || status=$?
|
|
120
|
+
|
|
121
|
+
if [ "$status" -eq 2 ]; then
|
|
122
|
+
# Could not finish the scan, as opposed to finding something. No
|
|
123
|
+
# --no-verify hint here: the check never ran, so bypassing it is not the
|
|
124
|
+
# remedy. See the native template for the same reasoning.
|
|
125
|
+
echo ""
|
|
126
|
+
echo "❌ COMMIT BLOCKED: vault-guard could not complete the scan"
|
|
127
|
+
echo " See the message above for what went unexamined. This commit has not been checked."
|
|
128
|
+
exit 1
|
|
129
|
+
fi
|
|
130
|
+
|
|
131
|
+
if [ "$status" -ne 0 ]; then
|
|
95
132
|
echo ""
|
|
96
133
|
echo "❌ COMMIT BLOCKED: secrets detected in staged files"
|
|
97
134
|
echo "💡 git commit --no-verify to bypass (discouraged)"
|
|
98
135
|
exit 1
|
|
99
|
-
|
|
136
|
+
fi
|
|
100
137
|
echo "✅ vault-guard: no secrets in staged files"
|
|
101
138
|
`;
|
|
102
139
|
const LEFTHOOK_LOCAL = `# Merged by Lefthook with lefthook.yml — added by vault-guard install-hook
|
|
@@ -1,3 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Absolute, symlink-resolved path of the worktree root containing `cwd`.
|
|
3
|
+
*
|
|
4
|
+
* Every path git reports for the index is relative to this directory, and
|
|
5
|
+
* `git show :<path>` resolves its argument against it too, so it is the only
|
|
6
|
+
* correct base for turning git's output into filesystem paths. Deriving it
|
|
7
|
+
* from `cwd` instead (what this module used to do) is right only when the
|
|
8
|
+
* caller happens to be standing at the root.
|
|
9
|
+
*
|
|
10
|
+
* Throws `GitError` rather than falling back to `cwd`: a wrong root silently
|
|
11
|
+
* mis-resolves every staged path, which is exactly the failure this function
|
|
12
|
+
* exists to prevent.
|
|
13
|
+
*/
|
|
14
|
+
export declare function getGitWorkTreeRoot(cwd?: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Exact argv passed to git when listing staged files, exported so the
|
|
17
|
+
* safety-critical parts of it can be asserted directly.
|
|
18
|
+
*
|
|
19
|
+
* The `-c diff.relative=false` override is deliberately BELT AND BRACES with
|
|
20
|
+
* running the command at the worktree root (see getGitStagedFilePaths):
|
|
21
|
+
* either one alone makes git's output root-relative. That redundancy is
|
|
22
|
+
* wanted in a published security gate, but redundant code that no test can
|
|
23
|
+
* observe is code the next person deletes as dead weight -- so
|
|
24
|
+
* `git-utils.test.ts` pins this argv, and removing the flag fails a test
|
|
25
|
+
* even though behaviour would not change.
|
|
26
|
+
*/
|
|
27
|
+
export declare const STAGED_DIFF_ARGV: readonly string[];
|
|
1
28
|
/**
|
|
2
29
|
* Return absolute paths of files staged for commit (cached index vs HEAD).
|
|
3
30
|
*
|
|
@@ -6,6 +33,14 @@
|
|
|
6
33
|
* was later deleted from disk (`AD` in `git status`) still appears — that
|
|
7
34
|
* blob will be committed and must be scanned.
|
|
8
35
|
*
|
|
36
|
+
* Three independent things keep `diff.relative` from deciding what the gate
|
|
37
|
+
* sees: the config is forced off in the argv, the command runs AT the
|
|
38
|
+
* worktree root where the setting has nothing to make relative, and the
|
|
39
|
+
* output is resolved against that root rather than against `cwd`. Any one of
|
|
40
|
+
* the first two would do; both are here because this is a pre-commit gate on
|
|
41
|
+
* a published package, and the failure mode is silent under-reporting rather
|
|
42
|
+
* than an error anyone would notice.
|
|
43
|
+
*
|
|
9
44
|
* Throws `GitError` on git failure rather than returning an empty list.
|
|
10
45
|
* Returning `[]` silently on git failure would produce a false "✅ nothing
|
|
11
46
|
* staged" result in pre-commit, letting secrets through undetected.
|
|
@@ -13,8 +48,19 @@
|
|
|
13
48
|
export declare function getGitStagedFilePaths(cwd?: string): string[];
|
|
14
49
|
/**
|
|
15
50
|
* Read a staged blob from the index (`git show :path`), not the worktree.
|
|
16
|
-
*
|
|
51
|
+
*
|
|
52
|
+
* `filePath` may be absolute or relative to `cwd`, and may use OS
|
|
53
|
+
* separators. It is re-expressed relative to the worktree root before it
|
|
54
|
+
* reaches git, because git's `:<path>` revision syntax is root-relative by
|
|
55
|
+
* definition. Handing it a path relative to a subdirectory is what produced
|
|
56
|
+
* "fatal: path 'pkg/deep/staged.ts' is in the index, but not 'staged.ts'"
|
|
57
|
+
* and, one silent downgrade later, a clean bill of health over a real
|
|
58
|
+
* staged credential.
|
|
59
|
+
*
|
|
60
|
+
* Unlike the staged listing, this command needs no worktree-root `cwd` of
|
|
61
|
+
* its own: `:<path>` is defined as root-relative, so once the path has been
|
|
62
|
+
* re-expressed the working directory git runs in cannot change the answer.
|
|
17
63
|
*/
|
|
18
|
-
export declare function readGitIndexFile(cwd: string,
|
|
64
|
+
export declare function readGitIndexFile(cwd: string, filePath: string): string;
|
|
19
65
|
/** True when `cwd` is inside a work tree with a `.git` directory or file. */
|
|
20
66
|
export declare function isInsideGitWorkTree(cwd?: string): boolean;
|
package/dist/utils/git-utils.js
CHANGED
|
@@ -3,12 +3,131 @@ 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.STAGED_DIFF_ARGV = void 0;
|
|
7
|
+
exports.getGitWorkTreeRoot = getGitWorkTreeRoot;
|
|
6
8
|
exports.getGitStagedFilePaths = getGitStagedFilePaths;
|
|
7
9
|
exports.readGitIndexFile = readGitIndexFile;
|
|
8
10
|
exports.isInsideGitWorkTree = isInsideGitWorkTree;
|
|
9
11
|
const child_process_1 = require("child_process");
|
|
12
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
13
|
const path_1 = __importDefault(require("path"));
|
|
11
14
|
const errors_1 = require("../errors");
|
|
15
|
+
/**
|
|
16
|
+
* Repository config that must never be allowed to decide what vault-guard
|
|
17
|
+
* sees, prepended to every git command this module runs.
|
|
18
|
+
*
|
|
19
|
+
* `diff.relative=false` is the load-bearing one. `diff.relative` is an
|
|
20
|
+
* ordinary, user-settable repo config, and with it on git makes
|
|
21
|
+
* `git diff --cached --name-only` both print paths relative to the process
|
|
22
|
+
* cwd instead of the worktree root AND omit every staged path outside that
|
|
23
|
+
* cwd. A pre-commit gate whose file list can be emptied by a line in
|
|
24
|
+
* `.git/config` is not a gate, so the setting is overridden per invocation
|
|
25
|
+
* rather than trusted.
|
|
26
|
+
*
|
|
27
|
+
* `core.quotePath=false` is belt-and-braces: every command below already
|
|
28
|
+
* passes `-z` or reads a single blob, so git never applies path quoting as
|
|
29
|
+
* things stand. Forcing it here means a future edit that drops `-z` cannot
|
|
30
|
+
* silently reintroduce `"\303\251.env"`-style names that would then resolve
|
|
31
|
+
* to a file that does not exist and be skipped.
|
|
32
|
+
*
|
|
33
|
+
* These are `-c key=value` arguments in the argv array, not a `git config`
|
|
34
|
+
* write: nothing on the user's disk is modified.
|
|
35
|
+
*/
|
|
36
|
+
const FORCED_GIT_CONFIG = ['-c', 'diff.relative=false', '-c', 'core.quotePath=false'];
|
|
37
|
+
/**
|
|
38
|
+
* Resolve symlinks in a directory path, falling back to the input when it
|
|
39
|
+
* cannot be resolved. Git reports the physical worktree root, so a caller
|
|
40
|
+
* that passes a symlinked path (`/var/folders/...` on macOS, where the real
|
|
41
|
+
* directory is `/private/var/folders/...`) would otherwise produce a bogus
|
|
42
|
+
* `path.relative` against it.
|
|
43
|
+
*/
|
|
44
|
+
function canonicalDir(dir) {
|
|
45
|
+
try {
|
|
46
|
+
return fs_1.default.realpathSync.native(dir);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return dir;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Successful worktree-root lookups, keyed by the `cwd` asked about.
|
|
54
|
+
*
|
|
55
|
+
* `readGitIndexFile` needs the root for every staged file it reads, and a
|
|
56
|
+
* pre-commit run can have hundreds; without this each one would spawn a
|
|
57
|
+
* second git process on the latency-sensitive path. Only successes are
|
|
58
|
+
* cached, so a failure is retried and still throws. A directory's worktree
|
|
59
|
+
* root does not change under a running scan, and the key set is bounded by
|
|
60
|
+
* the number of distinct directories a single process asks about (one, in
|
|
61
|
+
* every current caller).
|
|
62
|
+
*/
|
|
63
|
+
const worktreeRootCache = new Map();
|
|
64
|
+
/**
|
|
65
|
+
* Absolute, symlink-resolved path of the worktree root containing `cwd`.
|
|
66
|
+
*
|
|
67
|
+
* Every path git reports for the index is relative to this directory, and
|
|
68
|
+
* `git show :<path>` resolves its argument against it too, so it is the only
|
|
69
|
+
* correct base for turning git's output into filesystem paths. Deriving it
|
|
70
|
+
* from `cwd` instead (what this module used to do) is right only when the
|
|
71
|
+
* caller happens to be standing at the root.
|
|
72
|
+
*
|
|
73
|
+
* Throws `GitError` rather than falling back to `cwd`: a wrong root silently
|
|
74
|
+
* mis-resolves every staged path, which is exactly the failure this function
|
|
75
|
+
* exists to prevent.
|
|
76
|
+
*/
|
|
77
|
+
function getGitWorkTreeRoot(cwd = process.cwd()) {
|
|
78
|
+
const cached = worktreeRootCache.get(cwd);
|
|
79
|
+
if (cached !== undefined)
|
|
80
|
+
return cached;
|
|
81
|
+
const args = [...FORCED_GIT_CONFIG, 'rev-parse', '--show-toplevel'];
|
|
82
|
+
let out;
|
|
83
|
+
try {
|
|
84
|
+
out = (0, child_process_1.execFileSync)('git', args, {
|
|
85
|
+
cwd,
|
|
86
|
+
encoding: 'utf-8',
|
|
87
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
throw new errors_1.GitError(`Failed to locate the git worktree root. Is this a git repository? (cwd: ${cwd})\n` +
|
|
92
|
+
`Run 'git status' to verify.\nUnderlying error: ${String(err)}`, `git ${args.join(' ')}`, err);
|
|
93
|
+
}
|
|
94
|
+
const root = out.trim();
|
|
95
|
+
if (!root) {
|
|
96
|
+
throw new errors_1.GitError(`git reported no worktree root for ${cwd} (bare repository?).`, `git ${args.join(' ')}`, undefined);
|
|
97
|
+
}
|
|
98
|
+
const resolved = canonicalDir(root);
|
|
99
|
+
worktreeRootCache.set(cwd, resolved);
|
|
100
|
+
return resolved;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Exact argv passed to git when listing staged files, exported so the
|
|
104
|
+
* safety-critical parts of it can be asserted directly.
|
|
105
|
+
*
|
|
106
|
+
* The `-c diff.relative=false` override is deliberately BELT AND BRACES with
|
|
107
|
+
* running the command at the worktree root (see getGitStagedFilePaths):
|
|
108
|
+
* either one alone makes git's output root-relative. That redundancy is
|
|
109
|
+
* wanted in a published security gate, but redundant code that no test can
|
|
110
|
+
* observe is code the next person deletes as dead weight -- so
|
|
111
|
+
* `git-utils.test.ts` pins this argv, and removing the flag fails a test
|
|
112
|
+
* even though behaviour would not change.
|
|
113
|
+
*/
|
|
114
|
+
exports.STAGED_DIFF_ARGV = [
|
|
115
|
+
...FORCED_GIT_CONFIG,
|
|
116
|
+
'diff',
|
|
117
|
+
'--cached',
|
|
118
|
+
'--name-only',
|
|
119
|
+
'--diff-filter=ACMRT',
|
|
120
|
+
// A staged submodule pointer bump is listed here as an ordinary path,
|
|
121
|
+
// but its index entry is a gitlink (mode 160000) rather than a blob, so
|
|
122
|
+
// `git show :<path>` answers "fatal: bad object". There is no content
|
|
123
|
+
// behind a gitlink for this scanner to read, and treating one as an
|
|
124
|
+
// unreadable file made every routine submodule bump block the commit
|
|
125
|
+
// with a message about detected secrets. Dropping the entry is correct,
|
|
126
|
+
// not a suppression: nothing about the pointer is scannable, and the
|
|
127
|
+
// submodule's own contents are that repository's own gate to run.
|
|
128
|
+
'--ignore-submodules=all',
|
|
129
|
+
'-z',
|
|
130
|
+
];
|
|
12
131
|
/**
|
|
13
132
|
* Return absolute paths of files staged for commit (cached index vs HEAD).
|
|
14
133
|
*
|
|
@@ -17,16 +136,25 @@ const errors_1 = require("../errors");
|
|
|
17
136
|
* was later deleted from disk (`AD` in `git status`) still appears — that
|
|
18
137
|
* blob will be committed and must be scanned.
|
|
19
138
|
*
|
|
139
|
+
* Three independent things keep `diff.relative` from deciding what the gate
|
|
140
|
+
* sees: the config is forced off in the argv, the command runs AT the
|
|
141
|
+
* worktree root where the setting has nothing to make relative, and the
|
|
142
|
+
* output is resolved against that root rather than against `cwd`. Any one of
|
|
143
|
+
* the first two would do; both are here because this is a pre-commit gate on
|
|
144
|
+
* a published package, and the failure mode is silent under-reporting rather
|
|
145
|
+
* than an error anyone would notice.
|
|
146
|
+
*
|
|
20
147
|
* Throws `GitError` on git failure rather than returning an empty list.
|
|
21
148
|
* Returning `[]` silently on git failure would produce a false "✅ nothing
|
|
22
149
|
* staged" result in pre-commit, letting secrets through undetected.
|
|
23
150
|
*/
|
|
24
151
|
function getGitStagedFilePaths(cwd = process.cwd()) {
|
|
25
|
-
const
|
|
152
|
+
const root = getGitWorkTreeRoot(cwd);
|
|
153
|
+
const args = [...exports.STAGED_DIFF_ARGV];
|
|
26
154
|
let out;
|
|
27
155
|
try {
|
|
28
156
|
out = (0, child_process_1.execFileSync)('git', args, {
|
|
29
|
-
cwd,
|
|
157
|
+
cwd: root,
|
|
30
158
|
encoding: 'utf-8',
|
|
31
159
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
32
160
|
});
|
|
@@ -35,19 +163,35 @@ function getGitStagedFilePaths(cwd = process.cwd()) {
|
|
|
35
163
|
throw new errors_1.GitError(`Failed to list staged files — is this a git repository? (cwd: ${cwd})\n` +
|
|
36
164
|
`Run 'git status' to verify.\nUnderlying error: ${String(err)}`, `git ${args.join(' ')}`, err);
|
|
37
165
|
}
|
|
166
|
+
// `-z` gives NUL-separated, unquoted, verbatim paths, so entries are used
|
|
167
|
+
// exactly as git produced them. Trimming here would corrupt the legal (if
|
|
168
|
+
// unusual) filename with leading or trailing whitespace into a path that
|
|
169
|
+
// does not exist.
|
|
38
170
|
return out
|
|
39
171
|
.split('\0')
|
|
40
|
-
.map((line) => line.trim())
|
|
41
172
|
.filter(Boolean)
|
|
42
|
-
.map((rel) => path_1.default.resolve(
|
|
173
|
+
.map((rel) => path_1.default.resolve(root, rel));
|
|
43
174
|
}
|
|
44
175
|
/**
|
|
45
176
|
* Read a staged blob from the index (`git show :path`), not the worktree.
|
|
46
|
-
*
|
|
177
|
+
*
|
|
178
|
+
* `filePath` may be absolute or relative to `cwd`, and may use OS
|
|
179
|
+
* separators. It is re-expressed relative to the worktree root before it
|
|
180
|
+
* reaches git, because git's `:<path>` revision syntax is root-relative by
|
|
181
|
+
* definition. Handing it a path relative to a subdirectory is what produced
|
|
182
|
+
* "fatal: path 'pkg/deep/staged.ts' is in the index, but not 'staged.ts'"
|
|
183
|
+
* and, one silent downgrade later, a clean bill of health over a real
|
|
184
|
+
* staged credential.
|
|
185
|
+
*
|
|
186
|
+
* Unlike the staged listing, this command needs no worktree-root `cwd` of
|
|
187
|
+
* its own: `:<path>` is defined as root-relative, so once the path has been
|
|
188
|
+
* re-expressed the working directory git runs in cannot change the answer.
|
|
47
189
|
*/
|
|
48
|
-
function readGitIndexFile(cwd,
|
|
49
|
-
const
|
|
50
|
-
const
|
|
190
|
+
function readGitIndexFile(cwd, filePath) {
|
|
191
|
+
const root = getGitWorkTreeRoot(cwd);
|
|
192
|
+
const abs = path_1.default.resolve(canonicalDir(cwd), filePath);
|
|
193
|
+
const rootRelative = path_1.default.relative(root, abs).split(path_1.default.sep).join('/');
|
|
194
|
+
const args = [...FORCED_GIT_CONFIG, 'show', `:${rootRelative}`];
|
|
51
195
|
try {
|
|
52
196
|
return (0, child_process_1.execFileSync)('git', args, {
|
|
53
197
|
cwd,
|
|
@@ -57,13 +201,13 @@ function readGitIndexFile(cwd, relativePath) {
|
|
|
57
201
|
});
|
|
58
202
|
}
|
|
59
203
|
catch (err) {
|
|
60
|
-
throw new errors_1.GitError(`Failed to read staged blob for ${
|
|
204
|
+
throw new errors_1.GitError(`Failed to read staged blob for ${rootRelative}\nUnderlying error: ${String(err)}`, `git ${args.join(' ')}`, err);
|
|
61
205
|
}
|
|
62
206
|
}
|
|
63
207
|
/** True when `cwd` is inside a work tree with a `.git` directory or file. */
|
|
64
208
|
function isInsideGitWorkTree(cwd = process.cwd()) {
|
|
65
209
|
try {
|
|
66
|
-
(0, child_process_1.execFileSync)('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
210
|
+
(0, child_process_1.execFileSync)('git', [...FORCED_GIT_CONFIG, 'rev-parse', '--is-inside-work-tree'], {
|
|
67
211
|
cwd,
|
|
68
212
|
encoding: 'utf-8',
|
|
69
213
|
stdio: ['ignore', 'pipe', 'pipe'],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vaultcompass/vault-guard-core",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.6",
|
|
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",
|