@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 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
- .action(async (files) => {
153
- const exitCode = await (0, check_1.checkCommand)(files);
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
@@ -1 +1 @@
1
- export declare function checkCommand(files: string[]): Promise<number>;
1
+ export declare function checkCommand(files: string[], failOn?: string): Promise<number>;
@@ -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
  }
@@ -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;
@@ -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
- const hookPath = hook.getPreCommitHookPath(cwd, manager);
90
- if (!fs.existsSync(hookPath))
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
- const content = readFileIfExists(hookPath) ?? '';
120
- if (content.trim().length === 0)
121
- return undefined;
122
- return { path: rel, reason: 'foreign_hook' };
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 || plan.alreadyInitialized) {
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 && !plan.hook.installed && !options.skipHook) {
329
+ if (plan.hook && !options.skipHook) {
248
330
  const hook = new vault_guard_core_1.PreCommitHook();
249
- const result = hook.install({ cwd, manager });
250
- if (!result.success) {
251
- return {
252
- ...plan,
253
- ok: false,
254
- actions: [
255
- ...plan.actions,
256
- {
257
- kind: 'skip',
258
- path: plan.hook.path ?? 'pre-commit',
259
- detail: `hook install failed: ${result.message}`,
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 || plan.alreadyInitialized ? plan : applyInit(plan, options);
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));
@@ -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>;
@@ -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 results.reduce((n, r) => n + r.matches.length, 0) === 0 ? 0 : 1;
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 results.reduce((n, r) => n + r.matches.length, 0) === 0 ? 0 : 1;
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
- * Skips missing paths and non-files silently.
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;
@@ -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
- * Skips missing paths and non-files silently.
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, process.cwd())
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(process.cwd(), file)), chalk_1.default.gray(`(${(st.size / 1024 / 1024).toFixed(2)}MB)`));
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(process.cwd(), file), detail: String(error) },
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(process.cwd(), file)));
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
- console.log(chalk_1.default.red.bold('🚨 BLOCKED:'), chalk_1.default.white(`Found ${totalSecrets} secret${totalSecrets > 1 ? 's' : ''}\n`));
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
- console.log(chalk_1.default.red.bold('❌ BLOCKED:'), chalk_1.default.white('Commit blocked — remove secrets before pushing\n'));
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.2.3",
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-core": "1.2.3",
40
- "@vaultcompass/vault-guard-telemetry": "1.2.3"
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",