sec-gate 0.1.6 → 0.1.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sec-gate",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Pre-commit security gate for OWASP Top 10 2021 — SAST, SCA and misconfig checks for Node/Express, Go and React codebases",
5
5
  "author": {
6
6
  "name": "Sundram Bhardwaj",
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ // security-scan: disable rule-id: detect-non-literal-fs-filename reason: all paths derived from git rev-parse or hardcoded BIN_DIR constants, never user input
3
+ // security-scan: disable rule-id: path-join-resolve-traversal reason: all paths derived from git rev-parse or hardcoded BIN_DIR constants, never user input
2
4
 
3
5
  /**
4
6
  * sec-gate postinstall script
@@ -149,17 +151,28 @@ function installGovulncheck() {
149
151
 
150
152
  // ─────────────────────────────────────────────────────────
151
153
  // [3/3] Auto-install pre-commit hook in the current git repo
152
- // Backs up any existing hook before writing.
154
+ // Detects husky automatically and injects into .husky/pre-commit.
155
+ // Falls back to .git/hooks/pre-commit for non-husky repos.
153
156
  // Skipped silently if not inside a git repo.
154
157
  // ─────────────────────────────────────────────────────────
155
158
  const HOOK_MARKER = '# installed-by: sec-gate';
156
159
 
157
- function buildHookScript() {
160
+ function isHuskyRepo(repoRoot) {
161
+ if (fs.existsSync(path.join(repoRoot, '.husky'))) return true;
162
+ const pkgPath = path.join(repoRoot, 'package.json');
163
+ if (!fs.existsSync(pkgPath)) return false;
164
+ try {
165
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
166
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
167
+ return !!deps.husky;
168
+ } catch { return false; }
169
+ }
170
+
171
+ function buildStandaloneHook() {
158
172
  return [
159
173
  '#!/usr/bin/env sh',
160
174
  HOOK_MARKER,
161
175
  '',
162
- '# Set SEC_GATE_SKIP=1 to bypass (emergency only)',
163
176
  'if [ "$SEC_GATE_SKIP" = "1" ]; then',
164
177
  ' echo "sec-gate: skipped (SEC_GATE_SKIP=1)"',
165
178
  ' exit 0',
@@ -179,9 +192,31 @@ function buildHookScript() {
179
192
  ].join('\n');
180
193
  }
181
194
 
195
+ function buildHuskyInjectionBlock() {
196
+ return [
197
+ '',
198
+ HOOK_MARKER,
199
+ 'if [ "$SEC_GATE_SKIP" != "1" ]; then',
200
+ ' if command -v sec-gate >/dev/null 2>&1; then',
201
+ ' sec-gate scan --staged',
202
+ ' SEC_GATE_EXIT=$?',
203
+ ' if [ $SEC_GATE_EXIT -ne 0 ]; then exit $SEC_GATE_EXIT; fi',
204
+ ' else',
205
+ ' echo "sec-gate: not found in PATH. Run: npm install -g sec-gate"',
206
+ ' exit 1',
207
+ ' fi',
208
+ 'fi',
209
+ '# end-sec-gate',
210
+ ''
211
+ ].join('\n');
212
+ }
213
+
214
+ function buildNewHuskyHook() {
215
+ return ['#!/usr/bin/env sh', '. "$(dirname -- "$0")/_/husky.sh"', buildHuskyInjectionBlock()].join('\n');
216
+ }
217
+
182
218
  function autoInstallHook() {
183
219
  let repoRoot;
184
-
185
220
  try {
186
221
  repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
187
222
  encoding: 'utf8',
@@ -193,24 +228,51 @@ function autoInstallHook() {
193
228
  return;
194
229
  }
195
230
 
196
- const hookDir = path.join(repoRoot, '.git', 'hooks');
197
- const hookPath = path.join(hookDir, 'pre-commit');
231
+ if (isHuskyRepo(repoRoot)) {
232
+ // ── Husky repo ────────────────────────────────────────────────────────
233
+ const huskyHookPath = path.join(repoRoot, '.husky', 'pre-commit');
234
+ console.log('sec-gate [3/3]: husky detected — injecting into .husky/pre-commit');
198
235
 
199
- fs.mkdirSync(hookDir, { recursive: true });
236
+ if (fs.existsSync(huskyHookPath)) {
237
+ const existing = fs.readFileSync(huskyHookPath, 'utf8');
238
+ if (existing.includes(HOOK_MARKER)) {
239
+ console.log('sec-gate [3/3]: already injected into husky hook');
240
+ return;
241
+ }
242
+ // Inject after husky.sh source line
243
+ const lines = existing.split('\n');
244
+ let insertAt = lines.length;
245
+ for (let i = 0; i < lines.length; i++) {
246
+ if (lines[i].includes('husky.sh')) { insertAt = i + 1; break; }
247
+ }
248
+ lines.splice(insertAt, 0, ...buildHuskyInjectionBlock().split('\n'));
249
+ fs.writeFileSync(huskyHookPath, lines.join('\n'), { encoding: 'utf8', mode: 0o755 });
250
+ } else {
251
+ fs.mkdirSync(path.dirname(huskyHookPath), { recursive: true });
252
+ fs.writeFileSync(huskyHookPath, buildNewHuskyHook(), { encoding: 'utf8', mode: 0o755 });
253
+ }
254
+ console.log(`sec-gate [3/3]: injected into ${huskyHookPath}`);
255
+
256
+ } else {
257
+ // ── Standalone repo ───────────────────────────────────────────────────
258
+ const hookDir = path.join(repoRoot, '.git', 'hooks');
259
+ const hookPath = path.join(hookDir, 'pre-commit');
260
+ fs.mkdirSync(hookDir, { recursive: true });
200
261
 
201
- if (fs.existsSync(hookPath)) {
202
- const existing = fs.readFileSync(hookPath, 'utf8');
203
- if (existing.includes(HOOK_MARKER)) {
204
- console.log('sec-gate [3/3]: pre-commit hook already installed');
205
- return;
262
+ if (fs.existsSync(hookPath)) {
263
+ const existing = fs.readFileSync(hookPath, 'utf8');
264
+ if (existing.includes(HOOK_MARKER)) {
265
+ console.log('sec-gate [3/3]: pre-commit hook already installed');
266
+ return;
267
+ }
268
+ const backup = `${hookPath}.sec-gate.bak`;
269
+ fs.copyFileSync(hookPath, backup);
270
+ console.log(`sec-gate [3/3]: backed up existing hook → ${backup}`);
206
271
  }
207
- const backup = `${hookPath}.sec-gate.bak`;
208
- fs.copyFileSync(hookPath, backup);
209
- console.log(`sec-gate [3/3]: backed up existing hook → ${backup}`);
210
- }
211
272
 
212
- fs.writeFileSync(hookPath, buildHookScript(), { encoding: 'utf8', mode: 0o755 });
213
- console.log(`sec-gate [3/3]: pre-commit hook installed in ${repoRoot}`);
273
+ fs.writeFileSync(hookPath, buildStandaloneHook(), { encoding: 'utf8', mode: 0o755 });
274
+ console.log(`sec-gate [3/3]: pre-commit hook installed in ${repoRoot}`);
275
+ }
214
276
  }
215
277
 
216
278
  // ─────────────────────────────────────────────────────────
package/src/cli.js CHANGED
@@ -10,6 +10,7 @@ function usage() {
10
10
  ' sec-gate scan Scan all tracked files',
11
11
  ' sec-gate scan --staged Scan only staged files (used by pre-commit hook)',
12
12
  ' sec-gate doctor Check all components are installed and working',
13
+ ' sec-gate --version Print the installed version',
13
14
  ''
14
15
  ].join('\n'));
15
16
  }
@@ -19,6 +20,7 @@ function parseArgs(argv) {
19
20
  for (const a of argv) {
20
21
  if (a === '--staged') args.staged = true;
21
22
  else if (a === '--help' || a === '-h') args.help = true;
23
+ else if (a === '--version' || a === '-v') args.version = true;
22
24
  else args._.push(a);
23
25
  }
24
26
  return args;
@@ -28,6 +30,13 @@ async function run() {
28
30
  const argv = process.argv.slice(2);
29
31
  const args = parseArgs(argv);
30
32
 
33
+ if (args.version) {
34
+ const { version } = require('../package.json');
35
+ // eslint-disable-next-line no-console
36
+ console.log(`sec-gate v${version}`);
37
+ process.exit(0);
38
+ }
39
+
31
40
  if (args.help || args._.length === 0) {
32
41
  usage();
33
42
  process.exit(0);
@@ -1,19 +1,80 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const { getRepoRoot } = require('../git/repo');
1
+ 'use strict';
4
2
 
3
+ // security-scan: disable rule-id: path-join-resolve-traversal reason: all paths in this file are derived from `git rev-parse --show-toplevel` or hardcoded constants, never from user input
4
+ // security-scan: disable rule-id: detect-non-literal-fs-filename reason: all paths in this file are derived from `git rev-parse --show-toplevel` or hardcoded constants, never from user input
5
+
6
+ /**
7
+ * sec-gate install — Generic pre-commit hook injector
8
+ *
9
+ * STRATEGY (tool-agnostic):
10
+ * ─────────────────────────
11
+ * Every git hook manager (husky, lefthook, simple-git-hooks, pre-commit,
12
+ * custom hooksPath, etc.) ultimately tells git WHERE to look for hooks.
13
+ * Git has one source of truth for this: `git config core.hooksPath`.
14
+ * If that is not set, git falls back to `.git/hooks/`.
15
+ *
16
+ * So instead of enumerating every possible tool by name, we:
17
+ * 1. Ask git itself: "where will you look for the pre-commit hook?"
18
+ * 2. Resolve that path to an absolute location on disk.
19
+ * 3. If a pre-commit file already exists there → inject sec-gate as
20
+ * the first real command (after any shebang/bootstrap lines).
21
+ * 4. If no file exists yet → create a minimal shell hook.
22
+ *
23
+ * Special cases handled on top of the generic base:
24
+ * • Husky v4 — stores hooks in package.json, not in a shell file.
25
+ * • simple-git-hooks — same: package.json config, not a shell file.
26
+ * • lefthook — YAML config, not a shell file.
27
+ * • pre-commit (Python) — YAML config.
28
+ * These are detected BEFORE the generic path logic and handled
29
+ * by patching their config files. After patching we still also
30
+ * write to whatever path git resolves, as a safety net.
31
+ *
32
+ * Result: works for any tool — known or unknown — as long as it
33
+ * honours git's core.hooksPath or places hooks in .git/hooks/.
34
+ */
35
+
36
+ const fs = require('fs');
37
+ const path = require('path');
38
+ const { execFileSync } = require('child_process');
39
+ const { getRepoRoot } = require('../git/repo');
40
+
41
+ // ─────────────────────────────────────────────────────────────────────────────
42
+ // Constants
43
+ // ─────────────────────────────────────────────────────────────────────────────
5
44
  const HOOK_MARKER = '# installed-by: sec-gate';
45
+ const END_MARKER = '# end-sec-gate';
6
46
 
7
- function getHookPath(repoRoot) {
8
- return path.join(repoRoot, '.git', 'hooks', 'pre-commit');
47
+ // ─────────────────────────────────────────────────────────────────────────────
48
+ // Shell snippets
49
+ // ─────────────────────────────────────────────────────────────────────────────
50
+
51
+ /** Block injected into ANY existing shell hook file */
52
+ function secGateShellBlock() {
53
+ return [
54
+ '',
55
+ HOOK_MARKER,
56
+ 'if [ "$SEC_GATE_SKIP" != "1" ]; then',
57
+ ' if command -v sec-gate >/dev/null 2>&1; then',
58
+ ' sec-gate scan --staged',
59
+ ' _SG_EXIT=$?',
60
+ ' if [ $_SG_EXIT -ne 0 ]; then exit $_SG_EXIT; fi',
61
+ ' else',
62
+ ' echo "sec-gate: not found. Run: npm install -g sec-gate"',
63
+ ' exit 1',
64
+ ' fi',
65
+ 'fi',
66
+ END_MARKER,
67
+ ''
68
+ ].join('\n');
9
69
  }
10
70
 
11
- function buildHookScript() {
71
+ /** Full standalone hook — used when no file exists yet */
72
+ function standaloneHook() {
12
73
  return [
13
74
  '#!/usr/bin/env sh',
14
75
  HOOK_MARKER,
15
76
  '',
16
- '# Set SEC_GATE_SKIP=1 to bypass (emergency only)',
77
+ '# Bypass: SEC_GATE_SKIP=1 git commit -m "..."',
17
78
  'if [ "$SEC_GATE_SKIP" = "1" ]; then',
18
79
  ' echo "sec-gate: skipped (SEC_GATE_SKIP=1)"',
19
80
  ' exit 0',
@@ -26,72 +87,312 @@ function buildHookScript() {
26
87
  ' sec-gate scan --staged',
27
88
  ' exit $?',
28
89
  'else',
29
- ' echo "sec-gate: not found in PATH. Install it: npm install -g sec-gate"',
90
+ ' echo "sec-gate: not found. Run: npm install -g sec-gate"',
30
91
  ' exit 1',
31
92
  'fi',
32
93
  ''
33
94
  ].join('\n');
34
95
  }
35
96
 
36
- function isAlreadyInstalled(hookPath) {
37
- if (!fs.existsSync(hookPath)) return false;
38
- const content = fs.readFileSync(hookPath, 'utf8');
39
- return content.includes(HOOK_MARKER);
40
- }
97
+ // ─────────────────────────────────────────────────────────────────────────────
98
+ // Core: ask git where it will look for hooks
99
+ // ─────────────────────────────────────────────────────────────────────────────
41
100
 
42
- function suggestPrepareScript(repoRoot) {
43
- const pkgPath = path.join(repoRoot, 'package.json');
44
- if (!fs.existsSync(pkgPath)) return;
101
+ /**
102
+ * Returns the absolute path to the pre-commit hook file that git WILL execute.
103
+ * This is the single source of truth — works regardless of which hook manager
104
+ * set core.hooksPath.
105
+ *
106
+ * Special cases handled:
107
+ * - .husky/_ → husky's internal bootstrap shim dir, read-only, never write here.
108
+ * Fall back to .husky/pre-commit (the real hook file).
109
+ * - .husky → husky v6+ standard hooks dir, use .husky/pre-commit directly.
110
+ */
111
+ function resolveGitHookPath(repoRoot) {
112
+ let hooksDir;
45
113
 
46
- let pkg;
47
114
  try {
48
- pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
115
+ // git config core.hooksPath — set by husky v6, lefthook, custom configs, etc.
116
+ const configured = execFileSync(
117
+ 'git', ['config', '--local', 'core.hooksPath'],
118
+ { encoding: 'utf8', cwd: repoRoot, stdio: ['ignore', 'pipe', 'ignore'] }
119
+ ).trim();
120
+
121
+ if (configured) {
122
+ // Resolve relative paths (e.g. ".husky", ".githooks") against repo root
123
+ hooksDir = path.isAbsolute(configured)
124
+ ? configured
125
+ : path.join(repoRoot, configured);
126
+
127
+ // .husky/_ is husky's internal bootstrap shim directory — it is read-only
128
+ // and should never be written to. The actual user-editable hooks live in
129
+ // .husky/ (one level up). Redirect there.
130
+ const huskyShimDir = path.join(repoRoot, '.husky', '_');
131
+ if (hooksDir === huskyShimDir || hooksDir.startsWith(huskyShimDir + path.sep)) {
132
+ console.log('sec-gate: core.hooksPath points to .husky/_ (husky bootstrap shim) — redirecting to .husky/');
133
+ hooksDir = path.join(repoRoot, '.husky');
134
+ }
135
+ }
49
136
  } catch {
137
+ // core.hooksPath not set — use default
138
+ }
139
+
140
+ if (!hooksDir) {
141
+ hooksDir = path.join(repoRoot, '.git', 'hooks');
142
+ }
143
+
144
+ return path.join(hooksDir, 'pre-commit');
145
+ }
146
+
147
+ // ─────────────────────────────────────────────────────────────────────────────
148
+ // Core: inject into a shell hook file (generic, works for any manager)
149
+ // ─────────────────────────────────────────────────────────────────────────────
150
+
151
+ /**
152
+ * Injects the sec-gate shell block into hookPath.
153
+ *
154
+ * Insertion strategy (makes sec-gate run FIRST):
155
+ * - Skip shebang line (#!/...)
156
+ * - Skip any "bootstrap" lines (source/., export PATH, nvm/asdf/volta inits,
157
+ * husky.sh, lefthook bootstrap, etc.)
158
+ * - Insert sec-gate block after all those lines, before real commands
159
+ *
160
+ * This means sec-gate always runs before lint-staged, eslint, prettier,
161
+ * or whatever else the hook manager added.
162
+ */
163
+ function injectIntoShellHook(hookPath) {
164
+ if (alreadyInstalled(hookPath)) {
165
+ console.log(`sec-gate: already installed in ${hookPath}`);
50
166
  return;
51
167
  }
52
168
 
53
- const hasPrepare = pkg.scripts && pkg.scripts.prepare && pkg.scripts.prepare.includes('sec-gate install');
169
+ let lines;
170
+ if (fs.existsSync(hookPath)) {
171
+ lines = fs.readFileSync(hookPath, 'utf8').split('\n');
172
+ } else {
173
+ // File does not exist — create it from scratch
174
+ fs.mkdirSync(path.dirname(hookPath), { recursive: true });
175
+ fs.writeFileSync(hookPath, standaloneHook(), { encoding: 'utf8', mode: 0o755 });
176
+ console.log(`sec-gate: created pre-commit hook at ${hookPath}`);
177
+ return;
178
+ }
179
+
180
+ // Find insertion point: after shebang + bootstrap/sourcing lines
181
+ // Bootstrap patterns — lines that set up the environment, not real commands
182
+ const BOOTSTRAP_PATTERNS = [
183
+ /^#/, // comments (including shebang)
184
+ /^\s*$/, // blank lines
185
+ /\.\s+"[^"]*"/, // . "path/to/something.sh" (posix source)
186
+ /\.\s+\S+/, // . /path/to/script
187
+ /source\s+/i, // source /path/to/script
188
+ /export\s+/, // export PATH=...
189
+ /^eval\s+/, // eval "$(nvm/asdf/volta)"
190
+ /nvm|asdf|volta|rbenv|pyenv/ // version manager inits
191
+ ];
192
+
193
+ let insertAt = 0;
194
+ for (let i = 0; i < lines.length; i++) {
195
+ const isBootstrap = BOOTSTRAP_PATTERNS.some((re) => re.test(lines[i]));
196
+ if (isBootstrap) {
197
+ insertAt = i + 1; // keep moving insertion point past bootstrap lines
198
+ } else {
199
+ break; // first non-bootstrap line — stop here
200
+ }
201
+ }
202
+
203
+ lines.splice(insertAt, 0, ...secGateShellBlock().split('\n'));
204
+ fs.writeFileSync(hookPath, lines.join('\n'), { encoding: 'utf8', mode: 0o755 });
205
+ console.log(`sec-gate: injected into ${hookPath}`);
206
+ }
207
+
208
+ // ─────────────────────────────────────────────────────────────────────────────
209
+ // Helpers
210
+ // ─────────────────────────────────────────────────────────────────────────────
211
+
212
+ function alreadyInstalled(filePath) {
213
+ if (!fs.existsSync(filePath)) return false;
214
+ return fs.readFileSync(filePath, 'utf8').includes(HOOK_MARKER);
215
+ }
216
+
217
+ function readPkg(repoRoot) {
218
+ const p = path.join(repoRoot, 'package.json');
219
+ if (!fs.existsSync(p)) return null;
220
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
221
+ }
222
+
223
+ function writePkg(repoRoot, pkg) {
224
+ fs.writeFileSync(
225
+ path.join(repoRoot, 'package.json'),
226
+ JSON.stringify(pkg, null, 2) + '\n',
227
+ 'utf8'
228
+ );
229
+ }
230
+
231
+ function hasDep(pkg, name) {
232
+ if (!pkg) return false;
233
+ return !!(
234
+ (pkg.dependencies && pkg.dependencies[name]) ||
235
+ (pkg.devDependencies && pkg.devDependencies[name]) ||
236
+ (pkg.peerDependencies && pkg.peerDependencies[name])
237
+ );
238
+ }
239
+
240
+ // ─────────────────────────────────────────────────────────────────────────────
241
+ // Config-file-based hook managers
242
+ // These store their hook commands in YAML/JSON config, not shell files.
243
+ // We patch those config files AND then also inject into the resolved shell
244
+ // hook path as a safety net.
245
+ // ─────────────────────────────────────────────────────────────────────────────
246
+
247
+ /**
248
+ * Husky v4 — hooks live in package.json under "husky.hooks"
249
+ */
250
+ function patchHuskyV4(repoRoot, pkg) {
251
+ if (!pkg || !pkg.husky || !pkg.husky.hooks) return false;
252
+
253
+ const existing = (pkg.husky.hooks['pre-commit'] || '').trim();
254
+ if (existing.includes('sec-gate')) return false; // already there
255
+
256
+ pkg.husky.hooks['pre-commit'] = existing
257
+ ? `sec-gate scan --staged && ${existing}`
258
+ : 'sec-gate scan --staged';
259
+
260
+ writePkg(repoRoot, pkg);
261
+ console.log('sec-gate: patched husky v4 hooks in package.json');
262
+ return true;
263
+ }
264
+
265
+ /**
266
+ * simple-git-hooks — hooks live in package.json under "simple-git-hooks"
267
+ */
268
+ function patchSimpleGitHooks(repoRoot, pkg) {
269
+ if (!pkg) return false;
270
+ const sgh = pkg['simple-git-hooks'];
271
+ if (!sgh && !hasDep(pkg, 'simple-git-hooks')) return false;
272
+
273
+ const existing = ((sgh && sgh['pre-commit']) || '').trim();
274
+ if (existing.includes('sec-gate')) return false;
275
+
276
+ if (!pkg['simple-git-hooks']) pkg['simple-git-hooks'] = {};
277
+ pkg['simple-git-hooks']['pre-commit'] = existing
278
+ ? `sec-gate scan --staged && ${existing}`
279
+ : 'sec-gate scan --staged';
280
+
281
+ writePkg(repoRoot, pkg);
282
+ console.log('sec-gate: patched simple-git-hooks in package.json');
283
+ console.log(' run `npx simple-git-hooks` to apply.');
284
+ return true;
285
+ }
286
+
287
+ /**
288
+ * lefthook — hooks live in lefthook.yml / lefthook.json
289
+ */
290
+ function patchLefthook(repoRoot) {
291
+ const candidates = [
292
+ 'lefthook.yml', '.lefthook.yml',
293
+ 'lefthook.yaml', '.lefthook.yaml',
294
+ 'lefthook.json', '.lefthook.json'
295
+ ].map((f) => path.join(repoRoot, f));
296
+
297
+ const ymlPath = candidates.find(fs.existsSync);
298
+ if (!ymlPath) return false;
299
+
300
+ const content = fs.readFileSync(ymlPath, 'utf8');
301
+ if (content.includes('sec-gate')) return false;
302
+
303
+ // Inject a pre-commit command with priority 1 (runs first)
304
+ const injection = [
305
+ '',
306
+ '# installed-by: sec-gate',
307
+ 'pre-commit:',
308
+ ' commands:',
309
+ ' sec-gate:',
310
+ ' priority: 1',
311
+ ' run: sec-gate scan --staged',
312
+ ''
313
+ ].join('\n');
314
+
315
+ fs.writeFileSync(ymlPath, content + injection, 'utf8');
316
+ console.log(`sec-gate: patched ${path.basename(ymlPath)}`);
317
+ console.log(' run `lefthook install` to apply.');
318
+ return true;
319
+ }
320
+
321
+ /**
322
+ * pre-commit (Python tool) — .pre-commit-config.yaml
323
+ */
324
+ function patchPreCommitPy(repoRoot) {
325
+ const configPath = path.join(repoRoot, '.pre-commit-config.yaml');
326
+ if (!fs.existsSync(configPath)) return false;
327
+
328
+ const content = fs.readFileSync(configPath, 'utf8');
329
+ if (content.includes('sec-gate')) return false;
330
+
331
+ const localHook = [
332
+ '',
333
+ '# installed-by: sec-gate',
334
+ '- repo: local',
335
+ ' hooks:',
336
+ ' - id: sec-gate',
337
+ ' name: sec-gate OWASP security scan',
338
+ ' language: system',
339
+ ' entry: sec-gate scan --staged',
340
+ ' pass_filenames: false',
341
+ ' stages: [commit]',
342
+ ''
343
+ ].join('\n');
54
344
 
345
+ fs.writeFileSync(configPath, content + localHook, 'utf8');
346
+ console.log('sec-gate: patched .pre-commit-config.yaml');
347
+ console.log(' run `pre-commit install` to apply.');
348
+ return true;
349
+ }
350
+
351
+ // ─────────────────────────────────────────────────────────────────────────────
352
+ // Suggest prepare script tip
353
+ // ─────────────────────────────────────────────────────────────────────────────
354
+ function suggestPrepareScript(pkg) {
355
+ if (!pkg) return;
356
+ const hasPrepare = pkg.scripts && pkg.scripts.prepare &&
357
+ pkg.scripts.prepare.includes('sec-gate install');
55
358
  if (!hasPrepare) {
56
359
  console.log('');
57
- console.log(' TIP: To auto-install this hook for every developer on your team,');
58
- console.log(' add this to your repo\'s package.json "scripts":');
59
- console.log('');
60
- console.log(' "prepare": "sec-gate install"');
61
- console.log('');
62
- console.log(' Then any developer who runs `npm install` in this repo');
63
- console.log(' gets the pre-commit hook automatically — no manual step needed.');
360
+ console.log(' TIP: Add to package.json so teammates get the hook automatically:');
361
+ console.log(' "scripts": { "prepare": "sec-gate install" }');
362
+ console.log(' Then npm/pnpm/yarn install auto-runs sec-gate install for everyone.');
64
363
  console.log('');
65
364
  }
66
365
  }
67
366
 
367
+ // ─────────────────────────────────────────────────────────────────────────────
368
+ // Main
369
+ // ─────────────────────────────────────────────────────────────────────────────
68
370
  async function installHook() {
69
371
  const repoRoot = getRepoRoot();
70
- const hookPath = getHookPath(repoRoot);
71
372
 
72
373
  if (!fs.existsSync(path.join(repoRoot, '.git'))) {
73
- throw new Error('sec-gate install: .git directory not found. Run this inside a git repository.');
374
+ throw new Error('sec-gate install: .git not found. Run inside a git repository.');
74
375
  }
75
376
 
76
- fs.mkdirSync(path.dirname(hookPath), { recursive: true });
377
+ const pkg = readPkg(repoRoot);
77
378
 
78
- if (isAlreadyInstalled(hookPath)) {
79
- console.log('sec-gate: pre-commit hook is already installed.');
80
- suggestPrepareScript(repoRoot);
81
- return;
82
- }
83
-
84
- // Backup any existing hook that wasn't installed by us
85
- if (fs.existsSync(hookPath)) {
86
- const backupPath = `${hookPath}.sec-gate.bak`;
87
- fs.copyFileSync(hookPath, backupPath);
88
- console.log(`sec-gate: backed up existing hook to ${backupPath}`);
89
- }
379
+ // ── Step 1: patch any config-file-based hook managers ─────────────────────
380
+ // These tools store hooks in YAML/JSON, not shell files.
381
+ // We patch their config so their runner also executes sec-gate.
382
+ patchHuskyV4(repoRoot, pkg);
383
+ patchSimpleGitHooks(repoRoot, pkg);
384
+ patchLefthook(repoRoot);
385
+ patchPreCommitPy(repoRoot);
90
386
 
91
- fs.writeFileSync(hookPath, buildHookScript(), { encoding: 'utf8', mode: 0o755 });
92
- console.log(`sec-gate: pre-commit hook installed at ${hookPath}`);
387
+ // ── Step 2: resolve where git ACTUALLY runs the pre-commit hook ───────────
388
+ // This works for ANY tool — husky v6, lefthook, custom hooksPath, or bare.
389
+ // We just ask git itself where it will look, then inject there.
390
+ const resolvedHookPath = resolveGitHookPath(repoRoot);
391
+ console.log(`sec-gate: git will execute pre-commit hook from: ${resolvedHookPath}`);
392
+ injectIntoShellHook(resolvedHookPath);
93
393
 
94
- suggestPrepareScript(repoRoot);
394
+ // ── Step 3: suggest prepare script for team auto-setup ────────────────────
395
+ suggestPrepareScript(pkg);
95
396
  }
96
397
 
97
398
  module.exports = { installHook };
@@ -1,3 +1,4 @@
1
+ // security-scan: disable rule-id: detect-non-literal-fs-filename reason: lockfile paths come from a hardcoded allowlist of known filenames, never user input
1
2
  const { getStagedFiles, hasStagedDependencyFiles } = require('../git/stagedFiles');
2
3
  const { listTrackedFiles } = require('../git/trackedFiles');
3
4
  const { runSemgrep } = require('../scanners/semgrep');
@@ -12,10 +13,20 @@ function formatFinding(f) {
12
13
  return `- ${loc} [${f.checkId}]${owasp}\n ${f.message}`;
13
14
  }
14
15
 
16
+ const LOCKFILES = new Set([
17
+ 'pnpm-lock.yaml',
18
+ 'package-lock.json',
19
+ 'npm-shrinkwrap.json',
20
+ 'yarn.lock',
21
+ 'go.mod',
22
+ 'go.sum'
23
+ ]);
24
+
15
25
  function isSemgrepTargetPath(p) {
16
26
  if (!p) return false;
17
- if (p.endsWith('pnpm-lock.yaml')) return false;
18
- if (p === 'go.mod' || p === 'go.sum') return false;
27
+
28
+ const base = require('path').basename(p);
29
+ if (LOCKFILES.has(base)) return false;
19
30
 
20
31
  return (
21
32
  p.endsWith('.js') ||
@@ -60,12 +71,23 @@ async function scan({ staged }) {
60
71
  } else {
61
72
  const fs = require('fs');
62
73
 
63
- if (fs.existsSync('pnpm-lock.yaml')) {
64
- const scaOsv = await runOsvScanner({ lockfile: 'pnpm-lock.yaml' });
74
+ // Detect which Node lockfile exists — support npm, pnpm and yarn
75
+ const nodeLockfiles = [
76
+ 'pnpm-lock.yaml', // pnpm
77
+ 'package-lock.json', // npm
78
+ 'npm-shrinkwrap.json', // npm (legacy)
79
+ 'yarn.lock' // yarn
80
+ ];
81
+ const foundLockfile = nodeLockfiles.find((lf) => fs.existsSync(lf));
82
+
83
+ if (foundLockfile) {
84
+ // eslint-disable-next-line no-console
85
+ console.log(`sec-gate: running OSV-Scanner on ${foundLockfile}`);
86
+ const scaOsv = await runOsvScanner({ lockfile: foundLockfile });
65
87
  allFindings.push(...scaOsv);
66
88
  } else {
67
89
  // eslint-disable-next-line no-console
68
- console.log('sec-gate: pnpm-lock.yaml not found; skipping OSV-Scanner');
90
+ console.log('sec-gate: no Node lockfile found (pnpm-lock.yaml / package-lock.json / yarn.lock); skipping OSV-Scanner');
69
91
  }
70
92
 
71
93
  if (fs.existsSync('go.mod')) {
@@ -86,8 +108,24 @@ async function scan({ staged }) {
86
108
  process.exit(1);
87
109
  }
88
110
 
111
+ // ── Success summary ────────────────────────────────────────────────────────
112
+ const checks = [];
113
+ if (semgrepTargets.length > 0) {
114
+ checks.push(`SAST (${semgrepTargets.length} file${semgrepTargets.length > 1 ? 's' : ''})`);
115
+ }
116
+ if (depChanged || !staged) {
117
+ const fs = require('fs');
118
+ const nodeLockfilesCheck = ['pnpm-lock.yaml', 'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock'];
119
+ const foundLock = nodeLockfilesCheck.find((lf) => fs.existsSync(lf));
120
+ if (foundLock) checks.push(`SCA-node (${foundLock})`);
121
+ if (fs.existsSync('go.mod')) checks.push('SCA-go (go.mod)');
122
+ }
123
+
124
+ const checksRan = checks.length > 0 ? checks.join(', ') : 'no checks applicable';
125
+ // eslint-disable-next-line no-console
126
+ console.log(`sec-gate: all checks passed — no vulnerabilities found by sec-gate`);
89
127
  // eslint-disable-next-line no-console
90
- console.log('sec-gate: no findings after inline suppression');
128
+ console.log(`sec-gate: checks ran: ${checksRan}`);
91
129
  }
92
130
 
93
131
  module.exports = { scan };
@@ -1,3 +1,4 @@
1
+ const path = require('path');
1
2
  const { execSync } = require('child_process');
2
3
 
3
4
  function getStagedFiles() {
@@ -17,8 +18,15 @@ function getStagedFiles() {
17
18
 
18
19
  function hasStagedDependencyFiles(files) {
19
20
  if (!files || files.length === 0) return false;
20
- const depNames = new Set(['pnpm-lock.yaml', 'go.mod', 'go.sum']);
21
- return files.some((f) => depNames.has(f));
21
+ const depNames = new Set([
22
+ 'pnpm-lock.yaml', // pnpm
23
+ 'package-lock.json', // npm
24
+ 'npm-shrinkwrap.json', // npm (legacy)
25
+ 'yarn.lock', // yarn
26
+ 'go.mod', // Go
27
+ 'go.sum' // Go
28
+ ]);
29
+ return files.some((f) => depNames.has(path.basename(f)));
22
30
  }
23
31
 
24
32
  module.exports = { getStagedFiles, hasStagedDependencyFiles };
@@ -53,15 +53,18 @@ function applyInlineSuppressions({ findings }) {
53
53
 
54
54
  let suppressed = false;
55
55
 
56
- if (typeof f.line === 'number') {
56
+ // File-wide suppression: if the file contains a suppress comment for this
57
+ // rule-id (or '*') anywhere, suppress ALL findings of that rule in that file.
58
+ // This is the intended use of top-of-file suppression comments.
59
+ suppressed = hasInlineSuppressionAnywhere({ fileText: text, checkId: f.checkId });
60
+
61
+ // If not file-wide suppressed, check the near-line window for line findings
62
+ if (!suppressed && typeof f.line === 'number') {
57
63
  suppressed = hasInlineSuppressionNearLine({
58
64
  fileText: text,
59
65
  findingLine: f.line,
60
66
  checkId: f.checkId
61
67
  });
62
- } else {
63
- // SCA findings often lack line numbers; allow suppression anywhere in the file.
64
- suppressed = hasInlineSuppressionAnywhere({ fileText: text, checkId: f.checkId });
65
68
  }
66
69
 
67
70
  if (suppressed) continue;