itworksbut 0.2.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 CHANGED
@@ -6,7 +6,7 @@ It focuses on common "it works, but..." risks often found in AI-generated or rus
6
6
 
7
7
  For every finding, ItWorksBut gives you a copy-ready fix prompt you can paste into your coding agent. It does not just say what is wrong; it tells your AI exactly what to inspect, what to change, and what not to leak.
8
8
 
9
- It only reads files and reports findings. It does not call external APIs, does not send telemetry, and does not modify the scanned project.
9
+ It only reads files and reports findings. It does not call external APIs, does not send telemetry, and does not modify the scanned project unless you explicitly ask it to write `todo.md` with `--todo`.
10
10
 
11
11
  ## Installation
12
12
 
@@ -40,6 +40,7 @@ itworksbut scan --path .
40
40
  itworksbut scan --fail-on high
41
41
  itworksbut scan --json
42
42
  itworksbut scan --sarif > itworksbut.sarif
43
+ itworksbut scan --todo
43
44
  itworksbut scan --config itworksbut.config.json
44
45
  itworksbut scan --verbose
45
46
  itworksbut --version
@@ -56,6 +57,7 @@ itworksbut scan [options]
56
57
  - `--fail-on <severity>`: Exit with code `1` when a finding at or above the severity exists. Levels: `critical`, `high`, `medium`, `low`, `info`. Default: `low`.
57
58
  - `--json`: Print machine-readable JSON only. No banner, colors, spinner, table, or extra text.
58
59
  - `--sarif`: Print SARIF JSON for GitHub Code Scanning. No banner, colors, spinner, table, or extra text.
60
+ - `--todo`: Write an AI-ready `todo.md` into the scanned project with prioritized findings, fix prompts, and acceptance criteria.
59
61
  - `--verbose`: Include scanner warnings and extra metadata in console output.
60
62
  - `--quiet`: Print only the summary.
61
63
  - `--no-color`: Disable colored output.
@@ -87,6 +89,14 @@ Console-only flags:
87
89
 
88
90
  In CI, spinners and banners are automatically disabled. With `--json` and `--sarif`, stdout contains only valid machine-readable output. The edgy tone applies only to the Console Reporter.
89
91
 
92
+ To create a fix list for a coding agent:
93
+
94
+ ```sh
95
+ itworksbut scan --todo
96
+ ```
97
+
98
+ This writes `todo.md` to the scanned project. The file is ordered by severity and includes agent rules, exact fix prompts, locations, recommendations, and final verification checkboxes.
99
+
90
100
  ## GitHub Actions
91
101
 
92
102
  ```yaml
@@ -155,20 +165,21 @@ release/**
155
165
 
156
166
  ## Example Output
157
167
 
158
- ```text
159
- CRITICAL It works, but your .env is tracked.
160
- ✔ Check: env.env-file-tracked
161
- 📁 File: .env
162
- 🤔 Why: .env appears to be tracked by git. Secrets may be exposed.
163
- 🤖 prompt: You are a senior security engineer working in this repository. Fix the ItWorksBut finding env.env-file-tracked at .env. Treat this as a concrete finding. Problem: .env appears to be tracked by git. Secrets may be exposed. Required change: Remove tracked env files from git, add safe examples such as .env.example, and make sure any exposed credentials are treated as compromised. Do not print, log, or preserve raw secret values; use placeholders only. Keep existing behavior intact where possible, add or update focused tests when useful, and do not silence the check unless the underlying risk is actually fixed.
164
-
165
- HIGH It works, but your SQL query is one template string away from pain.
166
- ✔ Check: database.raw-sql-interpolation
167
- 📁 File: src/db.js:12
168
- 🤔 Why: Possible SQL injection risk: raw SQL appears to be built with template string interpolation.
169
- 🤖 prompt: You are a senior security engineer working in this repository. Fix the ItWorksBut finding database.raw-sql-interpolation at src/db.js:12. This finding is heuristic, so inspect the code first and only change behavior when the risk is real. Problem: Possible SQL injection risk: raw SQL appears to be built with template string interpolation. Required change: Replace SQL string interpolation or concatenation with parameterized queries, prepared statements, or a safe ORM query builder. Keep existing behavior intact where possible, add or update focused tests when useful, and do not silence the check unless the underlying risk is actually fixed.
168
+ ![screenshot of an example output](/assets/medium_problems.webp)
169
+ CRITICAL It works, but your .env is tracked.
170
+ ✔ Check: env.env-file-tracked
171
+ 📁 File: .env
172
+ 🤔 Why: .env appears to be tracked by git. Secrets may be exposed.
173
+ 🤖 prompt: You are a senior security engineer working in this repository. Fix the ItWorksBut finding env.env-file-tracked at .env. Treat this as a concrete finding. Problem: .env appears to be tracked by git. Secrets may be exposed. Required change: Remove tracked env files from git, add safe examples such as .env.example, and make sure any exposed credentials are treated as compromised. Do not print, log, or preserve raw secret values; use placeholders only. Keep existing behavior intact where possible, add or update focused tests when useful, and do not silence the check unless the underlying risk is actually fixed.
174
+
175
+ HIGH It works, but your SQL query is one template string away from pain.
176
+ ✔ Check: database.raw-sql-interpolation
177
+ 📁 File: src/db.js:12
178
+ 🤔 Why: Possible SQL injection risk: raw SQL appears to be built with template string interpolation.
179
+ 🤖 prompt: You are a senior security engineer working in this repository. Fix the ItWorksBut finding database.raw-sql-interpolation at src/db.js:12. This finding is heuristic, so inspect the code first and only change behavior when the risk is real. Problem: Possible SQL injection risk: raw SQL appears to be built with template string interpolation. Required change: Replace SQL string interpolation or concatenation with parameterized queries, prepared statements, or a safe ORM query builder. Keep existing behavior intact where possible, add or update focused tests when useful, and do not silence the check unless the underlying risk is actually fixed.
170
180
 
171
181
  SUMMARY
182
+
172
183
  - ship status: DO NOT SHIP
173
184
  - Fix the red stuff before production.
174
185
  - total findings: 2
@@ -179,13 +190,14 @@ SUMMARY
179
190
  - info: 0
180
191
  - fail-on: high
181
192
  - exit decision: 1
193
+
182
194
  ```
183
195
 
184
196
  Secret-like findings never print the full secret value. Findings report the file, line, and secret type where possible.
185
197
 
186
198
  ## What It Detects
187
199
 
188
- The baseline includes 30 modular checks:
200
+ The baseline includes 40 modular checks:
189
201
 
190
202
  - `git.gitignore-missing`
191
203
  - `git.gitignore-incomplete`
@@ -194,6 +206,7 @@ The baseline includes 30 modular checks:
194
206
  - `env.env-example-missing`
195
207
  - `env.possible-secret-in-code`
196
208
  - `env.frontend-secret-exposure`
209
+ - `secrets.secrets-in-logs`
197
210
  - `dependencies.lockfile-missing`
198
211
  - `dependencies.multiple-lockfiles`
199
212
  - `dependencies.install-scripts-risk`
@@ -207,13 +220,22 @@ The baseline includes 30 modular checks:
207
220
  - `node.rate-limit-missing`
208
221
  - `node.helmet-missing`
209
222
  - `node.cors-wildcard`
223
+ - `node.child-process-user-input`
210
224
  - `web.client-side-auth-only`
211
225
  - `web.dangerous-inner-html`
212
226
  - `web.missing-output-sanitization`
213
227
  - `api.missing-auth-on-routes`
214
228
  - `api.idor-risk`
229
+ - `auth.jwt-secret-weak-or-fallback`
230
+ - `auth.password-hashing-missing`
215
231
  - `database.raw-sql-interpolation`
216
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`
217
239
  - `electron.node-integration-enabled`
218
240
  - `electron.context-isolation-disabled`
219
241
  - `tauri.dangerous-allowlist-or-capabilities`
@@ -225,3 +247,4 @@ Each check is a plain ESM module with an `id`, metadata, and async `run(context)
225
247
  ItWorksBut is a static heuristic scanner, not a pentest, SAST replacement, dependency vulnerability database, or runtime security monitor. Findings intentionally use wording such as "possible", "potential", and "appears to" when a check is heuristic.
226
248
 
227
249
  Use it as a CI guardrail for common project hygiene and security mistakes. For production systems, combine it with code review, tests, dependency scanning, secrets scanning, threat modeling, and focused security assessment.
250
+ ```
package/bin/itworksbut.js CHANGED
@@ -9,6 +9,7 @@ import { getExitCode } from '../src/core/findings.js';
9
9
  import { reportConsole } from '../src/reporters/consoleReporter.js';
10
10
  import { reportJson } from '../src/reporters/jsonReporter.js';
11
11
  import { reportSarif } from '../src/reporters/sarifReporter.js';
12
+ import { writeTodoReport } from '../src/reporters/todoReporter.js';
12
13
 
13
14
  async function main() {
14
15
  const args = parseArgs(process.argv.slice(2));
@@ -51,6 +52,9 @@ async function main() {
51
52
  process.stdout.write(`${JSON.stringify(reportSarif(result), null, 2)}\n`);
52
53
  } else if (args.json) {
53
54
  process.stdout.write(`${JSON.stringify(reportJson(result), null, 2)}\n`);
55
+ } else if (args.todo) {
56
+ const filePath = await writeTodoReport(result);
57
+ if (!args.quiet) process.stdout.write(`Wrote AI todo file: ${filePath}\n`);
54
58
  } else {
55
59
  reportConsole(result, args);
56
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "itworksbut",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Static CI checks for common security, repo, dependency, build, and deployment risks in JavaScript vibe coding projects.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
+ }
@@ -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
+ }
package/src/cli/output.js CHANGED
@@ -14,6 +14,7 @@ Options:
14
14
  Levels: critical, high, medium, low, info. Default: low.
15
15
  --json Print machine-readable JSON.
16
16
  --sarif Print SARIF for GitHub Code Scanning.
17
+ --todo Write an AI-ready todo.md to the scanned project.
17
18
  --no-color Disable color styling.
18
19
  --no-banner Disable the intro banner.
19
20
  --quiet Print only the summary.
@@ -1,5 +1,5 @@
1
1
  const FLAG_WITH_VALUE = new Set(["--fail-on", "--config", "--path"]);
2
- const BOOLEAN_FLAGS = new Set(["--json", "--sarif", "--verbose", "--help", "-h", "--version", "-v", "--no-color", "--no-banner", "--quiet"]);
2
+ const BOOLEAN_FLAGS = new Set(["--json", "--sarif", "--todo", "--verbose", "--help", "-h", "--version", "-v", "--no-color", "--no-banner", "--quiet"]);
3
3
 
4
4
  export function parseArgs(argv) {
5
5
  const args = {
@@ -9,6 +9,7 @@ export function parseArgs(argv) {
9
9
  failOn: undefined,
10
10
  json: false,
11
11
  sarif: false,
12
+ todo: false,
12
13
  verbose: false,
13
14
  noColor: false,
14
15
  noBanner: false,
@@ -46,6 +47,7 @@ export function parseArgs(argv) {
46
47
  if (token === "--version" || token === "-v") args.version = true;
47
48
  if (token === "--json") args.json = true;
48
49
  if (token === "--sarif") args.sarif = true;
50
+ if (token === "--todo") args.todo = true;
49
51
  if (token === "--verbose") args.verbose = true;
50
52
  if (token === "--no-color") args.noColor = true;
51
53
  if (token === "--no-banner") args.noBanner = true;
@@ -56,8 +58,8 @@ export function parseArgs(argv) {
56
58
  throw new Error(`Unknown argument: ${token}`);
57
59
  }
58
60
 
59
- if (args.json && args.sarif) {
60
- throw new Error("Use only one output format: --json or --sarif");
61
+ if ([args.json, args.sarif, args.todo].filter(Boolean).length > 1) {
62
+ throw new Error("Use only one output format: --json, --sarif, or --todo");
61
63
  }
62
64
 
63
65
  return args;
@@ -19,11 +19,11 @@ const SPINNER_TEXT = {
19
19
  };
20
20
 
21
21
  export function isFancyOutputEnabled(options = {}, env = process.env, stdout = process.stdout) {
22
- return Boolean(stdout.isTTY) && !env.CI && !options.json && !options.sarif && !options.noColor;
22
+ return Boolean(stdout.isTTY) && !env.CI && !options.json && !options.sarif && !options.todo && !options.noColor;
23
23
  }
24
24
 
25
25
  export function isColorEnabled(options = {}, env = process.env, stdout = process.stdout) {
26
- if (options.noColor || options.json || options.sarif) return false;
26
+ if (options.noColor || options.json || options.sarif || options.todo) return false;
27
27
  if (env.FORCE_COLOR && env.FORCE_COLOR !== "0") return true;
28
28
  if (env.CI) return false;
29
29
  return Boolean(stdout.isTTY);
@@ -40,6 +40,7 @@ export function shouldUseSpinner(options = {}, env = process.env, stdout = proce
40
40
  !env.CI &&
41
41
  !options.json &&
42
42
  !options.sarif &&
43
+ !options.todo &&
43
44
  !options.quiet
44
45
  );
45
46
  }
@@ -54,7 +55,7 @@ export function createScanSpinner(options = {}) {
54
55
  }
55
56
 
56
57
  export function printIntro(options = {}) {
57
- if (options.json || options.sarif || options.noBanner || options.quiet || process.env.CI || !process.stdout.isTTY) {
58
+ if (options.json || options.sarif || options.todo || options.noBanner || options.quiet || process.env.CI || !process.stdout.isTTY) {
58
59
  return;
59
60
  }
60
61
 
@@ -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':
@@ -0,0 +1,133 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { SEVERITIES } from "../core/config.js";
4
+ import { countBySeverity, getExitCode } from "../core/findings.js";
5
+ import { getConsoleFindingTitle, getFixPrompt } from "./consoleStyle.js";
6
+
7
+ export async function writeTodoReport(result, options = {}) {
8
+ const filePath = options.filePath || path.join(result.meta.rootPath, "todo.md");
9
+ await fs.writeFile(filePath, reportTodo(result), "utf8");
10
+ return filePath;
11
+ }
12
+
13
+ export function reportTodo(result) {
14
+ const counts = countBySeverity(result.findings);
15
+ const exitCode = getExitCode(result.findings, result.config.failOn);
16
+ const findingsBySeverity = new Map(
17
+ SEVERITIES.map((severity) => [severity, result.findings.filter((finding) => finding.severity === severity)])
18
+ );
19
+
20
+ return `${[
21
+ "# AI Fix Todo",
22
+ "",
23
+ "This file was generated by ItWorksBut. It is optimized for coding agents: work top to bottom, keep changes focused, and mark tasks complete only after verification.",
24
+ "",
25
+ "## Agent Rules",
26
+ "",
27
+ "- Fix critical and high findings before lower-priority cleanup.",
28
+ "- Inspect the referenced code before editing, especially for heuristic findings.",
29
+ "- Preserve existing behavior unless the finding explicitly requires changing it.",
30
+ "- Add or update focused tests when the change has behavioral risk.",
31
+ "- Do not silence ItWorksBut checks unless the underlying issue is actually fixed.",
32
+ "- Never print, log, or preserve raw secret values. Use placeholders only.",
33
+ "",
34
+ "## Scan Summary",
35
+ "",
36
+ `- Tool: ${result.meta.tool || "ItWorksBut"} ${result.meta.version || ""}`.trim(),
37
+ `- Project: ${result.meta.rootPath || "unknown"}`,
38
+ `- Completed: ${result.meta.completedAt || "unknown"}`,
39
+ `- Files scanned: ${result.meta.filesScanned ?? "unknown"}`,
40
+ `- Text files scanned: ${result.meta.textFilesScanned ?? "unknown"}`,
41
+ `- Fail-on: ${result.config.failOn}`,
42
+ `- Exit decision: ${exitCode}`,
43
+ `- Total findings: ${result.findings.length}`,
44
+ ...SEVERITIES.map((severity) => `- ${capitalize(severity)}: ${counts[severity]}`),
45
+ "",
46
+ renderFindingSections(findingsBySeverity),
47
+ renderWarnings(result.warnings),
48
+ "## Final Verification",
49
+ "",
50
+ "- [ ] Run the relevant test suite.",
51
+ "- [ ] Run `itworksbut scan` again.",
52
+ "- [ ] Confirm there are no remaining findings at or above the configured fail-on severity.",
53
+ "",
54
+ ].join("\n")}\n`;
55
+ }
56
+
57
+ function renderFindingSections(findingsBySeverity) {
58
+ const sections = [];
59
+
60
+ for (const severity of SEVERITIES) {
61
+ const findings = findingsBySeverity.get(severity) || [];
62
+ if (findings.length === 0) continue;
63
+
64
+ sections.push(`## ${capitalize(severity)} Findings`);
65
+ sections.push("");
66
+
67
+ findings.forEach((finding, index) => {
68
+ sections.push(renderFinding(finding, index + 1));
69
+ sections.push("");
70
+ });
71
+ }
72
+
73
+ if (sections.length === 0) {
74
+ return [
75
+ "## Findings",
76
+ "",
77
+ "No findings were detected. Keep this file as a record or delete it when no longer useful.",
78
+ "",
79
+ ].join("\n");
80
+ }
81
+
82
+ return sections.join("\n");
83
+ }
84
+
85
+ function renderFinding(finding, number) {
86
+ const location = finding.file
87
+ ? `${finding.file}${finding.line ? `:${finding.line}` : ""}${finding.column ? `:${finding.column}` : ""}`
88
+ : "project-wide";
89
+ const heuristic = finding.heuristic ? "yes" : "no";
90
+ const tags = finding.tags?.length ? finding.tags.join(", ") : "none";
91
+
92
+ return [
93
+ `### ${number}. ${getConsoleFindingTitle(finding)}`,
94
+ "",
95
+ "- [ ] Fix this finding.",
96
+ `- Check ID: \`${finding.checkId}\``,
97
+ `- Severity: \`${finding.severity}\``,
98
+ `- Category: \`${finding.category || "unknown"}\``,
99
+ `- Location: \`${location}\``,
100
+ `- Heuristic: \`${heuristic}\``,
101
+ `- Tags: ${tags}`,
102
+ `- Problem: ${finding.message}`,
103
+ `- Recommendation: ${finding.recommendation || "Fix the underlying issue without suppressing the scanner."}`,
104
+ "",
105
+ "#### Agent Prompt",
106
+ "",
107
+ "```text",
108
+ getFixPrompt(finding),
109
+ "```",
110
+ "",
111
+ "#### Acceptance Criteria",
112
+ "",
113
+ `- [ ] The issue behind \`${finding.checkId}\` is fixed at the source.`,
114
+ "- [ ] Existing behavior is preserved or intentional behavior changes are covered by tests.",
115
+ "- [ ] No raw secrets, tokens, credentials, or private values were added to code, logs, tests, or this todo.",
116
+ "- [ ] The relevant scanner finding no longer appears after rerunning ItWorksBut.",
117
+ ].join("\n");
118
+ }
119
+
120
+ function renderWarnings(warnings = []) {
121
+ if (!warnings.length) return "";
122
+
123
+ return [
124
+ "## Scanner Warnings",
125
+ "",
126
+ ...warnings.map((warning) => `- \`${warning.checkId}\`: ${warning.message}`),
127
+ "",
128
+ ].join("\n");
129
+ }
130
+
131
+ function capitalize(value) {
132
+ return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
133
+ }