sec-gate 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,22 +11,56 @@ Supports **inline suppression** so developers can acknowledge known false positi
11
11
 
12
12
  ---
13
13
 
14
- ## Installone command, everything is set up automatically
14
+ ## Getting started developer setup
15
+
16
+ ### Step 1 — Install the tool globally (once per machine)
15
17
 
16
18
  ```bash
17
19
  npm install -g sec-gate
18
20
  ```
19
21
 
20
- That's it. This single command:
22
+ This single command installs the `sec-gate` CLI and automatically downloads **osv-scanner** and **govulncheck** for you. No separate tool installs needed.
23
+
24
+ > You only run this once per machine, not once per project.
25
+
26
+ ---
27
+
28
+ ### Step 2 — Connect it to your repo (once per cloned repo)
29
+
30
+ ```bash
31
+ cd your-project # go into the cloned repo root
32
+ sec-gate install # writes the pre-commit hook into .git/hooks/
33
+ ```
34
+
35
+ This tells Git to run `sec-gate scan` automatically before every commit in this repo.
36
+
37
+ > **You must run this in every repo you want protected.** The global install alone does not activate the hook anywhere — it just makes the `sec-gate` command available on your machine.
38
+
39
+ ---
40
+
41
+ ### Step 3 — Develop normally, commit as usual
42
+
43
+ ```bash
44
+ git add src/services/payment.js src/routes/user.js
45
+ git commit -m "feat: add payment service" # scan fires automatically here
46
+ ```
47
+
48
+ No extra commands. The hook handles everything.
21
49
 
22
- 1. Installs the `sec-gate` CLI globally
23
- 2. Downloads the **osv-scanner** binary for your OS automatically
24
- 3. Installs **govulncheck** via `go install` (if Go is available on your machine)
25
- 4. **Installs the pre-commit hook** in your current git repo automatically
50
+ ---
26
51
 
27
- No extra steps. No separate tool installs. Your next `git commit` is already security-checked.
52
+ ### Full example from scratch
28
53
 
29
- > **Note:** If you run `npm install -g sec-gate` from outside a git repo (e.g. your home directory), run `sec-gate install` once inside the repo afterwards.
54
+ ```bash
55
+ # On a fresh machine or a fresh clone:
56
+ npm install -g sec-gate # Step 1 — install tool globally (once per machine)
57
+ cd fmt-os # go into your project
58
+ sec-gate install # Step 2 — hook up this repo (once per clone)
59
+
60
+ # Now develop as normal:
61
+ git add .
62
+ git commit -m "my changes" # Step 3 — scan runs automatically here
63
+ ```
30
64
 
31
65
  ---
32
66
 
@@ -102,7 +136,7 @@ SEC_GATE_SKIP=1 git commit -m "emergency fix"
102
136
 
103
137
  ## Auto-setup for the whole team (optional but recommended)
104
138
 
105
- Add this to your **project's** `package.json` so every developer gets the hook automatically when they run `npm install`:
139
+ To avoid teammates forgetting `sec-gate install`, add this to your **project's** `package.json`:
106
140
 
107
141
  ```json
108
142
  "scripts": {
@@ -110,13 +144,17 @@ Add this to your **project's** `package.json` so every developer gets the hook a
110
144
  }
111
145
  ```
112
146
 
113
- Then the workflow for any new developer joining the team is:
147
+ Then the full onboarding flow for any new developer is just two commands:
114
148
 
115
149
  ```bash
116
- npm install -g sec-gate # global tool install (once per machine)
117
- npm install # prepare script auto-installs the hook
150
+ npm install -g sec-gate # Step 1 install tool globally (once per machine)
151
+ npm install # Step 2 — prepare script auto-runs sec-gate install
118
152
  ```
119
153
 
154
+ No need to remember `sec-gate install` separately — `npm install` handles it.
155
+
156
+ > Tip: document these two commands in your project's `CONTRIBUTING.md` so every new joiner knows the setup.
157
+
120
158
  ---
121
159
 
122
160
  ## GitHub Actions — CI gate + PR comments
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sec-gate",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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,298 @@ 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
+ function resolveGitHookPath(repoRoot) {
107
+ let hooksDir;
45
108
 
46
- let pkg;
47
109
  try {
48
- pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
110
+ // git config core.hooksPath — set by husky v6, lefthook, custom configs, etc.
111
+ const configured = execFileSync(
112
+ 'git', ['config', '--local', 'core.hooksPath'],
113
+ { encoding: 'utf8', cwd: repoRoot, stdio: ['ignore', 'pipe', 'ignore'] }
114
+ ).trim();
115
+
116
+ if (configured) {
117
+ // Resolve relative paths (e.g. ".husky", ".githooks") against repo root
118
+ hooksDir = path.isAbsolute(configured)
119
+ ? configured
120
+ : path.join(repoRoot, configured);
121
+ }
49
122
  } catch {
123
+ // core.hooksPath not set — use default
124
+ }
125
+
126
+ if (!hooksDir) {
127
+ hooksDir = path.join(repoRoot, '.git', 'hooks');
128
+ }
129
+
130
+ return path.join(hooksDir, 'pre-commit');
131
+ }
132
+
133
+ // ─────────────────────────────────────────────────────────────────────────────
134
+ // Core: inject into a shell hook file (generic, works for any manager)
135
+ // ─────────────────────────────────────────────────────────────────────────────
136
+
137
+ /**
138
+ * Injects the sec-gate shell block into hookPath.
139
+ *
140
+ * Insertion strategy (makes sec-gate run FIRST):
141
+ * - Skip shebang line (#!/...)
142
+ * - Skip any "bootstrap" lines (source/., export PATH, nvm/asdf/volta inits,
143
+ * husky.sh, lefthook bootstrap, etc.)
144
+ * - Insert sec-gate block after all those lines, before real commands
145
+ *
146
+ * This means sec-gate always runs before lint-staged, eslint, prettier,
147
+ * or whatever else the hook manager added.
148
+ */
149
+ function injectIntoShellHook(hookPath) {
150
+ if (alreadyInstalled(hookPath)) {
151
+ console.log(`sec-gate: already installed in ${hookPath}`);
152
+ return;
153
+ }
154
+
155
+ let lines;
156
+ if (fs.existsSync(hookPath)) {
157
+ lines = fs.readFileSync(hookPath, 'utf8').split('\n');
158
+ } else {
159
+ // File does not exist — create it from scratch
160
+ fs.mkdirSync(path.dirname(hookPath), { recursive: true });
161
+ fs.writeFileSync(hookPath, standaloneHook(), { encoding: 'utf8', mode: 0o755 });
162
+ console.log(`sec-gate: created pre-commit hook at ${hookPath}`);
50
163
  return;
51
164
  }
52
165
 
53
- const hasPrepare = pkg.scripts && pkg.scripts.prepare && pkg.scripts.prepare.includes('sec-gate install');
166
+ // Find insertion point: after shebang + bootstrap/sourcing lines
167
+ // Bootstrap patterns — lines that set up the environment, not real commands
168
+ const BOOTSTRAP_PATTERNS = [
169
+ /^#/, // comments (including shebang)
170
+ /^\s*$/, // blank lines
171
+ /\.\s+"[^"]*"/, // . "path/to/something.sh" (posix source)
172
+ /\.\s+\S+/, // . /path/to/script
173
+ /source\s+/i, // source /path/to/script
174
+ /export\s+/, // export PATH=...
175
+ /^eval\s+/, // eval "$(nvm/asdf/volta)"
176
+ /nvm|asdf|volta|rbenv|pyenv/ // version manager inits
177
+ ];
178
+
179
+ let insertAt = 0;
180
+ for (let i = 0; i < lines.length; i++) {
181
+ const isBootstrap = BOOTSTRAP_PATTERNS.some((re) => re.test(lines[i]));
182
+ if (isBootstrap) {
183
+ insertAt = i + 1; // keep moving insertion point past bootstrap lines
184
+ } else {
185
+ break; // first non-bootstrap line — stop here
186
+ }
187
+ }
188
+
189
+ lines.splice(insertAt, 0, ...secGateShellBlock().split('\n'));
190
+ fs.writeFileSync(hookPath, lines.join('\n'), { encoding: 'utf8', mode: 0o755 });
191
+ console.log(`sec-gate: injected into ${hookPath}`);
192
+ }
193
+
194
+ // ─────────────────────────────────────────────────────────────────────────────
195
+ // Helpers
196
+ // ─────────────────────────────────────────────────────────────────────────────
197
+
198
+ function alreadyInstalled(filePath) {
199
+ if (!fs.existsSync(filePath)) return false;
200
+ return fs.readFileSync(filePath, 'utf8').includes(HOOK_MARKER);
201
+ }
202
+
203
+ function readPkg(repoRoot) {
204
+ const p = path.join(repoRoot, 'package.json');
205
+ if (!fs.existsSync(p)) return null;
206
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
207
+ }
208
+
209
+ function writePkg(repoRoot, pkg) {
210
+ fs.writeFileSync(
211
+ path.join(repoRoot, 'package.json'),
212
+ JSON.stringify(pkg, null, 2) + '\n',
213
+ 'utf8'
214
+ );
215
+ }
216
+
217
+ function hasDep(pkg, name) {
218
+ if (!pkg) return false;
219
+ return !!(
220
+ (pkg.dependencies && pkg.dependencies[name]) ||
221
+ (pkg.devDependencies && pkg.devDependencies[name]) ||
222
+ (pkg.peerDependencies && pkg.peerDependencies[name])
223
+ );
224
+ }
54
225
 
226
+ // ─────────────────────────────────────────────────────────────────────────────
227
+ // Config-file-based hook managers
228
+ // These store their hook commands in YAML/JSON config, not shell files.
229
+ // We patch those config files AND then also inject into the resolved shell
230
+ // hook path as a safety net.
231
+ // ─────────────────────────────────────────────────────────────────────────────
232
+
233
+ /**
234
+ * Husky v4 — hooks live in package.json under "husky.hooks"
235
+ */
236
+ function patchHuskyV4(repoRoot, pkg) {
237
+ if (!pkg || !pkg.husky || !pkg.husky.hooks) return false;
238
+
239
+ const existing = (pkg.husky.hooks['pre-commit'] || '').trim();
240
+ if (existing.includes('sec-gate')) return false; // already there
241
+
242
+ pkg.husky.hooks['pre-commit'] = existing
243
+ ? `sec-gate scan --staged && ${existing}`
244
+ : 'sec-gate scan --staged';
245
+
246
+ writePkg(repoRoot, pkg);
247
+ console.log('sec-gate: patched husky v4 hooks in package.json');
248
+ return true;
249
+ }
250
+
251
+ /**
252
+ * simple-git-hooks — hooks live in package.json under "simple-git-hooks"
253
+ */
254
+ function patchSimpleGitHooks(repoRoot, pkg) {
255
+ if (!pkg) return false;
256
+ const sgh = pkg['simple-git-hooks'];
257
+ if (!sgh && !hasDep(pkg, 'simple-git-hooks')) return false;
258
+
259
+ const existing = ((sgh && sgh['pre-commit']) || '').trim();
260
+ if (existing.includes('sec-gate')) return false;
261
+
262
+ if (!pkg['simple-git-hooks']) pkg['simple-git-hooks'] = {};
263
+ pkg['simple-git-hooks']['pre-commit'] = existing
264
+ ? `sec-gate scan --staged && ${existing}`
265
+ : 'sec-gate scan --staged';
266
+
267
+ writePkg(repoRoot, pkg);
268
+ console.log('sec-gate: patched simple-git-hooks in package.json');
269
+ console.log(' run `npx simple-git-hooks` to apply.');
270
+ return true;
271
+ }
272
+
273
+ /**
274
+ * lefthook — hooks live in lefthook.yml / lefthook.json
275
+ */
276
+ function patchLefthook(repoRoot) {
277
+ const candidates = [
278
+ 'lefthook.yml', '.lefthook.yml',
279
+ 'lefthook.yaml', '.lefthook.yaml',
280
+ 'lefthook.json', '.lefthook.json'
281
+ ].map((f) => path.join(repoRoot, f));
282
+
283
+ const ymlPath = candidates.find(fs.existsSync);
284
+ if (!ymlPath) return false;
285
+
286
+ const content = fs.readFileSync(ymlPath, 'utf8');
287
+ if (content.includes('sec-gate')) return false;
288
+
289
+ // Inject a pre-commit command with priority 1 (runs first)
290
+ const injection = [
291
+ '',
292
+ '# installed-by: sec-gate',
293
+ 'pre-commit:',
294
+ ' commands:',
295
+ ' sec-gate:',
296
+ ' priority: 1',
297
+ ' run: sec-gate scan --staged',
298
+ ''
299
+ ].join('\n');
300
+
301
+ fs.writeFileSync(ymlPath, content + injection, 'utf8');
302
+ console.log(`sec-gate: patched ${path.basename(ymlPath)}`);
303
+ console.log(' run `lefthook install` to apply.');
304
+ return true;
305
+ }
306
+
307
+ /**
308
+ * pre-commit (Python tool) — .pre-commit-config.yaml
309
+ */
310
+ function patchPreCommitPy(repoRoot) {
311
+ const configPath = path.join(repoRoot, '.pre-commit-config.yaml');
312
+ if (!fs.existsSync(configPath)) return false;
313
+
314
+ const content = fs.readFileSync(configPath, 'utf8');
315
+ if (content.includes('sec-gate')) return false;
316
+
317
+ const localHook = [
318
+ '',
319
+ '# installed-by: sec-gate',
320
+ '- repo: local',
321
+ ' hooks:',
322
+ ' - id: sec-gate',
323
+ ' name: sec-gate OWASP security scan',
324
+ ' language: system',
325
+ ' entry: sec-gate scan --staged',
326
+ ' pass_filenames: false',
327
+ ' stages: [commit]',
328
+ ''
329
+ ].join('\n');
330
+
331
+ fs.writeFileSync(configPath, content + localHook, 'utf8');
332
+ console.log('sec-gate: patched .pre-commit-config.yaml');
333
+ console.log(' run `pre-commit install` to apply.');
334
+ return true;
335
+ }
336
+
337
+ // ─────────────────────────────────────────────────────────────────────────────
338
+ // Suggest prepare script tip
339
+ // ─────────────────────────────────────────────────────────────────────────────
340
+ function suggestPrepareScript(pkg) {
341
+ if (!pkg) return;
342
+ const hasPrepare = pkg.scripts && pkg.scripts.prepare &&
343
+ pkg.scripts.prepare.includes('sec-gate install');
55
344
  if (!hasPrepare) {
56
345
  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.');
346
+ console.log(' TIP: Add to package.json so teammates get the hook automatically:');
347
+ console.log(' "scripts": { "prepare": "sec-gate install" }');
348
+ console.log(' Then npm/pnpm/yarn install auto-runs sec-gate install for everyone.');
64
349
  console.log('');
65
350
  }
66
351
  }
67
352
 
353
+ // ─────────────────────────────────────────────────────────────────────────────
354
+ // Main
355
+ // ─────────────────────────────────────────────────────────────────────────────
68
356
  async function installHook() {
69
357
  const repoRoot = getRepoRoot();
70
- const hookPath = getHookPath(repoRoot);
71
358
 
72
359
  if (!fs.existsSync(path.join(repoRoot, '.git'))) {
73
- throw new Error('sec-gate install: .git directory not found. Run this inside a git repository.');
360
+ throw new Error('sec-gate install: .git not found. Run inside a git repository.');
74
361
  }
75
362
 
76
- fs.mkdirSync(path.dirname(hookPath), { recursive: true });
77
-
78
- if (isAlreadyInstalled(hookPath)) {
79
- console.log('sec-gate: pre-commit hook is already installed.');
80
- suggestPrepareScript(repoRoot);
81
- return;
82
- }
363
+ const pkg = readPkg(repoRoot);
83
364
 
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
- }
365
+ // ── Step 1: patch any config-file-based hook managers ─────────────────────
366
+ // These tools store hooks in YAML/JSON, not shell files.
367
+ // We patch their config so their runner also executes sec-gate.
368
+ patchHuskyV4(repoRoot, pkg);
369
+ patchSimpleGitHooks(repoRoot, pkg);
370
+ patchLefthook(repoRoot);
371
+ patchPreCommitPy(repoRoot);
90
372
 
91
- fs.writeFileSync(hookPath, buildHookScript(), { encoding: 'utf8', mode: 0o755 });
92
- console.log(`sec-gate: pre-commit hook installed at ${hookPath}`);
373
+ // ── Step 2: resolve where git ACTUALLY runs the pre-commit hook ───────────
374
+ // This works for ANY tool — husky v6, lefthook, custom hooksPath, or bare.
375
+ // We just ask git itself where it will look, then inject there.
376
+ const resolvedHookPath = resolveGitHookPath(repoRoot);
377
+ console.log(`sec-gate: git will execute pre-commit hook from: ${resolvedHookPath}`);
378
+ injectIntoShellHook(resolvedHookPath);
93
379
 
94
- suggestPrepareScript(repoRoot);
380
+ // ── Step 3: suggest prepare script for team auto-setup ────────────────────
381
+ suggestPrepareScript(pkg);
95
382
  }
96
383
 
97
384
  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;