@vaultcompass/vault-guard 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/cli.js +5 -3
- package/dist/commands/check.d.ts +1 -1
- package/dist/commands/check.js +2 -2
- package/dist/commands/init.d.ts +8 -0
- package/dist/commands/init.js +144 -38
- package/dist/commands/install-hook.js +4 -1
- package/dist/commands/scan.d.ts +1 -1
- package/dist/commands/scan.js +40 -4
- package/dist/utils/scan-utils.d.ts +11 -2
- package/dist/utils/scan-utils.js +45 -10
- package/package.json +3 -3
package/dist/cli.js
CHANGED
|
@@ -79,9 +79,10 @@ function buildCli() {
|
|
|
79
79
|
.argument('[path]', 'Path to scan', '.')
|
|
80
80
|
.option('-f, --format <format>', 'Output format: text | json | sarif', 'text')
|
|
81
81
|
.option('--staged', 'Scan git staged files only (uses index vs HEAD)', false)
|
|
82
|
+
.option('--fail-on <severity>', 'Minimum severity that fails the scan: critical | high | medium | low | none (default: medium, or fail_on in .vault-guard.json)')
|
|
82
83
|
.action(async (path, options) => {
|
|
83
84
|
const format = options.format ?? 'text';
|
|
84
|
-
const exitCode = await (0, scan_1.scanCommand)(path, format, Boolean(options.staged));
|
|
85
|
+
const exitCode = await (0, scan_1.scanCommand)(path, format, Boolean(options.staged), options.failOn);
|
|
85
86
|
setExitCode(exitCode);
|
|
86
87
|
});
|
|
87
88
|
program
|
|
@@ -149,8 +150,9 @@ function buildCli() {
|
|
|
149
150
|
.command('check')
|
|
150
151
|
.description('Scan files with config and baselines')
|
|
151
152
|
.argument('[files...]', 'Files to check')
|
|
152
|
-
.
|
|
153
|
-
|
|
153
|
+
.option('--fail-on <severity>', 'Minimum severity that fails the check: critical | high | medium | low | none (default: medium, or fail_on in .vault-guard.json)')
|
|
154
|
+
.action(async (files, options) => {
|
|
155
|
+
const exitCode = await (0, check_1.checkCommand)(files, options.failOn);
|
|
154
156
|
setExitCode(exitCode);
|
|
155
157
|
});
|
|
156
158
|
program
|
package/dist/commands/check.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function checkCommand(files: string[]): Promise<number>;
|
|
1
|
+
export declare function checkCommand(files: string[], failOn?: string): Promise<number>;
|
package/dist/commands/check.js
CHANGED
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.checkCommand = checkCommand;
|
|
4
4
|
const scan_1 = require("./scan");
|
|
5
|
-
async function checkCommand(files) {
|
|
6
|
-
return (0, scan_1.scanCommand)(files.length > 0 ? files : '.', 'text', false);
|
|
5
|
+
async function checkCommand(files, failOn) {
|
|
6
|
+
return (0, scan_1.scanCommand)(files.length > 0 ? files : '.', 'text', false, failOn);
|
|
7
7
|
}
|
package/dist/commands/init.d.ts
CHANGED
|
@@ -14,6 +14,12 @@ export interface InitConflict {
|
|
|
14
14
|
path: string;
|
|
15
15
|
reason: 'exists' | 'foreign_manifest' | 'manifest_mismatch' | 'not_a_git_repository' | 'foreign_hook';
|
|
16
16
|
}
|
|
17
|
+
export interface InitAdvisory {
|
|
18
|
+
/** Detected manager that may already own pre-commit. */
|
|
19
|
+
manager: 'husky' | 'lefthook' | 'precommit';
|
|
20
|
+
path: string;
|
|
21
|
+
guidance: string;
|
|
22
|
+
}
|
|
17
23
|
export interface InitPlannedAction {
|
|
18
24
|
kind: 'create' | 'hook-install' | 'skip';
|
|
19
25
|
path: string;
|
|
@@ -26,6 +32,8 @@ export interface InitResult {
|
|
|
26
32
|
alreadyInitialized: boolean;
|
|
27
33
|
actions: InitPlannedAction[];
|
|
28
34
|
conflicts: InitConflict[];
|
|
35
|
+
/** Non-blocking tips when other hook managers are present. */
|
|
36
|
+
advisories: InitAdvisory[];
|
|
29
37
|
hook?: {
|
|
30
38
|
manager: string;
|
|
31
39
|
path?: string;
|
package/dist/commands/init.js
CHANGED
|
@@ -86,18 +86,8 @@ function hookRelativePath(cwd, hookPath) {
|
|
|
86
86
|
}
|
|
87
87
|
function foreignHookConflict(cwd, manager) {
|
|
88
88
|
const hook = new vault_guard_core_1.PreCommitHook();
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return undefined;
|
|
92
|
-
if (hook.isInstalled({ cwd, manager }))
|
|
93
|
-
return undefined;
|
|
94
|
-
const rel = hookRelativePath(cwd, hookPath);
|
|
95
|
-
if (manager === 'husky') {
|
|
96
|
-
const content = readFileIfExists(hookPath) ?? '';
|
|
97
|
-
if (content.includes('vault-guard'))
|
|
98
|
-
return undefined;
|
|
99
|
-
return { path: rel, reason: 'foreign_hook' };
|
|
100
|
-
}
|
|
89
|
+
// Manager-specific paths first — lefthook/precommit do not use getPreCommitHookPath
|
|
90
|
+
// (that helper still resolves the native hooks dir for non-husky managers).
|
|
101
91
|
if (manager === 'lefthook') {
|
|
102
92
|
const localPath = path.join(cwd, 'lefthook-local.yml');
|
|
103
93
|
if (!fs.existsSync(localPath))
|
|
@@ -116,10 +106,88 @@ function foreignHookConflict(cwd, manager) {
|
|
|
116
106
|
return undefined;
|
|
117
107
|
return { path: '.pre-commit-config.yaml', reason: 'foreign_hook' };
|
|
118
108
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
109
|
+
if (manager === 'husky') {
|
|
110
|
+
const hookPath = hook.getPreCommitHookPath(cwd, 'husky');
|
|
111
|
+
if (!fs.existsSync(hookPath))
|
|
112
|
+
return undefined;
|
|
113
|
+
if (hook.isInstalled({ cwd, manager: 'husky' }))
|
|
114
|
+
return undefined;
|
|
115
|
+
const content = readFileIfExists(hookPath) ?? '';
|
|
116
|
+
if (content.includes('vault-guard'))
|
|
117
|
+
return undefined;
|
|
118
|
+
return { path: hookRelativePath(cwd, hookPath), reason: 'foreign_hook' };
|
|
119
|
+
}
|
|
120
|
+
// native: POSIX hook and optional Windows companion
|
|
121
|
+
const hookPath = hook.getPreCommitHookPath(cwd, 'native');
|
|
122
|
+
if (fs.existsSync(hookPath) && !hook.isInstalled({ cwd, manager: 'native' })) {
|
|
123
|
+
const content = readFileIfExists(hookPath) ?? '';
|
|
124
|
+
if (content.trim().length > 0) {
|
|
125
|
+
return { path: hookRelativePath(cwd, hookPath), reason: 'foreign_hook' };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const cmdPath = hook.getPreCommitCmdPath(cwd);
|
|
129
|
+
if (fs.existsSync(cmdPath)) {
|
|
130
|
+
const content = readFileIfExists(cmdPath) ?? '';
|
|
131
|
+
const isOurs = content.includes('vault-guard') && content.includes('scan --staged');
|
|
132
|
+
if (content.trim().length > 0 && !isOurs) {
|
|
133
|
+
return { path: hookRelativePath(cwd, cmdPath), reason: 'foreign_hook' };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
function detectOtherHookManagers(cwd, selected) {
|
|
139
|
+
const advisories = [];
|
|
140
|
+
const huskyDir = path.join(cwd, '.husky');
|
|
141
|
+
if (fs.existsSync(huskyDir) && selected !== 'husky') {
|
|
142
|
+
advisories.push({
|
|
143
|
+
manager: 'husky',
|
|
144
|
+
path: '.husky/',
|
|
145
|
+
guidance: 'Husky detected. Prefer `vault-guard init --manager husky` or `vault-guard install-hook --manager husky` so the scan runs from .husky/pre-commit. Native hooks may not run when husky owns core.hooksPath.',
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const lefthookYml = path.join(cwd, 'lefthook.yml');
|
|
149
|
+
const lefthookLocal = path.join(cwd, 'lefthook-local.yml');
|
|
150
|
+
if ((fs.existsSync(lefthookYml) || fs.existsSync(lefthookLocal)) && selected !== 'lefthook') {
|
|
151
|
+
advisories.push({
|
|
152
|
+
manager: 'lefthook',
|
|
153
|
+
path: fs.existsSync(lefthookLocal) ? 'lefthook-local.yml' : 'lefthook.yml',
|
|
154
|
+
guidance: 'Lefthook detected. Prefer `vault-guard init --manager lefthook` (writes lefthook-local.yml) or merge `vault-guard scan --staged` under pre-commit.commands manually. Init never overwrites existing lefthook files.',
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const precommitCfg = path.join(cwd, '.pre-commit-config.yaml');
|
|
158
|
+
if (fs.existsSync(precommitCfg) && selected !== 'precommit') {
|
|
159
|
+
advisories.push({
|
|
160
|
+
manager: 'precommit',
|
|
161
|
+
path: '.pre-commit-config.yaml',
|
|
162
|
+
guidance: 'pre-commit framework config detected. Prefer `vault-guard init --manager precommit` only if the file is absent, or merge the local vault-guard hook into repos: yourself. Init never overwrites an existing .pre-commit-config.yaml.',
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return advisories;
|
|
166
|
+
}
|
|
167
|
+
function conflictGuidance(c) {
|
|
168
|
+
switch (c.reason) {
|
|
169
|
+
case 'exists':
|
|
170
|
+
return 'File already exists with different content — edit manually or move it aside, then re-run init.';
|
|
171
|
+
case 'foreign_manifest':
|
|
172
|
+
return 'Existing .vault-guard/init-manifest.json is invalid or foreign — fix or remove it, then re-run.';
|
|
173
|
+
case 'manifest_mismatch':
|
|
174
|
+
return 'Init manifest does not match current templates/options — run `vault-guard init --revert` then init again, or update files by hand.';
|
|
175
|
+
case 'not_a_git_repository':
|
|
176
|
+
return 'Run `git init` first, or pass `--skip-hook` to scaffold config/workflow without a hook.';
|
|
177
|
+
case 'foreign_hook':
|
|
178
|
+
if (c.path.includes('husky') || c.path.startsWith('.husky')) {
|
|
179
|
+
return 'Existing Husky pre-commit has no vault-guard stanza. Append `vault-guard scan --staged` yourself, or use `install-hook --manager husky` after reviewing the file.';
|
|
180
|
+
}
|
|
181
|
+
if (c.path.includes('lefthook')) {
|
|
182
|
+
return 'Add under pre-commit.commands:\n vault-guard:\n run: vault-guard scan --staged';
|
|
183
|
+
}
|
|
184
|
+
if (c.path.includes('pre-commit-config')) {
|
|
185
|
+
return 'Merge a local vault-guard hook into repos: (see `vault-guard install-hook --manager precommit` error output for a snippet).';
|
|
186
|
+
}
|
|
187
|
+
return 'An existing pre-commit hook is present without vault-guard. Merge `vault-guard scan --staged` manually, or remove the foreign hook if unused.';
|
|
188
|
+
default:
|
|
189
|
+
return 'Resolve manually, then re-run vault-guard init.';
|
|
190
|
+
}
|
|
123
191
|
}
|
|
124
192
|
function ensureParentDir(filePath) {
|
|
125
193
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
@@ -130,6 +198,7 @@ function planInit(options = {}) {
|
|
|
130
198
|
const manager = options.manager ?? 'native';
|
|
131
199
|
const actions = [];
|
|
132
200
|
const conflicts = [];
|
|
201
|
+
const advisories = detectOtherHookManagers(cwd, manager);
|
|
133
202
|
const trackedFiles = [];
|
|
134
203
|
const manifestAbs = path.join(cwd, templates_1.MANIFEST_RELATIVE_PATH);
|
|
135
204
|
const manifestRaw = readFileIfExists(manifestAbs);
|
|
@@ -219,17 +288,30 @@ function planInit(options = {}) {
|
|
|
219
288
|
alreadyInitialized,
|
|
220
289
|
actions,
|
|
221
290
|
conflicts,
|
|
291
|
+
advisories,
|
|
222
292
|
hook: hookState,
|
|
223
293
|
manifestPath: templates_1.MANIFEST_RELATIVE_PATH,
|
|
224
294
|
mcpMergeHint: MCP_MERGE_HINT,
|
|
225
295
|
};
|
|
226
296
|
}
|
|
227
297
|
function applyInit(plan, options = {}) {
|
|
228
|
-
if (!plan.ok || plan.dryRun
|
|
298
|
+
if (!plan.ok || plan.dryRun) {
|
|
229
299
|
return plan;
|
|
230
300
|
}
|
|
231
301
|
const cwd = options.cwd ?? process.cwd();
|
|
232
302
|
const manager = options.manager ?? 'native';
|
|
303
|
+
// Idempotent native refresh: backfill/update optional pre-commit.cmd even when
|
|
304
|
+
// the repo is already fully initialized.
|
|
305
|
+
if (plan.alreadyInitialized &&
|
|
306
|
+
plan.hook &&
|
|
307
|
+
!options.skipHook &&
|
|
308
|
+
manager === 'native') {
|
|
309
|
+
new vault_guard_core_1.PreCommitHook().install({ cwd, manager: 'native' });
|
|
310
|
+
return plan;
|
|
311
|
+
}
|
|
312
|
+
if (plan.alreadyInitialized) {
|
|
313
|
+
return plan;
|
|
314
|
+
}
|
|
233
315
|
const trackedFiles = [];
|
|
234
316
|
const createdPaths = [];
|
|
235
317
|
const rollbackCreatedFiles = () => {
|
|
@@ -244,28 +326,34 @@ function applyInit(plan, options = {}) {
|
|
|
244
326
|
}
|
|
245
327
|
}
|
|
246
328
|
};
|
|
247
|
-
if (plan.hook && !
|
|
329
|
+
if (plan.hook && !options.skipHook) {
|
|
248
330
|
const hook = new vault_guard_core_1.PreCommitHook();
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
...plan
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
331
|
+
// Always call install for native when planning a hook: first-time write, or
|
|
332
|
+
// idempotent refresh of the optional pre-commit.cmd companion.
|
|
333
|
+
if (!plan.hook.installed || manager === 'native') {
|
|
334
|
+
const result = hook.install({ cwd, manager });
|
|
335
|
+
if (!result.success && !plan.hook.installed) {
|
|
336
|
+
return {
|
|
337
|
+
...plan,
|
|
338
|
+
ok: false,
|
|
339
|
+
actions: [
|
|
340
|
+
...plan.actions,
|
|
341
|
+
{
|
|
342
|
+
kind: 'skip',
|
|
343
|
+
path: plan.hook.path ?? 'pre-commit',
|
|
344
|
+
detail: `hook install failed: ${result.message}`,
|
|
345
|
+
},
|
|
346
|
+
],
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
if (result.success) {
|
|
350
|
+
plan.hook = {
|
|
351
|
+
manager,
|
|
352
|
+
path: result.hookPath ?? plan.hook.path,
|
|
353
|
+
installed: true,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
263
356
|
}
|
|
264
|
-
plan.hook = {
|
|
265
|
-
manager,
|
|
266
|
-
path: result.hookPath ?? plan.hook.path,
|
|
267
|
-
installed: true,
|
|
268
|
-
};
|
|
269
357
|
}
|
|
270
358
|
try {
|
|
271
359
|
for (const action of plan.actions) {
|
|
@@ -345,6 +433,7 @@ function revertInit(options = {}) {
|
|
|
345
433
|
reverted: false,
|
|
346
434
|
alreadyInitialized: false,
|
|
347
435
|
actions: [],
|
|
436
|
+
advisories: [],
|
|
348
437
|
conflicts: [{ path: templates_1.MANIFEST_RELATIVE_PATH, reason: 'foreign_manifest' }],
|
|
349
438
|
manifestPath: templates_1.MANIFEST_RELATIVE_PATH,
|
|
350
439
|
mcpMergeHint: MCP_MERGE_HINT,
|
|
@@ -358,6 +447,7 @@ function revertInit(options = {}) {
|
|
|
358
447
|
reverted: false,
|
|
359
448
|
alreadyInitialized: false,
|
|
360
449
|
actions: [],
|
|
450
|
+
advisories: [],
|
|
361
451
|
conflicts: [{ path: templates_1.MANIFEST_RELATIVE_PATH, reason: 'foreign_manifest' }],
|
|
362
452
|
manifestPath: templates_1.MANIFEST_RELATIVE_PATH,
|
|
363
453
|
mcpMergeHint: MCP_MERGE_HINT,
|
|
@@ -413,6 +503,7 @@ function revertInit(options = {}) {
|
|
|
413
503
|
reverted: !dryRun,
|
|
414
504
|
alreadyInitialized: false,
|
|
415
505
|
actions,
|
|
506
|
+
advisories: [],
|
|
416
507
|
conflicts: [],
|
|
417
508
|
manifestPath: templates_1.MANIFEST_RELATIVE_PATH,
|
|
418
509
|
mcpMergeHint: MCP_MERGE_HINT,
|
|
@@ -436,6 +527,14 @@ function printHuman(result, options) {
|
|
|
436
527
|
console.error(chalk_1.default.red.bold('❌ Init blocked — conflicts (no automatic overwrites):'));
|
|
437
528
|
for (const c of result.conflicts) {
|
|
438
529
|
console.error(chalk_1.default.white(` ${c.path}`), chalk_1.default.gray(`(${c.reason})`));
|
|
530
|
+
console.error(chalk_1.default.gray(` → ${conflictGuidance(c)}`));
|
|
531
|
+
}
|
|
532
|
+
if (result.advisories.length > 0) {
|
|
533
|
+
console.error(chalk_1.default.yellow('\nAlso noted:'));
|
|
534
|
+
for (const a of result.advisories) {
|
|
535
|
+
console.error(chalk_1.default.yellow(` [${a.manager}] ${a.path}`));
|
|
536
|
+
console.error(chalk_1.default.gray(` ${a.guidance}`));
|
|
537
|
+
}
|
|
439
538
|
}
|
|
440
539
|
console.error(chalk_1.default.gray('\nResolve manually, then re-run vault-guard init.'));
|
|
441
540
|
return;
|
|
@@ -458,6 +557,13 @@ function printHuman(result, options) {
|
|
|
458
557
|
console.log(chalk_1.default.white(` ${result.dryRun ? 'install' : 'installed'} hook (${a.detail})`));
|
|
459
558
|
}
|
|
460
559
|
}
|
|
560
|
+
if (result.advisories.length > 0) {
|
|
561
|
+
console.log(chalk_1.default.yellow('\nHook manager notes:'));
|
|
562
|
+
for (const a of result.advisories) {
|
|
563
|
+
console.log(chalk_1.default.yellow(` [${a.manager}] ${a.path}`));
|
|
564
|
+
console.log(chalk_1.default.gray(` ${a.guidance}`));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
461
567
|
console.log(chalk_1.default.gray(`\n${result.mcpMergeHint}`));
|
|
462
568
|
console.log(chalk_1.default.gray(`Manifest: ${result.manifestPath}`));
|
|
463
569
|
}
|
|
@@ -473,7 +579,7 @@ async function initCommand(options = {}) {
|
|
|
473
579
|
return result.ok ? 0 : 1;
|
|
474
580
|
}
|
|
475
581
|
const plan = planInit(options);
|
|
476
|
-
const result = plan.dryRun
|
|
582
|
+
const result = plan.dryRun ? plan : applyInit(plan, options);
|
|
477
583
|
if (options.json) {
|
|
478
584
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
479
585
|
}
|
|
@@ -16,7 +16,10 @@ async function installHookCommand(manager = 'native') {
|
|
|
16
16
|
if (result.hookPath) {
|
|
17
17
|
console.log(chalk_1.default.gray(` Path: ${result.hookPath}`));
|
|
18
18
|
}
|
|
19
|
-
console.log(chalk_1.default.gray('\nThe hook runs `vault-guard scan --staged` before each commit (staged files only).\n'
|
|
19
|
+
console.log(chalk_1.default.gray('\nThe hook runs `vault-guard scan --staged` before each commit (staged files only).\n' +
|
|
20
|
+
(manager === 'native'
|
|
21
|
+
? 'Also writes optional pre-commit.cmd (Git for Windows still runs the POSIX pre-commit via sh).\n'
|
|
22
|
+
: '')));
|
|
20
23
|
}
|
|
21
24
|
else {
|
|
22
25
|
console.error(chalk_1.default.red('❌ Error:'), chalk_1.default.white(result.message));
|
package/dist/commands/scan.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export type OutputFormat = 'text' | 'json' | 'sarif';
|
|
2
|
-
export declare function scanCommand(targetPath: string | string[], format?: OutputFormat, staged?: boolean): Promise<number>;
|
|
2
|
+
export declare function scanCommand(targetPath: string | string[], format?: OutputFormat, staged?: boolean, failOnFlag?: string): Promise<number>;
|
package/dist/commands/scan.js
CHANGED
|
@@ -7,7 +7,7 @@ exports.scanCommand = scanCommand;
|
|
|
7
7
|
const vault_guard_core_1 = require("@vaultcompass/vault-guard-core");
|
|
8
8
|
const chalk_1 = __importDefault(require("chalk"));
|
|
9
9
|
const scan_utils_1 = require("../utils/scan-utils");
|
|
10
|
-
async function scanCommand(targetPath, format = 'text', staged = false) {
|
|
10
|
+
async function scanCommand(targetPath, format = 'text', staged = false, failOnFlag) {
|
|
11
11
|
const cwd = process.cwd();
|
|
12
12
|
const targetPaths = Array.isArray(targetPath) ? targetPath : [targetPath];
|
|
13
13
|
const targetLabel = targetPaths.length === 1 ? targetPaths[0] : `${targetPaths.length} paths`;
|
|
@@ -25,6 +25,18 @@ async function scanCommand(targetPath, format = 'text', staged = false) {
|
|
|
25
25
|
}
|
|
26
26
|
throw e;
|
|
27
27
|
}
|
|
28
|
+
// Resolve the gate threshold before scanning so an invalid value fails fast
|
|
29
|
+
// rather than after a long scan.
|
|
30
|
+
const failOnResolved = (0, vault_guard_core_1.resolveFailOn)(failOnFlag, config.fail_on);
|
|
31
|
+
if (!failOnResolved.ok) {
|
|
32
|
+
console.error(chalk_1.default.red('❌ Invalid fail-on value:'), chalk_1.default.white(failOnResolved.invalid));
|
|
33
|
+
console.error(chalk_1.default.gray(` Expected one of: ${vault_guard_core_1.FAIL_ON_VALUES.join(' | ')}\n`));
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
const failOn = failOnResolved.threshold;
|
|
37
|
+
// True when neither the flag nor the config chose a threshold. Drives the
|
|
38
|
+
// 1.4.0 upgrade notice below: users who picked a value have already decided.
|
|
39
|
+
const gateIsImplicitDefault = failOnFlag === undefined && config.fail_on === undefined;
|
|
28
40
|
const scanner = new vault_guard_core_1.SecretScanner(config);
|
|
29
41
|
// Merge config ignore paths and patterns into a single list for file filtering.
|
|
30
42
|
const configIgnorePatterns = [
|
|
@@ -95,6 +107,8 @@ async function scanCommand(targetPath, format = 'text', staged = false) {
|
|
|
95
107
|
bus,
|
|
96
108
|
stats,
|
|
97
109
|
configIgnorePatterns,
|
|
110
|
+
fromGitIndex: true,
|
|
111
|
+
cwd,
|
|
98
112
|
});
|
|
99
113
|
}
|
|
100
114
|
else {
|
|
@@ -123,21 +137,36 @@ async function scanCommand(targetPath, format = 'text', staged = false) {
|
|
|
123
137
|
const { results: afterBaseline, suppressed: baselineSuppressed } = (0, vault_guard_core_1.filterResultsByBaseline)(process.cwd(), results, baselineLoad.fingerprints);
|
|
124
138
|
results = afterBaseline;
|
|
125
139
|
const durationMs = Date.now() - t0;
|
|
140
|
+
const totalMatches = results.reduce((n, r) => n + r.matches.length, 0);
|
|
141
|
+
const blocking = (0, vault_guard_core_1.countBlockingMatches)(results, failOn);
|
|
126
142
|
const run = {
|
|
127
143
|
duration_ms: durationMs,
|
|
128
144
|
files_scanned: stats.filesScanned,
|
|
129
145
|
bytes_scanned: stats.bytesScanned,
|
|
130
146
|
patterns_active: scanner.getActivePatternCount(),
|
|
131
147
|
diagnostics_count: diagnostics.length,
|
|
148
|
+
fail_on: failOn,
|
|
149
|
+
blocking_matches: blocking,
|
|
132
150
|
...(baselineSuppressed > 0 ? { baseline_suppressed: baselineSuppressed } : {}),
|
|
133
151
|
};
|
|
152
|
+
// Upgrade notice for the 1.4.0 default change. Before 1.4.0 any finding
|
|
153
|
+
// failed the scan; now the implicit default is `medium`. When that
|
|
154
|
+
// difference is what decides this run's outcome (findings exist, none
|
|
155
|
+
// block, and the user never chose a threshold), say so once on stderr —
|
|
156
|
+
// stderr so JSON/SARIF stdout stays parseable, and only for the implicit
|
|
157
|
+
// default so setting `fail_on` anywhere silences it for good.
|
|
158
|
+
if (gateIsImplicitDefault && totalMatches > 0 && blocking === 0) {
|
|
159
|
+
console.error(chalk_1.default.yellow(`note: earlier vault-guard versions failed on any finding; since 1.4.0 the default gate is "medium".`));
|
|
160
|
+
console.error(chalk_1.default.gray(` This run would have failed before. Set "fail_on" in .vault-guard.json ("low" restores the old\n` +
|
|
161
|
+
` behaviour, "medium" keeps this one) to silence this note.`));
|
|
162
|
+
}
|
|
134
163
|
if (format === 'json') {
|
|
135
164
|
process.stdout.write((0, scan_utils_1.formatJson)(results, { diagnostics, run }) + '\n');
|
|
136
|
-
return
|
|
165
|
+
return blocking === 0 ? 0 : 1;
|
|
137
166
|
}
|
|
138
167
|
if (format === 'sarif') {
|
|
139
168
|
process.stdout.write((0, scan_utils_1.formatSarif)(results, { diagnostics, run }) + '\n');
|
|
140
|
-
return
|
|
169
|
+
return blocking === 0 ? 0 : 1;
|
|
141
170
|
}
|
|
142
171
|
// Text mode: print one-line diagnostic summary when any non-fatal issues occurred
|
|
143
172
|
if (diagnostics.length > 0) {
|
|
@@ -147,7 +176,14 @@ async function scanCommand(targetPath, format = 'text', staged = false) {
|
|
|
147
176
|
console.log(chalk_1.default.green.bold('✅ SUCCESS:'), chalk_1.default.white('No secrets found\n'));
|
|
148
177
|
return 0;
|
|
149
178
|
}
|
|
150
|
-
(0, scan_utils_1.displayScanResults)(results);
|
|
179
|
+
(0, scan_utils_1.displayScanResults)(results, blocking);
|
|
180
|
+
if (blocking === 0) {
|
|
181
|
+
// Findings exist but all sit below the gate. Say so explicitly — a silent
|
|
182
|
+
// exit 0 after printing findings reads like a bug.
|
|
183
|
+
console.log(chalk_1.default.white(`${totalMatches} finding(s), none at or above severity "${failOn}" — not failing the gate.`));
|
|
184
|
+
console.log(chalk_1.default.gray(` Tighten with --fail-on low or "fail_on": "low" in .vault-guard.json\n`));
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
151
187
|
return 1;
|
|
152
188
|
}
|
|
153
189
|
catch (error) {
|
|
@@ -27,10 +27,19 @@ export interface ScanOptions {
|
|
|
27
27
|
* directory scans and staged-file scans.
|
|
28
28
|
*/
|
|
29
29
|
configIgnorePatterns?: string[];
|
|
30
|
+
/**
|
|
31
|
+
* When true, read each path from the git index (`git show :path`) so
|
|
32
|
+
* `--staged` matches what will actually be committed — including `AD`
|
|
33
|
+
* (added in index, deleted in worktree) and partially staged files.
|
|
34
|
+
*/
|
|
35
|
+
fromGitIndex?: boolean;
|
|
36
|
+
/** Repo root for `fromGitIndex` (defaults to `process.cwd()`). */
|
|
37
|
+
cwd?: string;
|
|
30
38
|
}
|
|
31
39
|
/**
|
|
32
40
|
* Scan an explicit list of files (e.g. paths from \`git diff --cached\`).
|
|
33
|
-
*
|
|
41
|
+
* Without `fromGitIndex`, skips missing worktree paths. With `fromGitIndex`,
|
|
42
|
+
* reads staged blobs so deleted worktree files are still scanned.
|
|
34
43
|
*/
|
|
35
44
|
export declare function scanFileListAsync(files: string[], scanner: SecretScanner, options?: ScanOptions): Promise<ScanResult[]>;
|
|
36
45
|
/**
|
|
@@ -61,4 +70,4 @@ export declare function scanFiles(targetPaths: string[], scanner: SecretScanner,
|
|
|
61
70
|
* - The redacted match value (`sk-a…(37c)`) is shown last and intentionally
|
|
62
71
|
* low-information.
|
|
63
72
|
*/
|
|
64
|
-
export declare function displayScanResults(results: ScanResult[]): void;
|
|
73
|
+
export declare function displayScanResults(results: ScanResult[], blocking?: number): void;
|
package/dist/utils/scan-utils.js
CHANGED
|
@@ -35,15 +35,16 @@ const BINARY_EXTENSIONS = [
|
|
|
35
35
|
];
|
|
36
36
|
/**
|
|
37
37
|
* Scan an explicit list of files (e.g. paths from \`git diff --cached\`).
|
|
38
|
-
*
|
|
38
|
+
* Without `fromGitIndex`, skips missing worktree paths. With `fromGitIndex`,
|
|
39
|
+
* reads staged blobs so deleted worktree files are still scanned.
|
|
39
40
|
*/
|
|
40
41
|
async function scanFileListAsync(files, scanner, options = {}) {
|
|
41
|
-
const { verbose = false, maxSize = MAX_FILE_SIZE, skipBinary = true, progress = false, concurrency = 10, configIgnorePatterns = [], } = options;
|
|
42
|
+
const { verbose = false, maxSize = MAX_FILE_SIZE, skipBinary = true, progress = false, concurrency = 10, configIgnorePatterns = [], fromGitIndex = false, cwd = process.cwd(), } = options;
|
|
42
43
|
// Apply config ignore patterns to the explicit file list (e.g. staged files).
|
|
43
44
|
// buildConfigIgnoreFilter matches relative to cwd so patterns like
|
|
44
45
|
// `packages/**/__tests__/**` work identically for staged and directory scans.
|
|
45
46
|
const configIgnoreTester = configIgnorePatterns.length > 0
|
|
46
|
-
? (0, vault_guard_core_1.buildConfigIgnoreFilter)(configIgnorePatterns,
|
|
47
|
+
? (0, vault_guard_core_1.buildConfigIgnoreFilter)(configIgnorePatterns, cwd)
|
|
47
48
|
: null;
|
|
48
49
|
const filteredFiles = configIgnoreTester
|
|
49
50
|
? files.filter(f => !configIgnoreTester(f))
|
|
@@ -51,6 +52,27 @@ async function scanFileListAsync(files, scanner, options = {}) {
|
|
|
51
52
|
const results = [];
|
|
52
53
|
const scanFile = async (file) => {
|
|
53
54
|
try {
|
|
55
|
+
if (fromGitIndex) {
|
|
56
|
+
const rel = path_1.default.relative(cwd, file).split(path_1.default.sep).join('/');
|
|
57
|
+
if (skipBinary && isBinaryFile(file))
|
|
58
|
+
return;
|
|
59
|
+
const content = (0, vault_guard_core_1.readGitIndexFile)(cwd, rel);
|
|
60
|
+
if (skipBinary && content.includes('\0'))
|
|
61
|
+
return;
|
|
62
|
+
const byteLen = Buffer.byteLength(content, 'utf-8');
|
|
63
|
+
if (byteLen > maxSize && verbose) {
|
|
64
|
+
console.warn(chalk_1.default.yellow(`⚠️ Large staged blob (scanning in memory):`), chalk_1.default.white(rel), chalk_1.default.gray(`(${(byteLen / 1024 / 1024).toFixed(2)}MB)`));
|
|
65
|
+
}
|
|
66
|
+
if (options.stats) {
|
|
67
|
+
options.stats.filesScanned += 1;
|
|
68
|
+
options.stats.bytesScanned += byteLen;
|
|
69
|
+
}
|
|
70
|
+
const matches = (0, vault_guard_core_1.applyPathAwareSeverity)(scanner.scanContent(content, { filePath: file }), file);
|
|
71
|
+
if (matches.length > 0) {
|
|
72
|
+
results.push({ file, matches });
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
54
76
|
if (!fs_1.default.existsSync(file))
|
|
55
77
|
return;
|
|
56
78
|
const st = await fs_1.default.promises.stat(file);
|
|
@@ -59,7 +81,7 @@ async function scanFileListAsync(files, scanner, options = {}) {
|
|
|
59
81
|
if (skipBinary && isBinaryFile(file))
|
|
60
82
|
return;
|
|
61
83
|
if (st.size > maxSize && verbose) {
|
|
62
|
-
console.warn(chalk_1.default.yellow(`⚠️ Large file (streaming line-by-line):`), chalk_1.default.white(path_1.default.relative(
|
|
84
|
+
console.warn(chalk_1.default.yellow(`⚠️ Large file (streaming line-by-line):`), chalk_1.default.white(path_1.default.relative(cwd, file)), chalk_1.default.gray(`(${(st.size / 1024 / 1024).toFixed(2)}MB)`));
|
|
63
85
|
}
|
|
64
86
|
if (options.stats) {
|
|
65
87
|
options.stats.filesScanned += 1;
|
|
@@ -77,11 +99,11 @@ async function scanFileListAsync(files, scanner, options = {}) {
|
|
|
77
99
|
options.bus.add({
|
|
78
100
|
code: 'file.read_error',
|
|
79
101
|
severity: 'error',
|
|
80
|
-
ctx: { file: path_1.default.relative(
|
|
102
|
+
ctx: { file: path_1.default.relative(cwd, file), detail: String(error) },
|
|
81
103
|
});
|
|
82
104
|
}
|
|
83
105
|
if (verbose) {
|
|
84
|
-
console.error(chalk_1.default.red('❌ Error scanning file:'), chalk_1.default.white(path_1.default.relative(
|
|
106
|
+
console.error(chalk_1.default.red('❌ Error scanning file:'), chalk_1.default.white(path_1.default.relative(cwd, file)));
|
|
85
107
|
console.error(chalk_1.default.gray(String(error)));
|
|
86
108
|
}
|
|
87
109
|
}
|
|
@@ -274,13 +296,22 @@ function scanFiles(targetPaths, scanner, options = {}) {
|
|
|
274
296
|
* - The redacted match value (`sk-a…(37c)`) is shown last and intentionally
|
|
275
297
|
* low-information.
|
|
276
298
|
*/
|
|
277
|
-
function displayScanResults(results) {
|
|
299
|
+
function displayScanResults(results, blocking) {
|
|
278
300
|
if (results.length === 0) {
|
|
279
301
|
console.log(chalk_1.default.green.bold('✅ SUCCESS:'), chalk_1.default.white('No secrets found\n'));
|
|
280
302
|
return;
|
|
281
303
|
}
|
|
282
304
|
const totalSecrets = results.reduce((sum, r) => sum + r.matches.length, 0);
|
|
283
|
-
|
|
305
|
+
// `blocking` is how many findings sit at or above the `--fail-on` threshold.
|
|
306
|
+
// When none do we still list everything, but the headline must not say
|
|
307
|
+
// "BLOCKED" over a run that is about to exit 0.
|
|
308
|
+
const willBlock = blocking === undefined || blocking > 0;
|
|
309
|
+
if (willBlock) {
|
|
310
|
+
console.log(chalk_1.default.red.bold('🚨 BLOCKED:'), chalk_1.default.white(`Found ${totalSecrets} secret${totalSecrets > 1 ? 's' : ''}\n`));
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
console.log(chalk_1.default.yellow.bold('⚠️ REPORT:'), chalk_1.default.white(`Found ${totalSecrets} finding${totalSecrets > 1 ? 's' : ''} below the fail threshold\n`));
|
|
314
|
+
}
|
|
284
315
|
for (const { file, matches } of results) {
|
|
285
316
|
const relativePath = relativeForDisplay(file);
|
|
286
317
|
for (const match of matches) {
|
|
@@ -291,7 +322,9 @@ function displayScanResults(results) {
|
|
|
291
322
|
}
|
|
292
323
|
}
|
|
293
324
|
console.log('');
|
|
294
|
-
|
|
325
|
+
if (willBlock) {
|
|
326
|
+
console.log(chalk_1.default.red.bold('❌ BLOCKED:'), chalk_1.default.white('Commit blocked — remove secrets before pushing\n'));
|
|
327
|
+
}
|
|
295
328
|
}
|
|
296
329
|
/** cwd-relative when inside cwd, absolute otherwise. Matches scan-output behaviour. */
|
|
297
330
|
function relativeForDisplay(file) {
|
|
@@ -324,8 +357,10 @@ function getSeverityEmoji(severity) {
|
|
|
324
357
|
return '⚠️';
|
|
325
358
|
case 'medium':
|
|
326
359
|
return 'ℹ️';
|
|
360
|
+
// Not a checkmark: every line here is a finding, and a green tick beside
|
|
361
|
+
// one reads as "this file is clean".
|
|
327
362
|
case 'low':
|
|
328
|
-
return '
|
|
363
|
+
return '🔵';
|
|
329
364
|
default:
|
|
330
365
|
return '•';
|
|
331
366
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vaultcompass/vault-guard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Block secrets at commit and in CI. Pre-commit hooks, SARIF output, and fast staged-file scans for AI-native workflows.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"chalk": "^4.1.2",
|
|
38
38
|
"commander": "^12.0.0",
|
|
39
|
-
"@vaultcompass/vault-guard-
|
|
40
|
-
"@vaultcompass/vault-guard-
|
|
39
|
+
"@vaultcompass/vault-guard-telemetry": "1.4.0",
|
|
40
|
+
"@vaultcompass/vault-guard-core": "1.4.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/jest": "^30.0.0",
|