sec-gate 0.1.9 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sec-gate",
3
- "version": "0.1.9",
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",
@@ -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 };