itworksbut 0.3.0 → 0.4.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/auth/jwt-secret-weak-or-fallback.js +72 -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/frontend/sourcemaps-production.js +90 -0
- package/src/checks/index.js +20 -0
- package/src/checks/llm/prompt-injection-risk.js +57 -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/uploads/public-executable-upload.js +63 -0
- package/src/checks/webhooks/missing-raw-body.js +82 -0
- 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 40 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,13 +220,22 @@ 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`
|
|
227
231
|
- `database.raw-sql-interpolation`
|
|
228
232
|
- `database.no-migrations`
|
|
233
|
+
- `cookies.insecure-session-cookie`
|
|
234
|
+
- `uploads.public-executable-upload`
|
|
235
|
+
- `webhooks.missing-raw-body`
|
|
236
|
+
- `llm.prompt-injection-risk`
|
|
237
|
+
- `frontend.sourcemaps-production`
|
|
238
|
+
- `config.debug-production`
|
|
229
239
|
- `electron.node-integration-enabled`
|
|
230
240
|
- `electron.context-isolation-disabled`
|
|
231
241
|
- `tauri.dangerous-allowlist-or-capabilities`
|
package/package.json
CHANGED
|
@@ -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,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,90 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isCodeLikeFile, isEnvExampleFile, isLockfile, lineFromOffset } from "../helpers.js";
|
|
4
|
+
import { normalizeRelativePath } from "../../utils/path.js";
|
|
5
|
+
|
|
6
|
+
const SOURCEMAP_CONFIG_RE =
|
|
7
|
+
/\b(?:sourcemap|productionBrowserSourceMaps)\s*:\s*true\b|\bGENERATE_SOURCEMAP\s*=\s*true\b|\bdevtool\s*:\s*["'`](?:source-map|inline-source-map|eval-source-map)["'`]/gi;
|
|
8
|
+
const SOURCEMAP_DIRS = ["dist", "build", ".next", "out"];
|
|
9
|
+
const MAX_SOURCEMAP_FILES = 100;
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
id: "frontend.sourcemaps-production",
|
|
13
|
+
title: "Production source maps should not be served publicly by accident",
|
|
14
|
+
category: "frontend",
|
|
15
|
+
severity: "medium",
|
|
16
|
+
tags: ["frontend", "sourcemaps", "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) continue;
|
|
24
|
+
|
|
25
|
+
SOURCEMAP_CONFIG_RE.lastIndex = 0;
|
|
26
|
+
let match;
|
|
27
|
+
while ((match = SOURCEMAP_CONFIG_RE.exec(content)) !== null) {
|
|
28
|
+
findings.push(sourceMapFinding(file, lineFromOffset(content, match.index), "sourcemap-config-enabled"));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const mapFiles = await collectGeneratedSourceMaps(context.rootPath);
|
|
33
|
+
for (const file of mapFiles) {
|
|
34
|
+
findings.push(sourceMapFinding(file, undefined, "generated-map-file"));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return findings.slice(0, 100);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function sourceMapFinding(file, line, pattern) {
|
|
42
|
+
return {
|
|
43
|
+
message: "Production source maps appear to be enabled or generated.",
|
|
44
|
+
file,
|
|
45
|
+
line,
|
|
46
|
+
recommendation:
|
|
47
|
+
"Disable public production source maps unless intentionally needed. If needed, upload them privately to error tracking instead of serving them publicly.",
|
|
48
|
+
heuristic: true,
|
|
49
|
+
metadata: {
|
|
50
|
+
pattern
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function collectGeneratedSourceMaps(rootPath) {
|
|
56
|
+
const results = [];
|
|
57
|
+
|
|
58
|
+
for (const directory of SOURCEMAP_DIRS) {
|
|
59
|
+
await visit(path.join(rootPath, directory), rootPath, results);
|
|
60
|
+
if (results.length >= MAX_SOURCEMAP_FILES) break;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return results.slice(0, MAX_SOURCEMAP_FILES);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function visit(directory, rootPath, results) {
|
|
67
|
+
if (results.length >= MAX_SOURCEMAP_FILES) return;
|
|
68
|
+
|
|
69
|
+
let entries;
|
|
70
|
+
try {
|
|
71
|
+
entries = await fs.readdir(directory, { withFileTypes: true });
|
|
72
|
+
} catch {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
if (results.length >= MAX_SOURCEMAP_FILES) return;
|
|
78
|
+
if (entry.name === "node_modules") continue;
|
|
79
|
+
|
|
80
|
+
const absolutePath = path.join(directory, entry.name);
|
|
81
|
+
if (entry.isDirectory()) {
|
|
82
|
+
await visit(absolutePath, rootPath, results);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (entry.isFile() && entry.name.endsWith(".map")) {
|
|
87
|
+
results.push(normalizeRelativePath(path.relative(rootPath, absolutePath)));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/checks/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import envFileTracked from "./env/env-file-tracked.js";
|
|
|
5
5
|
import envExampleMissing from "./env/env-example-missing.js";
|
|
6
6
|
import possibleSecretInCode from "./env/possible-secret-in-code.js";
|
|
7
7
|
import frontendSecretExposure from "./env/frontend-secret-exposure.js";
|
|
8
|
+
import secretsInLogs from "./secrets/secrets-in-logs.js";
|
|
8
9
|
import lockfileMissing from "./dependencies/lockfile-missing.js";
|
|
9
10
|
import multipleLockfiles from "./dependencies/multiple-lockfiles.js";
|
|
10
11
|
import installScriptsRisk from "./dependencies/install-scripts-risk.js";
|
|
@@ -18,13 +19,22 @@ import expressJsonLimitMissing from "./node/express-json-limit-missing.js";
|
|
|
18
19
|
import rateLimitMissing from "./node/rate-limit-missing.js";
|
|
19
20
|
import helmetMissing from "./node/helmet-missing.js";
|
|
20
21
|
import corsWildcard from "./node/cors-wildcard.js";
|
|
22
|
+
import childProcessUserInput from "./node/child-process-user-input.js";
|
|
21
23
|
import clientSideAuthOnly from "./web/client-side-auth-only.js";
|
|
22
24
|
import dangerousInnerHtml from "./web/dangerous-inner-html.js";
|
|
23
25
|
import missingOutputSanitization from "./web/missing-output-sanitization.js";
|
|
24
26
|
import missingAuthOnRoutes from "./auth/missing-auth-on-routes.js";
|
|
25
27
|
import idorRisk from "./auth/idor-risk.js";
|
|
28
|
+
import jwtSecretWeakOrFallback from "./auth/jwt-secret-weak-or-fallback.js";
|
|
29
|
+
import passwordHashingMissing from "./auth/password-hashing-missing.js";
|
|
26
30
|
import rawSqlInterpolation from "./database/raw-sql-interpolation.js";
|
|
27
31
|
import noMigrations from "./database/no-migrations.js";
|
|
32
|
+
import insecureSessionCookie from "./cookies/insecure-session-cookie.js";
|
|
33
|
+
import publicExecutableUpload from "./uploads/public-executable-upload.js";
|
|
34
|
+
import missingRawBody from "./webhooks/missing-raw-body.js";
|
|
35
|
+
import promptInjectionRisk from "./llm/prompt-injection-risk.js";
|
|
36
|
+
import sourceMapsProduction from "./frontend/sourcemaps-production.js";
|
|
37
|
+
import debugProduction from "./config/debug-production.js";
|
|
28
38
|
import electronNodeIntegrationEnabled from "./electron/node-integration-enabled.js";
|
|
29
39
|
import electronContextIsolationDisabled from "./electron/context-isolation-disabled.js";
|
|
30
40
|
import tauriDangerousAllowlistOrCapabilities from "./tauri/dangerous-allowlist-or-capabilities.js";
|
|
@@ -37,6 +47,7 @@ export default [
|
|
|
37
47
|
envExampleMissing,
|
|
38
48
|
possibleSecretInCode,
|
|
39
49
|
frontendSecretExposure,
|
|
50
|
+
secretsInLogs,
|
|
40
51
|
lockfileMissing,
|
|
41
52
|
multipleLockfiles,
|
|
42
53
|
installScriptsRisk,
|
|
@@ -50,13 +61,22 @@ export default [
|
|
|
50
61
|
rateLimitMissing,
|
|
51
62
|
helmetMissing,
|
|
52
63
|
corsWildcard,
|
|
64
|
+
childProcessUserInput,
|
|
53
65
|
clientSideAuthOnly,
|
|
54
66
|
dangerousInnerHtml,
|
|
55
67
|
missingOutputSanitization,
|
|
56
68
|
missingAuthOnRoutes,
|
|
57
69
|
idorRisk,
|
|
70
|
+
jwtSecretWeakOrFallback,
|
|
71
|
+
passwordHashingMissing,
|
|
58
72
|
rawSqlInterpolation,
|
|
59
73
|
noMigrations,
|
|
74
|
+
insecureSessionCookie,
|
|
75
|
+
publicExecutableUpload,
|
|
76
|
+
missingRawBody,
|
|
77
|
+
promptInjectionRisk,
|
|
78
|
+
sourceMapsProduction,
|
|
79
|
+
debugProduction,
|
|
60
80
|
electronNodeIntegrationEnabled,
|
|
61
81
|
electronContextIsolationDisabled,
|
|
62
82
|
tauriDangerousAllowlistOrCapabilities
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { isCodeLikeFile, isEnvExampleFile, isLockfile, lineFromOffset } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const LLM_USAGE_RE =
|
|
4
|
+
/\b(?:openai\.chat\.completions\.create|openai\.responses\.create|anthropic\.messages\.create|generateText|streamText|ollama|langchain|aiOutput|completion|modelOutput|llmResponse)\b/i;
|
|
5
|
+
const LLM_OUTPUT_RE = /\b(?:aiOutput|completion|modelOutput|llmResponse|llmResult|modelResponse)\b/i;
|
|
6
|
+
const DANGEROUS_USE_RE =
|
|
7
|
+
/\b(?:eval|exec|execSync|spawn|spawnSync|db\.query|JSON\.parse|fetch)\s*\(\s*([^)\n;]+)|\bnew\s+Function\s*\(\s*([^)\n;]+)|\bprisma\.\$queryRawUnsafe\s*\(\s*([^)\n;]+)|\binnerHTML\s*=\s*([^;\n]+)|dangerouslySetInnerHTML\s*=\s*{{[\s\S]{0,200}?__html\s*:\s*([^}\n]+)|\bfs\.writeFile\s*\([^,\n]+,\s*([^)\n;]+)/gi;
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
id: "llm.prompt-injection-risk",
|
|
11
|
+
title: "LLM output should not flow directly into dangerous actions",
|
|
12
|
+
category: "llm",
|
|
13
|
+
severity: "high",
|
|
14
|
+
tags: ["llm", "prompt-injection", "heuristic"],
|
|
15
|
+
run: async (context) => {
|
|
16
|
+
const findings = [];
|
|
17
|
+
|
|
18
|
+
for (const file of context.textFiles) {
|
|
19
|
+
if (isLockfile(file) || isEnvExampleFile(file) || !isCodeLikeFile(file)) continue;
|
|
20
|
+
const content = await context.readFileSafe(file);
|
|
21
|
+
if (!content || !LLM_USAGE_RE.test(content)) continue;
|
|
22
|
+
|
|
23
|
+
DANGEROUS_USE_RE.lastIndex = 0;
|
|
24
|
+
let match;
|
|
25
|
+
while ((match = DANGEROUS_USE_RE.exec(content)) !== null) {
|
|
26
|
+
const argument = match.slice(1).find(Boolean) || "";
|
|
27
|
+
if (!LLM_OUTPUT_RE.test(argument)) continue;
|
|
28
|
+
|
|
29
|
+
findings.push({
|
|
30
|
+
message:
|
|
31
|
+
"LLM output appears to flow into code execution, shell commands, HTML injection, database queries, file writes or network requests.",
|
|
32
|
+
file,
|
|
33
|
+
line: lineFromOffset(content, match.index),
|
|
34
|
+
recommendation:
|
|
35
|
+
"Treat model output as untrusted input. Validate with schemas, use allowlists, require human approval for dangerous actions, and never execute raw model output.",
|
|
36
|
+
heuristic: true,
|
|
37
|
+
metadata: {
|
|
38
|
+
pattern: classifyDangerousUse(match[0])
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return findings.slice(0, 100);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function classifyDangerousUse(value) {
|
|
49
|
+
if (/\beval\b|\bFunction\b/.test(value)) return "code-execution";
|
|
50
|
+
if (/\bexec|spawn/.test(value)) return "shell-command";
|
|
51
|
+
if (/innerHTML|dangerouslySetInnerHTML/.test(value)) return "html-injection";
|
|
52
|
+
if (/query/.test(value)) return "database-query";
|
|
53
|
+
if (/writeFile/.test(value)) return "file-write";
|
|
54
|
+
if (/fetch/.test(value)) return "network-request";
|
|
55
|
+
if (/JSON\.parse/.test(value)) return "unvalidated-json-parse";
|
|
56
|
+
return "dangerous-llm-output-use";
|
|
57
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { isCodeLikeFile, isEnvExampleFile, isLockfile, lineFromOffset } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const CHILD_PROCESS_RE = /\b(?:exec|execSync|spawn|spawnSync|fork)\s*\(([^;\n]*)/gi;
|
|
4
|
+
const CHILD_PROCESS_IMPORT_RE = /\bchild_process\b|\bfrom\s+["'`]node:child_process["'`]|\brequire\s*\(\s*["'`](?:node:)?child_process["'`]\s*\)/i;
|
|
5
|
+
const USER_INPUT_RE =
|
|
6
|
+
/\b(?:req\.(?:body|query|params)|request\.body|searchParams|process\.argv|formData|userInput|input|filename|branch|url)\b/i;
|
|
7
|
+
const ALLOWLIST_RE = /\b(?:allowlist|allowed|whitelist|safeList|zod|schema|validate|validator|assertAllowed)\b/i;
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
id: "node.child-process-user-input",
|
|
11
|
+
title: "Child process commands should not trust user input",
|
|
12
|
+
category: "node",
|
|
13
|
+
severity: "critical",
|
|
14
|
+
tags: ["node", "command-injection", "heuristic"],
|
|
15
|
+
run: async (context) => {
|
|
16
|
+
const findings = [];
|
|
17
|
+
|
|
18
|
+
for (const file of context.textFiles) {
|
|
19
|
+
if (isLockfile(file) || isEnvExampleFile(file) || !isCodeLikeFile(file)) continue;
|
|
20
|
+
const content = await context.readFileSafe(file);
|
|
21
|
+
if (!content || !CHILD_PROCESS_IMPORT_RE.test(content)) continue;
|
|
22
|
+
|
|
23
|
+
CHILD_PROCESS_RE.lastIndex = 0;
|
|
24
|
+
let match;
|
|
25
|
+
while ((match = CHILD_PROCESS_RE.exec(content)) !== null) {
|
|
26
|
+
const line = lineFromOffset(content, match.index);
|
|
27
|
+
const nearby = nearbyText(content, line, 8);
|
|
28
|
+
if (!USER_INPUT_RE.test(match[1] || nearby)) continue;
|
|
29
|
+
if (ALLOWLIST_RE.test(nearby)) continue;
|
|
30
|
+
|
|
31
|
+
findings.push({
|
|
32
|
+
message: "User-controlled input appears to flow into a child process command.",
|
|
33
|
+
file,
|
|
34
|
+
line,
|
|
35
|
+
recommendation:
|
|
36
|
+
"Avoid shell execution with user input. Use spawn with fixed command and argument arrays, validate against allowlists, and never concatenate shell strings.",
|
|
37
|
+
heuristic: true,
|
|
38
|
+
metadata: {
|
|
39
|
+
pattern: "child-process-user-input"
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return findings.slice(0, 100);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function nearbyText(content, line, radius) {
|
|
50
|
+
const lines = content.split(/\r?\n/);
|
|
51
|
+
const start = Math.max(0, line - radius - 1);
|
|
52
|
+
const end = Math.min(lines.length, line + radius);
|
|
53
|
+
return lines.slice(start, end).join("\n");
|
|
54
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { isCodeLikeFile, isEnvExampleFile, isLockfile } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const LOG_CALL_RE = /\b(?:console\.(?:log|error|debug|info|warn)|logger\.(?:info|debug|error|warn|trace))\s*\(([^)]*)\)/g;
|
|
4
|
+
const SECRET_TERMS = [
|
|
5
|
+
"SECRET",
|
|
6
|
+
"TOKEN",
|
|
7
|
+
"KEY",
|
|
8
|
+
"PASSWORD",
|
|
9
|
+
"DATABASE_URL",
|
|
10
|
+
"PRIVATE",
|
|
11
|
+
"SERVICE_ROLE",
|
|
12
|
+
"OPENAI_API_KEY",
|
|
13
|
+
"STRIPE_SECRET_KEY",
|
|
14
|
+
"JWT_SECRET",
|
|
15
|
+
"GITHUB_TOKEN",
|
|
16
|
+
"AWS_SECRET_ACCESS_KEY"
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export default {
|
|
20
|
+
id: "secrets.secrets-in-logs",
|
|
21
|
+
title: "Logs should not include secrets or sensitive request data",
|
|
22
|
+
category: "secrets",
|
|
23
|
+
severity: "high",
|
|
24
|
+
tags: ["secrets", "logging", "heuristic"],
|
|
25
|
+
run: async (context) => {
|
|
26
|
+
const findings = [];
|
|
27
|
+
|
|
28
|
+
for (const file of context.textFiles) {
|
|
29
|
+
if (isLockfile(file) || isEnvExampleFile(file) || !isCodeLikeFile(file)) continue;
|
|
30
|
+
const content = await context.readFileSafe(file);
|
|
31
|
+
if (!content) continue;
|
|
32
|
+
const lines = content.split(/\r?\n/);
|
|
33
|
+
|
|
34
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
35
|
+
const line = lines[index];
|
|
36
|
+
LOG_CALL_RE.lastIndex = 0;
|
|
37
|
+
|
|
38
|
+
let match;
|
|
39
|
+
while ((match = LOG_CALL_RE.exec(line)) !== null) {
|
|
40
|
+
const args = match[1] || "";
|
|
41
|
+
if (!containsSensitiveLogTarget(args)) continue;
|
|
42
|
+
|
|
43
|
+
findings.push({
|
|
44
|
+
message:
|
|
45
|
+
"Logging environment variables, headers, request bodies or secret-like config values may expose sensitive data.",
|
|
46
|
+
file,
|
|
47
|
+
line: index + 1,
|
|
48
|
+
recommendation:
|
|
49
|
+
"Remove sensitive logging, mask secrets, and log only explicit non-sensitive fields.",
|
|
50
|
+
heuristic: true,
|
|
51
|
+
metadata: {
|
|
52
|
+
secretType: detectSecretType(args),
|
|
53
|
+
valueRedacted: true
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return findings.slice(0, 100);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function containsSensitiveLogTarget(value) {
|
|
66
|
+
return (
|
|
67
|
+
/\bprocess\.env(?:\.[A-Z0-9_]+)?\b/.test(value) ||
|
|
68
|
+
/\b(?:req|request)\.(?:headers|body)\b/.test(value) ||
|
|
69
|
+
/\bconfig\b/i.test(value) ||
|
|
70
|
+
SECRET_TERMS.some((term) => new RegExp(`\\b${escapeRegExp(term)}\\b`, "i").test(value))
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function detectSecretType(value) {
|
|
75
|
+
const match = SECRET_TERMS.find((term) => new RegExp(`\\b${escapeRegExp(term)}\\b`, "i").test(value));
|
|
76
|
+
if (match) return match;
|
|
77
|
+
if (/\bprocess\.env\b/.test(value)) return "ENVIRONMENT";
|
|
78
|
+
if (/\b(?:req|request)\.headers\b/.test(value)) return "REQUEST_HEADERS";
|
|
79
|
+
if (/\b(?:req|request)\.body\b/.test(value)) return "REQUEST_BODY";
|
|
80
|
+
if (/\bconfig\b/i.test(value)) return "CONFIG";
|
|
81
|
+
return "UNKNOWN";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function escapeRegExp(value) {
|
|
85
|
+
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
86
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { isCodeLikeFile, isEnvExampleFile, isLockfile, lineFromOffset } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const PUBLIC_UPLOAD_PATH_RE = /(?:public|static|dist|build|\.next\/static)\/(?:uploads|files)/i;
|
|
4
|
+
const PUBLIC_UPLOAD_PATTERNS = [
|
|
5
|
+
/multer\s*\(\s*{[\s\S]{0,500}?dest\s*:\s*["'`](?:public|static|dist|build|\.next\/static)\/(?:uploads|files)["'`]/gi,
|
|
6
|
+
/\b(?:uploadDir|uploadsDir|destination)\b\s*=\s*["'`](?:public|static|dist|build|\.next\/static)\/(?:uploads|files)["'`]/gi,
|
|
7
|
+
/path\.join\s*\([^)]*["'`](?:public|static|dist|build)["'`]\s*,\s*["'`](?:uploads|files)["'`]/gi,
|
|
8
|
+
/fs\.writeFile\s*\(\s*["'`](?:public|static|dist|build|\.next\/static)\/(?:uploads|files)\//gi,
|
|
9
|
+
/app\.use\s*\(\s*["'`]\/(?:uploads|files)["'`]\s*,\s*express\.static\s*\(/gi,
|
|
10
|
+
/express\.static\s*\(\s*["'`](?:public|static)["'`]\s*\)/gi
|
|
11
|
+
];
|
|
12
|
+
const VALIDATION_RE = /\b(?:fileFilter|limits\s*:\s*{[\s\S]{0,120}?fileSize|limits\.fileSize|mimetype|allowedTypes|allowedMimeTypes)\b/i;
|
|
13
|
+
|
|
14
|
+
export default {
|
|
15
|
+
id: "uploads.public-executable-upload",
|
|
16
|
+
title: "Uploads should not be stored directly in public web roots",
|
|
17
|
+
category: "uploads",
|
|
18
|
+
severity: "high",
|
|
19
|
+
tags: ["uploads", "static-files", "heuristic"],
|
|
20
|
+
run: async (context) => {
|
|
21
|
+
const findings = [];
|
|
22
|
+
|
|
23
|
+
for (const file of context.textFiles) {
|
|
24
|
+
if (isLockfile(file) || isEnvExampleFile(file) || !isCodeLikeFile(file)) continue;
|
|
25
|
+
const content = await context.readFileSafe(file);
|
|
26
|
+
if (!content || !/(upload|multer|express\.static|writeFile|public\/|static\/)/i.test(content)) continue;
|
|
27
|
+
|
|
28
|
+
for (const regex of PUBLIC_UPLOAD_PATTERNS) {
|
|
29
|
+
regex.lastIndex = 0;
|
|
30
|
+
let match;
|
|
31
|
+
while ((match = regex.exec(content)) !== null) {
|
|
32
|
+
if (!PUBLIC_UPLOAD_PATH_RE.test(match[0]) && !/\/(?:uploads|files)/i.test(match[0])) continue;
|
|
33
|
+
const line = lineFromOffset(content, match.index);
|
|
34
|
+
const validationMissing = !VALIDATION_RE.test(nearbyText(content, line, 12));
|
|
35
|
+
|
|
36
|
+
findings.push({
|
|
37
|
+
message:
|
|
38
|
+
"Uploaded files appear to be stored in a public directory, possibly without strict file type and size validation.",
|
|
39
|
+
file,
|
|
40
|
+
line,
|
|
41
|
+
recommendation:
|
|
42
|
+
"Store uploads outside the public web root, validate MIME type and extension, enforce file size limits, and serve files through controlled routes.",
|
|
43
|
+
heuristic: true,
|
|
44
|
+
metadata: {
|
|
45
|
+
validationMissing
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
if (findings.some((finding) => finding.file === file)) break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return findings.slice(0, 100);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function nearbyText(content, line, radius) {
|
|
59
|
+
const lines = content.split(/\r?\n/);
|
|
60
|
+
const start = Math.max(0, line - radius - 1);
|
|
61
|
+
const end = Math.min(lines.length, line + radius);
|
|
62
|
+
return lines.slice(start, end).join("\n");
|
|
63
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { isCodeLikeFile, isEnvExampleFile, isLockfile, lineFromOffset } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const PROVIDER_RE =
|
|
4
|
+
/\b(?:stripe\.webhooks\.constructEvent|github webhook signature|x-hub-signature|svix|clerk|lemon\s*squeezy|lemonsqueezy|polar|paddle|webhook signature)\b/i;
|
|
5
|
+
const PARSED_BODY_SIGNATURE_RE = /\b(?:stripe\.webhooks\.)?constructEvent\s*\(\s*req\.body\b/gi;
|
|
6
|
+
const RAW_BODY_RE = /\b(?:express\.raw\s*\(|bodyParser\.raw\s*\(|rawBody|req\.rawBody|buffer)\b/i;
|
|
7
|
+
const GLOBAL_JSON_RE = /\bapp\.use\s*\(\s*express\.json\s*\(/i;
|
|
8
|
+
const WEBHOOK_ROUTE_RE = /\bapp\.(?:post|put|patch)\s*\(\s*["'`][^"'`]*webhook/i;
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
id: "webhooks.missing-raw-body",
|
|
12
|
+
title: "Signed webhooks should verify the exact raw body",
|
|
13
|
+
category: "webhooks",
|
|
14
|
+
severity: "high",
|
|
15
|
+
tags: ["webhooks", "signatures", "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 || !PROVIDER_RE.test(content)) continue;
|
|
23
|
+
|
|
24
|
+
PARSED_BODY_SIGNATURE_RE.lastIndex = 0;
|
|
25
|
+
let match;
|
|
26
|
+
while ((match = PARSED_BODY_SIGNATURE_RE.exec(content)) !== null) {
|
|
27
|
+
const line = lineFromOffset(content, match.index);
|
|
28
|
+
if (RAW_BODY_RE.test(nearbyText(content, line, 12))) continue;
|
|
29
|
+
findings.push(webhookFinding(file, line, "parsed-body-signature-check"));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const jsonLine = firstLineMatching(content, GLOBAL_JSON_RE);
|
|
33
|
+
const routeLine = firstLineMatching(content, WEBHOOK_ROUTE_RE);
|
|
34
|
+
if (jsonLine && routeLine && jsonLine < routeLine && !RAW_BODY_RE.test(content)) {
|
|
35
|
+
findings.push(webhookFinding(file, jsonLine, "json-parser-before-webhook-route"));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return dedupe(findings).slice(0, 100);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function webhookFinding(file, line, pattern) {
|
|
44
|
+
return {
|
|
45
|
+
message:
|
|
46
|
+
"Webhook signature verification appears to use a parsed request body. Some providers require the exact raw body.",
|
|
47
|
+
file,
|
|
48
|
+
line,
|
|
49
|
+
recommendation:
|
|
50
|
+
"Use a raw body parser for signed webhook routes and register it before JSON parsing middleware.",
|
|
51
|
+
heuristic: true,
|
|
52
|
+
metadata: {
|
|
53
|
+
pattern
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function firstLineMatching(content, regex) {
|
|
59
|
+
const lines = content.split(/\r?\n/);
|
|
60
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
61
|
+
regex.lastIndex = 0;
|
|
62
|
+
if (regex.test(lines[index])) return index + 1;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function nearbyText(content, line, radius) {
|
|
68
|
+
const lines = content.split(/\r?\n/);
|
|
69
|
+
const start = Math.max(0, line - radius - 1);
|
|
70
|
+
const end = Math.min(lines.length, line + radius);
|
|
71
|
+
return lines.slice(start, end).join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function dedupe(findings) {
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
return findings.filter((finding) => {
|
|
77
|
+
const key = `${finding.file}:${finding.line}:${finding.metadata.pattern}`;
|
|
78
|
+
if (seen.has(key)) return false;
|
|
79
|
+
seen.add(key);
|
|
80
|
+
return true;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -7,6 +7,7 @@ const EDGY_TITLES = {
|
|
|
7
7
|
'env.env-file-tracked': 'It works, but your .env is tracked.',
|
|
8
8
|
'env.possible-secret-in-code': 'It works, but your repo may be leaking secrets.',
|
|
9
9
|
'env.frontend-secret-exposure': 'It works, but your frontend env variable smells like a backend secret.',
|
|
10
|
+
'secrets.secrets-in-logs': 'It works, but your logs may be leaking secrets.',
|
|
10
11
|
'git.gitignore-missing': 'It works, but your repo forgot what not to commit.',
|
|
11
12
|
'git.gitignore-incomplete': 'It works, but your .gitignore has holes.',
|
|
12
13
|
'git.ignored-files-tracked': 'It works, but Git is already tracking files you meant to ignore.',
|
|
@@ -19,11 +20,20 @@ const EDGY_TITLES = {
|
|
|
19
20
|
'node.rate-limit-missing': 'It works, but your endpoints have no brakes.',
|
|
20
21
|
'node.helmet-missing': 'It works, but your HTTP headers are underdressed.',
|
|
21
22
|
'node.cors-wildcard': 'It works, but CORS is holding the door open.',
|
|
23
|
+
'node.child-process-user-input': 'It works, but your shell command trusts the internet.',
|
|
22
24
|
'web.dangerous-inner-html': 'It works, but your frontend is injecting HTML with sharp edges.',
|
|
23
25
|
'api.missing-auth-on-routes': 'It works, but this API route appears to trust strangers.',
|
|
24
26
|
'api.idor-risk': 'It works, but this ID lookup may belong to someone else.',
|
|
27
|
+
'auth.jwt-secret-weak-or-fallback': 'It works, but your JWT secret has a fallback key.',
|
|
28
|
+
'auth.password-hashing-missing': 'It works, but your passwords may be stored too honestly.',
|
|
25
29
|
'database.raw-sql-interpolation': 'It works, but your SQL query is one template string away from pain.',
|
|
26
30
|
'database.no-migrations': 'It works, but your database schema has no paper trail.',
|
|
31
|
+
'cookies.insecure-session-cookie': 'It works, but your session cookie is dressed for localhost.',
|
|
32
|
+
'uploads.public-executable-upload': 'It works, but your uploads are sitting in the front window.',
|
|
33
|
+
'webhooks.missing-raw-body': 'It works, but your webhook signature check may be checking the wrong body.',
|
|
34
|
+
'llm.prompt-injection-risk': 'It works, but your AI output has admin energy.',
|
|
35
|
+
'frontend.sourcemaps-production': 'It works, but your source code may be shipping with the app.',
|
|
36
|
+
'config.debug-production': 'It works, but production still thinks it is a dev server.',
|
|
27
37
|
'electron.node-integration-enabled': 'It works, but Electron is holding the Node.js door open.',
|
|
28
38
|
'electron.context-isolation-disabled': 'It works, but your renderer and backend are sharing a room.',
|
|
29
39
|
'tauri.dangerous-allowlist-or-capabilities': 'It works, but your Tauri permissions look too generous.',
|
|
@@ -44,6 +54,8 @@ const FIX_PROMPT_ACTIONS = {
|
|
|
44
54
|
'Move hardcoded secret material into a runtime secret store or CI secret, replace committed values with placeholders, and avoid printing secret values anywhere.',
|
|
45
55
|
'env.frontend-secret-exposure':
|
|
46
56
|
'Move secret-like frontend environment variables to server-side code and keep only intentionally public values behind public prefixes.',
|
|
57
|
+
'secrets.secrets-in-logs':
|
|
58
|
+
'Remove sensitive logging, mask secrets, and log only explicit non-sensitive fields.',
|
|
47
59
|
'git.gitignore-missing':
|
|
48
60
|
'Add a project-appropriate .gitignore for dependencies, local env files, build output, logs, databases, OS files, and coverage artifacts.',
|
|
49
61
|
'git.gitignore-incomplete':
|
|
@@ -73,6 +85,8 @@ const FIX_PROMPT_ACTIONS = {
|
|
|
73
85
|
'Install and apply Helmet or equivalent security headers early in the Express middleware stack.',
|
|
74
86
|
'node.cors-wildcard':
|
|
75
87
|
'Restrict CORS origins to trusted application origins and avoid wildcard or credentials-unsafe configurations.',
|
|
88
|
+
'node.child-process-user-input':
|
|
89
|
+
'Avoid shell execution with user input. Use spawn with fixed command and argument arrays, validate against allowlists, and never concatenate shell strings.',
|
|
76
90
|
'web.client-side-auth-only':
|
|
77
91
|
'Move authorization enforcement to server-side API or route handlers and keep frontend checks as UI-only hints.',
|
|
78
92
|
'web.dangerous-inner-html':
|
|
@@ -82,9 +96,25 @@ const FIX_PROMPT_ACTIONS = {
|
|
|
82
96
|
'Add explicit authentication and authorization to the route, or document why the route is intentionally public.',
|
|
83
97
|
'api.idor-risk':
|
|
84
98
|
'Scope object access by authenticated user, owner, tenant, account, or organization in addition to object id.',
|
|
99
|
+
'auth.jwt-secret-weak-or-fallback':
|
|
100
|
+
'Require a strong JWT secret from the environment in production and fail startup if it is missing.',
|
|
101
|
+
'auth.password-hashing-missing':
|
|
102
|
+
'Hash passwords with argon2, bcrypt, scrypt or PBKDF2 before storage. Never store raw passwords.',
|
|
85
103
|
'database.raw-sql-interpolation':
|
|
86
104
|
'Replace SQL string interpolation or concatenation with parameterized queries, prepared statements, or a safe ORM query builder.',
|
|
87
105
|
'database.no-migrations': 'Add versioned database migrations that match the detected ORM or database stack.',
|
|
106
|
+
'cookies.insecure-session-cookie':
|
|
107
|
+
'Set httpOnly, secure and sameSite for session cookies. Use secure: true in production.',
|
|
108
|
+
'uploads.public-executable-upload':
|
|
109
|
+
'Store uploads outside the public web root, validate MIME type and extension, enforce file size limits, and serve files through controlled routes.',
|
|
110
|
+
'webhooks.missing-raw-body':
|
|
111
|
+
'Use a raw body parser for signed webhook routes and register it before JSON parsing middleware.',
|
|
112
|
+
'llm.prompt-injection-risk':
|
|
113
|
+
'Treat model output as untrusted input. Validate with schemas, use allowlists, require human approval for dangerous actions, and never execute raw model output.',
|
|
114
|
+
'frontend.sourcemaps-production':
|
|
115
|
+
'Disable public production source maps unless intentionally needed. If needed, upload them privately to error tracking instead of serving them publicly.',
|
|
116
|
+
'config.debug-production':
|
|
117
|
+
'Disable verbose errors and debug flags in production. Avoid exposing stack traces, internal paths or development tooling.',
|
|
88
118
|
'electron.node-integration-enabled':
|
|
89
119
|
'Set nodeIntegration to false and expose only narrowly scoped APIs through preload.',
|
|
90
120
|
'electron.context-isolation-disabled':
|