sec-gate 0.1.9 → 0.3.4
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 +188 -127
- package/package.json +6 -3
- package/rules/custom-security.js +410 -247
- package/scripts/postinstall.js +5 -4
- package/scripts/preuninstall.js +164 -0
- package/src/commands/install.js +5 -4
package/rules/custom-security.js
CHANGED
|
@@ -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
|
|
8
|
+
* @description sec-gate custom security rules — AST-based analysis.
|
|
4
9
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
14
|
+
* REGEX (old): sees raw text line by line — misses multi-line patterns,
|
|
15
|
+
* variable assignments, and code structure
|
|
11
16
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
+
// ── Rule 1 & 2: SQL injection ───────────────────────────────────────────
|
|
192
|
+
VariableDeclarator(node) {
|
|
193
|
+
if (!node.init) return;
|
|
251
194
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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,
|
|
479
|
+
module.exports = { scanFileWithCustomRules, RULES };
|