iamsecure 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +127 -2
- package/bin/iamsecure.js +136 -0
- package/package.json +36 -4
- package/src/ai/index.js +170 -0
- package/src/ai/providers.js +161 -0
- package/src/code/context.js +123 -0
- package/src/code/engine.js +117 -0
- package/src/code/files.js +85 -0
- package/src/code/index.js +30 -0
- package/src/code/rules.js +545 -0
- package/src/code/secrets.js +123 -0
- package/src/code/strip.js +51 -0
- package/src/deps/cvss.js +59 -0
- package/src/deps/graph.js +88 -0
- package/src/deps/index.js +144 -0
- package/src/deps/lockfiles/composer.js +31 -0
- package/src/deps/lockfiles/npm.js +77 -0
- package/src/deps/lockfiles/pnpm.js +56 -0
- package/src/deps/lockfiles/python.js +101 -0
- package/src/deps/lockfiles/yarn.js +93 -0
- package/src/deps/osv.js +106 -0
- package/src/project.js +102 -0
- package/src/report/sarif.js +58 -0
- package/src/report/terminal.js +253 -0
- package/src/risk/exploit.js +53 -0
- package/src/risk/score.js +215 -0
- package/src/scan.js +91 -0
- package/src/ui/index.html +980 -0
- package/src/ui/server.js +97 -0
- package/src/util/cache.js +35 -0
- package/src/util/http.js +43 -0
- package/src/util/semver.js +23 -0
- package/src/util/toml.js +129 -0
- package/src/util/yaml.js +85 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
// Code rules. Each rule is a line-level sink pattern plus how much it matters:
|
|
2
|
+
// when: 'always' → report any match
|
|
3
|
+
// 'dynamic' → only when the argument is built at runtime (template literal, concatenation, variable)
|
|
4
|
+
// 'tainted' → only when user input flows in (req.query, searchParams, $_GET, request.args, …)
|
|
5
|
+
// sev / taintedSev: severity score without / with proven user input.
|
|
6
|
+
// category: vulnerability class (see risk/score.js) — decides how much browser-only code is discounted.
|
|
7
|
+
// requires: file must match this before the rule is tried. context: override where the issue "runs".
|
|
8
|
+
|
|
9
|
+
export const SOURCES = {
|
|
10
|
+
js: /\b(req|request|ctx|context)\.(query|params|body|headers|cookies|url|path)\b|\b(req|request)\.(json|text|formData)\(\)|\bsearchParams\b|\bparams\.\w+|\bformData\.get\(|\bevent\.(body|queryStringParameters|pathParameters|headers)\b|\b(window\.)?location\.(search|hash|href)\b|\bdocument\.(cookie|referrer|URL)\b|\buseSearchParams\(\)|\brouter\.query\b|\b(e|ev|evt|event|msg|message)\.data\b/,
|
|
11
|
+
php: /\$_(GET|POST|REQUEST|COOKIE|SERVER|FILES)\b|\$request->(input|get|query|post|all)\(|\bfile_get_contents\(\s*['"]php:\/\/input/,
|
|
12
|
+
py: /\brequest\.(args|form|values|json|data|files|cookies|headers|GET|POST|body|query_params|path_params)\b|\brequest\.get_json\(\)|\binput\(/,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const JS = [
|
|
16
|
+
{
|
|
17
|
+
id: 'js-eval',
|
|
18
|
+
title: 'Dynamic code execution with eval()',
|
|
19
|
+
sink: /(?<![\w.$])eval\s*\(/,
|
|
20
|
+
when: 'dynamic',
|
|
21
|
+
sev: 7.5,
|
|
22
|
+
taintedSev: 9.8,
|
|
23
|
+
category: 'rce',
|
|
24
|
+
cwe: 'CWE-95',
|
|
25
|
+
fix: 'Never eval strings. Use JSON.parse for data, or a lookup table of allowed operations.',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: 'js-new-function',
|
|
29
|
+
title: 'Dynamic code execution with new Function()',
|
|
30
|
+
sink: /\bnew\s+Function\s*\(/,
|
|
31
|
+
when: 'dynamic',
|
|
32
|
+
sev: 7.5,
|
|
33
|
+
taintedSev: 9.8,
|
|
34
|
+
category: 'rce',
|
|
35
|
+
cwe: 'CWE-95',
|
|
36
|
+
fix: 'Avoid compiling code from strings; restructure to call known functions.',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'js-command-injection',
|
|
40
|
+
title: 'Shell command built from variables',
|
|
41
|
+
requires: /child_process|execa|shelljs/,
|
|
42
|
+
// Bare exec(…) or child_process.exec(…) — not regex.exec(…).
|
|
43
|
+
sink: /(?:(?<![\w$.])|\b(?:child_process|childProcess|cp|shell)\.)(exec|execSync|execaCommand|execaCommandSync)\s*\(/,
|
|
44
|
+
when: 'dynamic',
|
|
45
|
+
sev: 7,
|
|
46
|
+
taintedSev: 9.8,
|
|
47
|
+
category: 'rce',
|
|
48
|
+
cwe: 'CWE-78',
|
|
49
|
+
fix: 'Use execFile/spawn with an argument array (no shell), and validate inputs against an allow-list.',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'js-spawn-injection',
|
|
53
|
+
title: 'Process spawned with user-controlled command or arguments',
|
|
54
|
+
requires: /child_process|execa/,
|
|
55
|
+
sink: /(?:(?<![\w$.])|\b(?:child_process|childProcess|cp)\.)(spawn|spawnSync|execFile|execFileSync|execa|execaSync|fork)\s*\(/,
|
|
56
|
+
when: 'tainted',
|
|
57
|
+
sev: 8.1,
|
|
58
|
+
category: 'rce',
|
|
59
|
+
cwe: 'CWE-78',
|
|
60
|
+
fix: 'Never let users pick the executable; allow-list argument values and keep shell: false.',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'js-template-injection',
|
|
64
|
+
title: 'Server-side template compiled from user input',
|
|
65
|
+
sink: /(?:\b_\.template|\bejs\.render|\bpug\.(?:render|compile)|\bHandlebars\.compile|\bhandlebars\.compile|\bnunjucks\.renderString|\bvm\.(?:runIn\w*Context|Script))\s*\(/,
|
|
66
|
+
when: 'tainted',
|
|
67
|
+
sev: 9.3,
|
|
68
|
+
category: 'rce',
|
|
69
|
+
cwe: 'CWE-1336',
|
|
70
|
+
fix: 'Never compile templates from user input — compile fixed templates and pass user data as variables.',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'js-sql-injection',
|
|
74
|
+
inStrings: true,
|
|
75
|
+
title: 'SQL query built with string interpolation',
|
|
76
|
+
sink: /\b(query|execute|raw|\$queryRawUnsafe|\$executeRawUnsafe|unsafe)\s*\(\s*(`[^`]*\$\{|['"][^'"]*['"]\s*\+|\w+\s*\+)/,
|
|
77
|
+
lineHint: /\b(select|insert|update|delete|where|from|order\s+by)\b/i,
|
|
78
|
+
when: 'dynamic',
|
|
79
|
+
sev: 7.5,
|
|
80
|
+
taintedSev: 9.8,
|
|
81
|
+
category: 'sqli',
|
|
82
|
+
cwe: 'CWE-89',
|
|
83
|
+
fix: 'Use parameterised queries (placeholders like $1 / ?) or the ORM\'s tagged-template API (Prisma $queryRaw`…`).',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'js-nosql-injection',
|
|
87
|
+
inStrings: true,
|
|
88
|
+
title: 'Raw request object used as a MongoDB query',
|
|
89
|
+
sink: /\.(find|findOne|findOneAndUpdate|updateOne|updateMany|deleteOne|deleteMany|countDocuments)\s*\(\s*(req|request)\.(body|query)\b/,
|
|
90
|
+
when: 'always',
|
|
91
|
+
sev: 8.1,
|
|
92
|
+
category: 'sqli',
|
|
93
|
+
cwe: 'CWE-943',
|
|
94
|
+
fix: 'Pick and type-check the fields you expect (e.g. String(req.body.email)) so operators like {$ne: null} can\'t be injected.',
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: 'js-xss-dangerously-set-html',
|
|
98
|
+
title: 'Unsanitised HTML rendered with dangerouslySetInnerHTML',
|
|
99
|
+
sink: /dangerouslySetInnerHTML\s*=\s*\{\s*\{\s*__html\s*:/,
|
|
100
|
+
exclude: /DOMPurify|sanitize|purify|xss\(|escape/i,
|
|
101
|
+
when: 'always',
|
|
102
|
+
sev: 6.1,
|
|
103
|
+
taintedSev: 8.2,
|
|
104
|
+
category: 'xss',
|
|
105
|
+
cwe: 'CWE-79',
|
|
106
|
+
fix: 'Sanitise first: __html: DOMPurify.sanitize(html) — or render as text/React elements instead.',
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'js-xss-inner-html',
|
|
110
|
+
inStrings: true,
|
|
111
|
+
title: 'HTML injected into the DOM',
|
|
112
|
+
sink: /\.(innerHTML|outerHTML)\s*\+?=(?!=)|\bdocument\.write(ln)?\s*\(|\.insertAdjacentHTML\s*\(/,
|
|
113
|
+
exclude: /DOMPurify|sanitize|=\s*['"`][^'"`$]*['"`]\s*;?\s*$/,
|
|
114
|
+
when: 'dynamic',
|
|
115
|
+
sev: 5.4,
|
|
116
|
+
taintedSev: 8.2,
|
|
117
|
+
category: 'xss',
|
|
118
|
+
cwe: 'CWE-79',
|
|
119
|
+
fix: 'Use textContent, or sanitise with DOMPurify before assigning HTML.',
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: 'js-ssrf',
|
|
123
|
+
title: 'Server-side request to a user-controlled URL',
|
|
124
|
+
sink: /\b(fetch|axios(?:\.(?:get|post|put|patch|delete|request|head))?|got(?:\.\w+)?|needle|superagent\.\w+|https?\.(?:get|request)|undici\.request)\s*\(/,
|
|
125
|
+
when: 'tainted',
|
|
126
|
+
sev: 8.6,
|
|
127
|
+
category: 'ssrf',
|
|
128
|
+
cwe: 'CWE-918',
|
|
129
|
+
fix: 'Validate the URL against an allow-list of hosts and block private/link-local IPs (169.254.169.254, 10.0.0.0/8, …).',
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: 'js-path-traversal',
|
|
133
|
+
title: 'File path built from user input',
|
|
134
|
+
sink: /\b(fs\.\w+|fsp\.\w+|readFile(Sync)?|writeFile(Sync)?|createReadStream|createWriteStream|unlink(Sync)?|res\.sendFile|res\.download|path\.(join|resolve))\s*\(/,
|
|
135
|
+
when: 'tainted',
|
|
136
|
+
sev: 8.1,
|
|
137
|
+
category: 'path',
|
|
138
|
+
cwe: 'CWE-22',
|
|
139
|
+
fix: 'Resolve the path, then check it starts with your base directory; reject "..", absolute paths and null bytes.',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: 'js-open-redirect',
|
|
143
|
+
title: 'Redirect to a user-controlled URL',
|
|
144
|
+
sink: /\b(res\.redirect|redirect|NextResponse\.redirect|location\.assign|location\.replace)\s*\(|\b(window\.)?location(\.href)?\s*=(?!=)/,
|
|
145
|
+
when: 'tainted',
|
|
146
|
+
sev: 6.1,
|
|
147
|
+
category: 'redirect',
|
|
148
|
+
cwe: 'CWE-601',
|
|
149
|
+
fix: 'Only redirect to relative paths or an allow-list of hosts.',
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: 'js-regex-injection',
|
|
153
|
+
title: 'Regular expression built from user input (ReDoS)',
|
|
154
|
+
sink: /\bnew\s+RegExp\s*\(/,
|
|
155
|
+
when: 'tainted',
|
|
156
|
+
sev: 5.3,
|
|
157
|
+
category: 'dos',
|
|
158
|
+
cwe: 'CWE-1333',
|
|
159
|
+
fix: 'Escape user input before building a RegExp (e.g. lodash.escapeRegExp) or avoid dynamic patterns.',
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: 'js-prototype-pollution',
|
|
163
|
+
title: 'Deep merge of request data (prototype pollution)',
|
|
164
|
+
sink: /\b(_\.merge|_\.mergeWith|_\.defaultsDeep|_\.set|merge|deepmerge|deepMerge|extend)\s*\(/,
|
|
165
|
+
when: 'tainted',
|
|
166
|
+
sev: 7.3,
|
|
167
|
+
category: 'proto',
|
|
168
|
+
cwe: 'CWE-1321',
|
|
169
|
+
fix: 'Validate input with a schema (zod/joi) and strip __proto__/constructor keys before merging.',
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
id: 'js-mass-assignment',
|
|
173
|
+
title: 'Request body written straight to the database (mass assignment)',
|
|
174
|
+
sink: /\b(data\s*:\s*(req|request)\.body\b|\.(create|update|insert\w*|save|findByIdAndUpdate)\s*\(\s*(req|request)\.body\s*[,)])/,
|
|
175
|
+
when: 'always',
|
|
176
|
+
sev: 6.5,
|
|
177
|
+
category: 'auth',
|
|
178
|
+
cwe: 'CWE-915',
|
|
179
|
+
fix: 'Whitelist fields: build the object from the specific properties you allow users to set.',
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
id: 'js-tls-verification-disabled',
|
|
183
|
+
inStrings: true,
|
|
184
|
+
title: 'TLS certificate verification disabled',
|
|
185
|
+
sink: /rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED['"]?\]?\s*=\s*['"]?0|strictSSL\s*:\s*false/,
|
|
186
|
+
when: 'always',
|
|
187
|
+
sev: 7.4,
|
|
188
|
+
category: 'crypto',
|
|
189
|
+
cwe: 'CWE-295',
|
|
190
|
+
fix: 'Keep certificate verification on; trust a custom CA via the `ca` option if you need one.',
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
id: 'js-jwt-none-alg',
|
|
194
|
+
inStrings: true,
|
|
195
|
+
title: 'JWT verification accepts the "none" algorithm',
|
|
196
|
+
sink: /algorithms\s*:\s*\[[^\]]*['"]none['"]/i,
|
|
197
|
+
when: 'always',
|
|
198
|
+
sev: 9.1,
|
|
199
|
+
category: 'auth',
|
|
200
|
+
cwe: 'CWE-347',
|
|
201
|
+
fix: 'Pin algorithms to the one you sign with, e.g. { algorithms: ["HS256"] }.',
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
id: 'js-jwt-decode-auth',
|
|
205
|
+
title: 'JWT decoded without verifying its signature',
|
|
206
|
+
sink: /\bjwt\.decode\s*\(|\bjwtDecode\s*\(/,
|
|
207
|
+
when: 'always',
|
|
208
|
+
sev: 5.9,
|
|
209
|
+
category: 'auth',
|
|
210
|
+
cwe: 'CWE-347',
|
|
211
|
+
fix: 'Use jwt.verify() with your secret/public key before trusting any claim (decode is fine for display only).',
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
id: 'js-jwt-hardcoded-secret',
|
|
215
|
+
inStrings: true,
|
|
216
|
+
title: 'JWT signed/verified with a hard-coded secret',
|
|
217
|
+
sink: /\bjwt\.(sign|verify)\s*\(.*,\s*['"][^'"]{3,}['"]\s*[,)]/,
|
|
218
|
+
when: 'always',
|
|
219
|
+
sev: 8.1,
|
|
220
|
+
category: 'secret',
|
|
221
|
+
cwe: 'CWE-798',
|
|
222
|
+
fix: 'Load the secret from an environment variable / secret manager and rotate the leaked one.',
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
id: 'js-weak-hash',
|
|
226
|
+
inStrings: true,
|
|
227
|
+
title: 'Weak hash algorithm (MD5/SHA-1)',
|
|
228
|
+
sink: /createHash\s*\(\s*['"](md5|sha1)['"]/i,
|
|
229
|
+
when: 'always',
|
|
230
|
+
sev: 4.3,
|
|
231
|
+
category: 'crypto',
|
|
232
|
+
cwe: 'CWE-328',
|
|
233
|
+
fix: 'Use SHA-256+ for integrity, and bcrypt/scrypt/argon2 for passwords.',
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
id: 'js-insecure-random',
|
|
237
|
+
title: 'Math.random() used for a security value',
|
|
238
|
+
sink: /Math\.random\s*\(\)/,
|
|
239
|
+
lineHint: /token|secret|password|passwd|nonce|otp|salt|session|apikey|api_key|reset|verify|csrf/i,
|
|
240
|
+
when: 'always',
|
|
241
|
+
sev: 5.3,
|
|
242
|
+
category: 'crypto',
|
|
243
|
+
cwe: 'CWE-338',
|
|
244
|
+
fix: 'Use crypto.randomBytes / crypto.randomUUID / crypto.getRandomValues.',
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
id: 'js-cors-credentials-any-origin',
|
|
248
|
+
inStrings: true,
|
|
249
|
+
title: 'CORS reflects any origin with credentials',
|
|
250
|
+
requires: /credentials\s*:\s*true/,
|
|
251
|
+
sink: /\borigin\s*:\s*(true|['"]\*['"]|\(\s*\w*\s*,\s*\w+\s*\)\s*=>\s*\w+\(null,\s*true\))/,
|
|
252
|
+
when: 'always',
|
|
253
|
+
sev: 7.1,
|
|
254
|
+
category: 'leak',
|
|
255
|
+
cwe: 'CWE-942',
|
|
256
|
+
fix: 'List the exact origins allowed to make credentialed requests.',
|
|
257
|
+
context: 'server',
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
id: 'js-public-env-secret',
|
|
261
|
+
title: 'Secret-looking env var exposed to the browser bundle',
|
|
262
|
+
sink: /\b(NEXT_PUBLIC|VITE|REACT_APP|PUBLIC|NUXT_PUBLIC|EXPO_PUBLIC)_\w*(SECRET|PRIVATE|PASSWORD|PASSWD|SERVICE_ROLE|ACCESS_TOKEN|AUTH_TOKEN|API_SECRET|CLIENT_SECRET)\w*/,
|
|
263
|
+
when: 'always',
|
|
264
|
+
sev: 7.5,
|
|
265
|
+
category: 'secret',
|
|
266
|
+
cwe: 'CWE-200',
|
|
267
|
+
fix: 'Variables with this prefix are inlined into client JS. Drop the prefix and only read the value on the server.',
|
|
268
|
+
context: 'client',
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
id: 'js-postmessage-no-origin-check',
|
|
272
|
+
inStrings: true,
|
|
273
|
+
title: 'postMessage handler without origin check',
|
|
274
|
+
requires: /addEventListener\(\s*['"]message['"]/,
|
|
275
|
+
exclude: /\.origin/,
|
|
276
|
+
sink: /addEventListener\(\s*['"]message['"]/,
|
|
277
|
+
fileExclude: /\.origin\b/,
|
|
278
|
+
when: 'always',
|
|
279
|
+
sev: 5.4,
|
|
280
|
+
category: 'xss',
|
|
281
|
+
cwe: 'CWE-346',
|
|
282
|
+
fix: 'Check event.origin against the origins you expect before using event.data.',
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
id: 'js-cookie-no-httponly',
|
|
286
|
+
title: 'Cookie set without HttpOnly',
|
|
287
|
+
sink: /httpOnly\s*:\s*false/,
|
|
288
|
+
lineHint: /session|token|auth|sid|jwt/i,
|
|
289
|
+
when: 'always',
|
|
290
|
+
sev: 4.3,
|
|
291
|
+
category: 'leak',
|
|
292
|
+
cwe: 'CWE-1004',
|
|
293
|
+
fix: 'Set httpOnly: true (and secure, sameSite) on session/auth cookies.',
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
id: 'next-svg-images',
|
|
297
|
+
title: 'Next.js image optimizer allows SVG',
|
|
298
|
+
sink: /dangerouslyAllowSVG\s*:\s*true/,
|
|
299
|
+
when: 'always',
|
|
300
|
+
sev: 6.1,
|
|
301
|
+
category: 'xss',
|
|
302
|
+
cwe: 'CWE-79',
|
|
303
|
+
fix: 'Remove dangerouslyAllowSVG, or also set contentSecurityPolicy and contentDispositionType: "attachment".',
|
|
304
|
+
context: 'server',
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
id: 'next-wildcard-remote-images',
|
|
308
|
+
inStrings: true,
|
|
309
|
+
title: 'Next.js image optimizer proxies any remote host',
|
|
310
|
+
sink: /hostname\s*:\s*['"]\*\*?['"]/,
|
|
311
|
+
when: 'always',
|
|
312
|
+
sev: 5.3,
|
|
313
|
+
category: 'ssrf',
|
|
314
|
+
cwe: 'CWE-918',
|
|
315
|
+
fix: 'List the specific image hosts in images.remotePatterns.',
|
|
316
|
+
context: 'server',
|
|
317
|
+
},
|
|
318
|
+
];
|
|
319
|
+
|
|
320
|
+
const PHP = [
|
|
321
|
+
{
|
|
322
|
+
id: 'php-code-exec',
|
|
323
|
+
title: 'PHP code execution (eval/assert/create_function)',
|
|
324
|
+
sink: /\b(eval|assert|create_function)\s*\(/,
|
|
325
|
+
when: 'dynamic',
|
|
326
|
+
sev: 8,
|
|
327
|
+
taintedSev: 9.8,
|
|
328
|
+
category: 'rce',
|
|
329
|
+
cwe: 'CWE-95',
|
|
330
|
+
fix: 'Remove eval; map inputs to known functions instead.',
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
id: 'php-command-injection',
|
|
334
|
+
inStrings: true,
|
|
335
|
+
title: 'Shell command built from variables',
|
|
336
|
+
sink: /\b(system|exec|shell_exec|passthru|popen|proc_open|pcntl_exec)\s*\(|`[^`]*\$[^`]*`/,
|
|
337
|
+
exclude: /escapeshellarg/,
|
|
338
|
+
when: 'dynamic',
|
|
339
|
+
sev: 7.5,
|
|
340
|
+
taintedSev: 9.8,
|
|
341
|
+
category: 'rce',
|
|
342
|
+
cwe: 'CWE-78',
|
|
343
|
+
fix: 'Wrap every argument in escapeshellarg(), or avoid the shell entirely.',
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
id: 'php-unserialize',
|
|
347
|
+
title: 'unserialize() on untrusted data (object injection)',
|
|
348
|
+
sink: /\bunserialize\s*\(/,
|
|
349
|
+
exclude: /allowed_classes\s*=>\s*false/,
|
|
350
|
+
when: 'dynamic',
|
|
351
|
+
sev: 7,
|
|
352
|
+
taintedSev: 9.8,
|
|
353
|
+
category: 'rce',
|
|
354
|
+
cwe: 'CWE-502',
|
|
355
|
+
fix: 'Use json_decode, or pass ["allowed_classes" => false].',
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
id: 'php-file-inclusion',
|
|
359
|
+
title: 'include/require with a variable path (file inclusion)',
|
|
360
|
+
sink: /\b(include|include_once|require|require_once)\b\s*\(?[^;]*\$\w+/,
|
|
361
|
+
when: 'dynamic',
|
|
362
|
+
sev: 7,
|
|
363
|
+
taintedSev: 9.8,
|
|
364
|
+
category: 'rce',
|
|
365
|
+
cwe: 'CWE-98',
|
|
366
|
+
fix: 'Map user choices to a fixed list of files; never include paths built from input.',
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
id: 'php-sql-injection',
|
|
370
|
+
title: 'SQL query built with variables',
|
|
371
|
+
sink: /\b(mysqli_query|mysql_query|pg_query|->query|->exec|->prepare|DB::(select|statement|raw|unprepared)|->whereRaw|->selectRaw|->orderByRaw)\s*\(/,
|
|
372
|
+
lineHint: /\$\w+|\.\s*\$|\{\$/,
|
|
373
|
+
when: 'dynamic',
|
|
374
|
+
sev: 7.5,
|
|
375
|
+
taintedSev: 9.8,
|
|
376
|
+
category: 'sqli',
|
|
377
|
+
cwe: 'CWE-89',
|
|
378
|
+
fix: 'Use prepared statements with bound parameters (PDO::prepare + execute([...])).',
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
id: 'php-xss-echo',
|
|
382
|
+
title: 'Request data echoed without escaping',
|
|
383
|
+
sink: /\b(echo|print)\b[^;]*\$_(GET|POST|REQUEST|COOKIE)/,
|
|
384
|
+
exclude: /htmlspecialchars|htmlentities|e\(|esc_html|esc_attr|intval|\(int\)/,
|
|
385
|
+
when: 'always',
|
|
386
|
+
sev: 6.1,
|
|
387
|
+
taintedSev: 8.2,
|
|
388
|
+
category: 'xss',
|
|
389
|
+
cwe: 'CWE-79',
|
|
390
|
+
fix: 'Escape output with htmlspecialchars($v, ENT_QUOTES, "UTF-8").',
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
id: 'php-path-traversal',
|
|
394
|
+
title: 'File access with a user-controlled path',
|
|
395
|
+
sink: /\b(file_get_contents|fopen|readfile|file|unlink|file_put_contents|move_uploaded_file)\s*\(/,
|
|
396
|
+
exclude: /\[\s*['"]tmp_name['"]\s*\]|is_uploaded_file/, // PHP chooses tmp_name, not the user
|
|
397
|
+
when: 'tainted',
|
|
398
|
+
sev: 8.1,
|
|
399
|
+
category: 'path',
|
|
400
|
+
cwe: 'CWE-22',
|
|
401
|
+
fix: 'Use basename() / realpath() and verify the result is inside the allowed directory.',
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
id: 'php-ssrf',
|
|
405
|
+
title: 'Request to a user-controlled URL',
|
|
406
|
+
sink: /\b(curl_init|curl_setopt|file_get_contents|fopen)\s*\(/,
|
|
407
|
+
lineHint: /CURLOPT_URL|https?:|\$url|\$_(GET|POST|REQUEST)/i,
|
|
408
|
+
exclude: /\[\s*['"]tmp_name['"]\s*\]|is_uploaded_file/, // PHP chooses tmp_name, not the user
|
|
409
|
+
when: 'tainted',
|
|
410
|
+
sev: 8.6,
|
|
411
|
+
category: 'ssrf',
|
|
412
|
+
cwe: 'CWE-918',
|
|
413
|
+
fix: 'Allow-list hosts and block internal IP ranges before fetching.',
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
id: 'php-weak-hash-password',
|
|
417
|
+
title: 'Password hashed with MD5/SHA-1',
|
|
418
|
+
sink: /\b(md5|sha1)\s*\(/,
|
|
419
|
+
lineHint: /pass|pwd/i,
|
|
420
|
+
when: 'always',
|
|
421
|
+
sev: 7.5,
|
|
422
|
+
category: 'crypto',
|
|
423
|
+
cwe: 'CWE-916',
|
|
424
|
+
fix: 'Use password_hash() / password_verify().',
|
|
425
|
+
},
|
|
426
|
+
{
|
|
427
|
+
id: 'php-extract-input',
|
|
428
|
+
title: 'extract() on request data (variable overwrite)',
|
|
429
|
+
sink: /\b(extract|parse_str)\s*\(\s*\$_(GET|POST|REQUEST|COOKIE)/,
|
|
430
|
+
when: 'always',
|
|
431
|
+
sev: 8.1,
|
|
432
|
+
category: 'auth',
|
|
433
|
+
cwe: 'CWE-621',
|
|
434
|
+
fix: 'Read the specific keys you need instead of extracting the whole array.',
|
|
435
|
+
},
|
|
436
|
+
];
|
|
437
|
+
|
|
438
|
+
const PY = [
|
|
439
|
+
{
|
|
440
|
+
id: 'py-code-exec',
|
|
441
|
+
title: 'Dynamic code execution (eval/exec)',
|
|
442
|
+
sink: /(?<![\w.])(eval|exec)\s*\(/,
|
|
443
|
+
exclude: /literal_eval/,
|
|
444
|
+
when: 'dynamic',
|
|
445
|
+
sev: 7.5,
|
|
446
|
+
taintedSev: 9.8,
|
|
447
|
+
category: 'rce',
|
|
448
|
+
cwe: 'CWE-95',
|
|
449
|
+
fix: 'Use ast.literal_eval for data, or a dispatch dict of allowed operations.',
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
id: 'py-command-injection',
|
|
453
|
+
title: 'Shell command built from variables',
|
|
454
|
+
sink: /\b(os\.system|os\.popen|subprocess\.\w+\([^)]*shell\s*=\s*True|commands\.getoutput)\s*\(?/,
|
|
455
|
+
when: 'dynamic',
|
|
456
|
+
sev: 7.5,
|
|
457
|
+
taintedSev: 9.8,
|
|
458
|
+
category: 'rce',
|
|
459
|
+
cwe: 'CWE-78',
|
|
460
|
+
fix: 'Call subprocess.run([...args]) without shell=True; use shlex.quote if a shell is unavoidable.',
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
id: 'py-unsafe-deserialization',
|
|
464
|
+
title: 'Unsafe deserialization (pickle / yaml.load / marshal)',
|
|
465
|
+
sink: /\b(pickle|cPickle|dill|marshal|shelve)\.loads?\s*\(|\byaml\.(load|load_all)\s*\((?![^)]*SafeLoader)/,
|
|
466
|
+
when: 'always',
|
|
467
|
+
sev: 7,
|
|
468
|
+
taintedSev: 9.8,
|
|
469
|
+
category: 'rce',
|
|
470
|
+
cwe: 'CWE-502',
|
|
471
|
+
fix: 'Use json, or yaml.safe_load; never unpickle data from users.',
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
id: 'py-sql-injection',
|
|
475
|
+
inStrings: true,
|
|
476
|
+
title: 'SQL query built with string formatting',
|
|
477
|
+
sink: /\.(execute|executemany|raw|extra)\s*\(\s*(f['"]|['"][^'"]*['"]\s*(%|\.format|\+))/,
|
|
478
|
+
when: 'always',
|
|
479
|
+
sev: 7.5,
|
|
480
|
+
taintedSev: 9.8,
|
|
481
|
+
category: 'sqli',
|
|
482
|
+
cwe: 'CWE-89',
|
|
483
|
+
fix: 'Pass parameters separately: cursor.execute("… WHERE id = %s", (user_id,)).',
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
id: 'py-ssti',
|
|
487
|
+
title: 'Template rendered from a user-controlled string (SSTI)',
|
|
488
|
+
sink: /\b(render_template_string|Template)\s*\(/,
|
|
489
|
+
when: 'tainted',
|
|
490
|
+
sev: 9.3,
|
|
491
|
+
category: 'rce',
|
|
492
|
+
cwe: 'CWE-1336',
|
|
493
|
+
fix: 'Render fixed templates and pass user values as context variables.',
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
id: 'py-ssrf',
|
|
497
|
+
title: 'Request to a user-controlled URL',
|
|
498
|
+
sink: /\b(requests|httpx|urllib\.request|urllib3)\.\w+\s*\(|\burlopen\s*\(/,
|
|
499
|
+
when: 'tainted',
|
|
500
|
+
sev: 8.6,
|
|
501
|
+
category: 'ssrf',
|
|
502
|
+
cwe: 'CWE-918',
|
|
503
|
+
fix: 'Allow-list hosts and block internal IP ranges before fetching.',
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
id: 'py-path-traversal',
|
|
507
|
+
title: 'File opened with a user-controlled path',
|
|
508
|
+
sink: /\b(open|send_file|send_from_directory|os\.remove|shutil\.\w+)\s*\(/,
|
|
509
|
+
when: 'tainted',
|
|
510
|
+
sev: 8.1,
|
|
511
|
+
category: 'path',
|
|
512
|
+
cwe: 'CWE-22',
|
|
513
|
+
fix: 'Use werkzeug.utils.secure_filename and confirm the resolved path stays inside the base directory.',
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
id: 'py-debug-enabled',
|
|
517
|
+
title: 'Debug mode enabled',
|
|
518
|
+
sink: /\bDEBUG\s*=\s*True\b|\.run\([^)]*debug\s*=\s*True/,
|
|
519
|
+
when: 'always',
|
|
520
|
+
sev: 7.5,
|
|
521
|
+
category: 'rce',
|
|
522
|
+
cwe: 'CWE-489',
|
|
523
|
+
fix: 'Read DEBUG from the environment and keep it off in production (the Werkzeug debugger allows code execution).',
|
|
524
|
+
context: 'server',
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
id: 'py-tls-verification-disabled',
|
|
528
|
+
title: 'TLS certificate verification disabled',
|
|
529
|
+
sink: /verify\s*=\s*False/,
|
|
530
|
+
when: 'always',
|
|
531
|
+
sev: 7.4,
|
|
532
|
+
category: 'crypto',
|
|
533
|
+
cwe: 'CWE-295',
|
|
534
|
+
fix: 'Keep verify=True; point it at a CA bundle if you need a custom CA.',
|
|
535
|
+
},
|
|
536
|
+
];
|
|
537
|
+
|
|
538
|
+
export const RULES = { js: JS, php: PHP, py: PY };
|
|
539
|
+
|
|
540
|
+
export function langOf(file) {
|
|
541
|
+
if (/\.php$/.test(file)) return 'php';
|
|
542
|
+
if (/\.py$/.test(file)) return 'py';
|
|
543
|
+
if (/\.([cm]?[jt]sx?|vue|svelte|astro)$/.test(file)) return 'js';
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { levelFromScore } from '../deps/cvss.js';
|
|
3
|
+
|
|
4
|
+
const PATTERNS = [
|
|
5
|
+
{ id: 'aws-access-key', title: 'AWS access key ID', re: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/, sev: 9 },
|
|
6
|
+
{ id: 'aws-secret-key', title: 'AWS secret access key', re: /aws_?secret_?access_?key['"]?\s*[:=]\s*['"]?([A-Za-z0-9/+=]{40})\b/i, sev: 9.5 },
|
|
7
|
+
{ id: 'github-token', title: 'GitHub token', re: /\b(gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{60,})\b/, sev: 9 },
|
|
8
|
+
{ id: 'gitlab-token', title: 'GitLab personal access token', re: /\bglpat-[A-Za-z0-9_-]{20,}\b/, sev: 9 },
|
|
9
|
+
{ id: 'npm-token', title: 'npm access token (can publish packages!)', re: /\bnpm_[A-Za-z0-9]{36}\b/, sev: 9.5 },
|
|
10
|
+
{ id: 'slack-token', title: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, sev: 8 },
|
|
11
|
+
{ id: 'slack-webhook', title: 'Slack webhook URL', re: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/, sev: 6.5 },
|
|
12
|
+
{ id: 'stripe-secret', title: 'Stripe live secret key', re: /\b(sk|rk)_live_[A-Za-z0-9]{20,}\b/, sev: 9.5 },
|
|
13
|
+
{ id: 'openai-key', title: 'OpenAI API key', re: /\bsk-(proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}T3BlbkFJ[A-Za-z0-9_-]{20,}\b|\bsk-(proj|svcacct|admin)-[A-Za-z0-9_-]{40,}\b/, sev: 8.5 },
|
|
14
|
+
{ id: 'anthropic-key', title: 'Anthropic API key', re: /\bsk-ant-(api|admin)\d{2}-[A-Za-z0-9_-]{60,}\b/, sev: 8.5 },
|
|
15
|
+
{ id: 'google-api-key', title: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/, sev: 5.5 },
|
|
16
|
+
{ id: 'sendgrid-key', title: 'SendGrid API key', re: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/, sev: 8 },
|
|
17
|
+
{ id: 'twilio-key', title: 'Twilio API key', re: /\bSK[0-9a-f]{32}\b/, sev: 7 },
|
|
18
|
+
{ id: 'private-key', title: 'Private key', re: /-----BEGIN (RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY( BLOCK)?-----/, sev: 9 },
|
|
19
|
+
{ id: 'db-url-password', title: 'Database URL with password', re: /\b(postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis|rediss|amqps?):\/\/[^:\s'"/@]+:([^@\s'"]{3,})@[^\s'"]+/, sev: 8, group: 2 },
|
|
20
|
+
{ id: 'jwt-literal', title: 'Hard-coded JWT', re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/, sev: 5 },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const GENERIC =
|
|
24
|
+
/\b([A-Za-z0-9_]*(?:secret|password|passwd|pwd|api[_-]?key|apikey|access[_-]?token|auth[_-]?token|private[_-]?key|client[_-]?secret)[A-Za-z0-9_]*)['"]?\s*[:=]\s*['"]([^'"\s]{8,})['"]/i;
|
|
25
|
+
|
|
26
|
+
const PLACEHOLDER =
|
|
27
|
+
/^(\$\{|<|\{\{|%|process\.env|env\(|xxx|\*{3}|changeme|change_me|your[_-]|example|sample|dummy|placeholder|password|secret|test|todo|null|undefined|none|redacted|replace)|(xxx|\.\.\.|example|your_|_here|placeholder)$/i;
|
|
28
|
+
|
|
29
|
+
function entropy(s) {
|
|
30
|
+
const freq = {};
|
|
31
|
+
for (const ch of s) freq[ch] = (freq[ch] ?? 0) + 1;
|
|
32
|
+
return -Object.values(freq).reduce((h, n) => h + (n / s.length) * Math.log2(n / s.length), 0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const mask = (s) => (s.length <= 8 ? '****' : `${s.slice(0, 4)}…${s.slice(-2)} (${s.length} chars)`);
|
|
36
|
+
|
|
37
|
+
function trackedFiles(root) {
|
|
38
|
+
const r = spawnSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' });
|
|
39
|
+
if (r.status !== 0) return null;
|
|
40
|
+
return new Set(r.stdout.split('\0').filter(Boolean));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function gitIgnored(root, files) {
|
|
44
|
+
if (!files.length) return new Set();
|
|
45
|
+
const r = spawnSync('git', ['check-ignore', '--stdin'], { cwd: root, input: files.join('\n'), encoding: 'utf8' });
|
|
46
|
+
return new Set((r.stdout ?? '').split('\n').filter(Boolean));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Hard-coded credentials in source/config, and .env files that are (or could easily be) committed.
|
|
51
|
+
* @param {{files:any[], texts:Map<string,string>, contexts:Map<string,string>}} src
|
|
52
|
+
* @param {string} root
|
|
53
|
+
*/
|
|
54
|
+
export function scanSecrets(src, root) {
|
|
55
|
+
const findings = [];
|
|
56
|
+
const tracked = trackedFiles(root);
|
|
57
|
+
const envFiles = src.files.filter((f) => f.kind === 'env').map((f) => f.rel);
|
|
58
|
+
const ignored = tracked ? gitIgnored(root, envFiles) : new Set();
|
|
59
|
+
|
|
60
|
+
for (const f of src.files) {
|
|
61
|
+
const text = src.texts.get(f.rel);
|
|
62
|
+
if (!text || /(^|\/)(test|tests|__tests__|fixtures?|__mocks__)\//.test(f.rel) && f.kind !== 'env') continue;
|
|
63
|
+
const context = src.contexts.get(f.rel) ?? (f.kind === 'env' ? 'server' : 'build');
|
|
64
|
+
|
|
65
|
+
let envState = null;
|
|
66
|
+
if (f.kind === 'env') {
|
|
67
|
+
if (tracked?.has(f.rel)) envState = 'committed';
|
|
68
|
+
else if (tracked && !ignored.has(f.rel)) envState = 'not-ignored';
|
|
69
|
+
else continue; // gitignored local .env (or not a git repo) is the expected setup
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const lines = text.split('\n');
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
for (let i = 0; i < lines.length; i++) {
|
|
75
|
+
const line = lines[i];
|
|
76
|
+
if (line.length > 500) continue;
|
|
77
|
+
let hit = null;
|
|
78
|
+
for (const p of PATTERNS) {
|
|
79
|
+
const m = p.re.exec(line);
|
|
80
|
+
if (m) {
|
|
81
|
+
hit = { id: p.id, title: p.title, sev: p.sev, value: m[p.group ?? 0] };
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (!hit) {
|
|
86
|
+
const m = GENERIC.exec(line) ?? (f.kind === 'env' ? /^\s*([A-Z0-9_]*(?:SECRET|PASSWORD|PASSWD|TOKEN|API_KEY|PRIVATE_KEY|CLIENT_SECRET)[A-Z0-9_]*)\s*=\s*['"]?([^'"\s#]{8,})/i.exec(line) : null);
|
|
87
|
+
if (m && !PLACEHOLDER.test(m[2]) && entropy(m[2]) >= 3 && !/^[a-z]+([._-][a-z]+)*$/i.test(m[2])) {
|
|
88
|
+
hit = { id: 'generic-secret', title: `Hard-coded secret in "${m[1]}"`, sev: 6.5, value: m[2] };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!hit || PLACEHOLDER.test(hit.value) || seen.has(hit.value)) continue;
|
|
92
|
+
seen.add(hit.value);
|
|
93
|
+
|
|
94
|
+
let sev = hit.sev;
|
|
95
|
+
let title = hit.title;
|
|
96
|
+
if (envState === 'committed') {
|
|
97
|
+
sev = Math.max(sev, 8);
|
|
98
|
+
title = `${hit.title} in committed ${f.rel}`;
|
|
99
|
+
} else if (envState === 'not-ignored') {
|
|
100
|
+
sev = Math.min(sev, 6);
|
|
101
|
+
title = `${hit.title} in ${f.rel}, which is not gitignored`;
|
|
102
|
+
}
|
|
103
|
+
findings.push({
|
|
104
|
+
id: `secret:${hit.id}:${f.rel}:${i + 1}`,
|
|
105
|
+
kind: 'secret',
|
|
106
|
+
title,
|
|
107
|
+
ruleId: `secret-${hit.id}`,
|
|
108
|
+
category: 'secret',
|
|
109
|
+
cwe: 'CWE-798',
|
|
110
|
+
severityScore: sev,
|
|
111
|
+
severity: levelFromScore(sev),
|
|
112
|
+
confidence: hit.id === 'generic-secret' ? 'medium' : 'high',
|
|
113
|
+
context,
|
|
114
|
+
location: { file: f.rel, line: i + 1, snippet: line.replace(hit.value, mask(hit.value)).trim().slice(0, 200) },
|
|
115
|
+
fix:
|
|
116
|
+
envState === 'not-ignored'
|
|
117
|
+
? `Add ${f.rel} to .gitignore before it gets committed.`
|
|
118
|
+
: `Rotate this credential now (assume it is compromised), move it to an env var / secret manager${envState === 'committed' ? `, untrack the file (git rm --cached ${f.rel}) and purge it from history` : ''}.`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return findings;
|
|
123
|
+
}
|