@vaultcompass/vault-guard-core 1.4.3 → 1.4.5
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/scan-output.d.ts +16 -1
- package/dist/scan-output.js +94 -10
- package/dist/scanners/pre-commit-hook.d.ts +96 -3
- package/dist/scanners/pre-commit-hook.js +186 -23
- package/package.json +1 -1
package/dist/scan-output.d.ts
CHANGED
|
@@ -62,9 +62,24 @@ export interface FormatOptions {
|
|
|
62
62
|
/**
|
|
63
63
|
* Base directory to render `file` paths relative to.
|
|
64
64
|
* Defaults to `process.cwd()`. Files outside this root are kept absolute.
|
|
65
|
-
* Pass `null` to skip relativization
|
|
65
|
+
* Pass `null` to skip relativization for `formatJson`'s `file` paths and
|
|
66
|
+
* for SARIF when no {@link scanRoot} is given either. A `scanRoot` still
|
|
67
|
+
* relativizes SARIF `artifactLocation.uri` (and diagnostic ctx) against
|
|
68
|
+
* itself in that case, so `cwd: null` does not skip SARIF relativization
|
|
69
|
+
* on its own.
|
|
66
70
|
*/
|
|
67
71
|
cwd?: string | null;
|
|
72
|
+
/**
|
|
73
|
+
* Directory actually being scanned (the scan target), when it differs from
|
|
74
|
+
* {@link cwd}. SARIF only: `artifactLocation.uri` is relativized against
|
|
75
|
+
* this instead of `cwd`, so a finding outside the process cwd but inside the
|
|
76
|
+
* scan target still gets a relative uri. Defaults to `cwd`.
|
|
77
|
+
*
|
|
78
|
+
* `formatJson` ignores this field. Its `file` paths stay cwd-relative,
|
|
79
|
+
* because they are what the terminal output and the baseline fingerprints
|
|
80
|
+
* are keyed on.
|
|
81
|
+
*/
|
|
82
|
+
scanRoot?: string;
|
|
68
83
|
/** Non-fatal diagnostics to include in structured output. */
|
|
69
84
|
diagnostics?: Diagnostic[];
|
|
70
85
|
/** 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
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
export type HookManager = 'native' | 'husky' | 'lefthook' | 'precommit';
|
|
2
2
|
export interface InstallHookOptions {
|
|
3
3
|
manager?: HookManager;
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Working directory. MUST be the git repository root -- the directory
|
|
6
|
+
* containing `.git` -- not a package subdirectory in a monorepo, even
|
|
7
|
+
* one that owns its own `.husky`. `install()`/`uninstall()` refuse
|
|
8
|
+
* outright when `.git` is not directly present (see the check at the
|
|
9
|
+
* top of each), and core.hooksPath is resolved relative to the
|
|
10
|
+
* worktree root regardless of where a nested `.husky` lives, so running
|
|
11
|
+
* from anywhere else either fails fast or resolves the wrong hooks
|
|
12
|
+
* directory entirely. Defaults to `process.cwd()`.
|
|
13
|
+
*/
|
|
5
14
|
cwd?: string;
|
|
6
15
|
}
|
|
7
16
|
export declare class PreCommitHook {
|
|
@@ -31,14 +40,71 @@ export declare class PreCommitHook {
|
|
|
31
40
|
hooksDir: string;
|
|
32
41
|
viaHooksPath: boolean;
|
|
33
42
|
};
|
|
43
|
+
/**
|
|
44
|
+
* Whether the resolved hooks directory is husky 9's GENERATED,
|
|
45
|
+
* gitignored directory (\`.husky/_\` by default) rather than a real
|
|
46
|
+
* hooks directory. Husky's own prepare script rewrites this directory on
|
|
47
|
+
* every \`pnpm install\`, so nothing vault-guard writes there survives;
|
|
48
|
+
* the durable, tracked hook lives one directory up at
|
|
49
|
+
* \`.husky/<hookname>\`.
|
|
50
|
+
*
|
|
51
|
+
* This is deliberately narrow: the ONLY signal that may trigger the
|
|
52
|
+
* redirect is the directory SHAPE -- a resolved basename of \`_\` under a
|
|
53
|
+
* directory literally named \`.husky\`. Two false-positive shapes were
|
|
54
|
+
* caught by review before shipping and must never redirect:
|
|
55
|
+
* - core.hooksPath pointing at an unrelated directory (e.g. .githooks)
|
|
56
|
+
* that happens to contain a file literally named \`h\` -- an \`h\` file
|
|
57
|
+
* is not evidence of husky on its own, only the directory shape is;
|
|
58
|
+
* - a dispatcher-shaped pre-commit file (the same two-line shebang
|
|
59
|
+
* body husky 9 writes) sitting somewhere that is NOT \`.husky/_\`
|
|
60
|
+
* (e.g. plain \`.git/hooks/pre-commit\`) -- content shape alone is not
|
|
61
|
+
* evidence either, since a foreign hook can coincidentally look like
|
|
62
|
+
* this.
|
|
63
|
+
* Neither the \`h\` shim nor dispatcher-shaped content is checked at all
|
|
64
|
+
* here; they would only ever have been used to confirm a shape match,
|
|
65
|
+
* never to trigger one on their own, and the shape check alone is both
|
|
66
|
+
* necessary and sufficient for every case this fix needs to handle.
|
|
67
|
+
*/
|
|
68
|
+
isHuskyGeneratedHooksDir(hooksDir: string): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Where husky's own \`h\` shim actually resolves and executes the tracked
|
|
71
|
+
* hook, given a husky-generated hooksDir (isHuskyGeneratedHooksDir(hooksDir)
|
|
72
|
+
* must already be true). Husky computes this as the PARENT of the
|
|
73
|
+
* generated \`_\` directory -- fixed relative to hooksDir, never a fixed
|
|
74
|
+
* \`<cwd>/.husky\`, because core.hooksPath can point at a NESTED
|
|
75
|
+
* \`.husky/_\` (e.g. \`packages/app/.husky/_\`, the ordinary shape for a
|
|
76
|
+
* monorepo package that owns husky's "prepare" script but is not itself
|
|
77
|
+
* the git root). In that case husky's shim genuinely runs
|
|
78
|
+
* \`packages/app/.husky/<hookname>\`, not \`<cwd>/.husky/<hookname>\` --
|
|
79
|
+
* proven wrong by independent review with a functional shim and a real
|
|
80
|
+
* commit before this was fixed. getPreCommitHookPath, installNative,
|
|
81
|
+
* uninstallNative, and getPreCommitCmdPath all resolve through this one
|
|
82
|
+
* place so the redirect target can never drift between them.
|
|
83
|
+
*/
|
|
84
|
+
private resolveHuskyDir;
|
|
85
|
+
/** \`absPath\`, relative to \`cwd\`, with forward slashes on every platform. */
|
|
86
|
+
private relFromCwd;
|
|
34
87
|
/**
|
|
35
88
|
* Absolute path to the \`pre-commit\` hook file for the given manager.
|
|
89
|
+
*
|
|
90
|
+
* For the \`native\` manager, when the resolved hooks directory is
|
|
91
|
+
* husky 9's generated directory (see isHuskyGeneratedHooksDir), this
|
|
92
|
+
* resolves to the TRACKED hook file husky's own \`h\` shim actually runs
|
|
93
|
+
* -- see resolveHuskyDir -- because nothing written under the generated
|
|
94
|
+
* directory survives husky's prepare script. See install()'s
|
|
95
|
+
* husky-delegation in installNative for the write side of this.
|
|
36
96
|
*/
|
|
37
97
|
getPreCommitHookPath(cwd: string, manager?: HookManager): string;
|
|
38
98
|
/**
|
|
39
|
-
* Absolute path to the Windows \`pre-commit.cmd\` companion (native manager
|
|
99
|
+
* Absolute path to the Windows \`pre-commit.cmd\` companion (native manager
|
|
100
|
+
* only). \`undefined\` under a husky-generated hooks directory: the .cmd
|
|
101
|
+
* companion is native-only and installNative's husky-redirect never
|
|
102
|
+
* writes one there (see installNative), so there is no meaningful path
|
|
103
|
+
* to report. Callers that used to guard this getter with their own
|
|
104
|
+
* isHuskyGeneratedHooksDir check (the CLI's foreignHookConflict did)
|
|
105
|
+
* can drop that guard now that it lives here instead.
|
|
40
106
|
*/
|
|
41
|
-
getPreCommitCmdPath(cwd: string): string;
|
|
107
|
+
getPreCommitCmdPath(cwd: string): string | undefined;
|
|
42
108
|
install(options?: InstallHookOptions): {
|
|
43
109
|
success: boolean;
|
|
44
110
|
message: string;
|
|
@@ -57,7 +123,34 @@ export declare class PreCommitHook {
|
|
|
57
123
|
private removeNativeCmdCompanion;
|
|
58
124
|
private installNative;
|
|
59
125
|
private uninstallNative;
|
|
126
|
+
/**
|
|
127
|
+
* @param huskyDir Directory holding the tracked hook. Defaults to
|
|
128
|
+
* \`<cwd>/.husky\`, correct for the explicit \`husky\` manager (it never
|
|
129
|
+
* consults core.hooksPath). installNative's redirect passes the
|
|
130
|
+
* ACTUAL directory resolveHuskyDir computed instead, which can be
|
|
131
|
+
* nested (e.g. \`packages/app/.husky\`) -- see resolveHuskyDir.
|
|
132
|
+
*/
|
|
60
133
|
private installHusky;
|
|
134
|
+
/**
|
|
135
|
+
* @param huskyDir See installHusky.
|
|
136
|
+
*
|
|
137
|
+
* Fix for a defect the reviewer found while checking uninstall after
|
|
138
|
+
* the redirect started routing every husky 9 repo through this
|
|
139
|
+
* function (previously only reachable via the explicit `husky`
|
|
140
|
+
* manager): when installHusky wrote the WHOLE file from
|
|
141
|
+
* HUSKY_HOOK_SCRIPT (the fresh-install path -- what both the redirect
|
|
142
|
+
* and a from-scratch \`--manager husky\` install take), there is no
|
|
143
|
+
* "# --- vault-guard ---" appended-block marker to strip, so the old
|
|
144
|
+
* logic here matched nothing, rewrote the file byte-identical, and
|
|
145
|
+
* reported success:true with isInstalled still true. Distinguishing
|
|
146
|
+
* the two shapes vault-guard itself ever produces -- the whole-file
|
|
147
|
+
* header vs. the appended-stanza marker -- fixes this: a whole-file
|
|
148
|
+
* hook is removed entirely; an appended stanza is stripped, keeping
|
|
149
|
+
* the pre-existing foreign content; anything else that merely mentions
|
|
150
|
+
* "vault-guard" in neither recognized shape is left untouched, with an
|
|
151
|
+
* honest message and success only if it happens not to still read as
|
|
152
|
+
* installed.
|
|
153
|
+
*/
|
|
61
154
|
private uninstallHusky;
|
|
62
155
|
private installLefthook;
|
|
63
156
|
private uninstallLefthook;
|
|
@@ -8,12 +8,23 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
9
|
const child_process_1 = require("child_process");
|
|
10
10
|
const errors_1 = require("../errors");
|
|
11
|
+
/**
|
|
12
|
+
* Marks a hook file vault-guard wrote WHOLE, from a template -- as
|
|
13
|
+
* opposed to a stanza vault-guard appended to a pre-existing (foreign)
|
|
14
|
+
* hook it does not own. uninstallHusky uses this to decide whether it is
|
|
15
|
+
* safe to delete the file outright: present means the whole file is
|
|
16
|
+
* vault-guard's, so removing it entirely is correct; absent (even when
|
|
17
|
+
* the file mentions "vault-guard" some other way) means the file
|
|
18
|
+
* predates vault-guard or was never fully vault-guard's, so it must not
|
|
19
|
+
* be deleted wholesale.
|
|
20
|
+
*/
|
|
21
|
+
const VAULT_GUARD_HOOK_HEADER = '# vault-guard pre-commit (installed by @vaultcompass/vault-guard)';
|
|
11
22
|
/**
|
|
12
23
|
* Shell hook body for **native** Git hooks (`core.hooksPath` or `.git/hooks`).
|
|
13
24
|
* Scans **staged files only** — fast and matches what will actually be committed.
|
|
14
25
|
*/
|
|
15
26
|
const NATIVE_HOOK_SCRIPT = `#!/bin/sh
|
|
16
|
-
|
|
27
|
+
${VAULT_GUARD_HOOK_HEADER}
|
|
17
28
|
set -e
|
|
18
29
|
|
|
19
30
|
# Re-attach stdin for GUI git clients when possible.
|
|
@@ -69,6 +80,7 @@ exit /b 0
|
|
|
69
80
|
`;
|
|
70
81
|
/** Husky-friendly hook (sources \`_/husky.sh\` when present). */
|
|
71
82
|
const HUSKY_HOOK_SCRIPT = `#!/usr/bin/env sh
|
|
83
|
+
${VAULT_GUARD_HOOK_HEADER}
|
|
72
84
|
if [ -f "$(dirname "$0")/_/husky.sh" ]; then
|
|
73
85
|
. "$(dirname "$0")/_/husky.sh"
|
|
74
86
|
fi
|
|
@@ -151,20 +163,93 @@ class PreCommitHook {
|
|
|
151
163
|
const worktreeRoot = this.resolveWorktreeRoot(cwd) ?? cwd;
|
|
152
164
|
return { hooksDir: path_1.default.join(worktreeRoot, hooksPath), viaHooksPath: true };
|
|
153
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Whether the resolved hooks directory is husky 9's GENERATED,
|
|
168
|
+
* gitignored directory (\`.husky/_\` by default) rather than a real
|
|
169
|
+
* hooks directory. Husky's own prepare script rewrites this directory on
|
|
170
|
+
* every \`pnpm install\`, so nothing vault-guard writes there survives;
|
|
171
|
+
* the durable, tracked hook lives one directory up at
|
|
172
|
+
* \`.husky/<hookname>\`.
|
|
173
|
+
*
|
|
174
|
+
* This is deliberately narrow: the ONLY signal that may trigger the
|
|
175
|
+
* redirect is the directory SHAPE -- a resolved basename of \`_\` under a
|
|
176
|
+
* directory literally named \`.husky\`. Two false-positive shapes were
|
|
177
|
+
* caught by review before shipping and must never redirect:
|
|
178
|
+
* - core.hooksPath pointing at an unrelated directory (e.g. .githooks)
|
|
179
|
+
* that happens to contain a file literally named \`h\` -- an \`h\` file
|
|
180
|
+
* is not evidence of husky on its own, only the directory shape is;
|
|
181
|
+
* - a dispatcher-shaped pre-commit file (the same two-line shebang
|
|
182
|
+
* body husky 9 writes) sitting somewhere that is NOT \`.husky/_\`
|
|
183
|
+
* (e.g. plain \`.git/hooks/pre-commit\`) -- content shape alone is not
|
|
184
|
+
* evidence either, since a foreign hook can coincidentally look like
|
|
185
|
+
* this.
|
|
186
|
+
* Neither the \`h\` shim nor dispatcher-shaped content is checked at all
|
|
187
|
+
* here; they would only ever have been used to confirm a shape match,
|
|
188
|
+
* never to trigger one on their own, and the shape check alone is both
|
|
189
|
+
* necessary and sufficient for every case this fix needs to handle.
|
|
190
|
+
*/
|
|
191
|
+
isHuskyGeneratedHooksDir(hooksDir) {
|
|
192
|
+
const base = path_1.default.basename(hooksDir);
|
|
193
|
+
const parentBase = path_1.default.basename(path_1.default.dirname(hooksDir));
|
|
194
|
+
return base === '_' && parentBase === '.husky';
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Where husky's own \`h\` shim actually resolves and executes the tracked
|
|
198
|
+
* hook, given a husky-generated hooksDir (isHuskyGeneratedHooksDir(hooksDir)
|
|
199
|
+
* must already be true). Husky computes this as the PARENT of the
|
|
200
|
+
* generated \`_\` directory -- fixed relative to hooksDir, never a fixed
|
|
201
|
+
* \`<cwd>/.husky\`, because core.hooksPath can point at a NESTED
|
|
202
|
+
* \`.husky/_\` (e.g. \`packages/app/.husky/_\`, the ordinary shape for a
|
|
203
|
+
* monorepo package that owns husky's "prepare" script but is not itself
|
|
204
|
+
* the git root). In that case husky's shim genuinely runs
|
|
205
|
+
* \`packages/app/.husky/<hookname>\`, not \`<cwd>/.husky/<hookname>\` --
|
|
206
|
+
* proven wrong by independent review with a functional shim and a real
|
|
207
|
+
* commit before this was fixed. getPreCommitHookPath, installNative,
|
|
208
|
+
* uninstallNative, and getPreCommitCmdPath all resolve through this one
|
|
209
|
+
* place so the redirect target can never drift between them.
|
|
210
|
+
*/
|
|
211
|
+
resolveHuskyDir(hooksDir) {
|
|
212
|
+
return path_1.default.dirname(hooksDir);
|
|
213
|
+
}
|
|
214
|
+
/** \`absPath\`, relative to \`cwd\`, with forward slashes on every platform. */
|
|
215
|
+
relFromCwd(cwd, absPath) {
|
|
216
|
+
return path_1.default.relative(cwd, absPath).split(path_1.default.sep).join('/');
|
|
217
|
+
}
|
|
154
218
|
/**
|
|
155
219
|
* Absolute path to the \`pre-commit\` hook file for the given manager.
|
|
220
|
+
*
|
|
221
|
+
* For the \`native\` manager, when the resolved hooks directory is
|
|
222
|
+
* husky 9's generated directory (see isHuskyGeneratedHooksDir), this
|
|
223
|
+
* resolves to the TRACKED hook file husky's own \`h\` shim actually runs
|
|
224
|
+
* -- see resolveHuskyDir -- because nothing written under the generated
|
|
225
|
+
* directory survives husky's prepare script. See install()'s
|
|
226
|
+
* husky-delegation in installNative for the write side of this.
|
|
156
227
|
*/
|
|
157
228
|
getPreCommitHookPath(cwd, manager = 'native') {
|
|
158
229
|
if (manager === 'husky') {
|
|
159
230
|
return path_1.default.join(cwd, '.husky', 'pre-commit');
|
|
160
231
|
}
|
|
161
|
-
|
|
232
|
+
const { hooksDir } = this.getEffectiveHooksDir(cwd);
|
|
233
|
+
if (this.isHuskyGeneratedHooksDir(hooksDir)) {
|
|
234
|
+
return path_1.default.join(this.resolveHuskyDir(hooksDir), 'pre-commit');
|
|
235
|
+
}
|
|
236
|
+
return path_1.default.join(hooksDir, 'pre-commit');
|
|
162
237
|
}
|
|
163
238
|
/**
|
|
164
|
-
* Absolute path to the Windows \`pre-commit.cmd\` companion (native manager
|
|
239
|
+
* Absolute path to the Windows \`pre-commit.cmd\` companion (native manager
|
|
240
|
+
* only). \`undefined\` under a husky-generated hooks directory: the .cmd
|
|
241
|
+
* companion is native-only and installNative's husky-redirect never
|
|
242
|
+
* writes one there (see installNative), so there is no meaningful path
|
|
243
|
+
* to report. Callers that used to guard this getter with their own
|
|
244
|
+
* isHuskyGeneratedHooksDir check (the CLI's foreignHookConflict did)
|
|
245
|
+
* can drop that guard now that it lives here instead.
|
|
165
246
|
*/
|
|
166
247
|
getPreCommitCmdPath(cwd) {
|
|
167
|
-
|
|
248
|
+
const { hooksDir } = this.getEffectiveHooksDir(cwd);
|
|
249
|
+
if (this.isHuskyGeneratedHooksDir(hooksDir)) {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
return path_1.default.join(hooksDir, 'pre-commit.cmd');
|
|
168
253
|
}
|
|
169
254
|
install(options = {}) {
|
|
170
255
|
const cwd = options.cwd ?? process.cwd();
|
|
@@ -245,6 +330,25 @@ class PreCommitHook {
|
|
|
245
330
|
}
|
|
246
331
|
installNative(cwd) {
|
|
247
332
|
const { hooksDir, viaHooksPath } = this.getEffectiveHooksDir(cwd);
|
|
333
|
+
// core.hooksPath points at husky 9's generated, gitignored directory
|
|
334
|
+
// (typically .husky/_, but see resolveHuskyDir for the nested case).
|
|
335
|
+
// Writing there is pointless -- husky's prepare script rewrites it on
|
|
336
|
+
// every `pnpm install` -- so install into the same tracked hook file
|
|
337
|
+
// the husky manager uses, and say so. Never write under the generated
|
|
338
|
+
// directory in this branch.
|
|
339
|
+
if (this.isHuskyGeneratedHooksDir(hooksDir)) {
|
|
340
|
+
const huskyDir = this.resolveHuskyDir(hooksDir);
|
|
341
|
+
const result = this.installHusky(cwd, huskyDir);
|
|
342
|
+
if (!result.success) {
|
|
343
|
+
return result;
|
|
344
|
+
}
|
|
345
|
+
const relHookPath = this.relFromCwd(cwd, result.hookPath ?? path_1.default.join(huskyDir, 'pre-commit'));
|
|
346
|
+
return {
|
|
347
|
+
success: true,
|
|
348
|
+
message: `Hooks are managed by husky; installing into ${relHookPath}. ${result.message}`,
|
|
349
|
+
hookPath: result.hookPath,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
248
352
|
const hookPath = path_1.default.join(hooksDir, 'pre-commit');
|
|
249
353
|
const cmdPath = path_1.default.join(hooksDir, 'pre-commit.cmd');
|
|
250
354
|
try {
|
|
@@ -287,6 +391,14 @@ class PreCommitHook {
|
|
|
287
391
|
}
|
|
288
392
|
uninstallNative(cwd) {
|
|
289
393
|
const { hooksDir } = this.getEffectiveHooksDir(cwd);
|
|
394
|
+
if (this.isHuskyGeneratedHooksDir(hooksDir)) {
|
|
395
|
+
const huskyDir = this.resolveHuskyDir(hooksDir);
|
|
396
|
+
const result = this.uninstallHusky(cwd, huskyDir);
|
|
397
|
+
return {
|
|
398
|
+
success: result.success,
|
|
399
|
+
message: `Hooks are managed by husky; ${result.message}`,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
290
402
|
const hookPath = path_1.default.join(hooksDir, 'pre-commit');
|
|
291
403
|
const cmdRemoved = this.removeNativeCmdCompanion(hooksDir);
|
|
292
404
|
if (!fs_1.default.existsSync(hookPath)) {
|
|
@@ -323,9 +435,16 @@ class PreCommitHook {
|
|
|
323
435
|
// -------------------------------------------------------------------------
|
|
324
436
|
// Husky — .husky/pre-commit
|
|
325
437
|
// -------------------------------------------------------------------------
|
|
326
|
-
|
|
327
|
-
|
|
438
|
+
/**
|
|
439
|
+
* @param huskyDir Directory holding the tracked hook. Defaults to
|
|
440
|
+
* \`<cwd>/.husky\`, correct for the explicit \`husky\` manager (it never
|
|
441
|
+
* consults core.hooksPath). installNative's redirect passes the
|
|
442
|
+
* ACTUAL directory resolveHuskyDir computed instead, which can be
|
|
443
|
+
* nested (e.g. \`packages/app/.husky\`) -- see resolveHuskyDir.
|
|
444
|
+
*/
|
|
445
|
+
installHusky(cwd, huskyDir = path_1.default.join(cwd, '.husky')) {
|
|
328
446
|
const hookPath = path_1.default.join(huskyDir, 'pre-commit');
|
|
447
|
+
const relHookPath = this.relFromCwd(cwd, hookPath);
|
|
329
448
|
try {
|
|
330
449
|
if (!fs_1.default.existsSync(huskyDir)) {
|
|
331
450
|
fs_1.default.mkdirSync(huskyDir, { recursive: true });
|
|
@@ -339,12 +458,12 @@ class PreCommitHook {
|
|
|
339
458
|
return { success: true, message: 'Husky hook already contains vault-guard block', hookPath };
|
|
340
459
|
}
|
|
341
460
|
fs_1.default.appendFileSync(hookPath, `\n# --- vault-guard ---\nvault-guard scan --staged || {\n echo "❌ vault-guard blocked commit"\n exit 1\n}\n`, { encoding: 'utf-8' });
|
|
342
|
-
return { success: true, message:
|
|
461
|
+
return { success: true, message: `Appended vault-guard to existing ${relHookPath}`, hookPath };
|
|
343
462
|
}
|
|
344
463
|
fs_1.default.writeFileSync(hookPath, HUSKY_HOOK_SCRIPT, { mode: 0o755 });
|
|
345
464
|
return {
|
|
346
465
|
success: true,
|
|
347
|
-
message:
|
|
466
|
+
message: `Created ${relHookPath} (run \`npx husky init\` first if _/husky.sh is missing)`,
|
|
348
467
|
hookPath,
|
|
349
468
|
};
|
|
350
469
|
}
|
|
@@ -353,28 +472,72 @@ class PreCommitHook {
|
|
|
353
472
|
return { success: false, message: hookError.message };
|
|
354
473
|
}
|
|
355
474
|
}
|
|
356
|
-
|
|
357
|
-
|
|
475
|
+
/**
|
|
476
|
+
* @param huskyDir See installHusky.
|
|
477
|
+
*
|
|
478
|
+
* Fix for a defect the reviewer found while checking uninstall after
|
|
479
|
+
* the redirect started routing every husky 9 repo through this
|
|
480
|
+
* function (previously only reachable via the explicit `husky`
|
|
481
|
+
* manager): when installHusky wrote the WHOLE file from
|
|
482
|
+
* HUSKY_HOOK_SCRIPT (the fresh-install path -- what both the redirect
|
|
483
|
+
* and a from-scratch \`--manager husky\` install take), there is no
|
|
484
|
+
* "# --- vault-guard ---" appended-block marker to strip, so the old
|
|
485
|
+
* logic here matched nothing, rewrote the file byte-identical, and
|
|
486
|
+
* reported success:true with isInstalled still true. Distinguishing
|
|
487
|
+
* the two shapes vault-guard itself ever produces -- the whole-file
|
|
488
|
+
* header vs. the appended-stanza marker -- fixes this: a whole-file
|
|
489
|
+
* hook is removed entirely; an appended stanza is stripped, keeping
|
|
490
|
+
* the pre-existing foreign content; anything else that merely mentions
|
|
491
|
+
* "vault-guard" in neither recognized shape is left untouched, with an
|
|
492
|
+
* honest message and success only if it happens not to still read as
|
|
493
|
+
* installed.
|
|
494
|
+
*/
|
|
495
|
+
uninstallHusky(cwd, huskyDir = path_1.default.join(cwd, '.husky')) {
|
|
496
|
+
const hookPath = path_1.default.join(huskyDir, 'pre-commit');
|
|
497
|
+
const relHookPath = this.relFromCwd(cwd, hookPath);
|
|
358
498
|
if (!fs_1.default.existsSync(hookPath)) {
|
|
359
|
-
return { success: true, message:
|
|
499
|
+
return { success: true, message: `No ${relHookPath} to remove` };
|
|
360
500
|
}
|
|
361
|
-
|
|
501
|
+
const content = fs_1.default.readFileSync(hookPath, 'utf-8');
|
|
362
502
|
if (!content.includes('vault-guard')) {
|
|
363
|
-
return { success: true, message:
|
|
503
|
+
return { success: true, message: `No vault-guard stanza in ${relHookPath}` };
|
|
364
504
|
}
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
|
|
505
|
+
// vault-guard wrote the ENTIRE file from HUSKY_HOOK_SCRIPT (whether
|
|
506
|
+
// via a fresh --manager husky install or the native husky-redirect):
|
|
507
|
+
// it is safe, and correct, to remove the file outright rather than
|
|
508
|
+
// try to strip individual lines from a template vault-guard owns.
|
|
509
|
+
if (content.includes(VAULT_GUARD_HOOK_HEADER)) {
|
|
510
|
+
fs_1.default.unlinkSync(hookPath);
|
|
511
|
+
return { success: true, message: `Removed ${relHookPath}` };
|
|
512
|
+
}
|
|
513
|
+
// vault-guard appended a stanza to a pre-existing (foreign) hook: strip
|
|
514
|
+
// just that stanza, preserving whatever the file had before.
|
|
515
|
+
if (content.includes('# --- vault-guard ---')) {
|
|
516
|
+
const stripped = content.replace(/\n# --- vault-guard ---[\s\S]*$/m, '');
|
|
517
|
+
if (stripped.trim().length === 0) {
|
|
370
518
|
fs_1.default.unlinkSync(hookPath);
|
|
371
|
-
return { success: true, message:
|
|
519
|
+
return { success: true, message: `Removed ${relHookPath}` };
|
|
372
520
|
}
|
|
373
|
-
fs_1.default.writeFileSync(hookPath,
|
|
374
|
-
|
|
521
|
+
fs_1.default.writeFileSync(hookPath, stripped, { mode: 0o755 });
|
|
522
|
+
const stillInstalled = stripped.includes('vault-guard') && stripped.includes('scan --staged');
|
|
523
|
+
return {
|
|
524
|
+
success: !stillInstalled,
|
|
525
|
+
message: stillInstalled
|
|
526
|
+
? `Removed the appended stanza from ${relHookPath}, but it still references vault-guard -- review manually`
|
|
527
|
+
: `Removed vault-guard stanza from ${relHookPath}`,
|
|
528
|
+
};
|
|
375
529
|
}
|
|
376
|
-
|
|
377
|
-
|
|
530
|
+
// Mentions "vault-guard" somewhere, but in NEITHER shape vault-guard
|
|
531
|
+
// itself ever writes (no whole-file header, no appended-stanza
|
|
532
|
+
// marker) -- do not guess at what to remove from a file this code did
|
|
533
|
+
// not write; leave it untouched and say so honestly. Report success
|
|
534
|
+
// only if it does not still read as installed (isInstalled uses the
|
|
535
|
+
// same two-substring check).
|
|
536
|
+
const stillInstalled = content.includes('vault-guard') && content.includes('scan --staged');
|
|
537
|
+
return {
|
|
538
|
+
success: !stillInstalled,
|
|
539
|
+
message: `${relHookPath} mentions vault-guard but does not match the shape this version writes; it may have been written by an older vault-guard, or by hand. Leaving it unchanged. Review and remove the vault-guard reference manually if needed.`,
|
|
540
|
+
};
|
|
378
541
|
}
|
|
379
542
|
// -------------------------------------------------------------------------
|
|
380
543
|
// Lefthook — lefthook-local.yml (merged with lefthook.yml)
|
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.5",
|
|
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",
|