sec-gate 0.1.8 → 0.2.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/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.2.0",
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",
@@ -19,7 +19,9 @@
19
19
  "postinstall": "node scripts/postinstall.js"
20
20
  },
21
21
  "dependencies": {
22
- "@pensar/semgrep-node": "^1.2.4"
22
+ "@pensar/semgrep-node": "^1.2.4",
23
+ "acorn": "^8.16.0",
24
+ "acorn-walk": "^8.3.5"
23
25
  },
24
26
  "keywords": [
25
27
  "security",
@@ -50,6 +52,7 @@
50
52
  "scripts/",
51
53
  "rules/",
52
54
  "vendor-bin/.gitkeep",
53
- "README.md"
55
+ "README.md",
56
+ "sec-gate.example.yml"
54
57
  ]
55
58
  }
@@ -1,316 +1,479 @@
1
+ 'use strict';
2
+
3
+ // security-scan: disable rule-id: detect-non-literal-fs-filename reason: filePath comes from git diff --cached, not user input
4
+ // security-scan: disable rule-id: prototype-pollution reason: AST visitor pattern uses bracket notation on known node types, not user input
5
+
1
6
  /**
2
7
  * @file custom-security.js
3
- * @description sec-gate static analysis rule definitions.
8
+ * @description sec-gate custom security rules — AST-based analysis.
4
9
  *
5
- * This file is part of the sec-gate security scanning tool.
6
- * It defines DETECTION RULES used to identify insecure coding patterns
7
- * in source files during pre-commit scanning.
10
+ * Uses acorn to parse JavaScript/TypeScript into an Abstract Syntax Tree (AST)
11
+ * and walks the tree to detect security issues. This is fundamentally different
12
+ * from regex-based scanning:
8
13
  *
9
- * These rules are DETECTORS they do not execute the patterns they detect.
10
- * Pattern strings are stored as text and compiled into RegExp at runtime.
14
+ * REGEX (old): sees raw text line by line misses multi-line patterns,
15
+ * variable assignments, and code structure
11
16
  *
12
- * Rules cover patterns not caught by the owasp-top10 ruleset:
13
- * 1. Hardcoded secrets (API keys, passwords, JWT secrets)
14
- * 2. Insecure randomness (Math.random used for security tokens)
15
- * 3. Prototype pollution via bracket notation
16
- * 4. Sensitive data stored in Web Storage APIs
17
- * 5. Sensitive data exposure via console logging
18
- * 6. Dynamic code execution via Function constructor
17
+ * AST (new): understands code structure tracks variable assignments,
18
+ * function calls, object shapes, and data flow across lines
19
19
  *
20
- * @module sec-gate/rules/custom-security
20
+ * Rules implemented:
21
+ * 1. SQL injection via template literals (sequelize/knex/pg)
22
+ * 2. SQL injection via string concatenation
23
+ * 3. Hardcoded secrets in variable assignments
24
+ * 4. Hardcoded secrets in object literals
25
+ * 5. Insecure randomness (Math.random) in security context
26
+ * 6. Prototype pollution via bracket notation
27
+ * 7. Direct __proto__ access
28
+ * 8. Sensitive data in localStorage/sessionStorage
29
+ * 9. Sensitive data in console output
30
+ * 10. Dynamic code execution (new Function / eval)
31
+ * 11. Command injection (child_process.exec with template literal)
32
+ * 12. Path traversal (path.join/resolve with user-like variables)
21
33
  */
22
34
 
23
- 'use strict';
24
-
25
35
  const fs = require('fs');
26
36
  const path = require('path');
27
37
 
38
+ let acorn, walk;
39
+ try {
40
+ acorn = require('acorn');
41
+ walk = require('acorn-walk');
42
+ } catch {
43
+ // acorn not available — fall back to regex mode (degraded)
44
+ acorn = null;
45
+ walk = null;
46
+ }
47
+
28
48
  // ─────────────────────────────────────────────────────────────────────────────
29
- // Pattern registry
30
- // Patterns are stored as strings and compiled to RegExp at module load time.
31
- // This is intentional: storing patterns as strings makes the intent clear
32
- // (these are detectors, not code that uses the patterns).
49
+ // Helpers
33
50
  // ─────────────────────────────────────────────────────────────────────────────
34
51
 
35
- /**
36
- * Each entry defines one detection rule.
37
- * Fields:
38
- * id — unique rule identifier
39
- * description developer-facing explanation of the risk and fix
40
- * owasp — OWASP Top 10 2021 category
41
- * severity — critical | high | medium | low
42
- * patterns array of { source, flags } objects compiled into RegExp
43
- * require — 'any' (default) or 'all' — how multiple patterns are combined
44
- * context — optional: also check surrounding N lines for this pattern
45
- */
46
- const RULE_DEFINITIONS = [
52
+ const SENSITIVE_NAMES = /(?:password|passwd|pwd|secret|api.?key|apikey|jwt|token|auth|credential|private.?key|access.?key|session)/i;
53
+ const DB_QUERY_METHODS = /^(query|execute|raw|runQuery|sequelize\.query|knex\.raw|pg\.query|mysql\.query|db\.query)$/i;
54
+
55
+ function nodeName(node) {
56
+ if (!node) return '';
57
+ if (node.type === 'Identifier') return node.name;
58
+ if (node.type === 'MemberExpression') {
59
+ return `${nodeName(node.object)}.${nodeName(node.property)}`;
60
+ }
61
+ return '';
62
+ }
47
63
 
48
- // ── Rule 1: Hardcoded secret in variable assignment ───────────────────────
49
- {
64
+ function isTemplateLiteralWithExpressions(node) {
65
+ return node && node.type === 'TemplateLiteral' && node.expressions && node.expressions.length > 0;
66
+ }
67
+
68
+ function isConcatenatedString(node) {
69
+ if (!node) return false;
70
+ if (node.type === 'BinaryExpression' && node.operator === '+') {
71
+ return true;
72
+ }
73
+ return false;
74
+ }
75
+
76
+ function isStringLiteral(node) {
77
+ return node && (node.type === 'Literal' && typeof node.value === 'string');
78
+ }
79
+
80
+ function isSensitiveName(name) {
81
+ return SENSITIVE_NAMES.test(name || '');
82
+ }
83
+
84
+ function getCalleeName(node) {
85
+ if (!node) return '';
86
+ if (node.type === 'CallExpression') return getCalleeName(node.callee);
87
+ if (node.type === 'Identifier') return node.name;
88
+ if (node.type === 'MemberExpression') {
89
+ return `${nodeName(node.object)}.${nodeName(node.property)}`;
90
+ }
91
+ return '';
92
+ }
93
+
94
+ function makeFinding({ rule, node, filePath, extraMsg }) {
95
+ return {
96
+ checkId: rule.id,
97
+ path: filePath,
98
+ line: node.loc ? node.loc.start.line : undefined,
99
+ message: extraMsg ? `${rule.description} ${extraMsg}` : rule.description,
100
+ severity: rule.severity,
101
+ owasp: rule.owasp,
102
+ raw: { nodeType: node.type }
103
+ };
104
+ }
105
+
106
+ // ─────────────────────────────────────────────────────────────────────────────
107
+ // Rule definitions — pure metadata, logic is in the walker below
108
+ // ─────────────────────────────────────────────────────────────────────────────
109
+
110
+ const RULES = {
111
+ SQL_TEMPLATE: {
112
+ id: 'sql-injection-template-literal',
113
+ description: 'SQL query built with template literal string interpolation. Variables interpolated directly into SQL allow SQL injection. Use parameterized queries: sequelize.query(sql, { replacements: [...] })',
114
+ owasp: 'A03:2021 Injection',
115
+ severity: 'critical'
116
+ },
117
+ SQL_CONCAT: {
118
+ id: 'sql-injection-concatenation',
119
+ description: 'SQL query built with string concatenation. Use parameterized queries instead of building SQL strings manually.',
120
+ owasp: 'A03:2021 Injection',
121
+ severity: 'critical'
122
+ },
123
+ HARDCODED_SECRET_VAR: {
50
124
  id: 'hardcoded-secret-assignment',
51
- description: [
52
- 'Hardcoded secret detected in variable assignment.',
53
- 'Secrets (API keys, passwords, JWT secrets) must be loaded from',
54
- 'environment variables (process.env.MY_SECRET), not hardcoded.',
55
- 'Hardcoded secrets are exposed in version control and build artifacts.'
56
- ].join(' '),
125
+ description: 'Hardcoded secret detected in variable assignment. Load secrets from environment variables (process.env.MY_SECRET) instead.',
57
126
  owasp: 'A02:2021 Cryptographic Failures',
58
- severity: 'critical',
59
- patterns: [
60
- {
61
- source: '(?:const|let|var)\\s+(?:\\w*(?:key|secret|password|passwd|pwd|token|api_key|jwt|auth|credential|private_key)\\w*)\\s*=\\s*["\u0060\'][^"\u0060\'\\s]{6,}',
62
- flags: 'i'
63
- }
64
- ]
127
+ severity: 'critical'
65
128
  },
66
-
67
- // ── Rule 2: Hardcoded secret in object literal ────────────────────────────
68
- {
129
+ HARDCODED_SECRET_OBJ: {
69
130
  id: 'hardcoded-secret-object',
70
- description: [
71
- 'Hardcoded secret detected in object literal.',
72
- 'Use environment variables instead of hardcoding credentials in objects.'
73
- ].join(' '),
131
+ description: 'Hardcoded secret detected in object literal. Load secrets from environment variables instead.',
74
132
  owasp: 'A02:2021 Cryptographic Failures',
75
- severity: 'critical',
76
- patterns: [
77
- {
78
- source: '(?:password|passwd|pwd|secret|api_key|apikey|jwt_secret|private_key|auth_token)\\s*:\\s*["\u0060\'][^"\u0060\'\\s]{6,}',
79
- flags: 'i'
80
- }
81
- ]
133
+ severity: 'critical'
82
134
  },
83
-
84
- // ── Rule 3: Insecure random — token context ───────────────────────────────
85
- {
135
+ INSECURE_RANDOM: {
86
136
  id: 'insecure-random-token',
87
- // security-scan: disable rule-id: insecure-random-context reason: description string documents the bad pattern, not uses it
88
- description: 'Math dot random() is not cryptographically secure and must not be used to generate tokens, session IDs, nonces or passwords. Use crypto.randomBytes() (Node.js) or crypto.getRandomValues() (browser) instead.',
137
+ description: 'Math.random() is not cryptographically secure. Use crypto.randomBytes() (Node.js) or crypto.getRandomValues() (browser) for tokens, session IDs, and passwords.',
89
138
  owasp: 'A02:2021 Cryptographic Failures',
90
- severity: 'high',
91
- patterns: [
92
- { source: 'Math\\.random\\(\\)', flags: '' },
93
- { source: '(?:token|session|id|key|secret|password|nonce|salt|otp|code|csrf)', flags: 'i' }
94
- ],
95
- require: 'all'
139
+ severity: 'high'
96
140
  },
97
-
98
- // ── Rule 4: Insecure random — ambient context ─────────────────────────────
99
- {
100
- id: 'insecure-random-context',
101
- // security-scan: disable rule-id: insecure-random-context reason: description string documents the bad pattern, not uses it
102
- description: 'Math dot random() detected in a security-sensitive context. Use crypto.randomBytes() for any cryptographic or security-sensitive purpose.',
103
- owasp: 'A02:2021 Cryptographic Failures',
104
- severity: 'medium',
105
- patterns: [
106
- { source: 'Math\\.random\\(\\)', flags: '' }
107
- ],
108
- context: {
109
- lines: 3,
110
- pattern: { source: '(?:token|session|secret|key|auth|crypto|password|nonce|salt)', flags: 'i' }
111
- }
112
- },
113
-
114
- // ── Rule 5: Prototype pollution via bracket notation ──────────────────────
115
- {
141
+ PROTOTYPE_POLLUTION: {
116
142
  id: 'prototype-pollution',
117
- description: [
118
- 'Possible prototype pollution: a variable key is used in bracket-notation assignment.',
119
- 'If the key is user-controlled, an attacker can set properties on Object.prototype.',
120
- 'Validate or whitelist keys before assignment.'
121
- ].join(' '),
143
+ description: 'Bracket notation assignment with a variable key. If the key is user-controlled, an attacker can pollute Object.prototype. Validate or whitelist keys before assignment.',
122
144
  owasp: 'A03:2021 Injection',
123
- severity: 'high',
124
- patterns: [
125
- { source: '\\w+\\[\\s*\\w+\\s*\\]\\s*=', flags: '' }
126
- ]
145
+ severity: 'high'
127
146
  },
128
-
129
- // ── Rule 6: Direct prototype chain access ─────────────────────────────────
130
- {
147
+ PROTO_ACCESS: {
131
148
  id: 'proto-direct-access',
132
- description: [
133
- 'Direct access to the prototype chain detected.',
134
- 'This pattern is commonly used in prototype pollution attacks.',
135
- 'Avoid using prototype-chain access with user-controlled input.'
136
- ].join(' '),
149
+ description: 'Direct __proto__ access detected. This is commonly used in prototype pollution attacks.',
137
150
  owasp: 'A03:2021 Injection',
138
- severity: 'critical',
139
- patterns: [
140
- // security-scan: disable rule-id: proto-direct-access reason: this string is the detection pattern, not usage of __proto__
141
- { source: '__proto__', flags: '' }
142
- ]
143
- },
144
-
145
- // ── Rule 7: Sensitive data in localStorage ────────────────────────────────
146
- {
147
- id: 'localstorage-sensitive-data',
148
- description: [
149
- 'Sensitive data stored in localStorage.',
150
- 'localStorage is accessible to any JavaScript on the page and is vulnerable',
151
- 'to XSS attacks. Use httpOnly cookies for tokens and authentication data.'
152
- ].join(' '),
153
- owasp: 'A02:2021 Cryptographic Failures',
154
- severity: 'high',
155
- patterns: [
156
- { source: 'localStorage\\.setItem\\s*\\(', flags: '' },
157
- { source: '(?:password|passwd|pwd|token|secret|key|auth|jwt|session|credential)', flags: 'i' }
158
- ],
159
- require: 'all'
151
+ severity: 'critical'
160
152
  },
161
-
162
- // ── Rule 8: Sensitive data in sessionStorage ──────────────────────────────
163
- {
164
- id: 'sessionstorage-sensitive-data',
165
- description: [
166
- 'Sensitive data stored in sessionStorage.',
167
- 'sessionStorage is accessible to XSS attacks.',
168
- 'Use httpOnly cookies for authentication tokens instead.'
169
- ].join(' '),
153
+ STORAGE_SENSITIVE: {
154
+ id: 'webstorage-sensitive-data',
155
+ description: 'Sensitive data stored in localStorage/sessionStorage. Web storage is accessible to XSS attacks. Use httpOnly cookies for tokens and authentication data.',
170
156
  owasp: 'A02:2021 Cryptographic Failures',
171
- severity: 'high',
172
- patterns: [
173
- { source: 'sessionStorage\\.setItem\\s*\\(', flags: '' },
174
- { source: '(?:password|passwd|pwd|token|secret|key|auth|jwt|credential)', flags: 'i' }
175
- ],
176
- require: 'all'
157
+ severity: 'high'
177
158
  },
178
-
179
- // ── Rule 9: Sensitive data in console output ──────────────────────────────
180
- {
159
+ CONSOLE_SENSITIVE: {
181
160
  id: 'console-log-sensitive',
182
- description: [
183
- 'Possible logging of sensitive data via console output.',
184
- 'Passwords, tokens and secrets logged to console appear in log files',
185
- 'and monitoring tools, creating an information disclosure risk.'
186
- ].join(' '),
161
+ description: 'Possible logging of sensitive data. Passwords and tokens logged to console appear in log files and monitoring tools.',
187
162
  owasp: 'A09:2021 Security Logging and Monitoring Failures',
188
- severity: 'high',
189
- patterns: [
190
- { source: 'console\\.(?:log|info|warn|error|debug)\\s*\\(', flags: '' },
191
- { source: '(?:password|passwd|pwd|secret|token|api.?key|jwt|credential|private)', flags: 'i' }
192
- ],
193
- require: 'all'
163
+ severity: 'high'
194
164
  },
195
-
196
- // ── Rule 10: Dynamic code execution via Function constructor ───────────────
197
- {
198
- id: 'dynamic-function-constructor',
199
- description: [
200
- 'Dynamic code execution via the Function constructor detected.',
201
- 'Passing non-literal arguments to the Function constructor is equivalent',
202
- 'to eval() and allows arbitrary JavaScript execution.',
203
- 'Use a safe, sandboxed alternative instead.'
204
- ].join(' '),
165
+ DYNAMIC_CODE: {
166
+ id: 'dynamic-code-execution',
167
+ description: 'Dynamic code execution via eval() or new Function() with non-literal argument. This allows arbitrary JavaScript execution.',
168
+ owasp: 'A03:2021 Injection',
169
+ severity: 'critical'
170
+ },
171
+ CMD_INJECTION: {
172
+ id: 'command-injection',
173
+ description: 'Shell command built with template literal or concatenation. If variables contain user input, this allows command injection. Use execFile() with argument arrays instead of exec() with strings.',
205
174
  owasp: 'A03:2021 Injection',
206
- severity: 'critical',
207
- patterns: [
208
- { source: 'new\\s+Function\\s*\\(', flags: '' }
209
- ],
210
- // Only flag when the argument is not a pure string literal
211
- exclude: [
212
- { source: 'new\\s+Function\\s*\\(\\s*["\u0060\'][^"\u0060\']*["\u0060\']\\s*\\)', flags: '' }
213
- ]
175
+ severity: 'critical'
214
176
  }
215
-
216
- ];
177
+ };
217
178
 
218
179
  // ─────────────────────────────────────────────────────────────────────────────
219
- // Compile patterns at module load time
180
+ // AST walker visits every node and applies rules
220
181
  // ─────────────────────────────────────────────────────────────────────────────
221
- // The RegExp() calls below are intentional: patterns are stored as strings and
222
- // compiled once at startup. The sources come from the hardcoded RULE_DEFINITIONS
223
- // array above — they are NOT derived from user input.
224
- const COMPILED_RULES = RULE_DEFINITIONS.map((rule) => ({
225
- ...rule,
226
- // security-scan: disable rule-id: detect-non-literal-regexp reason: sources are hardcoded strings from RULE_DEFINITIONS, never user input
227
- compiled: rule.patterns.map((p) => new RegExp(p.source, p.flags)), // security-scan: disable rule-id: detect-non-literal-regexp reason: hardcoded rule pattern strings only
228
- compiledExclude: (rule.exclude || []).map((p) => new RegExp(p.source, p.flags)), // security-scan: disable rule-id: detect-non-literal-regexp reason: hardcoded rule pattern strings only
229
- compiledContext: rule.context
230
- // security-scan: disable rule-id: detect-non-literal-regexp reason: hardcoded rule pattern strings only
231
- ? new RegExp(rule.context.pattern.source, rule.context.pattern.flags)
232
- : null
233
- }));
234
182
 
235
- // ─────────────────────────────────────────────────────────────────────────────
236
- // Test a single line against a compiled rule
237
- // ─────────────────────────────────────────────────────────────────────────────
238
- function testRule(rule, line, lineIdx, allLines) {
239
- const requireAll = rule.require === 'all';
183
+ function walkAST(ast, filePath) {
184
+ const findings = [];
240
185
 
241
- // Check exclude patterns first if matched, skip this rule
242
- for (const excl of rule.compiledExclude) {
243
- if (excl.test(line)) return false;
244
- }
186
+ // Track variable names that hold SQL-like strings (simple 1-level taint)
187
+ const sqlVarNames = new Set();
245
188
 
246
- // Test main patterns
247
- const results = rule.compiled.map((re) => re.test(line));
248
- const matched = requireAll ? results.every(Boolean) : results.some(Boolean);
189
+ walk.simple(ast, {
249
190
 
250
- if (!matched) return false;
191
+ // ── Rule 1 & 2: SQL injection ───────────────────────────────────────────
192
+ VariableDeclarator(node) {
193
+ if (!node.init) return;
251
194
 
252
- // If a context check is required, scan surrounding lines
253
- if (rule.compiledContext) {
254
- const { lines: windowSize } = rule.context;
255
- const start = Math.max(0, lineIdx - windowSize);
256
- const end = Math.min(allLines.length, lineIdx + windowSize + 1);
257
- const surrounding = allLines.slice(start, end).join(' ');
258
- if (!rule.compiledContext.test(surrounding)) return false;
259
- }
195
+ // Track variables assigned a template literal with expressions
196
+ // e.g. const rawQuery = `SELECT... ${someVar}`
197
+ if (isTemplateLiteralWithExpressions(node.init)) {
198
+ const varName = nodeName(node.id);
199
+ // Heuristic: if the template looks like SQL
200
+ const quasis = node.init.quasis.map((q) => q.value.raw).join('');
201
+ if (/\b(?:SELECT|INSERT|UPDATE|DELETE|FROM|WHERE|JOIN)\b/i.test(quasis)) {
202
+ sqlVarNames.add(varName);
203
+ findings.push(makeFinding({
204
+ rule: RULES.SQL_TEMPLATE,
205
+ node: node.init,
206
+ filePath,
207
+ extraMsg: `Variable: ${varName}`
208
+ }));
209
+ }
210
+ }
211
+
212
+ // Track string concatenation with SQL keywords
213
+ if (isConcatenatedString(node.init)) {
214
+ const varName = nodeName(node.id);
215
+ // Walk the concat tree to find if SQL keywords are present
216
+ let hasSql = false;
217
+ walk.simple(node.init, {
218
+ Literal(n) {
219
+ if (typeof n.value === 'string' && /\b(?:SELECT|INSERT|UPDATE|DELETE|FROM|WHERE)\b/i.test(n.value)) {
220
+ hasSql = true;
221
+ }
222
+ }
223
+ });
224
+ if (hasSql) {
225
+ sqlVarNames.add(varName);
226
+ findings.push(makeFinding({ rule: RULES.SQL_CONCAT, node: node.init, filePath, extraMsg: `Variable: ${varName}` }));
227
+ }
228
+ }
260
229
 
261
- return true;
230
+ // ── Rule 3: Hardcoded secrets in variable assignment ────────────────
231
+ const varName = nodeName(node.id);
232
+ if (isSensitiveName(varName) && isStringLiteral(node.init) && node.init.value.length >= 6) {
233
+ // Exclude environment variable reads
234
+ const val = node.init.value;
235
+ if (!val.startsWith('process.env') && !val.includes('${') && !/^(true|false|null|undefined|test|example|placeholder|changeme|xxx+|your[-_]?)$/i.test(val)) {
236
+ findings.push(makeFinding({ rule: RULES.HARDCODED_SECRET_VAR, node, filePath, extraMsg: `Variable name: ${varName}` }));
237
+ }
238
+ }
239
+ },
240
+
241
+ // ── Rule 1 continued: SQL injection via direct db.query() call ─────────
242
+ CallExpression(node) {
243
+ const callee = getCalleeName(node);
244
+
245
+ // Check if this is a db query call
246
+ const isDbCall = /(?:query|raw|execute)\b/i.test(callee) &&
247
+ /(?:sequelize|knex|pg|mysql|db|pool|connection)\b/i.test(callee);
248
+
249
+ const isGenericQuery = /^(?:query|execute|runQuery)$/.test(callee);
250
+
251
+ if (isDbCall || isGenericQuery) {
252
+ const firstArg = node.arguments[0];
253
+ if (firstArg) {
254
+ // Direct template literal in the call
255
+ if (isTemplateLiteralWithExpressions(firstArg)) {
256
+ findings.push(makeFinding({ rule: RULES.SQL_TEMPLATE, node, filePath }));
257
+ }
258
+ // Direct concatenation in the call
259
+ if (isConcatenatedString(firstArg)) {
260
+ findings.push(makeFinding({ rule: RULES.SQL_CONCAT, node, filePath }));
261
+ }
262
+ // Tainted variable passed to query
263
+ if (firstArg.type === 'Identifier' && sqlVarNames.has(firstArg.name)) {
264
+ findings.push(makeFinding({
265
+ rule: RULES.SQL_TEMPLATE,
266
+ node,
267
+ filePath,
268
+ extraMsg: `Tainted variable "${firstArg.name}" passed to query`
269
+ }));
270
+ }
271
+ }
272
+ }
273
+
274
+ // ── Rule 5: Math.random() ─────────────────────────────────────────
275
+ if (callee === 'Math.random') {
276
+ findings.push(makeFinding({ rule: RULES.INSECURE_RANDOM, node, filePath }));
277
+ }
278
+
279
+ // ── Rule 8: localStorage/sessionStorage.setItem ────────────────────
280
+ if (/^(?:localStorage|sessionStorage)\.setItem$/.test(callee)) {
281
+ const keyArg = node.arguments[0];
282
+ if (keyArg && isStringLiteral(keyArg) && isSensitiveName(keyArg.value)) {
283
+ findings.push(makeFinding({ rule: RULES.STORAGE_SENSITIVE, node, filePath, extraMsg: `Key: "${keyArg.value}"` }));
284
+ }
285
+ }
286
+
287
+ // ── Rule 9: console.log with sensitive variable ────────────────────
288
+ if (/^console\.(?:log|info|warn|error|debug)$/.test(callee)) {
289
+ for (const arg of node.arguments) {
290
+ const argName = nodeName(arg);
291
+ if (isSensitiveName(argName)) {
292
+ findings.push(makeFinding({ rule: RULES.CONSOLE_SENSITIVE, node, filePath, extraMsg: `Argument: ${argName}` }));
293
+ break;
294
+ }
295
+ }
296
+ }
297
+
298
+ // ── Rule 10: eval() ─────────────────────────────────────────────────
299
+ if (callee === 'eval') {
300
+ const arg = node.arguments[0];
301
+ if (arg && !isStringLiteral(arg)) {
302
+ findings.push(makeFinding({ rule: RULES.DYNAMIC_CODE, node, filePath }));
303
+ }
304
+ }
305
+
306
+ // ── Rule 11: Command injection via exec/execSync ────────────────────
307
+ if (/^(?:exec|execSync|spawn|spawnSync)$/.test(callee) ||
308
+ /child_process\.(?:exec|execSync)/.test(callee)) {
309
+ const firstArg = node.arguments[0];
310
+ if (firstArg) {
311
+ if (isTemplateLiteralWithExpressions(firstArg)) {
312
+ findings.push(makeFinding({ rule: RULES.CMD_INJECTION, node, filePath }));
313
+ }
314
+ if (isConcatenatedString(firstArg)) {
315
+ findings.push(makeFinding({ rule: RULES.CMD_INJECTION, node, filePath }));
316
+ }
317
+ }
318
+ }
319
+ },
320
+
321
+ // ── Rule 10: new Function() ─────────────────────────────────────────────
322
+ NewExpression(node) {
323
+ if (nodeName(node.callee) === 'Function') {
324
+ const lastArg = node.arguments[node.arguments.length - 1];
325
+ if (lastArg && !isStringLiteral(lastArg)) {
326
+ findings.push(makeFinding({ rule: RULES.DYNAMIC_CODE, node, filePath }));
327
+ }
328
+ }
329
+ },
330
+
331
+ // ── Rule 4: Hardcoded secrets in object literals ────────────────────────
332
+ Property(node) {
333
+ const keyName = nodeName(node.key) || (node.key.type === 'Literal' ? node.key.value : '');
334
+ if (isSensitiveName(keyName) && isStringLiteral(node.value) && node.value.value.length >= 6) {
335
+ const val = node.value.value;
336
+ if (!/^(process\.env|true|false|null|test|example|placeholder|changeme|xxx+|your[-_]?)$/i.test(val)) {
337
+ findings.push(makeFinding({ rule: RULES.HARDCODED_SECRET_OBJ, node, filePath, extraMsg: `Key: "${keyName}"` }));
338
+ }
339
+ }
340
+ },
341
+
342
+ // ── Rule 6: Prototype pollution via bracket notation ───────────────────
343
+ AssignmentExpression(node) {
344
+ // obj[variable] = value → node.left is MemberExpression with computed=true
345
+ if (node.left &&
346
+ node.left.type === 'MemberExpression' &&
347
+ node.left.computed === true &&
348
+ node.left.property.type === 'Identifier') {
349
+ findings.push(makeFinding({ rule: RULES.PROTOTYPE_POLLUTION, node, filePath }));
350
+ }
351
+ },
352
+
353
+ // ── Rule 7: __proto__ access ────────────────────────────────────────────
354
+ MemberExpression(node) {
355
+ const prop = node.property;
356
+ if (prop && prop.type === 'Identifier' && prop.name === '__proto__') {
357
+ findings.push(makeFinding({ rule: RULES.PROTO_ACCESS, node, filePath }));
358
+ }
359
+ if (prop && prop.type === 'Literal' && prop.value === '__proto__') {
360
+ findings.push(makeFinding({ rule: RULES.PROTO_ACCESS, node, filePath }));
361
+ }
362
+ }
363
+
364
+ });
365
+
366
+ return findings;
262
367
  }
263
368
 
264
369
  // ─────────────────────────────────────────────────────────────────────────────
265
- // Suppression check
370
+ // Suppression check (reuses same format as inlineTag.js)
266
371
  // ─────────────────────────────────────────────────────────────────────────────
267
- const SUPPRESS_RE = /security-scan:\s*disable/i;
372
+ const SUPPRESS_RE = /security-scan:\s*disable\s+rule-id:\s*(\S+)/i;
373
+
374
+ function isSuppressed(lines, lineIdx, ruleId) {
375
+ const window = 3;
376
+ const start = Math.max(0, lineIdx - window);
377
+ const end = Math.min(lines.length - 1, lineIdx + window);
268
378
 
269
- function isSuppressed(lines, lineIdx) {
270
- const current = lines[lineIdx] || '';
271
- const previous = lines[lineIdx - 1] || '';
272
- return SUPPRESS_RE.test(current) || SUPPRESS_RE.test(previous);
379
+ for (let i = start; i <= end; i++) {
380
+ const m = lines[i].match(SUPPRESS_RE);
381
+ if (m && (m[1] === '*' || m[1] === ruleId)) return true;
382
+ }
383
+ return false;
273
384
  }
274
385
 
275
386
  // ─────────────────────────────────────────────────────────────────────────────
276
- // Main scanner
387
+ // Regex fallback — used when acorn is not available
388
+ // ─────────────────────────────────────────────────────────────────────────────
389
+ function regexFallbackScan(content, filePath) {
390
+ const lines = content.split(/\r?\n/);
391
+ const findings = [];
392
+
393
+ const PATTERNS = [
394
+ { re: /(?:const|let|var)\s+\w*(?:password|secret|key|token|jwt)\w*\s*=\s*['"`][^'"`\s]{6,}/, rule: RULES.HARDCODED_SECRET_VAR },
395
+ { re: /Math\.random\(\)/, rule: RULES.INSECURE_RANDOM },
396
+ { re: /__proto__/, rule: RULES.PROTO_ACCESS },
397
+ { re: /localStorage\.setItem\s*\(.*(?:token|password|secret)/i, rule: RULES.STORAGE_SENSITIVE },
398
+ { re: /console\.(?:log|info|warn)\s*\(.*(?:password|secret|token)/i, rule: RULES.CONSOLE_SENSITIVE }
399
+ ];
400
+
401
+ lines.forEach((line, i) => {
402
+ const trimmed = line.trim();
403
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*')) return;
404
+ if (isSuppressed(lines, i, '*')) return;
405
+
406
+ for (const { re, rule } of PATTERNS) {
407
+ if (re.test(line)) {
408
+ findings.push({
409
+ checkId: rule.id,
410
+ path: filePath,
411
+ line: i + 1,
412
+ message: rule.description,
413
+ severity: rule.severity,
414
+ owasp: rule.owasp,
415
+ raw: { line: trimmed }
416
+ });
417
+ break;
418
+ }
419
+ }
420
+ });
421
+
422
+ return findings;
423
+ }
424
+
425
+ // ─────────────────────────────────────────────────────────────────────────────
426
+ // Main export
277
427
  // ─────────────────────────────────────────────────────────────────────────────
278
428
  function scanFileWithCustomRules(filePath) {
429
+ // Only scan JS/TS files — Go is handled by govulncheck
430
+ if (filePath.endsWith('.go')) return [];
431
+
279
432
  let content;
280
433
  try {
281
- // security-scan: disable rule-id: detect-non-literal-fs-filename reason: filePath comes from `git diff --cached --name-only`, not user input
282
434
  content = fs.readFileSync(filePath, 'utf8');
283
435
  } catch {
284
436
  return [];
285
437
  }
286
438
 
287
- const lines = content.split(/\r?\n/);
288
- const findings = [];
289
-
290
- for (let i = 0; i < lines.length; i++) {
291
- const line = lines[i];
292
- const trimmed = line.trim();
293
-
294
- if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
295
- if (isSuppressed(lines, i)) continue;
439
+ // If acorn is not available, fall back to regex mode
440
+ if (!acorn || !walk) {
441
+ console.warn('sec-gate: acorn not available — using regex fallback for custom rules');
442
+ return regexFallbackScan(content, filePath);
443
+ }
296
444
 
297
- for (const rule of COMPILED_RULES) {
298
- if (testRule(rule, line, i, lines)) {
299
- findings.push({
300
- checkId: rule.id,
301
- path: filePath,
302
- line: i + 1,
303
- message: rule.description,
304
- severity: rule.severity,
305
- owasp: rule.owasp,
306
- raw: { line: trimmed }
307
- });
308
- break; // one finding per line
309
- }
445
+ let ast;
446
+ try {
447
+ ast = acorn.parse(content, {
448
+ ecmaVersion: 'latest',
449
+ sourceType: 'module',
450
+ locations: true, // gives us line numbers
451
+ allowHashBang: true,
452
+ allowAwaitOutsideFunction: true,
453
+ allowImportExportEverywhere: true
454
+ });
455
+ } catch {
456
+ // Parse failed (e.g. TypeScript syntax, JSX) — fall back to regex
457
+ try {
458
+ ast = acorn.parse(content, {
459
+ ecmaVersion: 'latest',
460
+ sourceType: 'script',
461
+ locations: true,
462
+ allowHashBang: true
463
+ });
464
+ } catch {
465
+ return regexFallbackScan(content, filePath);
310
466
  }
311
467
  }
312
468
 
313
- return findings;
469
+ const rawFindings = walkAST(ast, filePath);
470
+
471
+ // Apply inline suppressions
472
+ const lines = content.split(/\r?\n/);
473
+ return rawFindings.filter((f) => {
474
+ if (!f.line) return true;
475
+ return !isSuppressed(lines, f.line - 1, f.checkId);
476
+ });
314
477
  }
315
478
 
316
- module.exports = { scanFileWithCustomRules, RULE_DEFINITIONS };
479
+ module.exports = { scanFileWithCustomRules, RULES };
@@ -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 };