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.
@@ -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
+ };
@@ -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
+ }
@@ -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,16 +19,35 @@ 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";
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";
26
34
  import rawSqlInterpolation from "./database/raw-sql-interpolation.js";
27
35
  import noMigrations from "./database/no-migrations.js";
36
+ import insecureSessionCookie from "./cookies/insecure-session-cookie.js";
37
+ import publicExecutableUpload from "./uploads/public-executable-upload.js";
38
+ import missingRawBody from "./webhooks/missing-raw-body.js";
39
+ import promptInjectionRisk from "./llm/prompt-injection-risk.js";
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";
45
+ import debugProduction from "./config/debug-production.js";
28
46
  import electronNodeIntegrationEnabled from "./electron/node-integration-enabled.js";
29
47
  import electronContextIsolationDisabled from "./electron/context-isolation-disabled.js";
48
+ import electronRemoteContentWithNode from "./electron/remote-content-with-node.js";
30
49
  import tauriDangerousAllowlistOrCapabilities from "./tauri/dangerous-allowlist-or-capabilities.js";
50
+ import tauriRemoteUrlPermissionsRisk from "./tauri/remote-url-permissions-risk.js";
31
51
 
32
52
  export default [
33
53
  gitignoreMissing,
@@ -37,6 +57,7 @@ export default [
37
57
  envExampleMissing,
38
58
  possibleSecretInCode,
39
59
  frontendSecretExposure,
60
+ secretsInLogs,
40
61
  lockfileMissing,
41
62
  multipleLockfiles,
42
63
  installScriptsRisk,
@@ -50,14 +71,33 @@ export default [
50
71
  rateLimitMissing,
51
72
  helmetMissing,
52
73
  corsWildcard,
74
+ childProcessUserInput,
53
75
  clientSideAuthOnly,
54
76
  dangerousInnerHtml,
55
77
  missingOutputSanitization,
56
78
  missingAuthOnRoutes,
57
79
  idorRisk,
80
+ jwtSecretWeakOrFallback,
81
+ passwordHashingMissing,
82
+ missingCsrfProtection,
83
+ missingMethodGuard,
84
+ massAssignmentRisk,
85
+ noSchemaValidation,
58
86
  rawSqlInterpolation,
59
87
  noMigrations,
88
+ insecureSessionCookie,
89
+ publicExecutableUpload,
90
+ missingRawBody,
91
+ promptInjectionRisk,
92
+ sourceMapsProduction,
93
+ localstorageToken,
94
+ pathTraversalRisk,
95
+ userControlledFetch,
96
+ nextPublicServerCodeRisk,
97
+ debugProduction,
60
98
  electronNodeIntegrationEnabled,
61
99
  electronContextIsolationDisabled,
62
- tauriDangerousAllowlistOrCapabilities
100
+ electronRemoteContentWithNode,
101
+ tauriDangerousAllowlistOrCapabilities,
102
+ tauriRemoteUrlPermissionsRisk
63
103
  ];
@@ -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,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,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,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
+ }