itworksbut 0.3.0 → 0.5.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 +21 -1
- package/package.json +1 -1
- package/src/checks/api/mass-assignment-risk.js +81 -0
- package/src/checks/api/missing-method-guard.js +68 -0
- package/src/checks/api/no-schema-validation.js +68 -0
- package/src/checks/auth/jwt-secret-weak-or-fallback.js +72 -0
- package/src/checks/auth/missing-csrf-protection.js +75 -0
- package/src/checks/auth/password-hashing-missing.js +56 -0
- package/src/checks/config/debug-production.js +65 -0
- package/src/checks/cookies/insecure-session-cookie.js +69 -0
- package/src/checks/electron/remote-content-with-node.js +52 -0
- package/src/checks/files/path-traversal-risk.js +62 -0
- package/src/checks/frontend/localstorage-token.js +42 -0
- package/src/checks/frontend/sourcemaps-production.js +90 -0
- package/src/checks/index.js +41 -1
- package/src/checks/llm/prompt-injection-risk.js +57 -0
- package/src/checks/next/public-server-code-risk.js +64 -0
- package/src/checks/node/child-process-user-input.js +54 -0
- package/src/checks/secrets/secrets-in-logs.js +86 -0
- package/src/checks/ssrf/user-controlled-fetch.js +60 -0
- package/src/checks/tauri/remote-url-permissions-risk.js +115 -0
- package/src/checks/uploads/public-executable-upload.js +63 -0
- package/src/checks/webhooks/missing-raw-body.js +82 -0
- package/src/cli/output.js +8 -9
- package/src/reporters/consoleStyle.js +60 -0
package/README.md
CHANGED
|
@@ -197,7 +197,7 @@ Secret-like findings never print the full secret value. Findings report the file
|
|
|
197
197
|
|
|
198
198
|
## What It Detects
|
|
199
199
|
|
|
200
|
-
The baseline includes
|
|
200
|
+
The baseline includes 50 modular checks:
|
|
201
201
|
|
|
202
202
|
- `git.gitignore-missing`
|
|
203
203
|
- `git.gitignore-incomplete`
|
|
@@ -206,6 +206,7 @@ The baseline includes 30 modular checks:
|
|
|
206
206
|
- `env.env-example-missing`
|
|
207
207
|
- `env.possible-secret-in-code`
|
|
208
208
|
- `env.frontend-secret-exposure`
|
|
209
|
+
- `secrets.secrets-in-logs`
|
|
209
210
|
- `dependencies.lockfile-missing`
|
|
210
211
|
- `dependencies.multiple-lockfiles`
|
|
211
212
|
- `dependencies.install-scripts-risk`
|
|
@@ -219,16 +220,35 @@ The baseline includes 30 modular checks:
|
|
|
219
220
|
- `node.rate-limit-missing`
|
|
220
221
|
- `node.helmet-missing`
|
|
221
222
|
- `node.cors-wildcard`
|
|
223
|
+
- `node.child-process-user-input`
|
|
222
224
|
- `web.client-side-auth-only`
|
|
223
225
|
- `web.dangerous-inner-html`
|
|
224
226
|
- `web.missing-output-sanitization`
|
|
225
227
|
- `api.missing-auth-on-routes`
|
|
226
228
|
- `api.idor-risk`
|
|
229
|
+
- `auth.jwt-secret-weak-or-fallback`
|
|
230
|
+
- `auth.password-hashing-missing`
|
|
231
|
+
- `auth.missing-csrf-protection`
|
|
232
|
+
- `api.missing-method-guard`
|
|
233
|
+
- `api.mass-assignment-risk`
|
|
234
|
+
- `api.no-schema-validation`
|
|
227
235
|
- `database.raw-sql-interpolation`
|
|
228
236
|
- `database.no-migrations`
|
|
237
|
+
- `cookies.insecure-session-cookie`
|
|
238
|
+
- `uploads.public-executable-upload`
|
|
239
|
+
- `webhooks.missing-raw-body`
|
|
240
|
+
- `llm.prompt-injection-risk`
|
|
241
|
+
- `frontend.sourcemaps-production`
|
|
242
|
+
- `frontend.localstorage-token`
|
|
243
|
+
- `files.path-traversal-risk`
|
|
244
|
+
- `ssrf.user-controlled-fetch`
|
|
245
|
+
- `next.public-server-code-risk`
|
|
246
|
+
- `config.debug-production`
|
|
229
247
|
- `electron.node-integration-enabled`
|
|
230
248
|
- `electron.context-isolation-disabled`
|
|
249
|
+
- `electron.remote-content-with-node`
|
|
231
250
|
- `tauri.dangerous-allowlist-or-capabilities`
|
|
251
|
+
- `tauri.remote-url-permissions-risk`
|
|
232
252
|
|
|
233
253
|
Each check is a plain ESM module with an `id`, metadata, and async `run(context)` function. Add new checks under `src/checks/` and register them in `src/checks/index.js`.
|
|
234
254
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { isCodeLikeFile, isEnvExampleFile, isLockfile, lineFromOffset, readNearby } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const MASS_ASSIGNMENT_PATTERNS = [
|
|
4
|
+
{
|
|
5
|
+
regex: /\b(?:prisma\.\w+\.)?(?:create|update|upsert)\s*\(\s*{[\s\S]{0,600}?\bdata\s*:\s*(?:req\.body|body|input)\b/g,
|
|
6
|
+
label: "direct data object",
|
|
7
|
+
severity: "high"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
regex: /\b(?:db|database|collection|\w+)\.(?:update|updateOne|updateMany|findOneAndUpdate)\s*\([\s\S]{0,300}?(?:req\.body|body|input)\b/g,
|
|
11
|
+
label: "direct update payload",
|
|
12
|
+
severity: "high"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
regex: /\b(?:User|Account|Profile|Model|model|\w+)\.(?:create|update)\s*\(\s*(?:req\.body|body|input)\b/g,
|
|
16
|
+
label: "model create/update payload",
|
|
17
|
+
severity: "high"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
regex: /\$set\s*:\s*(?:req\.body|body|input)\b/g,
|
|
21
|
+
label: "mongodb set payload",
|
|
22
|
+
severity: "high"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
regex: /Object\.assign\s*\(\s*(?:user|entity|account|profile|record|model)[\w$]*\s*,\s*(?:req\.body|body|input)\b/g,
|
|
26
|
+
label: "object assign from request input",
|
|
27
|
+
severity: "medium"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
regex: /\{\s*\.\.\.(?:req\.body|body)\s*}/g,
|
|
31
|
+
label: "spread request body",
|
|
32
|
+
severity: "medium",
|
|
33
|
+
requiresCreateOrUpdateContext: true
|
|
34
|
+
}
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const SAFE_FIELD_RE =
|
|
38
|
+
/\b(?:pick|omit|allowedFields|allowlist|whitelist|safeData|validatedData|schema\.parse|schema\.safeParse|safeParse|zod|Joi|joi|yup|valibot)\b/i;
|
|
39
|
+
const RISKY_FIELD_RE =
|
|
40
|
+
/\b(?:role|isAdmin|admin|plan|verified|emailVerified|ownerId|userId|tenantId|accountId|permissions|credits|balance|price|status)\b/i;
|
|
41
|
+
const CREATE_OR_UPDATE_RE = /\b(?:create|update|upsert|insert|save|data\s*:|\$set)\b/i;
|
|
42
|
+
|
|
43
|
+
export default {
|
|
44
|
+
id: "api.mass-assignment-risk",
|
|
45
|
+
title: "Create and update operations should not trust raw request bodies",
|
|
46
|
+
category: "api",
|
|
47
|
+
severity: "high",
|
|
48
|
+
tags: ["api", "database", "mass-assignment", "heuristic"],
|
|
49
|
+
run: async (context) => {
|
|
50
|
+
const findings = [];
|
|
51
|
+
|
|
52
|
+
for (const file of context.textFiles) {
|
|
53
|
+
if (isLockfile(file) || isEnvExampleFile(file) || !isCodeLikeFile(file)) continue;
|
|
54
|
+
const content = await context.readFileSafe(file);
|
|
55
|
+
if (!content || !/\b(?:req\.body|body|input|Object\.assign|\$set)\b/.test(content)) continue;
|
|
56
|
+
|
|
57
|
+
for (const pattern of MASS_ASSIGNMENT_PATTERNS) {
|
|
58
|
+
pattern.regex.lastIndex = 0;
|
|
59
|
+
let match;
|
|
60
|
+
while ((match = pattern.regex.exec(content)) !== null) {
|
|
61
|
+
const line = lineFromOffset(content, match.index);
|
|
62
|
+
const nearby = await readNearby(context, file, line, 8);
|
|
63
|
+
if (pattern.requiresCreateOrUpdateContext && !CREATE_OR_UPDATE_RE.test(nearby)) continue;
|
|
64
|
+
if (SAFE_FIELD_RE.test(nearby)) continue;
|
|
65
|
+
|
|
66
|
+
findings.push({
|
|
67
|
+
severity: RISKY_FIELD_RE.test(nearby) ? "high" : pattern.severity === "high" ? "high" : "medium",
|
|
68
|
+
message: "User-controlled input appears to be passed directly into a create or update operation.",
|
|
69
|
+
file,
|
|
70
|
+
line,
|
|
71
|
+
recommendation: "Whitelist allowed fields explicitly. Never pass req.body directly into database create/update calls.",
|
|
72
|
+
heuristic: true,
|
|
73
|
+
metadata: { pattern: pattern.label }
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return findings.slice(0, 100);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isServerOrApiFile, lineFromOffset, readNearby } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const ALL_ROUTE_RE = /\b(?:app|router|server)\.all\s*\(/g;
|
|
4
|
+
const NEXT_PAGES_HANDLER_RE = /\bexport\s+default\s+(?:async\s+)?function\s+\w*\s*\(\s*(?:req|request)\s*,\s*(?:res|response)\s*\)/g;
|
|
5
|
+
const NAMED_HANDLER_RE = /\bexport\s+(?:async\s+)?function\s+handler\s*\(\s*(?:req|request)\s*,\s*(?:res|response)\s*\)/g;
|
|
6
|
+
const METHOD_GUARD_RE =
|
|
7
|
+
/\b(?:req|request)\.method\b|\bswitch\s*\(\s*(?:req|request)\.method\s*\)|\ballowedMethods\b|\bmethodNotAllowed\b|\breturn\s+new\s+Response\s*\([^)]*405|\bstatus\s*\(\s*405\s*\)/i;
|
|
8
|
+
const METHOD_EXPORT_RE = /\bexport\s+(?:async\s+)?function\s+(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*\(/;
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
id: "api.missing-method-guard",
|
|
12
|
+
title: "API handlers should restrict HTTP methods",
|
|
13
|
+
category: "api",
|
|
14
|
+
severity: "medium",
|
|
15
|
+
tags: ["api", "http-methods", "heuristic"],
|
|
16
|
+
run: async (context) => {
|
|
17
|
+
const findings = [];
|
|
18
|
+
|
|
19
|
+
for (const file of context.textFiles) {
|
|
20
|
+
if (!isApiCandidate(file)) continue;
|
|
21
|
+
const content = await context.readFileSafe(file);
|
|
22
|
+
if (!content) continue;
|
|
23
|
+
|
|
24
|
+
ALL_ROUTE_RE.lastIndex = 0;
|
|
25
|
+
let allMatch;
|
|
26
|
+
while ((allMatch = ALL_ROUTE_RE.exec(content)) !== null) {
|
|
27
|
+
const line = lineFromOffset(content, allMatch.index);
|
|
28
|
+
const nearby = await readNearby(context, file, line, 8);
|
|
29
|
+
if (/\b(?:allowedMethods|methodNotAllowed|405)\b/i.test(nearby)) continue;
|
|
30
|
+
findings.push(methodFinding(file, line, "app-router-all"));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (METHOD_EXPORT_RE.test(content) || METHOD_GUARD_RE.test(content)) continue;
|
|
34
|
+
|
|
35
|
+
for (const pattern of [NEXT_PAGES_HANDLER_RE, NAMED_HANDLER_RE]) {
|
|
36
|
+
pattern.lastIndex = 0;
|
|
37
|
+
let match;
|
|
38
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
39
|
+
findings.push(methodFinding(file, lineFromOffset(content, match.index), "handler-without-method-guard"));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return findings.slice(0, 100);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function isApiCandidate(file) {
|
|
49
|
+
return (
|
|
50
|
+
/\.[cm]?[jt]sx?$/.test(file) &&
|
|
51
|
+
(isServerOrApiFile(file) ||
|
|
52
|
+
file.startsWith("routes/") ||
|
|
53
|
+
file.startsWith("api/") ||
|
|
54
|
+
file.includes("/controllers/") ||
|
|
55
|
+
file.includes("/handlers/"))
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function methodFinding(file, line, pattern) {
|
|
60
|
+
return {
|
|
61
|
+
message: "This API handler appears to process requests without an explicit HTTP method guard.",
|
|
62
|
+
file,
|
|
63
|
+
line,
|
|
64
|
+
recommendation: "Restrict API routes to the intended HTTP methods and return 405 Method Not Allowed for unsupported methods.",
|
|
65
|
+
heuristic: true,
|
|
66
|
+
metadata: { pattern }
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isServerOrApiFile, lineFromOffset, readNearby } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const REQUEST_INPUT_RE =
|
|
4
|
+
/\breq\.(?:body|query|params)\b|\brequest\.json\s*\(|\bsearchParams\.get\s*\(|\bnew\s+URL\s*\(\s*(?:req|request)\.url\s*\)\.searchParams\b|\bformData\s*\(|\bctx\.request\.body\b/g;
|
|
5
|
+
const VALIDATION_RE =
|
|
6
|
+
/\b(?:zod|Joi|joi|yup|valibot|ajv|superstruct|TypeBox|validator|validatedData)\b|\.safeParse\s*\(|\.parse\s*\(\s*(?:req\.body|body|input|payload|data)|\.validate\s*\(\s*(?:req\.body|body|input|payload|data)|\bschema\.(?:validate|parse|safeParse)\b/i;
|
|
7
|
+
const GLOBAL_VALIDATION_RE = /\b(?:app|router|server)\.use\s*\([^)]*(?:validate|validator|schema|zod|joi|yup|ajv|valibot)/i;
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
id: "api.no-schema-validation",
|
|
11
|
+
title: "API request input should be schema validated",
|
|
12
|
+
category: "api",
|
|
13
|
+
severity: "high",
|
|
14
|
+
tags: ["api", "validation", "heuristic"],
|
|
15
|
+
run: async (context) => {
|
|
16
|
+
const findings = [];
|
|
17
|
+
const globalValidationSeen = await hasGlobalValidationMiddleware(context);
|
|
18
|
+
if (globalValidationSeen) return [];
|
|
19
|
+
|
|
20
|
+
for (const file of context.textFiles) {
|
|
21
|
+
if (!isApiFile(file)) continue;
|
|
22
|
+
const content = await context.readFileSafe(file);
|
|
23
|
+
if (!content) continue;
|
|
24
|
+
if (VALIDATION_RE.test(content)) continue;
|
|
25
|
+
|
|
26
|
+
REQUEST_INPUT_RE.lastIndex = 0;
|
|
27
|
+
const match = REQUEST_INPUT_RE.exec(content);
|
|
28
|
+
if (!match) continue;
|
|
29
|
+
|
|
30
|
+
const line = lineFromOffset(content, match.index);
|
|
31
|
+
const nearby = await readNearby(context, file, line, 10);
|
|
32
|
+
if (VALIDATION_RE.test(nearby)) continue;
|
|
33
|
+
|
|
34
|
+
findings.push({
|
|
35
|
+
message: "This API route appears to consume request input without an obvious schema validation step.",
|
|
36
|
+
file,
|
|
37
|
+
line,
|
|
38
|
+
recommendation: "Validate request body, query and params with a schema library such as Zod, Joi, Valibot, AJV or equivalent.",
|
|
39
|
+
heuristic: true,
|
|
40
|
+
metadata: { pattern: "request-input-without-schema-validation" }
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return findings.slice(0, 100);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function isApiFile(file) {
|
|
49
|
+
return (
|
|
50
|
+
/\.[cm]?[jt]sx?$/.test(file) &&
|
|
51
|
+
(isServerOrApiFile(file) ||
|
|
52
|
+
file.startsWith("api/") ||
|
|
53
|
+
file.startsWith("routes/") ||
|
|
54
|
+
file.startsWith("handlers/") ||
|
|
55
|
+
file.startsWith("controllers/") ||
|
|
56
|
+
file.includes("/handlers/") ||
|
|
57
|
+
file.includes("/controllers/"))
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function hasGlobalValidationMiddleware(context) {
|
|
62
|
+
for (const file of context.textFiles) {
|
|
63
|
+
if (!/\.[cm]?[jt]sx?$/.test(file) || !isServerOrApiFile(file)) continue;
|
|
64
|
+
const content = await context.readFileSafe(file);
|
|
65
|
+
if (content && GLOBAL_VALIDATION_RE.test(content)) return true;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { isCodeLikeFile, isEnvExampleFile, isLockfile } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const WEAK_JWT_VALUES = [
|
|
4
|
+
"secret",
|
|
5
|
+
"changeme",
|
|
6
|
+
"change-me",
|
|
7
|
+
"dev-secret",
|
|
8
|
+
"development",
|
|
9
|
+
"password",
|
|
10
|
+
"123456",
|
|
11
|
+
"jwt-secret",
|
|
12
|
+
"supersecret",
|
|
13
|
+
"test",
|
|
14
|
+
"local"
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const WEAK_VALUE_RE = `(?:${WEAK_JWT_VALUES.map(escapeRegExp).join("|")})`;
|
|
18
|
+
const DIRECT_JWT_RE = new RegExp(`\\bjwt\\.(?:sign|verify)\\s*\\([^\\n;]*?,\\s*["'\`]${WEAK_VALUE_RE}["'\`]`, "i");
|
|
19
|
+
const FALLBACK_RE = new RegExp(`\\bprocess\\.env\\.JWT_SECRET\\s*(?:\\|\\||\\?\\?)\\s*["'\`]${WEAK_VALUE_RE}["'\`]`, "i");
|
|
20
|
+
const ASSIGNMENT_RE = new RegExp(`\\bJWT_SECRET\\b\\s*(?:=|:)\\s*["'\`]${WEAK_VALUE_RE}["'\`]`, "i");
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
id: "auth.jwt-secret-weak-or-fallback",
|
|
24
|
+
title: "JWT secrets should not use weak hardcoded values or fallbacks",
|
|
25
|
+
category: "auth",
|
|
26
|
+
severity: "high",
|
|
27
|
+
tags: ["auth", "jwt", "secrets", "heuristic"],
|
|
28
|
+
run: async (context) => {
|
|
29
|
+
const findings = [];
|
|
30
|
+
|
|
31
|
+
for (const file of context.textFiles) {
|
|
32
|
+
if (isLockfile(file) || isEnvExampleFile(file) || !isCodeLikeFile(file)) continue;
|
|
33
|
+
const content = await context.readFileSafe(file);
|
|
34
|
+
if (!content || !/\bJWT_SECRET\b|\bjwt\.(?:sign|verify)\b/i.test(content)) continue;
|
|
35
|
+
|
|
36
|
+
const lines = content.split(/\r?\n/);
|
|
37
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
38
|
+
const line = lines[index];
|
|
39
|
+
|
|
40
|
+
if (DIRECT_JWT_RE.test(line)) {
|
|
41
|
+
findings.push(jwtFinding(file, index + 1, "direct-hardcoded-jwt-secret", "critical"));
|
|
42
|
+
} else if (FALLBACK_RE.test(line)) {
|
|
43
|
+
findings.push(jwtFinding(file, index + 1, "environment-fallback", "high"));
|
|
44
|
+
} else if (ASSIGNMENT_RE.test(line)) {
|
|
45
|
+
findings.push(jwtFinding(file, index + 1, "weak-jwt-secret-assignment", "high"));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return findings.slice(0, 100);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function jwtFinding(file, line, pattern, severity) {
|
|
55
|
+
return {
|
|
56
|
+
severity,
|
|
57
|
+
message: "JWT signing or verification appears to use a weak hardcoded secret or a development fallback.",
|
|
58
|
+
file,
|
|
59
|
+
line,
|
|
60
|
+
recommendation:
|
|
61
|
+
"Require a strong JWT secret from the environment in production and fail startup if it is missing.",
|
|
62
|
+
heuristic: true,
|
|
63
|
+
metadata: {
|
|
64
|
+
pattern,
|
|
65
|
+
valueRedacted: true
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function escapeRegExp(value) {
|
|
71
|
+
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
72
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { isCodeLikeFile, isEnvExampleFile, isLockfile, lineFromOffset } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const COOKIE_AUTH_RE =
|
|
4
|
+
/\b(?:res\.cookie|cookies\(\)\.set|response\.cookies\.set|setCookie|serialize)\s*\(\s*["'`](?:session|token|auth|jwt)["'`]|cookieSession\s*\(|express-session|\bsession\s*\(|credentials\s*:\s*["'`]include["'`]|withCredentials\s*:\s*true/gi;
|
|
5
|
+
const CSRF_PROTECTION_RE =
|
|
6
|
+
/\b(?:csrf|csurf|csrfToken|anti-csrf|verifyCsrf|validateCsrf|csrfProtection)\b|double\s+submit|\bsameSite\s*:\s*["'`]?(?:strict|lax)["'`]?\b/gi;
|
|
7
|
+
const STATE_CHANGING_ROUTE_RE =
|
|
8
|
+
/\b(?:app|router|server)\.(?:post|put|patch|delete)\s*\(|\bmethod\s*:\s*["'`](?:POST|PUT|PATCH|DELETE)["'`]|\breq\.method\s*={0,3}\s*["'`](?:POST|PUT|PATCH|DELETE)["'`]|\bexport\s+async\s+function\s+(?:POST|PUT|PATCH|DELETE)\s*\(/g;
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
id: "auth.missing-csrf-protection",
|
|
12
|
+
title: "Cookie-based authentication should include CSRF protection",
|
|
13
|
+
category: "auth",
|
|
14
|
+
severity: "high",
|
|
15
|
+
tags: ["auth", "csrf", "cookies", "heuristic"],
|
|
16
|
+
run: async (context) => {
|
|
17
|
+
const cookieMatches = [];
|
|
18
|
+
const stateChangingRoutes = [];
|
|
19
|
+
let csrfProtectionSeen = false;
|
|
20
|
+
|
|
21
|
+
for (const file of context.textFiles) {
|
|
22
|
+
if (isLockfile(file) || isEnvExampleFile(file) || !isCodeLikeFile(file)) continue;
|
|
23
|
+
const content = await context.readFileSafe(file);
|
|
24
|
+
if (!content) continue;
|
|
25
|
+
|
|
26
|
+
CSRF_PROTECTION_RE.lastIndex = 0;
|
|
27
|
+
if (CSRF_PROTECTION_RE.test(content)) {
|
|
28
|
+
csrfProtectionSeen = true;
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
COOKIE_AUTH_RE.lastIndex = 0;
|
|
33
|
+
let cookieMatch;
|
|
34
|
+
while ((cookieMatch = COOKIE_AUTH_RE.exec(content)) !== null) {
|
|
35
|
+
cookieMatches.push({
|
|
36
|
+
file,
|
|
37
|
+
line: lineFromOffset(content, cookieMatch.index),
|
|
38
|
+
pattern: normalizePattern(cookieMatch[0])
|
|
39
|
+
});
|
|
40
|
+
if (cookieMatches.length >= 25) break;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
STATE_CHANGING_ROUTE_RE.lastIndex = 0;
|
|
44
|
+
let routeMatch;
|
|
45
|
+
while ((routeMatch = STATE_CHANGING_ROUTE_RE.exec(content)) !== null) {
|
|
46
|
+
stateChangingRoutes.push({
|
|
47
|
+
file,
|
|
48
|
+
line: lineFromOffset(content, routeMatch.index),
|
|
49
|
+
pattern: normalizePattern(routeMatch[0])
|
|
50
|
+
});
|
|
51
|
+
if (stateChangingRoutes.length >= 25) break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (csrfProtectionSeen || cookieMatches.length === 0 || stateChangingRoutes.length === 0) return [];
|
|
56
|
+
|
|
57
|
+
const primaryCookie = cookieMatches[0];
|
|
58
|
+
return stateChangingRoutes.slice(0, 25).map((route) => ({
|
|
59
|
+
message: "Cookie-based authentication appears to be used without an obvious CSRF protection mechanism.",
|
|
60
|
+
file: route.file,
|
|
61
|
+
line: route.line,
|
|
62
|
+
recommendation: "Use SameSite cookies, CSRF tokens or another explicit CSRF mitigation for state-changing routes.",
|
|
63
|
+
heuristic: true,
|
|
64
|
+
metadata: {
|
|
65
|
+
pattern: "cookie-auth-with-state-changing-route",
|
|
66
|
+
cookieFile: primaryCookie.file,
|
|
67
|
+
cookieLine: primaryCookie.line
|
|
68
|
+
}
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function normalizePattern(value) {
|
|
74
|
+
return String(value || "").replace(/\s+/g, " ").slice(0, 80);
|
|
75
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { isCodeLikeFile, isEnvExampleFile, isLockfile, lineFromOffset } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const USER_CREATE_TERMS_RE = /\b(register|signup|createUser|users|INSERT\s+INTO\s+users|prisma\.user\.create|db\.user\.create)\b/i;
|
|
4
|
+
const PASSWORD_TERM_RE = /\bpassword\b/i;
|
|
5
|
+
const HASHING_RE = /\b(?:bcrypt|bcryptjs|argon2|scrypt|crypto\.scrypt|pbkdf2|hashPassword|passwordHash|hashedPassword)\b/i;
|
|
6
|
+
|
|
7
|
+
const RISKY_PASSWORD_STORAGE_RE =
|
|
8
|
+
/\bpassword\s*:\s*(?:password|req\.body\.password|request\.body\.password|body\.password)|\bpassword\s*=\s*(?:password|req\.body\.password|request\.body\.password|body\.password)|INSERT\s+INTO\s+users\s*\([^)]*password|prisma\.user\.create\s*\(\s*{[\s\S]{0,800}?data\s*:\s*{[\s\S]{0,500}?password\s*:|db\.user\.create\s*\(\s*{[\s\S]{0,800}?password\s*:/gi;
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
id: "auth.password-hashing-missing",
|
|
12
|
+
title: "User passwords should be hashed before storage",
|
|
13
|
+
category: "auth",
|
|
14
|
+
severity: "critical",
|
|
15
|
+
tags: ["auth", "passwords", "heuristic"],
|
|
16
|
+
run: async (context) => {
|
|
17
|
+
const findings = [];
|
|
18
|
+
|
|
19
|
+
for (const file of context.textFiles) {
|
|
20
|
+
if (isLockfile(file) || isEnvExampleFile(file) || !isCodeLikeFile(file)) continue;
|
|
21
|
+
const content = await context.readFileSafe(file);
|
|
22
|
+
if (!content || !PASSWORD_TERM_RE.test(content) || !USER_CREATE_TERMS_RE.test(content)) continue;
|
|
23
|
+
|
|
24
|
+
RISKY_PASSWORD_STORAGE_RE.lastIndex = 0;
|
|
25
|
+
let match;
|
|
26
|
+
while ((match = RISKY_PASSWORD_STORAGE_RE.exec(content)) !== null) {
|
|
27
|
+
const line = lineFromOffset(content, match.index);
|
|
28
|
+
const nearby = nearbyText(content, line, 10);
|
|
29
|
+
if (HASHING_RE.test(nearby)) continue;
|
|
30
|
+
|
|
31
|
+
findings.push({
|
|
32
|
+
message:
|
|
33
|
+
"This code appears to create users or store passwords without an obvious password hashing step.",
|
|
34
|
+
file,
|
|
35
|
+
line,
|
|
36
|
+
recommendation:
|
|
37
|
+
"Hash passwords with argon2, bcrypt, scrypt or PBKDF2 before storage. Never store raw passwords.",
|
|
38
|
+
heuristic: true,
|
|
39
|
+
metadata: {
|
|
40
|
+
pattern: "password-storage-without-nearby-hashing"
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return findings.slice(0, 100);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function nearbyText(content, line, radius) {
|
|
52
|
+
const lines = content.split(/\r?\n/);
|
|
53
|
+
const start = Math.max(0, line - radius - 1);
|
|
54
|
+
const end = Math.min(lines.length, line + radius);
|
|
55
|
+
return lines.slice(start, end).join("\n");
|
|
56
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { isCodeLikeFile, isEnvExampleFile, isLockfile, lineFromOffset } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const DEBUG_FLAG_RE =
|
|
4
|
+
/\b(?:debug|verbose|dev|exposeErrors|stackTrace|showStack)\s*:\s*true\b|\bapp\.set\s*\(\s*["'`]env["'`]\s*,\s*["'`]development["'`]\s*\)|\bNODE_ENV\s*=\s*["'`]development["'`]|\bdevtool\s*:\s*["'`](?:eval|eval-source-map|inline-source-map|cheap-module-source-map)["'`]/gi;
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
id: "config.debug-production",
|
|
8
|
+
title: "Production configuration should not enable debug behavior",
|
|
9
|
+
category: "config",
|
|
10
|
+
severity: "medium",
|
|
11
|
+
tags: ["config", "debug", "heuristic"],
|
|
12
|
+
run: async (context) => {
|
|
13
|
+
const findings = [];
|
|
14
|
+
|
|
15
|
+
for (const file of context.textFiles) {
|
|
16
|
+
if (isLockfile(file) || isEnvExampleFile(file) || !isCodeLikeFile(file)) continue;
|
|
17
|
+
if (!isRiskyConfigFile(file)) continue;
|
|
18
|
+
|
|
19
|
+
const content = await context.readFileSafe(file);
|
|
20
|
+
if (!content) continue;
|
|
21
|
+
|
|
22
|
+
DEBUG_FLAG_RE.lastIndex = 0;
|
|
23
|
+
let match;
|
|
24
|
+
while ((match = DEBUG_FLAG_RE.exec(content)) !== null) {
|
|
25
|
+
const productionLike = isProductionLikeFile(file);
|
|
26
|
+
findings.push({
|
|
27
|
+
severity: productionLike ? "high" : "medium",
|
|
28
|
+
message: "Debug or development flags appear to be enabled in production-like configuration.",
|
|
29
|
+
file,
|
|
30
|
+
line: lineFromOffset(content, match.index),
|
|
31
|
+
recommendation:
|
|
32
|
+
"Disable verbose errors and debug flags in production. Avoid exposing stack traces, internal paths or development tooling.",
|
|
33
|
+
heuristic: true,
|
|
34
|
+
metadata: {
|
|
35
|
+
productionLike,
|
|
36
|
+
pattern: classifyDebugPattern(match[0])
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return findings.slice(0, 100);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function isRiskyConfigFile(file) {
|
|
47
|
+
return (
|
|
48
|
+
isProductionLikeFile(file) ||
|
|
49
|
+
/^next\.config\.[cm]?[jt]s$/.test(file) ||
|
|
50
|
+
/^vite\.config\.[cm]?[jt]s$/.test(file) ||
|
|
51
|
+
/^webpack\.config\.[cm]?[jt]s$/.test(file) ||
|
|
52
|
+
/(^|\/)(server|app)\.[cm]?[jt]s$/.test(file)
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isProductionLikeFile(file) {
|
|
57
|
+
return /^config\/production\./.test(file) || /\.production\./.test(file);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function classifyDebugPattern(value) {
|
|
61
|
+
if (/devtool/i.test(value)) return "unsafe-devtool";
|
|
62
|
+
if (/NODE_ENV|app\.set/i.test(value)) return "development-environment";
|
|
63
|
+
if (/stack|showStack|exposeErrors/i.test(value)) return "verbose-errors";
|
|
64
|
+
return "debug-flag";
|
|
65
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { isCodeLikeFile, isEnvExampleFile, isLockfile, readNearby } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const COOKIE_CALL_RE =
|
|
4
|
+
/\b(?:res\.cookie|cookies\(\)\.set|response\.cookies\.set|setCookie|serialize)\s*\(\s*["'`]([^"'`]+)["'`]/g;
|
|
5
|
+
const AUTH_COOKIE_NAME_RE = /\b(session|auth|token|jwt)\b/i;
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
id: "cookies.insecure-session-cookie",
|
|
9
|
+
title: "Session cookies should use secure attributes",
|
|
10
|
+
category: "cookies",
|
|
11
|
+
severity: "high",
|
|
12
|
+
tags: ["cookies", "auth", "heuristic"],
|
|
13
|
+
run: async (context) => {
|
|
14
|
+
const findings = [];
|
|
15
|
+
|
|
16
|
+
for (const file of context.textFiles) {
|
|
17
|
+
if (isLockfile(file) || isEnvExampleFile(file) || !isCodeLikeFile(file)) continue;
|
|
18
|
+
const content = await context.readFileSafe(file);
|
|
19
|
+
if (!content || !/cookie|setCookie|serialize/i.test(content)) continue;
|
|
20
|
+
const lines = content.split(/\r?\n/);
|
|
21
|
+
|
|
22
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
23
|
+
COOKIE_CALL_RE.lastIndex = 0;
|
|
24
|
+
let match;
|
|
25
|
+
while ((match = COOKIE_CALL_RE.exec(lines[index])) !== null) {
|
|
26
|
+
const cookieName = match[1] || "";
|
|
27
|
+
const nearby = await readNearby(context, file, index + 1, 6);
|
|
28
|
+
if (hasSecureCookieAttributes(nearby)) continue;
|
|
29
|
+
|
|
30
|
+
findings.push({
|
|
31
|
+
severity: AUTH_COOKIE_NAME_RE.test(cookieName) ? "high" : "medium",
|
|
32
|
+
message: "A session or auth cookie appears to be set without secure cookie attributes.",
|
|
33
|
+
file,
|
|
34
|
+
line: index + 1,
|
|
35
|
+
recommendation:
|
|
36
|
+
"Set httpOnly, secure and sameSite for session cookies. Use secure: true in production.",
|
|
37
|
+
heuristic: true,
|
|
38
|
+
metadata: {
|
|
39
|
+
cookieName: redactCookieName(cookieName),
|
|
40
|
+
missingAttributes: missingAttributes(nearby)
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return findings.slice(0, 100);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function hasSecureCookieAttributes(value) {
|
|
52
|
+
return (
|
|
53
|
+
/\bhttpOnly\s*:\s*true\b/i.test(value) &&
|
|
54
|
+
/\bsecure\s*:\s*true\b/i.test(value) &&
|
|
55
|
+
/\bsameSite\s*:\s*["'`]?(?:lax|strict|none)["'`]?/i.test(value)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function missingAttributes(value) {
|
|
60
|
+
const missing = [];
|
|
61
|
+
if (!/\bhttpOnly\s*:\s*true\b/i.test(value)) missing.push("httpOnly");
|
|
62
|
+
if (!/\bsecure\s*:\s*true\b/i.test(value)) missing.push("secure");
|
|
63
|
+
if (!/\bsameSite\s*:\s*["'`]?(?:lax|strict|none)["'`]?/i.test(value)) missing.push("sameSite");
|
|
64
|
+
return missing;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function redactCookieName(cookieName) {
|
|
68
|
+
return AUTH_COOKIE_NAME_RE.test(cookieName) ? cookieName : "non-auth-cookie";
|
|
69
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { hasText, lineFromOffset, readNearby } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const LOAD_REMOTE_RE =
|
|
4
|
+
/\b(?:mainWindow|win|window|BrowserWindow|\w+)\.loadURL\s*\(\s*(?:["'`]https?:\/\/[^"'`]+["'`]|remoteUrl|process\.env\.\w+|config\.url)\s*\)/g;
|
|
5
|
+
const RISKY_WEB_PREFERENCES_RE =
|
|
6
|
+
/\b(?:nodeIntegration\s*:\s*true|contextIsolation\s*:\s*false|webSecurity\s*:\s*false|allowRunningInsecureContent\s*:\s*true|experimentalFeatures\s*:\s*true|enableRemoteModule\s*:\s*true|sandbox\s*:\s*false)\b/i;
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
id: "electron.remote-content-with-node",
|
|
10
|
+
title: "Electron remote content should not run with privileged renderer settings",
|
|
11
|
+
category: "electron",
|
|
12
|
+
severity: "critical",
|
|
13
|
+
tags: ["electron", "desktop", "xss", "heuristic"],
|
|
14
|
+
run: async (context) => {
|
|
15
|
+
if (!(await isElectronProject(context))) return [];
|
|
16
|
+
const findings = [];
|
|
17
|
+
|
|
18
|
+
for (const file of context.textFiles) {
|
|
19
|
+
if (!/\.[cm]?[jt]s$/.test(file)) continue;
|
|
20
|
+
const content = await context.readFileSafe(file);
|
|
21
|
+
if (!content || !/loadURL\s*\(/.test(content) || !RISKY_WEB_PREFERENCES_RE.test(content)) continue;
|
|
22
|
+
|
|
23
|
+
LOAD_REMOTE_RE.lastIndex = 0;
|
|
24
|
+
let match;
|
|
25
|
+
while ((match = LOAD_REMOTE_RE.exec(content)) !== null) {
|
|
26
|
+
const line = lineFromOffset(content, match.index);
|
|
27
|
+
const nearby = await readNearby(context, file, line, 20);
|
|
28
|
+
if (!RISKY_WEB_PREFERENCES_RE.test(nearby) && !RISKY_WEB_PREFERENCES_RE.test(content)) continue;
|
|
29
|
+
|
|
30
|
+
findings.push({
|
|
31
|
+
message: "Electron appears to load remote content while enabling risky renderer privileges.",
|
|
32
|
+
file,
|
|
33
|
+
line,
|
|
34
|
+
recommendation:
|
|
35
|
+
"Avoid loading remote content with Node.js integration. Use nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true and a minimal preload bridge.",
|
|
36
|
+
heuristic: true,
|
|
37
|
+
metadata: { pattern: "remote-load-url-with-risky-web-preferences" }
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return findings.slice(0, 100);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
async function isElectronProject(context) {
|
|
47
|
+
return (
|
|
48
|
+
context.hasDependency("electron") ||
|
|
49
|
+
context.hasDevDependency("electron") ||
|
|
50
|
+
(await hasText(context, /\b(?:from\s+["'`]electron["'`]|require\s*\(\s*["'`]electron["'`]\s*\)|BrowserWindow)\b/g))
|
|
51
|
+
);
|
|
52
|
+
}
|