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 +34 -0
- package/package.json +6 -3
- package/rules/custom-security.js +410 -247
- package/sec-gate.example.yml +39 -0
- package/src/commands/scan.js +115 -26
- package/src/config/loader.js +214 -0
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.
|
|
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
|
}
|
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 };
|
|
@@ -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
|
package/src/commands/scan.js
CHANGED
|
@@ -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
|
|
17
|
+
const loc = f.line ? `${f.path}:${f.line}` : f.path;
|
|
12
18
|
const owasp = f.owasp ? ` (${f.owasp})` : '';
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
68
|
-
if (
|
|
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',
|
|
77
|
-
'package-lock.json',
|
|
78
|
-
'npm-shrinkwrap.json',
|
|
79
|
-
'yarn.lock'
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 };
|