itworksbut 0.4.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 +11 -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/missing-csrf-protection.js +75 -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/index.js +21 -1
- package/src/checks/next/public-server-code-risk.js +64 -0
- package/src/checks/ssrf/user-controlled-fetch.js +60 -0
- package/src/checks/tauri/remote-url-permissions-risk.js +115 -0
- package/src/cli/output.js +8 -9
- package/src/reporters/consoleStyle.js +30 -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`
|
|
@@ -228,6 +228,10 @@ The baseline includes 40 modular checks:
|
|
|
228
228
|
- `api.idor-risk`
|
|
229
229
|
- `auth.jwt-secret-weak-or-fallback`
|
|
230
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`
|
|
231
235
|
- `database.raw-sql-interpolation`
|
|
232
236
|
- `database.no-migrations`
|
|
233
237
|
- `cookies.insecure-session-cookie`
|
|
@@ -235,10 +239,16 @@ The baseline includes 40 modular checks:
|
|
|
235
239
|
- `webhooks.missing-raw-body`
|
|
236
240
|
- `llm.prompt-injection-risk`
|
|
237
241
|
- `frontend.sourcemaps-production`
|
|
242
|
+
- `frontend.localstorage-token`
|
|
243
|
+
- `files.path-traversal-risk`
|
|
244
|
+
- `ssrf.user-controlled-fetch`
|
|
245
|
+
- `next.public-server-code-risk`
|
|
238
246
|
- `config.debug-production`
|
|
239
247
|
- `electron.node-integration-enabled`
|
|
240
248
|
- `electron.context-isolation-disabled`
|
|
249
|
+
- `electron.remote-content-with-node`
|
|
241
250
|
- `tauri.dangerous-allowlist-or-capabilities`
|
|
251
|
+
- `tauri.remote-url-permissions-risk`
|
|
242
252
|
|
|
243
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`.
|
|
244
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,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,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
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { isCodeLikeFile, isEnvExampleFile, isLockfile, isServerOrApiFile, lineFromOffset, readNearby } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const FILE_PATH_SINK_RE =
|
|
4
|
+
/\b(?:fs\.(?:readFile|readFileSync|writeFile|writeFileSync|createReadStream|createWriteStream)|res\.sendFile|reply\.sendFile|path\.(?:join|resolve)|Bun\.file|Deno\.readTextFile)\s*\(/g;
|
|
5
|
+
const REQUEST_PATH_SOURCE_RE =
|
|
6
|
+
/\breq\.(?:query|params|body)\.(?:file|path|filename|filepath|filePath)\b|\brequest\.query\b|\bsearchParams\.get\s*\(\s*["'`](?:file|path)["'`]\s*\)|\bformData\.get\s*\(\s*["'`](?:file|path)["'`]\s*\)/i;
|
|
7
|
+
const GENERIC_PATH_SOURCE_RE = /\b(?:userInput|filename|filepath|filePath|pathParam)\b/i;
|
|
8
|
+
const PATH_MITIGATION_RE =
|
|
9
|
+
/\b(?:path\.basename|allowlist|allowedPaths|sanitizeFilename|safeJoin|validatePath|rejectPathSeparators)\b|(?:\bnormalize\b[\s\S]{0,160}\bstartsWith\s*\()|(?:\bstartsWith\s*\([\s\S]{0,160}\bbaseDir\b)|(?:\.\.["'`][\s\S]{0,120}(?:includes|reject|throw|return))/i;
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
id: "files.path-traversal-risk",
|
|
13
|
+
title: "File path operations should not trust request input",
|
|
14
|
+
category: "files",
|
|
15
|
+
severity: "critical",
|
|
16
|
+
tags: ["files", "path-traversal", "heuristic"],
|
|
17
|
+
run: async (context) => {
|
|
18
|
+
const findings = [];
|
|
19
|
+
|
|
20
|
+
for (const file of context.textFiles) {
|
|
21
|
+
if (isLockfile(file) || isEnvExampleFile(file) || !isCodeLikeFile(file)) continue;
|
|
22
|
+
const content = await context.readFileSafe(file);
|
|
23
|
+
if (!content || !/\b(?:fs\.|sendFile|path\.|Bun\.file|Deno\.readTextFile)\b/.test(content)) continue;
|
|
24
|
+
|
|
25
|
+
FILE_PATH_SINK_RE.lastIndex = 0;
|
|
26
|
+
let match;
|
|
27
|
+
while ((match = FILE_PATH_SINK_RE.exec(content)) !== null) {
|
|
28
|
+
const line = lineFromOffset(content, match.index);
|
|
29
|
+
const nearby = await readNearby(context, file, line, 8);
|
|
30
|
+
if (!hasRiskyPathSource(file, nearby)) continue;
|
|
31
|
+
if (PATH_MITIGATION_RE.test(nearby)) continue;
|
|
32
|
+
|
|
33
|
+
findings.push({
|
|
34
|
+
message: "User-controlled input appears to be used in a file path operation.",
|
|
35
|
+
file,
|
|
36
|
+
line,
|
|
37
|
+
recommendation:
|
|
38
|
+
"Normalize and validate paths, use allowlists, reject traversal sequences, and ensure resolved paths stay inside an intended base directory.",
|
|
39
|
+
heuristic: true,
|
|
40
|
+
metadata: { pattern: "user-input-file-path" }
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return findings.slice(0, 100);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function hasRiskyPathSource(file, nearby) {
|
|
50
|
+
if (REQUEST_PATH_SOURCE_RE.test(nearby)) return true;
|
|
51
|
+
if (!GENERIC_PATH_SOURCE_RE.test(nearby)) return false;
|
|
52
|
+
return (
|
|
53
|
+
isServerOrApiFile(file) ||
|
|
54
|
+
file.startsWith("server/") ||
|
|
55
|
+
file.startsWith("routes/") ||
|
|
56
|
+
file.startsWith("api/") ||
|
|
57
|
+
file.includes("/server/") ||
|
|
58
|
+
file.includes("/routes/") ||
|
|
59
|
+
file.includes("/controllers/") ||
|
|
60
|
+
file.includes("/handlers/")
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { isCodeLikeFile, isEnvExampleFile, isLockfile } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const STORAGE_TOKEN_RE =
|
|
4
|
+
/\b(?:window\.)?(localStorage|sessionStorage)\.setItem\s*\(\s*["'`]([^"'`]*(?:token|jwt|access|refresh|auth|bearer|session)[^"'`]*)["'`]/gi;
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
id: "frontend.localstorage-token",
|
|
8
|
+
title: "Authentication tokens should not live in browser storage by default",
|
|
9
|
+
category: "frontend",
|
|
10
|
+
severity: "high",
|
|
11
|
+
tags: ["frontend", "auth", "xss", "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
|
+
const content = await context.readFileSafe(file);
|
|
18
|
+
if (!content || !/(?:localStorage|sessionStorage)\.setItem/.test(content)) continue;
|
|
19
|
+
const lines = content.split(/\r?\n/);
|
|
20
|
+
|
|
21
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
22
|
+
STORAGE_TOKEN_RE.lastIndex = 0;
|
|
23
|
+
let match;
|
|
24
|
+
while ((match = STORAGE_TOKEN_RE.exec(lines[index])) !== null) {
|
|
25
|
+
findings.push({
|
|
26
|
+
message: "Authentication tokens appear to be stored in localStorage or sessionStorage.",
|
|
27
|
+
file,
|
|
28
|
+
line: index + 1,
|
|
29
|
+
recommendation:
|
|
30
|
+
"Prefer secure, httpOnly cookies for session tokens where appropriate. If browser storage is unavoidable, minimize token lifetime and harden XSS protections.",
|
|
31
|
+
heuristic: true,
|
|
32
|
+
metadata: {
|
|
33
|
+
pattern: `${match[1]}.setItem(auth-token-key)`
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return findings.slice(0, 100);
|
|
41
|
+
}
|
|
42
|
+
};
|
package/src/checks/index.js
CHANGED
|
@@ -27,6 +27,10 @@ import missingAuthOnRoutes from "./auth/missing-auth-on-routes.js";
|
|
|
27
27
|
import idorRisk from "./auth/idor-risk.js";
|
|
28
28
|
import jwtSecretWeakOrFallback from "./auth/jwt-secret-weak-or-fallback.js";
|
|
29
29
|
import passwordHashingMissing from "./auth/password-hashing-missing.js";
|
|
30
|
+
import missingCsrfProtection from "./auth/missing-csrf-protection.js";
|
|
31
|
+
import missingMethodGuard from "./api/missing-method-guard.js";
|
|
32
|
+
import massAssignmentRisk from "./api/mass-assignment-risk.js";
|
|
33
|
+
import noSchemaValidation from "./api/no-schema-validation.js";
|
|
30
34
|
import rawSqlInterpolation from "./database/raw-sql-interpolation.js";
|
|
31
35
|
import noMigrations from "./database/no-migrations.js";
|
|
32
36
|
import insecureSessionCookie from "./cookies/insecure-session-cookie.js";
|
|
@@ -34,10 +38,16 @@ import publicExecutableUpload from "./uploads/public-executable-upload.js";
|
|
|
34
38
|
import missingRawBody from "./webhooks/missing-raw-body.js";
|
|
35
39
|
import promptInjectionRisk from "./llm/prompt-injection-risk.js";
|
|
36
40
|
import sourceMapsProduction from "./frontend/sourcemaps-production.js";
|
|
41
|
+
import localstorageToken from "./frontend/localstorage-token.js";
|
|
42
|
+
import pathTraversalRisk from "./files/path-traversal-risk.js";
|
|
43
|
+
import userControlledFetch from "./ssrf/user-controlled-fetch.js";
|
|
44
|
+
import nextPublicServerCodeRisk from "./next/public-server-code-risk.js";
|
|
37
45
|
import debugProduction from "./config/debug-production.js";
|
|
38
46
|
import electronNodeIntegrationEnabled from "./electron/node-integration-enabled.js";
|
|
39
47
|
import electronContextIsolationDisabled from "./electron/context-isolation-disabled.js";
|
|
48
|
+
import electronRemoteContentWithNode from "./electron/remote-content-with-node.js";
|
|
40
49
|
import tauriDangerousAllowlistOrCapabilities from "./tauri/dangerous-allowlist-or-capabilities.js";
|
|
50
|
+
import tauriRemoteUrlPermissionsRisk from "./tauri/remote-url-permissions-risk.js";
|
|
41
51
|
|
|
42
52
|
export default [
|
|
43
53
|
gitignoreMissing,
|
|
@@ -69,6 +79,10 @@ export default [
|
|
|
69
79
|
idorRisk,
|
|
70
80
|
jwtSecretWeakOrFallback,
|
|
71
81
|
passwordHashingMissing,
|
|
82
|
+
missingCsrfProtection,
|
|
83
|
+
missingMethodGuard,
|
|
84
|
+
massAssignmentRisk,
|
|
85
|
+
noSchemaValidation,
|
|
72
86
|
rawSqlInterpolation,
|
|
73
87
|
noMigrations,
|
|
74
88
|
insecureSessionCookie,
|
|
@@ -76,8 +90,14 @@ export default [
|
|
|
76
90
|
missingRawBody,
|
|
77
91
|
promptInjectionRisk,
|
|
78
92
|
sourceMapsProduction,
|
|
93
|
+
localstorageToken,
|
|
94
|
+
pathTraversalRisk,
|
|
95
|
+
userControlledFetch,
|
|
96
|
+
nextPublicServerCodeRisk,
|
|
79
97
|
debugProduction,
|
|
80
98
|
electronNodeIntegrationEnabled,
|
|
81
99
|
electronContextIsolationDisabled,
|
|
82
|
-
|
|
100
|
+
electronRemoteContentWithNode,
|
|
101
|
+
tauriDangerousAllowlistOrCapabilities,
|
|
102
|
+
tauriRemoteUrlPermissionsRisk
|
|
83
103
|
];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { hasText, isEnvExampleFile, isLockfile, lineFromOffset } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const CLIENT_DIRECTIVE_RE = /^\s*["'`]use client["'`]\s*;?/m;
|
|
4
|
+
const RISKY_CLIENT_CODE_RE =
|
|
5
|
+
/\bfrom\s+["'`](?:node:)?(?:fs|path|child_process|crypto)["'`]|\brequire\s*\(\s*["'`](?:node:)?(?:fs|path|child_process|crypto)["'`]\s*\)|\bfrom\s+["'`](?:@prisma\/client|server-only|@\/lib\/(?:db|prisma)|(?:\.\.\/)+lib\/(?:db|prisma))["'`]|\brequire\s*\(\s*["'`](?:@prisma\/client|server-only|@\/lib\/(?:db|prisma)|(?:\.\.\/)+lib\/(?:db|prisma))["'`]\s*\)|\bprisma\b|\bprocess\.env\.(?:DATABASE_URL|JWT_SECRET|STRIPE_SECRET_KEY|OPENAI_API_KEY)\b/g;
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
id: "next.public-server-code-risk",
|
|
9
|
+
title: "Next.js Client Components should not import server-only code",
|
|
10
|
+
category: "next",
|
|
11
|
+
severity: "high",
|
|
12
|
+
tags: ["next", "frontend", "server-only", "heuristic"],
|
|
13
|
+
run: async (context) => {
|
|
14
|
+
if (!(await isNextProject(context))) return [];
|
|
15
|
+
|
|
16
|
+
const findings = [];
|
|
17
|
+
for (const file of context.textFiles) {
|
|
18
|
+
if (isLockfile(file) || isEnvExampleFile(file) || !isNextClientCandidate(file)) continue;
|
|
19
|
+
const content = await context.readFileSafe(file);
|
|
20
|
+
if (!content || !CLIENT_DIRECTIVE_RE.test(content)) continue;
|
|
21
|
+
|
|
22
|
+
RISKY_CLIENT_CODE_RE.lastIndex = 0;
|
|
23
|
+
let match;
|
|
24
|
+
while ((match = RISKY_CLIENT_CODE_RE.exec(content)) !== null) {
|
|
25
|
+
findings.push({
|
|
26
|
+
message: "A Next.js Client Component appears to import server-side code or access server-only configuration.",
|
|
27
|
+
file,
|
|
28
|
+
line: lineFromOffset(content, match.index),
|
|
29
|
+
recommendation:
|
|
30
|
+
"Move database, filesystem, secret and server-only logic into Server Components, API routes or server actions. Keep Client Components free of backend dependencies.",
|
|
31
|
+
heuristic: true,
|
|
32
|
+
metadata: { pattern: classifyRisk(match[0]) }
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return findings.slice(0, 100);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
async function isNextProject(context) {
|
|
42
|
+
return (
|
|
43
|
+
context.hasDependency("next") ||
|
|
44
|
+
context.hasDevDependency("next") ||
|
|
45
|
+
context.allFiles.some((file) => /^next\.config\.[cm]?[jt]s$/.test(file)) ||
|
|
46
|
+
context.allFiles.some((file) => file.startsWith("app/")) ||
|
|
47
|
+
(await hasText(context, /\bfrom\s+["'`]next\//g, { files: context.textFiles.filter((file) => /\.[cm]?[jt]sx?$/.test(file)) }))
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isNextClientCandidate(file) {
|
|
52
|
+
return (
|
|
53
|
+
/\.(?:js|jsx|ts|tsx)$/.test(file) &&
|
|
54
|
+
(file.startsWith("app/") || file.startsWith("components/") || file.includes("/components/"))
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function classifyRisk(value) {
|
|
59
|
+
if (/process\.env/.test(value)) return "server-secret-env-access";
|
|
60
|
+
if (/prisma|db/.test(value)) return "database-import";
|
|
61
|
+
if (/child_process/.test(value)) return "child-process-import";
|
|
62
|
+
if (/server-only/.test(value)) return "server-only-import";
|
|
63
|
+
return "node-server-import";
|
|
64
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { isCodeLikeFile, isEnvExampleFile, isLockfile, isFrontendFile, isServerOrApiFile, lineFromOffset, readNearby } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const HTTP_REQUEST_RE =
|
|
4
|
+
/\b(?:fetch|got|request|ky)\s*\(|\baxios\.(?:get|post|put|patch|delete|request)\s*\(|\baxios\s*\(\s*{|\bundici\.request\s*\(|\bhttps?\.get\s*\(/g;
|
|
5
|
+
const USER_URL_SOURCE_RE =
|
|
6
|
+
/\breq\.(?:body|query|params)\.url\b|\bsearchParams\.get\s*\(\s*["'`]url["'`]\s*\)|\bformData\.get\s*\(\s*["'`]url["'`]\s*\)|\b(?:requestUrl|userUrl|targetUrl|webhookUrl|callbackUrl|imageUrl|avatarUrl)\b/i;
|
|
7
|
+
const URL_ALLOWLIST_RE =
|
|
8
|
+
/\b(?:allowlist|allowedHosts|allowedDomains|hostname\s*(?:===|!==|==|!=)|private\s+IP|localhost\s+block|metadata\s+IP|validateUrl|isAllowedUrl|isPrivateIp|blockPrivate|blockLocalhost)\b|169\.254\.169\.254|127\.0\.0\.1|localhost/i;
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
id: "ssrf.user-controlled-fetch",
|
|
12
|
+
title: "Server-side HTTP requests should not trust user-controlled URLs",
|
|
13
|
+
category: "ssrf",
|
|
14
|
+
severity: "critical",
|
|
15
|
+
tags: ["ssrf", "api", "network", "heuristic"],
|
|
16
|
+
run: async (context) => {
|
|
17
|
+
const findings = [];
|
|
18
|
+
|
|
19
|
+
for (const file of context.textFiles) {
|
|
20
|
+
if (isLockfile(file) || isEnvExampleFile(file) || !isCodeLikeFile(file) || isFrontendFile(file)) continue;
|
|
21
|
+
const content = await context.readFileSafe(file);
|
|
22
|
+
if (!content || !isLikelyServerSide(file, content) || !/\b(?:fetch|axios|got|request|undici|http|https|ky)\b/.test(content)) continue;
|
|
23
|
+
|
|
24
|
+
HTTP_REQUEST_RE.lastIndex = 0;
|
|
25
|
+
let match;
|
|
26
|
+
while ((match = HTTP_REQUEST_RE.exec(content)) !== null) {
|
|
27
|
+
const line = lineFromOffset(content, match.index);
|
|
28
|
+
const nearby = await readNearby(context, file, line, 8);
|
|
29
|
+
if (!USER_URL_SOURCE_RE.test(nearby)) continue;
|
|
30
|
+
if (URL_ALLOWLIST_RE.test(nearby)) continue;
|
|
31
|
+
|
|
32
|
+
findings.push({
|
|
33
|
+
message: "User-controlled input appears to flow into a server-side HTTP request.",
|
|
34
|
+
file,
|
|
35
|
+
line,
|
|
36
|
+
recommendation:
|
|
37
|
+
"Use strict URL allowlists, block private/internal IP ranges including 127.0.0.1, localhost, 169.254.169.254 and RFC1918 ranges, and avoid fetching arbitrary user-provided URLs.",
|
|
38
|
+
heuristic: true,
|
|
39
|
+
metadata: { pattern: "user-controlled-server-fetch" }
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return findings.slice(0, 100);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function isLikelyServerSide(file, content) {
|
|
49
|
+
return (
|
|
50
|
+
isServerOrApiFile(file) ||
|
|
51
|
+
/^server\.[cm]?[jt]s$/.test(file) ||
|
|
52
|
+
file.startsWith("server/") ||
|
|
53
|
+
file.startsWith("routes/") ||
|
|
54
|
+
file.startsWith("api/") ||
|
|
55
|
+
file.includes("/server/") ||
|
|
56
|
+
file.includes("/routes/") ||
|
|
57
|
+
file.includes("/controllers/") ||
|
|
58
|
+
/\b(?:req\.body|req\.query|req\.params|request\.json|ctx\.request|express|fastify|hono)\b/.test(content)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { lineFromOffset, parseJsonWithComments } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const CONFIG_FILES = ["src-tauri/tauri.conf.json", "src-tauri/tauri.conf.json5", "src-tauri/Tauri.toml"];
|
|
4
|
+
const WEAK_CSP_RE =
|
|
5
|
+
/"csp"\s*:\s*(?:null|false|""|"[^"]*(?:\*|unsafe-inline|unsafe-eval)[^"]*")|csp\s*=\s*(?:false|""|"[^"]*(?:\*|unsafe-inline|unsafe-eval)[^"]*")/gi;
|
|
6
|
+
const DANGEROUS_ASSET_CSP_RE = /"dangerousDisableAssetCspModification"\s*:\s*true|dangerousDisableAssetCspModification\s*=\s*true/gi;
|
|
7
|
+
const BROAD_ALLOWLIST_RE = /"allowlist"\s*:\s*{[\s\S]{0,400}?"all"\s*:\s*true|"all"\s*:\s*true[\s\S]{0,120}?"(?:shell|fs|http)"/gi;
|
|
8
|
+
const BROAD_PERMISSION_RE =
|
|
9
|
+
/"permissions"\s*:\s*\[[\s\S]{0,240}?["'`]\*["'`]|["'`](?:shell|fs|http):(?:\*|allow-\*|allow-all|allow-execute|allow-fetch)["'`]|["'`]fs:allow-[^"'`]*["'`][\s\S]{0,160}(?:\*\*|["'`]\*["'`]|\/)|["'`]http:allow-fetch["'`][\s\S]{0,220}(?:["'`]\*["'`]|https?:\/\/\*)/gi;
|
|
10
|
+
const REMOTE_URL_RE = /"(?:devUrl|frontendDist|url)"\s*:\s*"https?:\/\/(?!localhost\b|127\.0\.0\.1\b|0\.0\.0\.0\b)[^"]+"|(?:devUrl|frontendDist|url)\s*=\s*"https?:\/\/(?!localhost\b|127\.0\.0\.1\b|0\.0\.0\.0\b)[^"]+"/gi;
|
|
11
|
+
|
|
12
|
+
export default {
|
|
13
|
+
id: "tauri.remote-url-permissions-risk",
|
|
14
|
+
title: "Tauri remote URLs and permissions should be least privilege",
|
|
15
|
+
category: "tauri",
|
|
16
|
+
severity: "high",
|
|
17
|
+
tags: ["tauri", "desktop", "permissions", "heuristic"],
|
|
18
|
+
run: async (context) => {
|
|
19
|
+
if (!isTauriProject(context)) return [];
|
|
20
|
+
const findings = [];
|
|
21
|
+
|
|
22
|
+
for (const file of collectTauriFiles(context)) {
|
|
23
|
+
const content = await context.readFileSafe(file);
|
|
24
|
+
if (!content) continue;
|
|
25
|
+
|
|
26
|
+
if (file.endsWith(".json") || file.endsWith(".json5")) {
|
|
27
|
+
inspectParsedJson(content, file, findings);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
inspectRegexPattern(content, file, findings, WEAK_CSP_RE, "weak-csp");
|
|
31
|
+
inspectRegexPattern(content, file, findings, DANGEROUS_ASSET_CSP_RE, "dangerous-asset-csp");
|
|
32
|
+
inspectRegexPattern(content, file, findings, BROAD_ALLOWLIST_RE, "broad-allowlist");
|
|
33
|
+
inspectRegexPattern(content, file, findings, BROAD_PERMISSION_RE, "broad-permission");
|
|
34
|
+
inspectRegexPattern(content, file, findings, REMOTE_URL_RE, "remote-url");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return dedupe(findings).slice(0, 100);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function isTauriProject(context) {
|
|
42
|
+
return (
|
|
43
|
+
context.allFiles.some((file) => file.startsWith("src-tauri/")) ||
|
|
44
|
+
context.hasDependency("@tauri-apps/api") ||
|
|
45
|
+
context.hasDevDependency("@tauri-apps/cli") ||
|
|
46
|
+
context.hasDependency("@tauri-apps/cli")
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function collectTauriFiles(context) {
|
|
51
|
+
return context.textFiles.filter((file) => {
|
|
52
|
+
return (
|
|
53
|
+
CONFIG_FILES.includes(file) ||
|
|
54
|
+
/^src-tauri\/capabilities\/[^/]+\.json$/i.test(file) ||
|
|
55
|
+
/^src-tauri\/permissions\/[^/]+\.json$/i.test(file)
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function inspectParsedJson(content, file, findings) {
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = parseJsonWithComments(content);
|
|
64
|
+
} catch {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const security = parsed?.app?.security || parsed?.tauri?.security || {};
|
|
69
|
+
if (security.csp === null || security.csp === false || security.csp === "") {
|
|
70
|
+
findings.push(finding(file, lineOfKey(content, "csp"), "weak-csp"));
|
|
71
|
+
}
|
|
72
|
+
if (security.dangerousDisableAssetCspModification === true) {
|
|
73
|
+
findings.push(finding(file, lineOfKey(content, "dangerousDisableAssetCspModification"), "dangerous-asset-csp"));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const permissions = JSON.stringify(parsed?.permissions || parsed?.app?.permissions || parsed?.tauri?.allowlist || []);
|
|
77
|
+
if (/"\*"|"all":true|shell:allow-execute|fs:allow-|http:allow-fetch/.test(permissions)) {
|
|
78
|
+
findings.push(finding(file, undefined, "broad-permission"));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function inspectRegexPattern(content, file, findings, regex, pattern) {
|
|
83
|
+
regex.lastIndex = 0;
|
|
84
|
+
let match;
|
|
85
|
+
while ((match = regex.exec(content)) !== null) {
|
|
86
|
+
findings.push(finding(file, lineFromOffset(content, match.index), pattern));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function finding(file, line, pattern) {
|
|
91
|
+
return {
|
|
92
|
+
message: "Tauri configuration appears to allow broad permissions, remote URLs or weak CSP settings.",
|
|
93
|
+
file,
|
|
94
|
+
line,
|
|
95
|
+
recommendation:
|
|
96
|
+
"Use least-privilege capabilities, restrict shell/fs/http permissions, avoid broad wildcards, and configure a strict CSP.",
|
|
97
|
+
heuristic: true,
|
|
98
|
+
metadata: { pattern }
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function lineOfKey(content, key) {
|
|
103
|
+
const index = content.indexOf(`"${key}"`);
|
|
104
|
+
return index >= 0 ? lineFromOffset(content, index) : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function dedupe(findings) {
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
return findings.filter((item) => {
|
|
110
|
+
const key = `${item.file}:${item.line || 0}:${item.metadata?.pattern || ""}`;
|
|
111
|
+
if (seen.has(key)) return false;
|
|
112
|
+
seen.add(key);
|
|
113
|
+
return true;
|
|
114
|
+
});
|
|
115
|
+
}
|
package/src/cli/output.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import gradient from 'gradient-string';
|
|
2
2
|
|
|
3
3
|
export function printUsage() {
|
|
4
|
-
|
|
4
|
+
process.stdout.write(`ItWorksBut
|
|
5
5
|
|
|
6
6
|
Usage:
|
|
7
7
|
itworksbut scan [options]
|
|
8
|
-
node ./bin/itworksbut.js scan [options]
|
|
9
8
|
|
|
10
9
|
Options:
|
|
11
10
|
--path <path> Project path to scan. Defaults to current directory.
|
|
@@ -25,14 +24,14 @@ Options:
|
|
|
25
24
|
}
|
|
26
25
|
|
|
27
26
|
export function printVersion(version) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
try {
|
|
28
|
+
process.stdout.write(`${gradient.rainbow(version)}\n`);
|
|
29
|
+
} catch {
|
|
30
|
+
process.stdout.write(`${version}\n`);
|
|
31
|
+
}
|
|
33
32
|
}
|
|
34
33
|
|
|
35
34
|
export function printRuntimeError(error) {
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
36
|
+
process.stderr.write(`ItWorksBut runtime error: ${message}\n`);
|
|
38
37
|
}
|
|
@@ -26,6 +26,10 @@ const EDGY_TITLES = {
|
|
|
26
26
|
'api.idor-risk': 'It works, but this ID lookup may belong to someone else.',
|
|
27
27
|
'auth.jwt-secret-weak-or-fallback': 'It works, but your JWT secret has a fallback key.',
|
|
28
28
|
'auth.password-hashing-missing': 'It works, but your passwords may be stored too honestly.',
|
|
29
|
+
'auth.missing-csrf-protection': 'It works, but your browser may be submitting forms behind your back.',
|
|
30
|
+
'api.missing-method-guard': 'It works, but your API does not care how it gets called.',
|
|
31
|
+
'api.mass-assignment-risk': 'It works, but your users may be editing fields they should never touch.',
|
|
32
|
+
'api.no-schema-validation': 'It works, but your API believes whatever the request says.',
|
|
29
33
|
'database.raw-sql-interpolation': 'It works, but your SQL query is one template string away from pain.',
|
|
30
34
|
'database.no-migrations': 'It works, but your database schema has no paper trail.',
|
|
31
35
|
'cookies.insecure-session-cookie': 'It works, but your session cookie is dressed for localhost.',
|
|
@@ -33,10 +37,16 @@ const EDGY_TITLES = {
|
|
|
33
37
|
'webhooks.missing-raw-body': 'It works, but your webhook signature check may be checking the wrong body.',
|
|
34
38
|
'llm.prompt-injection-risk': 'It works, but your AI output has admin energy.',
|
|
35
39
|
'frontend.sourcemaps-production': 'It works, but your source code may be shipping with the app.',
|
|
40
|
+
'frontend.localstorage-token': 'It works, but your auth token lives where XSS can read it.',
|
|
41
|
+
'files.path-traversal-risk': 'It works, but your file path may be taking requests too literally.',
|
|
42
|
+
'ssrf.user-controlled-fetch': 'It works, but your server is fetching whatever strangers ask for.',
|
|
43
|
+
'next.public-server-code-risk': 'It works, but your client component is carrying server baggage.',
|
|
36
44
|
'config.debug-production': 'It works, but production still thinks it is a dev server.',
|
|
37
45
|
'electron.node-integration-enabled': 'It works, but Electron is holding the Node.js door open.',
|
|
38
46
|
'electron.context-isolation-disabled': 'It works, but your renderer and backend are sharing a room.',
|
|
47
|
+
'electron.remote-content-with-node': 'It works, but Electron is letting the internet sit next to Node.js.',
|
|
39
48
|
'tauri.dangerous-allowlist-or-capabilities': 'It works, but your Tauri permissions look too generous.',
|
|
49
|
+
'tauri.remote-url-permissions-risk': 'It works, but your Tauri app is trusting too much surface area.',
|
|
40
50
|
};
|
|
41
51
|
|
|
42
52
|
const SEVERITY_META = {
|
|
@@ -100,6 +110,14 @@ const FIX_PROMPT_ACTIONS = {
|
|
|
100
110
|
'Require a strong JWT secret from the environment in production and fail startup if it is missing.',
|
|
101
111
|
'auth.password-hashing-missing':
|
|
102
112
|
'Hash passwords with argon2, bcrypt, scrypt or PBKDF2 before storage. Never store raw passwords.',
|
|
113
|
+
'auth.missing-csrf-protection':
|
|
114
|
+
'Use SameSite cookies, CSRF tokens or another explicit CSRF mitigation for state-changing routes.',
|
|
115
|
+
'api.missing-method-guard':
|
|
116
|
+
'Restrict API routes to the intended HTTP methods and return 405 Method Not Allowed for unsupported methods.',
|
|
117
|
+
'api.mass-assignment-risk':
|
|
118
|
+
'Whitelist allowed fields explicitly. Never pass req.body directly into database create/update calls.',
|
|
119
|
+
'api.no-schema-validation':
|
|
120
|
+
'Validate request body, query and params with a schema library such as Zod, Joi, Valibot, AJV or equivalent.',
|
|
103
121
|
'database.raw-sql-interpolation':
|
|
104
122
|
'Replace SQL string interpolation or concatenation with parameterized queries, prepared statements, or a safe ORM query builder.',
|
|
105
123
|
'database.no-migrations': 'Add versioned database migrations that match the detected ORM or database stack.',
|
|
@@ -113,14 +131,26 @@ const FIX_PROMPT_ACTIONS = {
|
|
|
113
131
|
'Treat model output as untrusted input. Validate with schemas, use allowlists, require human approval for dangerous actions, and never execute raw model output.',
|
|
114
132
|
'frontend.sourcemaps-production':
|
|
115
133
|
'Disable public production source maps unless intentionally needed. If needed, upload them privately to error tracking instead of serving them publicly.',
|
|
134
|
+
'frontend.localstorage-token':
|
|
135
|
+
'Prefer secure, httpOnly cookies for session tokens where appropriate. If browser storage is unavoidable, minimize token lifetime and harden XSS protections.',
|
|
136
|
+
'files.path-traversal-risk':
|
|
137
|
+
'Normalize and validate paths, use allowlists, reject traversal sequences, and ensure resolved paths stay inside an intended base directory.',
|
|
138
|
+
'ssrf.user-controlled-fetch':
|
|
139
|
+
'Use strict URL allowlists, block private/internal IP ranges including 127.0.0.1, localhost, 169.254.169.254 and RFC1918 ranges, and avoid fetching arbitrary user-provided URLs.',
|
|
140
|
+
'next.public-server-code-risk':
|
|
141
|
+
'Move database, filesystem, secret and server-only logic into Server Components, API routes or server actions. Keep Client Components free of backend dependencies.',
|
|
116
142
|
'config.debug-production':
|
|
117
143
|
'Disable verbose errors and debug flags in production. Avoid exposing stack traces, internal paths or development tooling.',
|
|
118
144
|
'electron.node-integration-enabled':
|
|
119
145
|
'Set nodeIntegration to false and expose only narrowly scoped APIs through preload.',
|
|
120
146
|
'electron.context-isolation-disabled':
|
|
121
147
|
'Enable contextIsolation and review preload boundaries for renderer-to-main communication.',
|
|
148
|
+
'electron.remote-content-with-node':
|
|
149
|
+
'Avoid loading remote content with Node.js integration. Use nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true and a minimal preload bridge.',
|
|
122
150
|
'tauri.dangerous-allowlist-or-capabilities':
|
|
123
151
|
'Tighten Tauri allowlists, capabilities, scopes, shell access, filesystem access, remote URLs, and CSP.',
|
|
152
|
+
'tauri.remote-url-permissions-risk':
|
|
153
|
+
'Use least-privilege capabilities, restrict shell/fs/http permissions, avoid broad wildcards, and configure a strict CSP.',
|
|
124
154
|
};
|
|
125
155
|
|
|
126
156
|
export function getConsoleFindingTitle(finding) {
|