sec-gate 0.1.8 → 0.1.9

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
@@ -126,6 +126,40 @@ doSomethingDangerous();
126
126
 
127
127
  ---
128
128
 
129
+ ## Configuration (optional)
130
+
131
+ Create a `.sec-gate.yml` file in your project root to tune the scanner:
132
+
133
+ ```yaml
134
+ # .sec-gate.yml
135
+
136
+ # Only block commits on high/critical findings (medium/low are reported but don't block)
137
+ severity_threshold: high
138
+
139
+ # Exclude specific high-noise rules
140
+ exclude_rules:
141
+ - path-join-resolve-traversal
142
+ - detect-non-literal-regexp
143
+ - detect-non-literal-fs-filename
144
+
145
+ # Skip test/mock files
146
+ exclude_paths:
147
+ - "**/__tests__/**"
148
+ - "**/*.test.js"
149
+ - "**/*.spec.ts"
150
+ - "**/mocks/**"
151
+
152
+ # Disable SCA if you use Snyk/Dependabot separately
153
+ sca: true
154
+
155
+ # Disable custom rules
156
+ custom_rules: true
157
+ ```
158
+
159
+ A full example with all options is at `sec-gate.example.yml` inside the package.
160
+
161
+ ---
162
+
129
163
  ## Bypass (emergency only)
130
164
 
131
165
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sec-gate",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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",
@@ -50,6 +50,7 @@
50
50
  "scripts/",
51
51
  "rules/",
52
52
  "vendor-bin/.gitkeep",
53
- "README.md"
53
+ "README.md",
54
+ "sec-gate.example.yml"
54
55
  ]
55
56
  }
@@ -0,0 +1,39 @@
1
+ # .sec-gate.yml — sec-gate configuration
2
+ # Copy this file to your project root as .sec-gate.yml and customize.
3
+ # All fields are optional. Defaults shown below.
4
+
5
+ # Severity threshold — only block commits on findings at or above this level.
6
+ # Options: all (default), critical, high, medium, low
7
+ # Examples:
8
+ # severity_threshold: all → block on any finding (most strict)
9
+ # severity_threshold: high → block on high + critical only
10
+ # severity_threshold: critical → block only on critical findings
11
+ severity_threshold: all
12
+
13
+ # Rule IDs to exclude globally (never report these, even if found).
14
+ # The defaults below exclude the highest false-positive rules.
15
+ # Set to [] to re-enable everything.
16
+ exclude_rules:
17
+ - path-join-resolve-traversal
18
+ - detect-non-literal-regexp
19
+ - detect-non-literal-fs-filename
20
+
21
+ # File paths/patterns to skip entirely (supports glob patterns).
22
+ # Useful for test files, mocks, fixtures where dangerous patterns are intentional.
23
+ exclude_paths:
24
+ - "**/__tests__/**"
25
+ - "**/*.test.js"
26
+ - "**/*.test.ts"
27
+ - "**/*.spec.js"
28
+ - "**/*.spec.ts"
29
+ - "**/test/**"
30
+ - "**/tests/**"
31
+ - "**/mocks/**"
32
+ - "**/fixtures/**"
33
+
34
+ # Enable/disable dependency vulnerability scanning (SCA).
35
+ # Disable if you manage dependencies with a separate tool (Snyk, Dependabot etc.)
36
+ sca: true
37
+
38
+ # Enable/disable custom security rules (hardcoded secrets, insecure random, etc.)
39
+ custom_rules: true
@@ -6,11 +6,18 @@ const { runOsvScanner } = require('../scanners/osv');
6
6
  const { runGovulncheck } = require('../scanners/govulncheck');
7
7
  const { applyInlineSuppressions } = require('../suppressions/inlineTag');
8
8
  const { scanFileWithCustomRules } = require('../../rules/custom-security');
9
+ const { loadConfig, meetsThreshold, isExcludedPath } = require('../config/loader');
10
+ const { getRepoRoot } = require('../git/repo');
11
+
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+ // Helpers
14
+ // ─────────────────────────────────────────────────────────────────────────────
9
15
 
10
16
  function formatFinding(f) {
11
- const loc = f.line ? `${f.path}:${f.line}` : f.path;
17
+ const loc = f.line ? `${f.path}:${f.line}` : f.path;
12
18
  const owasp = f.owasp ? ` (${f.owasp})` : '';
13
- return `- ${loc} [${f.checkId}]${owasp}\n ${f.message}`;
19
+ const sev = f.severity ? ` [${f.severity.toUpperCase()}]` : '';
20
+ return `- ${loc}${sev} [${f.checkId}]${owasp}\n ${f.message}`;
14
21
  }
15
22
 
16
23
  const LOCKFILES = new Set([
@@ -22,61 +29,121 @@ const LOCKFILES = new Set([
22
29
  'go.sum'
23
30
  ]);
24
31
 
25
- function isSemgrepTargetPath(p) {
32
+ function isSemgrepTargetPath(p, config) {
26
33
  if (!p) return false;
27
34
 
28
35
  const base = require('path').basename(p);
29
36
  if (LOCKFILES.has(base)) return false;
30
37
 
38
+ // Skip paths excluded in config
39
+ if (isExcludedPath(p, config.exclude_paths)) return false;
40
+
31
41
  return (
32
- p.endsWith('.js') ||
42
+ p.endsWith('.js') ||
33
43
  p.endsWith('.jsx') ||
34
44
  p.endsWith('.mjs') ||
35
45
  p.endsWith('.cjs') ||
36
- p.endsWith('.ts') ||
46
+ p.endsWith('.ts') ||
37
47
  p.endsWith('.tsx') ||
38
48
  p.endsWith('.go')
39
49
  );
40
50
  }
41
51
 
52
+ // ─────────────────────────────────────────────────────────────────────────────
53
+ // Apply config filters to findings list
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+ function applyConfigFilters(findings, config) {
56
+ const excludedRules = new Set(config.exclude_rules || []);
57
+ const threshold = config.severity_threshold || 'all';
58
+
59
+ const excluded = [];
60
+ const belowThreshold = [];
61
+ const remaining = [];
62
+
63
+ for (const f of findings) {
64
+ // 1. Exclude by rule ID
65
+ if (excludedRules.has(f.checkId)) {
66
+ excluded.push(f);
67
+ continue;
68
+ }
69
+
70
+ // 2. Exclude by path
71
+ if (isExcludedPath(f.path || '', config.exclude_paths)) {
72
+ excluded.push(f);
73
+ continue;
74
+ }
75
+
76
+ // 3. Filter by severity threshold
77
+ if (!meetsThreshold(f.severity, threshold)) {
78
+ belowThreshold.push(f);
79
+ continue;
80
+ }
81
+
82
+ remaining.push(f);
83
+ }
84
+
85
+ return { remaining, excluded, belowThreshold };
86
+ }
87
+
88
+ // ─────────────────────────────────────────────────────────────────────────────
89
+ // Main scan
90
+ // ─────────────────────────────────────────────────────────────────────────────
42
91
  async function scan({ staged }) {
43
- const files = staged ? getStagedFiles() : listTrackedFiles();
92
+ // Load per-repo config (.sec-gate.yml)
93
+ let repoRoot;
94
+ try { repoRoot = getRepoRoot(); } catch { repoRoot = process.cwd(); }
95
+ const config = loadConfig(repoRoot);
96
+
97
+ const files = staged ? getStagedFiles() : listTrackedFiles();
44
98
  const depChanged = staged ? hasStagedDependencyFiles(files) : true;
45
99
 
46
100
  // eslint-disable-next-line no-console
47
101
  console.log(`sec-gate: scan started (${staged ? 'staged files' : 'tracked files'})`);
48
102
 
49
- const allFindings = [];
50
- const semgrepTargets = (files || []).filter(isSemgrepTargetPath);
103
+ // Print active config summary
104
+ if (config.severity_threshold !== 'all') {
105
+ // eslint-disable-next-line no-console
106
+ console.log(`sec-gate: severity threshold: ${config.severity_threshold} and above`);
107
+ }
108
+ if (config.exclude_rules.length > 0) {
109
+ // eslint-disable-next-line no-console
110
+ console.log(`sec-gate: excluding ${config.exclude_rules.length} high-noise rule(s)`);
111
+ }
51
112
 
113
+ const allFindings = [];
114
+ const semgrepTargets = (files || []).filter((f) => isSemgrepTargetPath(f, config));
115
+
116
+ // ── SAST ──────────────────────────────────────────────────────────────────
52
117
  if (semgrepTargets.length > 0) {
53
- // SAST — owasp-top10 via @pensar/semgrep-node
54
118
  const sast = await runSemgrep({ files: semgrepTargets });
55
119
  allFindings.push(...sast);
56
120
 
57
- // Custom rules patterns not covered by owasp-top10 ruleset
58
- for (const filePath of semgrepTargets) {
59
- const custom = scanFileWithCustomRules(filePath);
60
- allFindings.push(...custom);
121
+ if (config.custom_rules !== false) {
122
+ for (const filePath of semgrepTargets) {
123
+ const custom = scanFileWithCustomRules(filePath);
124
+ allFindings.push(...custom);
125
+ }
61
126
  }
62
127
  } else {
63
128
  // eslint-disable-next-line no-console
64
129
  console.log('sec-gate: no relevant staged/tracked source files; skipping SAST');
65
130
  }
66
131
 
67
- // SCA (only when dependency lockfiles or go module files are staged)
68
- if (staged && !depChanged) {
132
+ // ── SCA ───────────────────────────────────────────────────────────────────
133
+ if (config.sca === false) {
134
+ // eslint-disable-next-line no-console
135
+ console.log('sec-gate: SCA disabled in config; skipping');
136
+ } else if (staged && !depChanged) {
69
137
  // eslint-disable-next-line no-console
70
138
  console.log('sec-gate: dependency files not staged; skipping SCA');
71
139
  } else {
72
140
  const fs = require('fs');
73
141
 
74
- // Detect which Node lockfile exists — support npm, pnpm and yarn
75
142
  const nodeLockfiles = [
76
- 'pnpm-lock.yaml', // pnpm
77
- 'package-lock.json', // npm
78
- 'npm-shrinkwrap.json', // npm (legacy)
79
- 'yarn.lock' // yarn
143
+ 'pnpm-lock.yaml',
144
+ 'package-lock.json',
145
+ 'npm-shrinkwrap.json',
146
+ 'yarn.lock'
80
147
  ];
81
148
  const foundLockfile = nodeLockfiles.find((lf) => fs.existsSync(lf));
82
149
 
@@ -87,7 +154,7 @@ async function scan({ staged }) {
87
154
  allFindings.push(...scaOsv);
88
155
  } else {
89
156
  // eslint-disable-next-line no-console
90
- console.log('sec-gate: no Node lockfile found (pnpm-lock.yaml / package-lock.json / yarn.lock); skipping OSV-Scanner');
157
+ console.log('sec-gate: no Node lockfile found; skipping OSV-Scanner');
91
158
  }
92
159
 
93
160
  if (fs.existsSync('go.mod')) {
@@ -99,21 +166,43 @@ async function scan({ staged }) {
99
166
  }
100
167
  }
101
168
 
102
- const filtered = applyInlineSuppressions({ findings: allFindings });
169
+ // ── Apply inline suppressions ─────────────────────────────────────────────
170
+ const afterSuppressions = applyInlineSuppressions({ findings: allFindings });
171
+
172
+ // ── Apply config filters (excluded rules, paths, severity threshold) ──────
173
+ const { remaining, excluded, belowThreshold } = applyConfigFilters(afterSuppressions, config);
174
+
175
+ // Report what was filtered (only in verbose — summarised in one line)
176
+ if (excluded.length > 0) {
177
+ // eslint-disable-next-line no-console
178
+ console.log(`sec-gate: filtered ${excluded.length} finding(s) by excluded rules/paths`);
179
+ }
180
+ if (belowThreshold.length > 0) {
181
+ // eslint-disable-next-line no-console
182
+ console.log(`sec-gate: filtered ${belowThreshold.length} finding(s) below severity threshold (${config.severity_threshold})`);
183
+ }
103
184
 
104
- if (filtered.length > 0) {
185
+ // ── Block or pass ─────────────────────────────────────────────────────────
186
+ if (remaining.length > 0) {
105
187
  // eslint-disable-next-line no-console
106
188
  console.log('\nsec-gate: SECURITY FINDINGS (commit blocked):');
107
- for (const f of filtered) console.log(formatFinding(f));
189
+ for (const f of remaining) console.log(formatFinding(f));
190
+
191
+ // Show hint about severity threshold if there are lower-severity findings
192
+ if (belowThreshold.length > 0 && config.severity_threshold === 'all') {
193
+ // eslint-disable-next-line no-console
194
+ console.log('\n TIP: Set severity_threshold in .sec-gate.yml to only block on high/critical.');
195
+ }
196
+
108
197
  process.exit(1);
109
198
  }
110
199
 
111
- // ── Success summary ────────────────────────────────────────────────────────
200
+ // ── Success summary ───────────────────────────────────────────────────────
112
201
  const checks = [];
113
202
  if (semgrepTargets.length > 0) {
114
203
  checks.push(`SAST (${semgrepTargets.length} file${semgrepTargets.length > 1 ? 's' : ''})`);
115
204
  }
116
- if (depChanged || !staged) {
205
+ if (config.sca !== false && (depChanged || !staged)) {
117
206
  const fs = require('fs');
118
207
  const nodeLockfilesCheck = ['pnpm-lock.yaml', 'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock'];
119
208
  const foundLock = nodeLockfilesCheck.find((lf) => fs.existsSync(lf));
@@ -0,0 +1,214 @@
1
+ 'use strict';
2
+
3
+ // security-scan: disable rule-id: path-join-resolve-traversal reason: repoRoot comes from git rev-parse, not user input
4
+ // security-scan: disable rule-id: detect-non-literal-fs-filename reason: repoRoot comes from git rev-parse, not user input
5
+ // security-scan: disable rule-id: detect-non-literal-regexp reason: patterns come from the config file written by the developer, not from end users
6
+ // security-scan: disable rule-id: prototype-pollution reason: result[key] assignment is parsing a config file where keys are validated against a known whitelist of fields
7
+
8
+ /**
9
+ * sec-gate config loader
10
+ *
11
+ * Reads .sec-gate.yml (or .sec-gate.yaml / sec-gate.config.js) from the
12
+ * repo root and merges it with built-in defaults.
13
+ *
14
+ * Config file example (.sec-gate.yml):
15
+ * ─────────────────────────────────────
16
+ * severity_threshold: high # block only on: critical, high, medium, low, all (default: all)
17
+ * exclude_rules: # rule IDs to never report
18
+ * - path-join-resolve-traversal
19
+ * - detect-non-literal-regexp
20
+ * exclude_paths: # glob patterns to skip
21
+ * - "**\/__tests__\/**"
22
+ * - "**\/mocks\/**"
23
+ * - "**\/fixtures\/**"
24
+ * sca: true # enable/disable SCA (default: true)
25
+ * custom_rules: true # enable/disable custom rules (default: true)
26
+ */
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+ // Severity ordering — higher index = more severe
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+ const SEVERITY_ORDER = ['low', 'medium', 'high', 'critical'];
35
+
36
+ // ─────────────────────────────────────────────────────────────────────────────
37
+ // Built-in defaults
38
+ // ─────────────────────────────────────────────────────────────────────────────
39
+ const DEFAULTS = {
40
+ // Block commit on any finding regardless of severity
41
+ severity_threshold: 'all',
42
+
43
+ // Rules excluded by default — these have very high false positive rates
44
+ // and rarely indicate real vulnerabilities in typical codebases.
45
+ // Developers can re-enable them by setting exclude_rules: [] in their config.
46
+ exclude_rules: [
47
+ 'path-join-resolve-traversal', // flags ANY variable in path.join — ~75% FP rate
48
+ 'detect-non-literal-regexp', // flags RegExp(var) even with hardcoded sources
49
+ 'detect-non-literal-fs-filename' // flags ANY variable in fs calls — ~70% FP rate
50
+ ],
51
+
52
+ // Paths excluded from scanning by default
53
+ exclude_paths: [
54
+ '**/__tests__/**',
55
+ '**/*.test.js',
56
+ '**/*.test.ts',
57
+ '**/*.spec.js',
58
+ '**/*.spec.ts',
59
+ '**/test/**',
60
+ '**/tests/**',
61
+ '**/mocks/**',
62
+ '**/fixtures/**',
63
+ '**/vendor/**',
64
+ '**/node_modules/**'
65
+ ],
66
+
67
+ sca: true,
68
+ custom_rules: true
69
+ };
70
+
71
+ // ─────────────────────────────────────────────────────────────────────────────
72
+ // Simple YAML parser (only handles the subset we need — no external dep)
73
+ // Supports: string values, boolean values, string arrays
74
+ // ─────────────────────────────────────────────────────────────────────────────
75
+ function parseYaml(text) {
76
+ const result = {};
77
+ let currentKey = null;
78
+ let currentArray = null;
79
+
80
+ for (const raw of text.split('\n')) {
81
+ const line = raw.replace(/#.*$/, '').trimEnd(); // strip comments
82
+ if (!line.trim()) continue;
83
+
84
+ // Array item: " - value"
85
+ if (/^\s+-\s+/.test(line) && currentKey && currentArray !== null) {
86
+ const val = line.replace(/^\s+-\s+/, '').replace(/^['"]|['"]$/g, '').trim();
87
+ currentArray.push(val);
88
+ continue;
89
+ }
90
+
91
+ // Key-value: "key: value" or "key:" (start of array)
92
+ const kvMatch = line.match(/^(\w+):\s*(.*)?$/);
93
+ if (kvMatch) {
94
+ if (currentKey && currentArray !== null) {
95
+ result[currentKey] = currentArray;
96
+ }
97
+ currentKey = kvMatch[1].trim();
98
+ const rawVal = (kvMatch[2] || '').trim().replace(/^['"]|['"]$/g, '');
99
+
100
+ if (rawVal === '') {
101
+ // Start of array block
102
+ currentArray = [];
103
+ } else if (rawVal === 'true') {
104
+ result[currentKey] = true;
105
+ currentKey = null;
106
+ currentArray = null;
107
+ } else if (rawVal === 'false') {
108
+ result[currentKey] = false;
109
+ currentKey = null;
110
+ currentArray = null;
111
+ } else {
112
+ result[currentKey] = rawVal;
113
+ currentKey = null;
114
+ currentArray = null;
115
+ }
116
+ continue;
117
+ }
118
+ }
119
+
120
+ // Flush last array
121
+ if (currentKey && currentArray !== null) {
122
+ result[currentKey] = currentArray;
123
+ }
124
+
125
+ return result;
126
+ }
127
+
128
+ // ─────────────────────────────────────────────────────────────────────────────
129
+ // Load and merge config
130
+ // ─────────────────────────────────────────────────────────────────────────────
131
+ function loadConfig(repoRoot) {
132
+ const candidates = [
133
+ path.join(repoRoot, '.sec-gate.yml'),
134
+ path.join(repoRoot, '.sec-gate.yaml'),
135
+ path.join(repoRoot, 'sec-gate.config.yml')
136
+ ];
137
+
138
+ let userConfig = {};
139
+
140
+ for (const candidate of candidates) {
141
+ if (fs.existsSync(candidate)) {
142
+ try {
143
+ const text = fs.readFileSync(candidate, 'utf8');
144
+ userConfig = parseYaml(text);
145
+ // eslint-disable-next-line no-console
146
+ console.log(`sec-gate: loaded config from ${path.basename(candidate)}`);
147
+ break;
148
+ } catch (err) {
149
+ // eslint-disable-next-line no-console
150
+ console.warn(`sec-gate: warning — could not parse ${candidate}: ${err.message}`);
151
+ }
152
+ }
153
+ }
154
+
155
+ // Merge: user config overrides defaults
156
+ // For arrays, user config REPLACES defaults (not merges), so teams have full control
157
+ const merged = {
158
+ severity_threshold: userConfig.severity_threshold || DEFAULTS.severity_threshold,
159
+ exclude_rules: Array.isArray(userConfig.exclude_rules)
160
+ ? userConfig.exclude_rules
161
+ : DEFAULTS.exclude_rules,
162
+ exclude_paths: Array.isArray(userConfig.exclude_paths)
163
+ ? userConfig.exclude_paths
164
+ : DEFAULTS.exclude_paths,
165
+ sca: userConfig.sca !== undefined ? userConfig.sca : DEFAULTS.sca,
166
+ custom_rules: userConfig.custom_rules !== undefined
167
+ ? userConfig.custom_rules
168
+ : DEFAULTS.custom_rules
169
+ };
170
+
171
+ return merged;
172
+ }
173
+
174
+ // ─────────────────────────────────────────────────────────────────────────────
175
+ // Severity check — should this finding be blocked given the threshold?
176
+ // ─────────────────────────────────────────────────────────────────────────────
177
+ function meetsThreshold(findingSeverity, threshold) {
178
+ if (threshold === 'all') return true;
179
+
180
+ const findingLevel = SEVERITY_ORDER.indexOf((findingSeverity || 'low').toLowerCase());
181
+ const thresholdLevel = SEVERITY_ORDER.indexOf((threshold || 'all').toLowerCase());
182
+
183
+ if (thresholdLevel === -1) return true; // unknown threshold → block everything
184
+ if (findingLevel === -1) return true; // unknown severity → be safe, block it
185
+
186
+ return findingLevel >= thresholdLevel;
187
+ }
188
+
189
+ // ─────────────────────────────────────────────────────────────────────────────
190
+ // Path exclusion check
191
+ // ─────────────────────────────────────────────────────────────────────────────
192
+ function isExcludedPath(filePath, excludePatterns) {
193
+ if (!excludePatterns || excludePatterns.length === 0) return false;
194
+
195
+ const normalized = filePath.replace(/\\/g, '/');
196
+
197
+ for (const pattern of excludePatterns) {
198
+ // Convert glob to simple regex:
199
+ // **/ matches any directory depth
200
+ // * matches anything except /
201
+ const regexStr = pattern
202
+ .replace(/\\/g, '/')
203
+ .replace(/\./g, '\\.')
204
+ .replace(/\*\*\//g, '(?:.+/)?')
205
+ .replace(/\*/g, '[^/]*');
206
+
207
+ const re = new RegExp(`(^|/)${regexStr}(/|$)`);
208
+ if (re.test(normalized)) return true;
209
+ }
210
+
211
+ return false;
212
+ }
213
+
214
+ module.exports = { loadConfig, meetsThreshold, isExcludedPath, SEVERITY_ORDER };