getdoorman 1.0.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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +181 -0
  3. package/bin/doorman.js +444 -0
  4. package/package.json +74 -0
  5. package/src/ai-fixer.js +559 -0
  6. package/src/ast-scanner.js +434 -0
  7. package/src/auth.js +149 -0
  8. package/src/baseline.js +48 -0
  9. package/src/compliance.js +539 -0
  10. package/src/config.js +466 -0
  11. package/src/custom-rules.js +32 -0
  12. package/src/dashboard.js +202 -0
  13. package/src/detector.js +142 -0
  14. package/src/fix-engine.js +48 -0
  15. package/src/fix-registry-extra.js +95 -0
  16. package/src/fix-registry-go-rust.js +77 -0
  17. package/src/fix-registry-java-csharp.js +77 -0
  18. package/src/fix-registry-js.js +99 -0
  19. package/src/fix-registry-mcp-ai.js +57 -0
  20. package/src/fix-registry-python.js +87 -0
  21. package/src/fixer-ruby-php.js +608 -0
  22. package/src/fixer.js +2113 -0
  23. package/src/hooks.js +115 -0
  24. package/src/ignore.js +176 -0
  25. package/src/index.js +384 -0
  26. package/src/metrics.js +126 -0
  27. package/src/monorepo.js +65 -0
  28. package/src/presets.js +54 -0
  29. package/src/reporter.js +975 -0
  30. package/src/rule-worker.js +36 -0
  31. package/src/rules/ast-rules.js +756 -0
  32. package/src/rules/bugs/accessibility.js +235 -0
  33. package/src/rules/bugs/ai-codegen-fixable.js +172 -0
  34. package/src/rules/bugs/ai-codegen.js +365 -0
  35. package/src/rules/bugs/code-smell-bugs.js +247 -0
  36. package/src/rules/bugs/crypto-bugs.js +195 -0
  37. package/src/rules/bugs/docker-bugs.js +158 -0
  38. package/src/rules/bugs/general.js +361 -0
  39. package/src/rules/bugs/go-bugs.js +279 -0
  40. package/src/rules/bugs/index.js +73 -0
  41. package/src/rules/bugs/js-api.js +257 -0
  42. package/src/rules/bugs/js-array-object.js +210 -0
  43. package/src/rules/bugs/js-async-fixable.js +223 -0
  44. package/src/rules/bugs/js-async.js +211 -0
  45. package/src/rules/bugs/js-closure-scope.js +182 -0
  46. package/src/rules/bugs/js-database.js +203 -0
  47. package/src/rules/bugs/js-error-handling.js +148 -0
  48. package/src/rules/bugs/js-logic.js +261 -0
  49. package/src/rules/bugs/js-memory.js +214 -0
  50. package/src/rules/bugs/js-node.js +361 -0
  51. package/src/rules/bugs/js-react.js +373 -0
  52. package/src/rules/bugs/js-regex.js +200 -0
  53. package/src/rules/bugs/js-state.js +272 -0
  54. package/src/rules/bugs/js-type-coercion.js +318 -0
  55. package/src/rules/bugs/nextjs-bugs.js +242 -0
  56. package/src/rules/bugs/nextjs-fixable.js +120 -0
  57. package/src/rules/bugs/node-fixable.js +178 -0
  58. package/src/rules/bugs/python-advanced.js +245 -0
  59. package/src/rules/bugs/python-fixable.js +98 -0
  60. package/src/rules/bugs/python.js +284 -0
  61. package/src/rules/bugs/react-fixable.js +207 -0
  62. package/src/rules/bugs/ruby-bugs.js +182 -0
  63. package/src/rules/bugs/shell-bugs.js +181 -0
  64. package/src/rules/bugs/silent-failures.js +261 -0
  65. package/src/rules/bugs/ts-bugs.js +235 -0
  66. package/src/rules/bugs/unused-vars.js +65 -0
  67. package/src/rules/compliance/accessibility-ext.js +468 -0
  68. package/src/rules/compliance/education.js +322 -0
  69. package/src/rules/compliance/financial.js +421 -0
  70. package/src/rules/compliance/frameworks.js +507 -0
  71. package/src/rules/compliance/healthcare.js +520 -0
  72. package/src/rules/compliance/index.js +2714 -0
  73. package/src/rules/compliance/regional-eu.js +480 -0
  74. package/src/rules/compliance/regional-international.js +903 -0
  75. package/src/rules/cost/index.js +1993 -0
  76. package/src/rules/data/index.js +2503 -0
  77. package/src/rules/dependencies/index.js +1684 -0
  78. package/src/rules/deployment/index.js +2050 -0
  79. package/src/rules/index.js +71 -0
  80. package/src/rules/infrastructure/index.js +3048 -0
  81. package/src/rules/performance/index.js +3455 -0
  82. package/src/rules/quality/index.js +3175 -0
  83. package/src/rules/reliability/index.js +3040 -0
  84. package/src/rules/scope-rules.js +815 -0
  85. package/src/rules/security/ai-api.js +1177 -0
  86. package/src/rules/security/auth.js +1328 -0
  87. package/src/rules/security/cors.js +127 -0
  88. package/src/rules/security/crypto.js +527 -0
  89. package/src/rules/security/csharp.js +862 -0
  90. package/src/rules/security/csrf.js +193 -0
  91. package/src/rules/security/dart.js +835 -0
  92. package/src/rules/security/deserialization.js +291 -0
  93. package/src/rules/security/file-upload.js +187 -0
  94. package/src/rules/security/go.js +850 -0
  95. package/src/rules/security/headers.js +235 -0
  96. package/src/rules/security/index.js +65 -0
  97. package/src/rules/security/injection.js +1639 -0
  98. package/src/rules/security/mcp-server.js +71 -0
  99. package/src/rules/security/misconfiguration.js +660 -0
  100. package/src/rules/security/oauth-jwt.js +329 -0
  101. package/src/rules/security/path-traversal.js +295 -0
  102. package/src/rules/security/php.js +1054 -0
  103. package/src/rules/security/prototype-pollution.js +283 -0
  104. package/src/rules/security/rate-limiting.js +208 -0
  105. package/src/rules/security/ruby.js +1061 -0
  106. package/src/rules/security/rust.js +693 -0
  107. package/src/rules/security/secrets.js +747 -0
  108. package/src/rules/security/shell.js +647 -0
  109. package/src/rules/security/ssrf.js +298 -0
  110. package/src/rules/security/supply-chain-advanced.js +393 -0
  111. package/src/rules/security/supply-chain.js +734 -0
  112. package/src/rules/security/swift.js +835 -0
  113. package/src/rules/security/taint.js +27 -0
  114. package/src/rules/security/xss.js +520 -0
  115. package/src/scan-cache.js +71 -0
  116. package/src/scanner.js +710 -0
  117. package/src/scope-analyzer.js +685 -0
  118. package/src/share.js +88 -0
  119. package/src/taint.js +300 -0
  120. package/src/telemetry.js +183 -0
  121. package/src/tracer.js +190 -0
  122. package/src/upload.js +35 -0
  123. package/src/worker.js +31 -0
package/src/fixer.js ADDED
@@ -0,0 +1,2113 @@
1
+ import chalk from 'chalk';
2
+ import { readFileSync, writeFileSync, statSync, existsSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { execSync } from 'child_process';
5
+ import { check } from './index.js';
6
+ import { recordFix } from './metrics.js';
7
+ import { applyRegistryFix, applyAllRegistryFixes } from './fix-engine.js';
8
+ import jsFixEntries from './fix-registry-js.js';
9
+ import pyFixEntries from './fix-registry-python.js';
10
+ import goRustFixEntries from './fix-registry-go-rust.js';
11
+ import javaCsharpFixEntries from './fix-registry-java-csharp.js';
12
+ import mcpAiFixEntries from './fix-registry-mcp-ai.js';
13
+ import extraFixEntries from './fix-registry-extra.js';
14
+
15
+ // Merge all registry-based fix entries
16
+ const registryEntries = [
17
+ ...jsFixEntries, ...pyFixEntries, ...goRustFixEntries,
18
+ ...javaCsharpFixEntries, ...mcpAiFixEntries, ...extraFixEntries,
19
+ ];
20
+
21
+ // Build patternFixes entries from registry
22
+ const registryPatternFixes = {};
23
+ for (const entry of registryEntries) {
24
+ registryPatternFixes[entry.id] = {
25
+ fn: (content) => applyRegistryFix(content, entry),
26
+ tier: entry.tier,
27
+ title: entry.title,
28
+ };
29
+ }
30
+
31
+ const MAX_FILE_SIZE = 500 * 1024; // 500KB
32
+ const MAX_FILES_PER_RUN = 50;
33
+ const FREE_FIX_LIMIT = 3;
34
+
35
+ /**
36
+ * Check if the modified content still has valid syntax.
37
+ * Catches broken JS/TS/JSON before writing to disk.
38
+ */
39
+ function isSyntaxValid(filePath, content) {
40
+ try {
41
+ if (filePath.endsWith('.json')) {
42
+ JSON.parse(content);
43
+ return true;
44
+ }
45
+ if (/\.(js|jsx|mjs|cjs)$/.test(filePath)) {
46
+ // Use Node's built-in parser via new Function (catches syntax errors)
47
+ new Function(content);
48
+ return true;
49
+ }
50
+ // For other file types (TS, Python, etc.) — assume valid
51
+ // Full parsing would require language-specific parsers
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Check if the user has a Pro license key.
60
+ * Accepts DOORMAN_KEY env var or --license-key CLI option.
61
+ */
62
+ export function isProUser(options = {}) {
63
+ const key = options.licenseKey || process.env.DOORMAN_KEY || '';
64
+ return key.length > 0;
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Pattern-based auto-fix functions (no AI required)
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * Each fix function takes (content, finding) and returns the modified content
73
+ * string, or null if the fix could not be applied safely.
74
+ *
75
+ * The `finding` object may include: { file, line, ruleId, title, match, ... }
76
+ */
77
+
78
+ // 1. Replace eval(x) with safer alternatives
79
+ export function fixEval(content) {
80
+ let modified = content;
81
+ // Replace eval(JSON.parse(...)) or eval on JSON-like strings with JSON.parse
82
+ modified = modified.replace(
83
+ /\beval\(\s*(['"`])\s*\{[\s\S]*?\}\s*\1\s*\)/g,
84
+ (match) => {
85
+ const inner = match.slice(match.indexOf('(') + 1, -1).trim();
86
+ return `JSON.parse(${inner})`;
87
+ },
88
+ );
89
+ // Replace eval(variable) with Function constructor + warning comment
90
+ modified = modified.replace(
91
+ /\beval\(([^)]+)\)/g,
92
+ (match, inner) => {
93
+ const trimmed = inner.trim();
94
+ // Don't double-fix if already replaced
95
+ if (match.startsWith('JSON.parse') || match.startsWith('new Function')) return match;
96
+ // If it looks like JSON parsing, use JSON.parse
97
+ if (/json/i.test(trimmed)) {
98
+ return `JSON.parse(${trimmed})`;
99
+ }
100
+ return `/* SECURITY: replaced unsafe eval — review this usage */ new Function(${trimmed})()`;
101
+ },
102
+ );
103
+ return modified === content ? null : modified;
104
+ }
105
+
106
+ // 2. Replace Math.random() in security context with crypto.randomUUID()
107
+ export function fixMathRandom(content) {
108
+ // Only replace Math.random() that appears near security-related tokens
109
+ const lines = content.split('\n');
110
+ let changed = false;
111
+ const securityKeywords = /token|secret|key|password|auth|session|csrf|nonce|salt|random.*id|id.*random/i;
112
+
113
+ const result = lines.map((line) => {
114
+ if (/Math\.random\(\)/.test(line) && securityKeywords.test(line)) {
115
+ changed = true;
116
+ return line.replace(/Math\.random\(\)/g, 'crypto.randomUUID()');
117
+ }
118
+ return line;
119
+ }).join('\n');
120
+
121
+ if (!changed) {
122
+ // Fallback: replace any Math.random() usage
123
+ const fallback = content.replace(/Math\.random\(\)/g, 'crypto.randomUUID()');
124
+ return fallback === content ? null : fallback;
125
+ }
126
+ return result;
127
+ }
128
+
129
+ // 3. Add httpOnly, secure, sameSite to cookie options
130
+ export function fixCookieFlags(content) {
131
+ let modified = content;
132
+ // Match res.cookie(..., { ... }) and add missing flags
133
+ modified = modified.replace(
134
+ /(res\.cookie\([^,]+,\s*[^,]+,\s*\{)([^}]*)\}/g,
135
+ (match, prefix, options) => {
136
+ const hasHttpOnly = /httpOnly\s*:/.test(options);
137
+ const hasSecure = /\bsecure\s*:/.test(options);
138
+ const hasSameSite = /sameSite\s*:/.test(options);
139
+ // If all flags present, skip
140
+ if (hasHttpOnly && hasSecure && hasSameSite) return match;
141
+ let opts = options;
142
+ if (!hasHttpOnly) {
143
+ opts = opts.trimEnd();
144
+ if (opts.length > 0 && !opts.endsWith(',')) opts += ',';
145
+ opts += ' httpOnly: true,';
146
+ }
147
+ if (!hasSecure) {
148
+ opts = opts.trimEnd();
149
+ if (!opts.endsWith(',')) opts += ',';
150
+ opts += ' secure: true,';
151
+ }
152
+ if (!hasSameSite) {
153
+ opts = opts.trimEnd();
154
+ if (!opts.endsWith(',')) opts += ',';
155
+ opts += " sameSite: 'strict'";
156
+ }
157
+ return `${prefix}${opts} }`;
158
+ },
159
+ );
160
+ // Handle res.cookie with only 2 args (no options) — add options object
161
+ modified = modified.replace(
162
+ /(res\.cookie\(\s*[^,]+,\s*[^,)]+)\s*\)/g,
163
+ (match, prefix) => {
164
+ // Avoid matching if already has 3+ args
165
+ if (prefix.split(',').length > 2) return match;
166
+ return `${prefix}, { httpOnly: true, secure: true, sameSite: 'strict' })`;
167
+ },
168
+ );
169
+ return modified === content ? null : modified;
170
+ }
171
+
172
+ // 4. Replace http:// URLs with https:// in config files
173
+ export function fixHttpUrls(content) {
174
+ const modified = content.replace(
175
+ /(['"`])http:\/\/((?!localhost|127\.0\.0\.1|0\.0\.0\.0|::1)[^'"`\s]+)(['"`])/g,
176
+ (match, q1, url, q2) => `${q1}https://${url}${q2}`,
177
+ );
178
+ return modified === content ? null : modified;
179
+ }
180
+
181
+ // 5. Add helmet() import and middleware to Express apps
182
+ export function fixHelmetMissing(content) {
183
+ // Only apply if this looks like an Express app without helmet
184
+ if (!/express\(\)/.test(content)) return null;
185
+ if (/helmet/.test(content)) return null;
186
+
187
+ let modified = content;
188
+ // Add import after the express import
189
+ modified = modified.replace(
190
+ /((?:const|let|var)\s+\w+\s*=\s*require\s*\(\s*['"]express['"]\s*\).*?;?\n)/,
191
+ `$1const helmet = require('helmet');\n`,
192
+ );
193
+ // If using ES module imports
194
+ modified = modified.replace(
195
+ /(import\s+\w+\s+from\s+['"]express['"].*?\n)/,
196
+ `$1import helmet from 'helmet';\n`,
197
+ );
198
+ // Add app.use(helmet()) after app creation
199
+ modified = modified.replace(
200
+ /((?:const|let|var)\s+(\w+)\s*=\s*express\(\).*?;?\n)/,
201
+ `$1$2.use(helmet());\n`,
202
+ );
203
+ return modified === content ? null : modified;
204
+ }
205
+
206
+ // 6. Replace == with === in auth comparisons
207
+ export function fixLooseEquality(content) {
208
+ const authKeywords = /password|token|secret|auth|session|user|role|admin|login|credential|apiKey|api_key/i;
209
+ const lines = content.split('\n');
210
+ let changed = false;
211
+
212
+ const result = lines.map((line) => {
213
+ // Skip comments
214
+ if (/^\s*\/\//.test(line) || /^\s*\*/.test(line)) return line;
215
+ if (authKeywords.test(line) && /[^!=]==[^=]/.test(line)) {
216
+ changed = true;
217
+ return line.replace(/([^!=])={2}([^=])/g, '$1===$2');
218
+ }
219
+ if (authKeywords.test(line) && /[^!]=![^=]/.test(line)) {
220
+ // skip — != to !== is rarer, handle == only
221
+ }
222
+ return line;
223
+ }).join('\n');
224
+
225
+ return changed ? result : null;
226
+ }
227
+
228
+ // 7. Add algorithms option to jwt.verify calls
229
+ export function fixJwtVerifyAlgorithm(content) {
230
+ const modified = content.replace(
231
+ /jwt\.verify\(\s*([^,]+),\s*([^,)]+)\s*\)/g,
232
+ (match, token, secret) => {
233
+ return `jwt.verify(${token}, ${secret}, { algorithms: ['HS256'] })`;
234
+ },
235
+ );
236
+ return modified === content ? null : modified;
237
+ }
238
+
239
+ // 8. Replace MD5/SHA1 with SHA256 in crypto.createHash
240
+ export function fixWeakHash(content) {
241
+ const modified = content.replace(
242
+ /crypto\.createHash\(\s*['"](?:md5|sha1|MD5|SHA1)['"]\s*\)/g,
243
+ "crypto.createHash('sha256')",
244
+ );
245
+ return modified === content ? null : modified;
246
+ }
247
+
248
+ // 9. Add -- comment terminator to SQL queries with string interpolation
249
+ export function fixSqlTerminator(content) {
250
+ // Match template literals and string concat that look like SQL with interpolation
251
+ const modified = content.replace(
252
+ /(`\s*(?:SELECT|INSERT|UPDATE|DELETE|DROP|ALTER)\b[^`]*\$\{[^}]+\}[^`]*?)(`)/gi,
253
+ (match, query, close) => {
254
+ if (/--\s*$/.test(query.trim())) return match; // already has terminator
255
+ return `${query} --${close}`;
256
+ },
257
+ );
258
+ return modified === content ? null : modified;
259
+ }
260
+
261
+ // 10. Replace child_process.exec with execFile
262
+ export function fixExecToExecFile(content) {
263
+ let modified = content;
264
+ // Replace require destructuring
265
+ modified = modified.replace(
266
+ /\b(require\(\s*['"]child_process['"]\s*\))\.exec\b/g,
267
+ '$1.execFile',
268
+ );
269
+ // Replace destructured import: { exec } -> { execFile }
270
+ modified = modified.replace(
271
+ /\{\s*exec\s*\}(\s*=\s*require\(\s*['"]child_process['"]\s*\))/g,
272
+ '{ execFile }$1',
273
+ );
274
+ // Replace ES module import
275
+ modified = modified.replace(
276
+ /import\s*\{\s*exec\s*\}\s*from\s*['"]child_process['"]/g,
277
+ "import { execFile } from 'child_process'",
278
+ );
279
+ // Replace direct exec( calls with execFile( — only when clearly from child_process
280
+ modified = modified.replace(
281
+ /\bexec\(\s*(['"`])/g,
282
+ (match, quote) => `execFile(${quote}`,
283
+ );
284
+ return modified === content ? null : modified;
285
+ }
286
+
287
+ // 11. Add expiresIn to jwt.sign calls missing expiration
288
+ export function fixJwtSignExpiry(content) {
289
+ // Match jwt.sign(payload, secret) without options or with options missing expiresIn
290
+ let modified = content;
291
+
292
+ // Case 1: jwt.sign(payload, secret) — no options at all
293
+ modified = modified.replace(
294
+ /jwt\.sign\(\s*([^,]+),\s*([^,)]+)\s*\)(?!\s*;?\s*\/\*.*expiresIn)/g,
295
+ (match, payload, secret) => {
296
+ // Make sure this isn't already a 3-arg call
297
+ if (secret.includes('{')) return match;
298
+ return `jwt.sign(${payload}, ${secret}, { expiresIn: '1h' })`;
299
+ },
300
+ );
301
+
302
+ // Case 2: jwt.sign(payload, secret, { ... }) where options exist but no expiresIn
303
+ modified = modified.replace(
304
+ /jwt\.sign\(\s*([^,]+),\s*([^,]+),\s*\{([^}]*)\}\s*\)/g,
305
+ (match, payload, secret, options) => {
306
+ if (/expiresIn/.test(options)) return match;
307
+ let opts = options.trimEnd();
308
+ if (opts.length > 0 && !opts.endsWith(',')) opts += ',';
309
+ return `jwt.sign(${payload}, ${secret}, {${opts} expiresIn: '1h' })`;
310
+ },
311
+ );
312
+
313
+ return modified === content ? null : modified;
314
+ }
315
+
316
+ // 12. Add rate-limit middleware to Express apps
317
+ export function fixRateLimitMissing(content) {
318
+ if (!/express\(\)/.test(content)) return null;
319
+ if (/rate.?limit/i.test(content)) return null;
320
+
321
+ let modified = content;
322
+ // Add require after express require
323
+ modified = modified.replace(
324
+ /((?:const|let|var)\s+\w+\s*=\s*require\s*\(\s*['"]express['"]\s*\).*?;?\n)/,
325
+ `$1const rateLimit = require('express-rate-limit');\n`,
326
+ );
327
+ // ES module import
328
+ modified = modified.replace(
329
+ /(import\s+\w+\s+from\s+['"]express['"].*?\n)/,
330
+ `$1import rateLimit from 'express-rate-limit';\n`,
331
+ );
332
+ // Add middleware after app creation
333
+ modified = modified.replace(
334
+ /((?:const|let|var)\s+(\w+)\s*=\s*express\(\).*?;?\n)/,
335
+ `$1$2.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }));\n`,
336
+ );
337
+ return modified === content ? null : modified;
338
+ }
339
+
340
+ // 13. Replace JSON.parse(req.body) with body-parser middleware
341
+ export function fixJsonParseReqBody(content) {
342
+ let modified = content;
343
+ // Replace JSON.parse(req.body) with just req.body (assumes body-parser)
344
+ modified = modified.replace(
345
+ /JSON\.parse\(\s*req\.body\s*\)/g,
346
+ 'req.body /* use express.json() middleware instead of manual JSON.parse */',
347
+ );
348
+ // If this is an Express app, add express.json() middleware
349
+ if (/express\(\)/.test(modified) && !/\.use\(\s*express\.json\(\)\s*\)/.test(modified) && !/bodyParser/.test(modified)) {
350
+ modified = modified.replace(
351
+ /((?:const|let|var)\s+(\w+)\s*=\s*express\(\).*?;?\n)/,
352
+ `$1$2.use(express.json());\n`,
353
+ );
354
+ }
355
+ return modified === content ? null : modified;
356
+ }
357
+
358
+ // 14. Add .escape() to user input before SQL (stopgap)
359
+ export function fixSqlEscape(content) {
360
+ // Look for direct string interpolation in SQL-like queries
361
+ const modified = content.replace(
362
+ /(`\s*(?:SELECT|INSERT|UPDATE|DELETE)\b[^`]*)\$\{(\s*(?:req\.(?:body|query|params)\.\w+|userInput|input)\s*)\}([^`]*`)/gi,
363
+ (match, before, variable, after) => {
364
+ const trimmedVar = variable.trim();
365
+ // Don't double-wrap
366
+ if (trimmedVar.includes('escape(') || trimmedVar.includes('sanitize(')) return match;
367
+ return `${before}\${escape(${trimmedVar})}${after}`;
368
+ },
369
+ );
370
+ return modified === content ? null : modified;
371
+ }
372
+
373
+ // 15. Add Content-Security-Policy header via helmet
374
+ export function fixContentSecurityPolicy(content) {
375
+ // If helmet is already used with contentSecurityPolicy, skip
376
+ if (/contentSecurityPolicy/.test(content)) return null;
377
+ if (!/express\(\)/.test(content)) return null;
378
+
379
+ let modified = content;
380
+ // If helmet() is already used, upgrade to include CSP directives
381
+ if (/helmet\(\)/.test(modified)) {
382
+ modified = modified.replace(
383
+ /helmet\(\)/,
384
+ `helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"] } } })`,
385
+ );
386
+ } else {
387
+ // Add helmet with CSP
388
+ modified = modified.replace(
389
+ /((?:const|let|var)\s+(\w+)\s*=\s*express\(\).*?;?\n)/,
390
+ `$1$2.use(require('helmet')({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"] } } }));\n`,
391
+ );
392
+ }
393
+ return modified === content ? null : modified;
394
+ }
395
+
396
+ // 16. Remove console.log statements (replace with logger)
397
+ export function fixConsoleLog(content) {
398
+ const lines = content.split('\n');
399
+ let changed = false;
400
+
401
+ const result = lines.map((line) => {
402
+ // Skip comments
403
+ if (/^\s*\/\//.test(line)) return line;
404
+ // Replace console.log/debug/info/warn/error with logger equivalents
405
+ if (/\bconsole\.(log|debug|info)\s*\(/.test(line)) {
406
+ changed = true;
407
+ return line
408
+ .replace(/\bconsole\.log\s*\(/, 'logger.info(')
409
+ .replace(/\bconsole\.debug\s*\(/, 'logger.debug(')
410
+ .replace(/\bconsole\.info\s*\(/, 'logger.info(');
411
+ }
412
+ return line;
413
+ }).join('\n');
414
+
415
+ return changed ? result : null;
416
+ }
417
+
418
+ // 17. Add return type to exported TypeScript functions
419
+ export function fixTsReturnType(content) {
420
+ const modified = content.replace(
421
+ /^(export\s+(?:async\s+)?function\s+\w+\s*\([^)]*\))\s*\{/gm,
422
+ (match, sig) => {
423
+ // Skip if already has return type annotation
424
+ if (/\)\s*:\s*\S/.test(sig)) return match;
425
+ return `${sig}: void {`;
426
+ },
427
+ );
428
+ return modified === content ? null : modified;
429
+ }
430
+
431
+ // 18. Convert var to const/let
432
+ export function fixVarToConstLet(content) {
433
+ const lines = content.split('\n');
434
+ let changed = false;
435
+
436
+ const result = lines.map((line) => {
437
+ // Skip comments
438
+ if (/^\s*\/\//.test(line) || /^\s*\*/.test(line)) return line;
439
+ // Replace var with const for assignments that look like constants
440
+ const varMatch = line.match(/^(\s*)var\s+(\w+)\s*=/);
441
+ if (varMatch) {
442
+ changed = true;
443
+ // Use const by default (safer); let if reassigned later is a manual step
444
+ return line.replace(/\bvar\s+/, 'const ');
445
+ }
446
+ // var without assignment (declaration only) => let
447
+ const varDeclMatch = line.match(/^(\s*)var\s+(\w+)\s*;/);
448
+ if (varDeclMatch) {
449
+ changed = true;
450
+ return line.replace(/\bvar\s+/, 'let ');
451
+ }
452
+ return line;
453
+ }).join('\n');
454
+
455
+ return changed ? result : null;
456
+ }
457
+
458
+ // 19. Add error parameter to empty catch blocks
459
+ export function fixEmptyCatch(content) {
460
+ const modified = content.replace(
461
+ /catch\s*\(\s*\)\s*\{/g,
462
+ 'catch (error) {',
463
+ );
464
+ return modified === content ? null : modified;
465
+ }
466
+
467
+ // 20. Convert callback-style to async/await (fs.readFile as common case)
468
+ export function fixCallbackToAsync(content) {
469
+ // Convert fs.readFile(path, encoding, (err, data) => { ... }) to await
470
+ const modified = content.replace(
471
+ /fs\.readFile\(\s*([^,]+),\s*(['"][^'"]+['"])\s*,\s*(?:function\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)|(?:\(\s*(\w+)\s*,\s*(\w+)\s*\)|\(\s*(\w+)\s*,\s*(\w+)\s*\))\s*=>)\s*\{/g,
472
+ (match, path, encoding, err1, data1, err2, data2, err3, data3) => {
473
+ const dataVar = data1 || data2 || data3 || 'data';
474
+ return `const ${dataVar} = await fs.promises.readFile(${path}, ${encoding});\n{`;
475
+ },
476
+ );
477
+ return modified === content ? null : modified;
478
+ }
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // Advanced JS/TS auto-fix functions (21-40)
482
+ // ---------------------------------------------------------------------------
483
+
484
+ // 21. Replace dangerouslySetInnerHTML={{__html: var}} with DOMPurify.sanitize wrapper
485
+ export function fixDangerouslySetInnerHTML(content) {
486
+ const modified = content.replace(
487
+ /dangerouslySetInnerHTML\s*=\s*\{\s*\{\s*__html\s*:\s*([^}]+)\}\s*\}/g,
488
+ (match, variable) => {
489
+ const trimmed = variable.trim();
490
+ if (/DOMPurify\.sanitize/.test(trimmed)) return match;
491
+ return `dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(${trimmed}) }}`;
492
+ },
493
+ );
494
+ return modified === content ? null : modified;
495
+ }
496
+
497
+ // 22. Replace document.write(var) with textContent assignment
498
+ export function fixDocumentWrite(content) {
499
+ const modified = content.replace(
500
+ /document\.write\(([^)]+)\)/g,
501
+ (match, arg) => {
502
+ const trimmed = arg.trim();
503
+ return `document.body.textContent = ${trimmed} /* SECURITY: replaced unsafe document.write */`;
504
+ },
505
+ );
506
+ return modified === content ? null : modified;
507
+ }
508
+
509
+ // 23. Replace el.innerHTML = var with el.textContent = var
510
+ export function fixInnerHTML(content) {
511
+ const modified = content.replace(
512
+ /(\w+(?:\.\w+)*)\.innerHTML\s*=\s*([^;]+);/g,
513
+ (match, el, value) => {
514
+ const trimmed = value.trim();
515
+ if (/DOMPurify|sanitize|escapeHtml/.test(trimmed)) return match;
516
+ return `${el}.textContent = ${trimmed};`;
517
+ },
518
+ );
519
+ return modified === content ? null : modified;
520
+ }
521
+
522
+ // 24. Replace {$where: userInput} with safe MongoDB query
523
+ export function fixNoSqlInjection(content) {
524
+ const modified = content.replace(
525
+ /\{\s*\$where\s*:\s*([^}]+)\}/g,
526
+ (match, expr) => {
527
+ const trimmed = expr.trim();
528
+ return `{ /* SECURITY: replaced unsafe $where */ $expr: { $eq: [${trimmed}, true] } }`;
529
+ },
530
+ );
531
+ return modified === content ? null : modified;
532
+ }
533
+
534
+ // 25. Add null prototype to object merge to prevent prototype pollution
535
+ export function fixPrototypePollution(content) {
536
+ let modified = content;
537
+ modified = modified.replace(
538
+ /Object\.assign\(\s*\{\s*\}\s*,\s*([^)]+)\)/g,
539
+ (match, args) => {
540
+ if (/Object\.create\(null\)/.test(match)) return match;
541
+ return `Object.assign(Object.create(null), ${args})`;
542
+ },
543
+ );
544
+ return modified === content ? null : modified;
545
+ }
546
+
547
+ // 26. Replace string === comparison for secrets with crypto.timingSafeEqual
548
+ export function fixTimingAttack(content) {
549
+ const secretKeywords = /secret|token|apiKey|api_key|password|hash|signature|hmac|digest/i;
550
+ const lines = content.split('\n');
551
+ let changed = false;
552
+
553
+ const result = lines.map((line) => {
554
+ if (/^\s*\/\//.test(line) || /^\s*\*/.test(line)) return line;
555
+ if (secretKeywords.test(line) && /===/.test(line) && !/timingSafeEqual/.test(line)) {
556
+ const m = line.match(/(\w+)\s*===\s*(\w+)/);
557
+ if (m) {
558
+ changed = true;
559
+ return line.replace(
560
+ /(\w+)\s*===\s*(\w+)/,
561
+ `crypto.timingSafeEqual(Buffer.from(${m[1]}), Buffer.from(${m[2]}))`,
562
+ );
563
+ }
564
+ }
565
+ return line;
566
+ }).join('\n');
567
+
568
+ return changed ? result : null;
569
+ }
570
+
571
+ // 27. Add path.resolve + startsWith check around fs operations with user input
572
+ export function fixPathTraversal(content) {
573
+ const modified = content.replace(
574
+ /(fs\.(?:readFileSync|readFile|writeFileSync|writeFile|createReadStream|createWriteStream)\(\s*)(req\.(?:params|query|body)\.\w+|userPath|filePath|inputPath)/g,
575
+ (match, fsCall, pathVar) => {
576
+ return `/* SECURITY: path traversal guard */\n const safePath = path.resolve(__dirname, ${pathVar});\n if (!safePath.startsWith(path.resolve(__dirname))) throw new Error('Path traversal detected');\n ${fsCall}safePath`;
577
+ },
578
+ );
579
+ return modified === content ? null : modified;
580
+ }
581
+
582
+ // 28. Add safe-regex wrapper to new RegExp(userInput)
583
+ export function fixRegexDos(content) {
584
+ const modified = content.replace(
585
+ /new RegExp\((\s*(?:req\.(?:body|query|params)\.\w+|userInput|userPattern|pattern|input)\s*)\)/g,
586
+ (match, variable) => {
587
+ const trimmed = variable.trim();
588
+ return `/* SECURITY: sanitize regex input to prevent ReDoS */ new RegExp(${trimmed}.replace(/[.*+?^$\{\}()|[\\]\\\\]/g, '\\\\$&'))`;
589
+ },
590
+ );
591
+ return modified === content ? null : modified;
592
+ }
593
+
594
+ // 29. Add URL validation/whitelist before res.redirect(userInput)
595
+ export function fixOpenRedirect(content) {
596
+ const modified = content.replace(
597
+ /res\.redirect\(\s*(req\.(?:query|body|params)\.\w+|redirectUrl|returnUrl)\s*\)/g,
598
+ (match, variable) => {
599
+ return `/* SECURITY: validate redirect URL to prevent open redirect */\n const allowedHosts = [process.env.APP_HOST || 'localhost'];\n const redirectTarget = new URL(${variable}, \`https://\${allowedHosts[0]}\`);\n if (!allowedHosts.includes(redirectTarget.hostname)) { return res.redirect('/'); }\n res.redirect(redirectTarget.pathname)`;
600
+ },
601
+ );
602
+ return modified === content ? null : modified;
603
+ }
604
+
605
+ // 30. Add express-rate-limit to route handlers
606
+ export function fixNoRateLimit(content) {
607
+ if (!/\.(get|post|put|delete|patch)\s*\(/.test(content)) return null;
608
+ if (/rateLimit|rate.?limit/i.test(content)) return null;
609
+ if (!/express|router|app/.test(content)) return null;
610
+
611
+ let modified = content;
612
+ if (/require/.test(modified)) {
613
+ modified = `const rateLimit = require('express-rate-limit');\nconst limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100 });\n${modified}`;
614
+ } else {
615
+ modified = `import rateLimit from 'express-rate-limit';\nconst limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100 });\n${modified}`;
616
+ }
617
+ return modified === content ? null : modified;
618
+ }
619
+
620
+ // 31. Replace req.query.x in response with escaped version
621
+ export function fixXssQueryParam(content) {
622
+ const modified = content.replace(
623
+ /(res\.(?:send|write|end)\(\s*)(req\.query\.\w+)(\s*\))/g,
624
+ (match, prefix, param, suffix) => {
625
+ return `${prefix}String(${param}).replace(/[<>&"']/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;' })[c])${suffix}`;
626
+ },
627
+ );
628
+ return modified === content ? null : modified;
629
+ }
630
+
631
+ // 32. Replace hardcoded 0.0.0.0 with process.env.HOST
632
+ export function fixHardcodedIp(content) {
633
+ const modified = content.replace(
634
+ /(['"])0\.0\.0\.0\1/g,
635
+ "process.env.HOST || '127.0.0.1'",
636
+ );
637
+ return modified === content ? null : modified;
638
+ }
639
+
640
+ // 33. Add CSP directives to existing helmet() call
641
+ export function fixNoHelmetCsp(content) {
642
+ if (!/helmet\(\s*\)/.test(content)) return null;
643
+ if (/contentSecurityPolicy/.test(content)) return null;
644
+
645
+ const modified = content.replace(
646
+ /helmet\(\s*\)/g,
647
+ `helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], objectSrc: ["'none'"], upgradeInsecureRequests: [] } } })`,
648
+ );
649
+ return modified === content ? null : modified;
650
+ }
651
+
652
+ // 34. Replace string concat SQL with parameterized query
653
+ export function fixSqlConcat(content) {
654
+ const modified = content.replace(
655
+ /(['"])(\s*SELECT\s+.+?FROM\s+\w+\s+WHERE\s+\w+\s*=\s*)['"]\s*\+\s*(\w+)/gi,
656
+ (match, quote, sqlPart, variable) => {
657
+ return `${quote}${sqlPart}?${quote}, [${variable}]`;
658
+ },
659
+ );
660
+ return modified === content ? null : modified;
661
+ }
662
+
663
+ // 35. Replace localStorage JWT storage with httpOnly cookie
664
+ export function fixJwtStorageLocal(content) {
665
+ const modified = content.replace(
666
+ /localStorage\.setItem\(\s*(['"])(?:jwt|token|accessToken|access_token|authToken|auth_token)\1\s*,\s*([^)]+)\)/g,
667
+ (match, quote, tokenVar) => {
668
+ return `/* SECURITY: Do NOT store JWTs in localStorage - use httpOnly cookies instead */\n // localStorage.setItem(${quote}token${quote}, ${tokenVar});\n document.cookie = 'token=' + ${tokenVar} + '; Secure; SameSite=Strict; path=/'`;
669
+ },
670
+ );
671
+ return modified === content ? null : modified;
672
+ }
673
+
674
+ // 36. Replace origin: '*' with specific origins array
675
+ export function fixCorsWildcard(content) {
676
+ const modified = content.replace(
677
+ /origin\s*:\s*(['"])\*\1/g,
678
+ "origin: [process.env.ALLOWED_ORIGIN || 'https://yourdomain.com']",
679
+ );
680
+ return modified === content ? null : modified;
681
+ }
682
+
683
+ // 37. Add basic input validation stub for req.body
684
+ export function fixNoInputValidation(content) {
685
+ if (!/req\.body/.test(content)) return null;
686
+ if (/validate|schema|zod|joi|yup|ajv/i.test(content)) return null;
687
+ if (!/express|router|app/.test(content)) return null;
688
+
689
+ let modified = content;
690
+ modified = modified.replace(
691
+ /((?:app|router)\.(?:post|put|patch)\([^)]+,\s*(?:async\s+)?(?:function\s*)?\([^)]*\)\s*(?:=>)?\s*\{[^}]*)(req\.body)/,
692
+ (match, before, reqBody) => {
693
+ return `${before}/* TODO: Add input validation, e.g.:\n const schema = Joi.object({ name: Joi.string().required() });\n const { error, value } = schema.validate(req.body);\n if (error) return res.status(400).json({ error: error.message });\n */\n ${reqBody}`;
694
+ },
695
+ );
696
+ return modified === content ? null : modified;
697
+ }
698
+
699
+ // 38. Replace console.error in catch with proper logger
700
+ export function fixConsoleError(content) {
701
+ const lines = content.split('\n');
702
+ let changed = false;
703
+
704
+ const result = lines.map((line) => {
705
+ if (/^\s*\/\//.test(line)) return line;
706
+ if (/\bconsole\.error\s*\(/.test(line)) {
707
+ changed = true;
708
+ return line.replace(/\bconsole\.error\s*\(/, 'logger.error(');
709
+ }
710
+ return line;
711
+ }).join('\n');
712
+
713
+ return changed ? result : null;
714
+ }
715
+
716
+ // 39. Replace await-in-loop with Promise.all pattern
717
+ export function fixAwaitInLoop(content) {
718
+ const modified = content.replace(
719
+ /for\s*\(\s*(?:const|let|var)\s+(\w+)\s+of\s+(\w+)\s*\)\s*\{([^}]*await\s+(\w+)\(([^)]*)\)[^}]*)\}/gs,
720
+ (match, item, collection, body) => {
721
+ if ((body.match(/await/g) || []).length > 1) return match;
722
+ return `/* PERF: parallelized sequential awaits */\nawait Promise.all(${collection}.map(async (${item}) => {\n${body}\n}))`;
723
+ },
724
+ );
725
+ return modified === content ? null : modified;
726
+ }
727
+
728
+ // 40. Add Express error handler middleware at end of app
729
+ export function fixMissingErrorHandler(content) {
730
+ if (!/express\(\)/.test(content)) return null;
731
+ if (/err\s*,\s*req\s*,\s*res\s*,\s*next/.test(content)) return null;
732
+
733
+ let modified = content;
734
+ const errorHandler = `\n// SECURITY: Global error handler - do not leak stack traces\napp.use((err, req, res, next) => {\n logger.error(err.message);\n res.status(err.status || 500).json({ error: 'Internal server error' });\n});\n`;
735
+
736
+ if (/module\.exports/.test(modified)) {
737
+ modified = modified.replace(
738
+ /(module\.exports)/,
739
+ `${errorHandler}\n$1`,
740
+ );
741
+ } else if (/export\s+default/.test(modified)) {
742
+ modified = modified.replace(
743
+ /(export\s+default)/,
744
+ `${errorHandler}\n$1`,
745
+ );
746
+ } else {
747
+ modified = modified + errorHandler;
748
+ }
749
+ return modified === content ? null : modified;
750
+ }
751
+
752
+ // ---------------------------------------------------------------------------
753
+ // Python auto-fix functions
754
+ // ---------------------------------------------------------------------------
755
+
756
+ // P1. Replace DEBUG = True with env-based config
757
+ export function fixPythonDebugTrue(content) {
758
+ const modified = content.replace(
759
+ /^(\s*)DEBUG\s*=\s*True\s*$/gm,
760
+ `$1DEBUG = os.environ.get('DEBUG', 'False') == 'True'`,
761
+ );
762
+ return modified === content ? null : modified;
763
+ }
764
+
765
+ // P2. Replace hardcoded SECRET_KEY with env variable
766
+ export function fixPythonHardcodedSecret(content) {
767
+ const modified = content.replace(
768
+ /^(\s*)SECRET_KEY\s*=\s*(['"])(?!os\.environ)[^'"]+\2\s*$/gm,
769
+ `$1SECRET_KEY = os.environ['SECRET_KEY']`,
770
+ );
771
+ return modified === content ? null : modified;
772
+ }
773
+
774
+ // P3. Replace subprocess.call(cmd, shell=True) with shlex.split
775
+ export function fixPythonShellTrue(content) {
776
+ const modified = content.replace(
777
+ /subprocess\.call\((\w+),\s*shell\s*=\s*True\)/g,
778
+ 'subprocess.call(shlex.split($1))',
779
+ );
780
+ return modified === content ? null : modified;
781
+ }
782
+
783
+ // P4. Replace eval(user_input) with ast.literal_eval
784
+ export function fixPythonEval(content) {
785
+ const modified = content.replace(
786
+ /\beval\((\w+)\)/g,
787
+ 'ast.literal_eval($1)',
788
+ );
789
+ return modified === content ? null : modified;
790
+ }
791
+
792
+ // P5. Replace pickle.loads(data) with json.loads(data)
793
+ export function fixPythonPickleLoad(content) {
794
+ const modified = content.replace(
795
+ /pickle\.loads\((\w+)\)/g,
796
+ 'json.loads($1) # SECURITY: replaced unsafe pickle.loads with json.loads',
797
+ );
798
+ return modified === content ? null : modified;
799
+ }
800
+
801
+ // P6. Replace yaml.load(data) with yaml.safe_load(data)
802
+ export function fixPythonYamlLoad(content) {
803
+ const modified = content.replace(
804
+ /yaml\.load\(([^)]+)\)/g,
805
+ (match, args) => {
806
+ // Don't replace if already safe_load
807
+ if (match.includes('safe_load')) return match;
808
+ return `yaml.safe_load(${args})`;
809
+ },
810
+ );
811
+ return modified === content ? null : modified;
812
+ }
813
+
814
+ // P7. Replace hashlib.md5( with hashlib.sha256(
815
+ export function fixPythonMd5(content) {
816
+ const modified = content.replace(
817
+ /hashlib\.md5\(/g,
818
+ 'hashlib.sha256(',
819
+ );
820
+ return modified === content ? null : modified;
821
+ }
822
+
823
+ // P8. Replace hashlib.sha1( with hashlib.sha256(
824
+ export function fixPythonSha1(content) {
825
+ const modified = content.replace(
826
+ /hashlib\.sha1\(/g,
827
+ 'hashlib.sha256(',
828
+ );
829
+ return modified === content ? null : modified;
830
+ }
831
+
832
+ // P9. Replace verify=False with verify=True in requests calls
833
+ export function fixPythonRequestsNoVerify(content) {
834
+ const modified = content.replace(
835
+ /(requests\.(?:get|post|put|patch|delete|head|options)\([^)]*)\bverify\s*=\s*False/g,
836
+ '$1verify=True',
837
+ );
838
+ return modified === content ? null : modified;
839
+ }
840
+
841
+ // P10. Add timeout=30 to requests.get/post calls missing timeout
842
+ export function fixPythonRequestsNoTimeout(content) {
843
+ const modified = content.replace(
844
+ /(requests\.(?:get|post|put|patch|delete)\([^)]*)\)(?!.*timeout)/g,
845
+ (match, prefix) => {
846
+ if (/timeout/.test(match)) return match;
847
+ // Check if there are existing kwargs
848
+ const hasArgs = prefix.includes(',');
849
+ return hasArgs ? `${prefix}, timeout=30)` : `${prefix}, timeout=30)`;
850
+ },
851
+ );
852
+ return modified === content ? null : modified;
853
+ }
854
+
855
+ // P11. Replace os.system(cmd) with subprocess.run(shlex.split(cmd), check=True)
856
+ export function fixPythonOsSystem(content) {
857
+ const modified = content.replace(
858
+ /os\.system\((\w+)\)/g,
859
+ 'subprocess.run(shlex.split($1), check=True)',
860
+ );
861
+ return modified === content ? null : modified;
862
+ }
863
+
864
+ // P12. Replace random.randint with secrets.randbelow in security contexts
865
+ export function fixPythonInsecureRandom(content) {
866
+ const lines = content.split('\n');
867
+ let changed = false;
868
+ const securityKeywords = /token|secret|key|password|auth|session|csrf|nonce|salt|otp|pin|code/i;
869
+
870
+ const result = lines.map((line) => {
871
+ if (/random\.randint\(/.test(line) && securityKeywords.test(line)) {
872
+ changed = true;
873
+ return line.replace(
874
+ /random\.randint\(\s*\d+\s*,\s*(\w+|\d+)\s*\)/g,
875
+ 'secrets.randbelow($1)',
876
+ );
877
+ }
878
+ return line;
879
+ }).join('\n');
880
+
881
+ return changed ? result : null;
882
+ }
883
+
884
+ // P13. Replace cursor.execute("SELECT... %s" % var) with parameterized query
885
+ export function fixPythonSqlFormat(content) {
886
+ const modified = content.replace(
887
+ /cursor\.execute\(\s*"([^"]*%s[^"]*)"\s*%\s*(\w+)\s*\)/g,
888
+ 'cursor.execute("$1", ($2,))',
889
+ );
890
+ return modified === content ? null : modified;
891
+ }
892
+
893
+ // P14. Replace app.run(debug=True) with env-based debug
894
+ export function fixPythonFlaskDebug(content) {
895
+ const modified = content.replace(
896
+ /app\.run\(([^)]*)debug\s*=\s*True([^)]*)\)/g,
897
+ `app.run($1debug=os.environ.get('FLASK_DEBUG', False)$2)`,
898
+ );
899
+ return modified === content ? null : modified;
900
+ }
901
+
902
+ // P15. Replace tempfile.mktemp() with tempfile.mkstemp()
903
+ export function fixPythonMktemp(content) {
904
+ const modified = content.replace(
905
+ /tempfile\.mktemp\(\)/g,
906
+ 'tempfile.mkstemp()',
907
+ );
908
+ return modified === content ? null : modified;
909
+ }
910
+
911
+ // P16. Add Py2/Py3 safety comment for input()
912
+ export function fixPythonInputPy2(content) {
913
+ const modified = content.replace(
914
+ /^(\s*)(\w+\s*=\s*)input\(([^)]*)\)/gm,
915
+ '$1$2input($3) # NOTE: In Python 2, use raw_input() instead of input() for safety',
916
+ );
917
+ return modified === content ? null : modified;
918
+ }
919
+
920
+ // P17. Replace assert for security checks with proper if/raise
921
+ export function fixPythonAssertSecurity(content) {
922
+ const modified = content.replace(
923
+ /^(\s*)assert\s+(user\.\w+|is_admin|is_authenticated|has_permission\([^)]*\))/gm,
924
+ '$1if not $2:\n$1 raise PermissionError("Access denied")',
925
+ );
926
+ return modified === content ? null : modified;
927
+ }
928
+
929
+ // P18. Replace 0.0.0.0 with 127.0.0.1 in bind calls
930
+ export function fixPythonBindAllInterfaces(content) {
931
+ const modified = content.replace(
932
+ /(\.(?:bind|run|listen)\([^)]*)(["'])0\.0\.0\.0\2/g,
933
+ '$1$2127.0.0.1$2',
934
+ );
935
+ return modified === content ? null : modified;
936
+ }
937
+
938
+ // P19. Replace etree.parse( with defusedxml.parse(
939
+ export function fixPythonXmlParse(content) {
940
+ const modified = content.replace(
941
+ /etree\.parse\(/g,
942
+ 'defusedxml.parse(',
943
+ );
944
+ return modified === content ? null : modified;
945
+ }
946
+
947
+ // P20. Replace render_template_string(user_data) with safe version
948
+ export function fixPythonRenderTemplateString(content) {
949
+ const modified = content.replace(
950
+ /render_template_string\((\w+)\)/g,
951
+ 'render_template_string(safe_template, data=$1)',
952
+ );
953
+ return modified === content ? null : modified;
954
+ }
955
+
956
+ // ---------------------------------------------------------------------------
957
+ // Go auto-fix functions
958
+ // ---------------------------------------------------------------------------
959
+
960
+ // G1. Replace db.Query(fmt.Sprintf("...%s", var)) with parameterized query
961
+ export function fixGoFmtSprintfSql(content) {
962
+ const modified = content.replace(
963
+ /db\.Query\(\s*fmt\.Sprintf\(\s*"([^"]*?)%s([^"]*?)"\s*,\s*(\w+)\s*\)\s*\)/g,
964
+ (match, before, after, variable) => {
965
+ return `db.Query("${before}$1${after}", ${variable})`;
966
+ },
967
+ );
968
+ return modified === content ? null : modified;
969
+ }
970
+
971
+ // G2. Replace InsecureSkipVerify: true with false
972
+ export function fixGoInsecureSkipVerify(content) {
973
+ const modified = content.replace(
974
+ /InsecureSkipVerify:\s*true/g,
975
+ 'InsecureSkipVerify: false',
976
+ );
977
+ return modified === content ? null : modified;
978
+ }
979
+
980
+ // G3. Replace crypto/md5 import with crypto/sha256
981
+ export function fixGoMd5Import(content) {
982
+ const modified = content.replace(
983
+ /"crypto\/md5"/g,
984
+ '"crypto/sha256"',
985
+ );
986
+ return modified === content ? null : modified;
987
+ }
988
+
989
+ // G4. Replace result, _ := someFunc() with result, err := someFunc() + error check
990
+ export function fixGoIgnoredError(content) {
991
+ const modified = content.replace(
992
+ /^(\s*)(\w+),\s*_\s*:=\s*(.+)$/gm,
993
+ (match, indent, result, call) => {
994
+ return `${indent}${result}, err := ${call}\n${indent}if err != nil {\n${indent}\treturn err\n${indent}}`;
995
+ },
996
+ );
997
+ return modified === content ? null : modified;
998
+ }
999
+
1000
+ // G5. Add Timeout to &http.Client{}
1001
+ export function fixGoHttpNoTimeout(content) {
1002
+ const modified = content.replace(
1003
+ /&http\.Client\{\s*\}/g,
1004
+ '&http.Client{Timeout: 30 * time.Second}',
1005
+ );
1006
+ return modified === content ? null : modified;
1007
+ }
1008
+
1009
+ // G6. Replace :8080 with 127.0.0.1:8080 in net.Listen
1010
+ export function fixGoBindAllInterfaces(content) {
1011
+ const modified = content.replace(
1012
+ /(net\.Listen\(\s*"[^"]*"\s*,\s*"):(\d+"\s*\))/g,
1013
+ '$1127.0.0.1:$2',
1014
+ );
1015
+ return modified === content ? null : modified;
1016
+ }
1017
+
1018
+ // G7. Replace template.HTML(userInput) with template.HTMLEscapeString(userInput)
1019
+ export function fixGoTemplateHtml(content) {
1020
+ const modified = content.replace(
1021
+ /template\.HTML\((\w+)\)/g,
1022
+ 'template.HTMLEscapeString($1)',
1023
+ );
1024
+ return modified === content ? null : modified;
1025
+ }
1026
+
1027
+ // G8. Add comment warning when exec.Command uses variable
1028
+ export function fixGoExecCommand(content) {
1029
+ const lines = content.split('\n');
1030
+ let changed = false;
1031
+ const result = lines.map((line) => {
1032
+ if (/exec\.Command\(/.test(line) && !/exec\.Command\(\s*"[^"]*"\s*\)/.test(line) && !/SECURITY/.test(line)) {
1033
+ changed = true;
1034
+ return `/* SECURITY: exec.Command with variable input — validate/sanitize before use */ ${line}`;
1035
+ }
1036
+ return line;
1037
+ }).join('\n');
1038
+ return changed ? result : null;
1039
+ }
1040
+
1041
+ // G9. Replace math/rand with crypto/rand in security contexts
1042
+ export function fixGoWeakRand(content) {
1043
+ const modified = content.replace(
1044
+ /"math\/rand"/g,
1045
+ '"crypto/rand"',
1046
+ );
1047
+ return modified === content ? null : modified;
1048
+ }
1049
+
1050
+ // G10. Replace defer f.Close() with defer func() { _ = f.Close() }()
1051
+ export function fixGoDeferClose(content) {
1052
+ const modified = content.replace(
1053
+ /defer\s+(\w+)\.Close\(\)/g,
1054
+ 'defer func() { _ = $1.Close() }()',
1055
+ );
1056
+ return modified === content ? null : modified;
1057
+ }
1058
+
1059
+ // ---------------------------------------------------------------------------
1060
+ // Ruby auto-fix functions
1061
+ // ---------------------------------------------------------------------------
1062
+
1063
+ // R1. Replace find_by_sql("...#{var}") with parameterized version
1064
+ export function fixRubyFindBySql(content) {
1065
+ const modified = content.replace(
1066
+ /find_by_sql\(\s*"([^"]*?)#\{(\w+)\}([^"]*?)"\s*\)/g,
1067
+ (match, before, variable, after) => {
1068
+ return `find_by_sql(["${before}?${after}", ${variable}])`;
1069
+ },
1070
+ );
1071
+ return modified === content ? null : modified;
1072
+ }
1073
+
1074
+ // R2. Replace .html_safe with sanitize() wrapper
1075
+ export function fixRubyHtmlSafe(content) {
1076
+ const modified = content.replace(
1077
+ /(\w+(?:\.\w+)*)\.html_safe/g,
1078
+ 'sanitize($1)',
1079
+ );
1080
+ return modified === content ? null : modified;
1081
+ }
1082
+
1083
+ // R3. Replace system("cmd #{var}") with system("cmd", var)
1084
+ export function fixRubySystemCall(content) {
1085
+ const modified = content.replace(
1086
+ /system\(\s*"([^"]*?)#\{(\w+)\}([^"]*?)"\s*\)/g,
1087
+ (match, before, variable, after) => {
1088
+ const cmd = (before + after).trim();
1089
+ return `system("${cmd}", ${variable})`;
1090
+ },
1091
+ );
1092
+ return modified === content ? null : modified;
1093
+ }
1094
+
1095
+ // R4. Replace YAML.load( with YAML.safe_load(
1096
+ export function fixRubyYamlLoad(content) {
1097
+ const modified = content.replace(
1098
+ /YAML\.load\(/g,
1099
+ 'YAML.safe_load(',
1100
+ );
1101
+ return modified === content ? null : modified;
1102
+ }
1103
+
1104
+ // R5. Add warning comment to Marshal.load usage
1105
+ export function fixRubyMarshalLoad(content) {
1106
+ const lines = content.split('\n');
1107
+ let changed = false;
1108
+ const result = lines.map((line) => {
1109
+ if (/Marshal\.load\(/.test(line) && !/SECURITY/.test(line)) {
1110
+ changed = true;
1111
+ return `# SECURITY WARNING: Marshal.load can execute arbitrary code — use JSON.parse instead\n${line}`;
1112
+ }
1113
+ return line;
1114
+ }).join('\n');
1115
+ return changed ? result : null;
1116
+ }
1117
+
1118
+ // R6. Replace permit! with permit(:specific_fields) + TODO comment
1119
+ export function fixRubyPermitAll(content) {
1120
+ const modified = content.replace(
1121
+ /\.permit!/g,
1122
+ '.permit(:id) # TODO: replace :id with actual permitted fields',
1123
+ );
1124
+ return modified === content ? null : modified;
1125
+ }
1126
+
1127
+ // R7. Comment out skip_forgery_protection with warning
1128
+ export function fixRubySkipCsrf(content) {
1129
+ const modified = content.replace(
1130
+ /^(\s*)(skip_forgery_protection.*)/gm,
1131
+ '$1# SECURITY WARNING: CSRF protection disabled — re-enable in production\n$1# $2',
1132
+ );
1133
+ return modified === content ? null : modified;
1134
+ }
1135
+
1136
+ // R8. Replace eval(user_input) with safer alternative + comment
1137
+ export function fixRubyEval(content) {
1138
+ const modified = content.replace(
1139
+ /\beval\((\w+)\)/g,
1140
+ '# SECURITY: eval is unsafe — use a sandboxed evaluator or whitelist approach\nJSON.parse($1) # TODO: replace with appropriate safe alternative',
1141
+ );
1142
+ return modified === content ? null : modified;
1143
+ }
1144
+
1145
+ // R9. Replace open(url) with URI.open(url)
1146
+ export function fixRubyOpenUri(content) {
1147
+ const modified = content.replace(
1148
+ /(?<!\w)(?<!\.)open\((\s*(?:url|uri|link|\w+_url|\w+_uri)\s*)\)/gi,
1149
+ 'URI.open($1)',
1150
+ );
1151
+ return modified === content ? null : modified;
1152
+ }
1153
+
1154
+ // R10. Replace Digest::MD5 with Digest::SHA256
1155
+ export function fixRubyWeakHash(content) {
1156
+ const modified = content.replace(
1157
+ /Digest::MD5/g,
1158
+ 'Digest::SHA256',
1159
+ );
1160
+ return modified === content ? null : modified;
1161
+ }
1162
+
1163
+ // ---------------------------------------------------------------------------
1164
+ // PHP auto-fix functions
1165
+ // ---------------------------------------------------------------------------
1166
+
1167
+ // P1. Replace mysql_query("...{$var}") with PDO prepared statement
1168
+ export function fixPhpMysqlQuery(content) {
1169
+ const modified = content.replace(
1170
+ /mysql_query\(\s*"([^"]*?)\{\$(\w+)\}([^"]*?)"\s*\)/g,
1171
+ (match, before, variable, after) => {
1172
+ return `$stmt = $pdo->prepare("${before}?${after}"); $stmt->execute([$${variable}])`;
1173
+ },
1174
+ );
1175
+ return modified === content ? null : modified;
1176
+ }
1177
+
1178
+ // P2. Replace echo $_GET['x'] with echo htmlspecialchars($_GET['x'])
1179
+ export function fixPhpEcho(content) {
1180
+ const modified = content.replace(
1181
+ /echo\s+(\$_(?:GET|POST|REQUEST)\s*\[\s*['"][^'"]+['"]\s*\])/g,
1182
+ (match, variable) => {
1183
+ if (match.includes('htmlspecialchars')) return match;
1184
+ return `echo htmlspecialchars(${variable}, ENT_QUOTES, 'UTF-8')`;
1185
+ },
1186
+ );
1187
+ return modified === content ? null : modified;
1188
+ }
1189
+
1190
+ // P3. Add security warning comment to eval() usage (PHP)
1191
+ export function fixPhpEval(content) {
1192
+ const lines = content.split('\n');
1193
+ let changed = false;
1194
+ const result = lines.map((line) => {
1195
+ if (/\beval\s*\(/.test(line) && !/SECURITY/.test(line)) {
1196
+ changed = true;
1197
+ return `/* SECURITY WARNING: eval() executes arbitrary code — remove or replace with safe alternative */ ${line}`;
1198
+ }
1199
+ return line;
1200
+ }).join('\n');
1201
+ return changed ? result : null;
1202
+ }
1203
+
1204
+ // P4. Replace exec($cmd) with escapeshellarg() wrapped version
1205
+ export function fixPhpExec(content) {
1206
+ const modified = content.replace(
1207
+ /\bexec\(\s*(\$\w+)\s*\)/g,
1208
+ 'exec(escapeshellcmd(escapeshellarg($1)))',
1209
+ );
1210
+ return modified === content ? null : modified;
1211
+ }
1212
+
1213
+ // P5. Replace md5($password) with password_hash($password, PASSWORD_DEFAULT)
1214
+ export function fixPhpMd5Password(content) {
1215
+ const modified = content.replace(
1216
+ /md5\(\s*(\$password|\$pass|\$passwd|\$pwd)\s*\)/g,
1217
+ 'password_hash($1, PASSWORD_DEFAULT)',
1218
+ );
1219
+ return modified === content ? null : modified;
1220
+ }
1221
+
1222
+ // P6. Replace extract($_POST) with explicit variable assignment
1223
+ export function fixPhpExtract(content) {
1224
+ const modified = content.replace(
1225
+ /extract\(\s*(\$_(POST|GET|REQUEST))\s*\)/g,
1226
+ (match, superglobal) => {
1227
+ return `/* SECURITY: extract() replaced — assign variables explicitly */\n// TODO: Replace with explicit assignments, e.g.: $name = ${superglobal}['name'];\n// extract(${superglobal})`;
1228
+ },
1229
+ );
1230
+ return modified === content ? null : modified;
1231
+ }
1232
+
1233
+ // P7. Replace == with === in auth contexts (PHP)
1234
+ export function fixPhpLooseComparison(content) {
1235
+ const authKeywords = /password|token|secret|auth|session|user|role|admin|login|hash/i;
1236
+ const lines = content.split('\n');
1237
+ let changed = false;
1238
+ const result = lines.map((line) => {
1239
+ if (/^\s*\/\//.test(line) || /^\s*\/\*/.test(line) || /^\s*\*/.test(line) || /^\s*#/.test(line)) return line;
1240
+ if (authKeywords.test(line) && /[^!=<>]==[^=]/.test(line)) {
1241
+ changed = true;
1242
+ return line.replace(/([^!=<>])={2}([^=])/g, '$1===$2');
1243
+ }
1244
+ return line;
1245
+ }).join('\n');
1246
+ return changed ? result : null;
1247
+ }
1248
+
1249
+ // P8. Add session.cookie_httponly and session.cookie_secure
1250
+ export function fixPhpSessionConfig(content) {
1251
+ if (!/session_start\s*\(/.test(content)) return null;
1252
+ if (/cookie_httponly/.test(content) && /cookie_secure/.test(content)) return null;
1253
+ const modified = content.replace(
1254
+ /session_start\s*\(\s*\)/g,
1255
+ "ini_set('session.cookie_httponly', 1); ini_set('session.cookie_secure', 1); session_start()",
1256
+ );
1257
+ return modified === content ? null : modified;
1258
+ }
1259
+
1260
+ // P9. Add ['allowed_classes' => false] to unserialize()
1261
+ export function fixPhpUnserialize(content) {
1262
+ const modified = content.replace(
1263
+ /unserialize\(\s*(\$\w+)\s*\)/g,
1264
+ "unserialize($1, ['allowed_classes' => false])",
1265
+ );
1266
+ return modified === content ? null : modified;
1267
+ }
1268
+
1269
+ // P10. Add warning to include($_GET[...]) with whitelist suggestion
1270
+ export function fixPhpInclude(content) {
1271
+ const modified = content.replace(
1272
+ /^(\s*)((?:include|require)(?:_once)?\s*\(\s*\$_(?:GET|POST|REQUEST)\s*\[.*?\]\s*\))/gm,
1273
+ '$1/* SECURITY: Never include files from user input — use a whitelist of allowed paths */\n$1// $2',
1274
+ );
1275
+ return modified === content ? null : modified;
1276
+ }
1277
+
1278
+ /**
1279
+ * Registry of all pattern-based fix functions, keyed by fix ID.
1280
+ */
1281
+ export const patternFixes = {
1282
+ 'fix-eval': { fn: fixEval, tier: 2, title: 'Replace eval() with safer alternative' },
1283
+ 'fix-math-random': { fn: fixMathRandom, tier: 1, title: 'Replace Math.random() with crypto.randomUUID()' },
1284
+ 'fix-cookie-flags': { fn: fixCookieFlags, tier: 1, title: 'Add httpOnly/secure/sameSite to cookies' },
1285
+ 'fix-http-urls': { fn: fixHttpUrls, tier: 1, title: 'Replace http:// with https://' },
1286
+ 'fix-helmet-missing': { fn: fixHelmetMissing, tier: 1, title: 'Add helmet() middleware' },
1287
+ 'fix-loose-equality': { fn: fixLooseEquality, tier: 1, title: 'Replace == with === in auth comparisons' },
1288
+ 'fix-jwt-verify-alg': { fn: fixJwtVerifyAlgorithm, tier: 1, title: 'Add algorithms option to jwt.verify()' },
1289
+ 'fix-weak-hash': { fn: fixWeakHash, tier: 1, title: 'Replace MD5/SHA1 with SHA256' },
1290
+ 'fix-sql-terminator': { fn: fixSqlTerminator, tier: 2, title: 'Add SQL comment terminator' },
1291
+ 'fix-exec-to-execfile': { fn: fixExecToExecFile, tier: 2, title: 'Replace exec() with execFile()' },
1292
+ 'fix-jwt-sign-expiry': { fn: fixJwtSignExpiry, tier: 1, title: 'Add expiresIn to jwt.sign()' },
1293
+ 'fix-rate-limit': { fn: fixRateLimitMissing, tier: 2, title: 'Add rate-limit middleware' },
1294
+ 'fix-json-parse-body': { fn: fixJsonParseReqBody, tier: 2, title: 'Replace JSON.parse(req.body) with body-parser' },
1295
+ 'fix-sql-escape': { fn: fixSqlEscape, tier: 2, title: 'Add escape() to SQL user input' },
1296
+ 'fix-csp-header': { fn: fixContentSecurityPolicy, tier: 1, title: 'Add Content-Security-Policy via helmet' },
1297
+ 'fix-console-log': { fn: fixConsoleLog, tier: 1, title: 'Replace console.log with logger' },
1298
+ 'fix-ts-return-type': { fn: fixTsReturnType, tier: 1, title: 'Add return type to exported TS functions' },
1299
+ 'fix-var-to-const': { fn: fixVarToConstLet, tier: 1, title: 'Convert var to const/let' },
1300
+ 'fix-empty-catch': { fn: fixEmptyCatch, tier: 1, title: 'Add error parameter to empty catch blocks' },
1301
+ 'fix-callback-to-async': { fn: fixCallbackToAsync, tier: 2, title: 'Convert callback-style to async/await' },
1302
+ 'fix-python-debug-true': { fn: fixPythonDebugTrue, tier: 1, title: 'Replace Python DEBUG = True with env-based config' },
1303
+ 'fix-python-hardcoded-secret': { fn: fixPythonHardcodedSecret, tier: 1, title: 'Replace hardcoded SECRET_KEY with os.environ' },
1304
+ 'fix-python-shell-true': { fn: fixPythonShellTrue, tier: 2, title: 'Replace subprocess shell=True with shlex.split' },
1305
+ 'fix-python-eval': { fn: fixPythonEval, tier: 2, title: 'Replace Python eval() with ast.literal_eval()' },
1306
+ 'fix-python-pickle-load': { fn: fixPythonPickleLoad, tier: 2, title: 'Replace pickle.loads() with json.loads()' },
1307
+ 'fix-python-yaml-load': { fn: fixPythonYamlLoad, tier: 1, title: 'Replace yaml.load() with yaml.safe_load()' },
1308
+ 'fix-python-md5': { fn: fixPythonMd5, tier: 1, title: 'Replace hashlib.md5 with hashlib.sha256' },
1309
+ 'fix-python-sha1': { fn: fixPythonSha1, tier: 1, title: 'Replace hashlib.sha1 with hashlib.sha256' },
1310
+ 'fix-python-requests-no-verify': { fn: fixPythonRequestsNoVerify, tier: 1, title: 'Replace verify=False with verify=True in requests' },
1311
+ 'fix-python-requests-no-timeout': { fn: fixPythonRequestsNoTimeout, tier: 1, title: 'Add timeout to requests calls' },
1312
+ 'fix-python-os-system': { fn: fixPythonOsSystem, tier: 2, title: 'Replace os.system() with subprocess.run()' },
1313
+ 'fix-python-insecure-random': { fn: fixPythonInsecureRandom, tier: 2, title: 'Replace random.randint with secrets.randbelow' },
1314
+ 'fix-python-sql-format': { fn: fixPythonSqlFormat, tier: 2, title: 'Replace string-formatted SQL with parameterized query' },
1315
+ 'fix-python-flask-debug': { fn: fixPythonFlaskDebug, tier: 1, title: 'Replace Flask debug=True with env-based config' },
1316
+ 'fix-python-mktemp': { fn: fixPythonMktemp, tier: 1, title: 'Replace tempfile.mktemp() with tempfile.mkstemp()' },
1317
+ 'fix-python-input-py2': { fn: fixPythonInputPy2, tier: 1, title: 'Add Python 2 safety comment for input()' },
1318
+ 'fix-python-assert-security': { fn: fixPythonAssertSecurity, tier: 2, title: 'Replace assert with proper permission check' },
1319
+ 'fix-python-bind-all-interfaces': { fn: fixPythonBindAllInterfaces, tier: 1, title: 'Replace 0.0.0.0 with 127.0.0.1 in bind calls' },
1320
+ 'fix-python-xml-parse': { fn: fixPythonXmlParse, tier: 2, title: 'Replace etree.parse with defusedxml.parse' },
1321
+ 'fix-python-render-template-string': { fn: fixPythonRenderTemplateString, tier: 2, title: 'Replace render_template_string with safe version' },
1322
+ // Go fixes
1323
+ 'fix-go-fmt-sprintf-sql': { fn: fixGoFmtSprintfSql, tier: 2, title: 'Replace fmt.Sprintf SQL with parameterized query' },
1324
+ 'fix-go-insecure-skip-verify': { fn: fixGoInsecureSkipVerify, tier: 1, title: 'Disable InsecureSkipVerify in TLS config' },
1325
+ 'fix-go-md5-import': { fn: fixGoMd5Import, tier: 1, title: 'Replace crypto/md5 with crypto/sha256' },
1326
+ 'fix-go-ignored-error': { fn: fixGoIgnoredError, tier: 2, title: 'Handle ignored Go error return values' },
1327
+ 'fix-go-http-no-timeout': { fn: fixGoHttpNoTimeout, tier: 1, title: 'Add timeout to http.Client' },
1328
+ 'fix-go-bind-all-interfaces': { fn: fixGoBindAllInterfaces, tier: 1, title: 'Replace :port with 127.0.0.1:port in net.Listen' },
1329
+ 'fix-go-template-html': { fn: fixGoTemplateHtml, tier: 2, title: 'Replace template.HTML with HTMLEscapeString' },
1330
+ 'fix-go-exec-command': { fn: fixGoExecCommand, tier: 2, title: 'Add warning to exec.Command with variable input' },
1331
+ 'fix-go-weak-rand': { fn: fixGoWeakRand, tier: 1, title: 'Replace math/rand with crypto/rand' },
1332
+ 'fix-go-defer-close': { fn: fixGoDeferClose, tier: 1, title: 'Wrap defer Close() in anonymous function' },
1333
+ // Ruby fixes
1334
+ 'fix-ruby-find-by-sql': { fn: fixRubyFindBySql, tier: 2, title: 'Replace find_by_sql interpolation with parameterized query' },
1335
+ 'fix-ruby-html-safe': { fn: fixRubyHtmlSafe, tier: 2, title: 'Replace html_safe with sanitize()' },
1336
+ 'fix-ruby-system-call': { fn: fixRubySystemCall, tier: 2, title: 'Replace system() interpolation with argument list' },
1337
+ 'fix-ruby-yaml-load': { fn: fixRubyYamlLoad, tier: 1, title: 'Replace YAML.load with YAML.safe_load' },
1338
+ 'fix-ruby-marshal-load': { fn: fixRubyMarshalLoad, tier: 2, title: 'Add warning to Marshal.load usage' },
1339
+ 'fix-ruby-permit-all': { fn: fixRubyPermitAll, tier: 2, title: 'Replace permit! with explicit field list' },
1340
+ 'fix-ruby-skip-csrf': { fn: fixRubySkipCsrf, tier: 2, title: 'Comment out skip_forgery_protection' },
1341
+ 'fix-ruby-eval': { fn: fixRubyEval, tier: 2, title: 'Replace Ruby eval() with safer alternative' },
1342
+ 'fix-ruby-open-uri': { fn: fixRubyOpenUri, tier: 1, title: 'Replace open(url) with URI.open(url)' },
1343
+ 'fix-ruby-weak-hash': { fn: fixRubyWeakHash, tier: 1, title: 'Replace Digest::MD5 with Digest::SHA256' },
1344
+ // PHP fixes
1345
+ 'fix-php-mysql-query': { fn: fixPhpMysqlQuery, tier: 2, title: 'Replace mysql_query with PDO prepared statement' },
1346
+ 'fix-php-echo': { fn: fixPhpEcho, tier: 1, title: 'Add htmlspecialchars to echoed user input' },
1347
+ 'fix-php-eval': { fn: fixPhpEval, tier: 2, title: 'Add security warning to PHP eval()' },
1348
+ 'fix-php-exec': { fn: fixPhpExec, tier: 2, title: 'Add escapeshellarg to exec() calls' },
1349
+ 'fix-php-md5-password': { fn: fixPhpMd5Password, tier: 1, title: 'Replace md5() with password_hash()' },
1350
+ 'fix-php-extract': { fn: fixPhpExtract, tier: 2, title: 'Replace extract() with explicit assignments' },
1351
+ 'fix-php-loose-comparison': { fn: fixPhpLooseComparison, tier: 1, title: 'Replace == with === in PHP auth contexts' },
1352
+ 'fix-php-session-config': { fn: fixPhpSessionConfig, tier: 1, title: 'Add secure session cookie settings' },
1353
+ 'fix-php-unserialize': { fn: fixPhpUnserialize, tier: 2, title: 'Add allowed_classes restriction to unserialize()' },
1354
+ 'fix-php-include': { fn: fixPhpInclude, tier: 2, title: 'Add warning to include with user input' },
1355
+ // Advanced JS/TS fixes (21-40)
1356
+ 'fix-dangerously-set-innerhtml': { fn: fixDangerouslySetInnerHTML, tier: 2, title: 'Wrap dangerouslySetInnerHTML with DOMPurify.sanitize' },
1357
+ 'fix-document-write': { fn: fixDocumentWrite, tier: 2, title: 'Replace document.write with textContent' },
1358
+ 'fix-innerHTML': { fn: fixInnerHTML, tier: 2, title: 'Replace innerHTML with textContent' },
1359
+ 'fix-nosql-injection': { fn: fixNoSqlInjection, tier: 2, title: 'Replace unsafe $where with safe MongoDB query' },
1360
+ 'fix-prototype-pollution': { fn: fixPrototypePollution, tier: 2, title: 'Add null prototype to Object.assign' },
1361
+ 'fix-timing-attack': { fn: fixTimingAttack, tier: 2, title: 'Replace === with crypto.timingSafeEqual for secrets' },
1362
+ 'fix-path-traversal': { fn: fixPathTraversal, tier: 2, title: 'Add path traversal guard to fs operations' },
1363
+ 'fix-regex-dos': { fn: fixRegexDos, tier: 2, title: 'Sanitize user input in RegExp constructor' },
1364
+ 'fix-open-redirect': { fn: fixOpenRedirect, tier: 2, title: 'Add URL validation before redirect' },
1365
+ 'fix-no-rate-limit': { fn: fixNoRateLimit, tier: 2, title: 'Add express-rate-limit to route handlers' },
1366
+ 'fix-xss-query-param': { fn: fixXssQueryParam, tier: 2, title: 'Escape req.query in response output' },
1367
+ 'fix-hardcoded-ip': { fn: fixHardcodedIp, tier: 1, title: 'Replace hardcoded 0.0.0.0 with env-based host' },
1368
+ 'fix-no-helmet-csp': { fn: fixNoHelmetCsp, tier: 1, title: 'Add CSP directives to helmet()' },
1369
+ 'fix-sql-concat': { fn: fixSqlConcat, tier: 2, title: 'Replace SQL string concat with parameterized query' },
1370
+ 'fix-jwt-storage-local': { fn: fixJwtStorageLocal, tier: 2, title: 'Replace localStorage JWT with httpOnly cookie' },
1371
+ 'fix-cors-wildcard': { fn: fixCorsWildcard, tier: 1, title: 'Replace CORS origin wildcard with specific origins' },
1372
+ 'fix-no-input-validation': { fn: fixNoInputValidation, tier: 2, title: 'Add input validation stub for req.body' },
1373
+ 'fix-console-error': { fn: fixConsoleError, tier: 1, title: 'Replace console.error with logger.error' },
1374
+ 'fix-await-in-loop': { fn: fixAwaitInLoop, tier: 2, title: 'Replace await-in-loop with Promise.all' },
1375
+ 'fix-missing-error-handler': { fn: fixMissingErrorHandler, tier: 2, title: 'Add Express error handler middleware' },
1376
+ // --- Data-driven registry fixes (340 entries) ---
1377
+ ...registryPatternFixes,
1378
+ };
1379
+
1380
+ /**
1381
+ * Apply a pattern-based fix by ID. Returns { content, applied } or null.
1382
+ *
1383
+ * @param {string} fixId - The fix identifier from patternFixes registry.
1384
+ * @param {string} content - The file content to transform.
1385
+ * @param {object} [finding] - Optional finding context.
1386
+ * @returns {{ content: string, applied: boolean } | null}
1387
+ */
1388
+ export function applyPatternFix(fixId, content, finding = {}) {
1389
+ const entry = patternFixes[fixId];
1390
+ if (!entry) return null;
1391
+ const result = entry.fn(content, finding);
1392
+ if (result === null) return { content, applied: false };
1393
+ return { content: result, applied: true };
1394
+ }
1395
+
1396
+ /**
1397
+ * Auto-detect which pattern fixes apply to the given content and apply all of them.
1398
+ * Returns { content, appliedFixes: string[] }.
1399
+ */
1400
+ export function applyAllPatternFixes(content, finding = {}) {
1401
+ let current = content;
1402
+ const appliedFixes = [];
1403
+
1404
+ for (const [id, entry] of Object.entries(patternFixes)) {
1405
+ const result = entry.fn(current, finding);
1406
+ if (result !== null) {
1407
+ current = result;
1408
+ appliedFixes.push(id);
1409
+ }
1410
+ }
1411
+
1412
+ return { content: current, appliedFixes };
1413
+ }
1414
+
1415
+ /**
1416
+ * Generate a unified diff string between old and new content (for --dry-run).
1417
+ */
1418
+ export function generateDiff(oldContent, newContent, filename = 'file') {
1419
+ const oldLines = oldContent.split('\n');
1420
+ const newLines = newContent.split('\n');
1421
+ const diff = [];
1422
+
1423
+ diff.push(`--- a/${filename}`);
1424
+ diff.push(`+++ b/${filename}`);
1425
+
1426
+ // Simple line-by-line diff
1427
+ const maxLen = Math.max(oldLines.length, newLines.length);
1428
+ let inHunk = false;
1429
+ let hunkStart = -1;
1430
+
1431
+ for (let i = 0; i < maxLen; i++) {
1432
+ const oldLine = i < oldLines.length ? oldLines[i] : undefined;
1433
+ const newLine = i < newLines.length ? newLines[i] : undefined;
1434
+
1435
+ if (oldLine !== newLine) {
1436
+ if (!inHunk) {
1437
+ hunkStart = Math.max(0, i - 2);
1438
+ diff.push(`@@ -${hunkStart + 1},${oldLines.length - hunkStart} +${hunkStart + 1},${newLines.length - hunkStart} @@`);
1439
+ // Context lines before
1440
+ for (let j = hunkStart; j < i; j++) {
1441
+ if (j < oldLines.length) diff.push(` ${oldLines[j]}`);
1442
+ }
1443
+ inHunk = true;
1444
+ }
1445
+ if (oldLine !== undefined) diff.push(`-${oldLine}`);
1446
+ if (newLine !== undefined) diff.push(`+${newLine}`);
1447
+ } else {
1448
+ if (inHunk) {
1449
+ // One context line after
1450
+ if (oldLine !== undefined) diff.push(` ${oldLine}`);
1451
+ inHunk = false;
1452
+ }
1453
+ }
1454
+ }
1455
+
1456
+ return diff.join('\n');
1457
+ }
1458
+
1459
+ const TIER_LABELS = {
1460
+ 1: chalk.green('safe'),
1461
+ 2: chalk.yellow('review'),
1462
+ 3: chalk.red('manual'),
1463
+ };
1464
+
1465
+ const TIER_DESCRIPTIONS = {
1466
+ 1: 'Auto-applied without confirmation',
1467
+ 2: 'Requires review and confirmation',
1468
+ 3: 'Manual changes only — instructions provided',
1469
+ };
1470
+
1471
+ /**
1472
+ * Classify a finding into its fix tier.
1473
+ * Tier 1 (safe): Config changes, headers, .gitignore, creating security files.
1474
+ * Tier 2 (review): Code changes like parameterized queries, bcrypt swaps.
1475
+ * Tier 3 (manual): Architecture changes — never auto-applied.
1476
+ */
1477
+ function getTier(finding) {
1478
+ return finding.fix?.tier ?? 2;
1479
+ }
1480
+
1481
+ /**
1482
+ * Generate a unified-diff-style preview for a fix.
1483
+ */
1484
+ function formatDiff(fix, file) {
1485
+ const lines = [];
1486
+ if (fix.type === 'replace' && fix.old && fix.new) {
1487
+ lines.push(chalk.gray(` --- a/${file}`));
1488
+ lines.push(chalk.gray(` +++ b/${file}`));
1489
+ for (const l of fix.old.split('\n')) {
1490
+ lines.push(chalk.red(` - ${l}`));
1491
+ }
1492
+ for (const l of fix.new.split('\n')) {
1493
+ lines.push(chalk.green(` + ${l}`));
1494
+ }
1495
+ } else if (fix.type === 'insert' && fix.content) {
1496
+ lines.push(chalk.gray(` --- a/${file}`));
1497
+ lines.push(chalk.gray(` +++ b/${file}`));
1498
+ for (const l of fix.content.split('\n')) {
1499
+ lines.push(chalk.green(` + ${l}`));
1500
+ }
1501
+ } else if (fix.type === 'create') {
1502
+ lines.push(chalk.gray(` +++ b/${fix.filePath}`));
1503
+ for (const l of (fix.content || '').split('\n').slice(0, 10)) {
1504
+ lines.push(chalk.green(` + ${l}`));
1505
+ }
1506
+ const total = (fix.content || '').split('\n').length;
1507
+ if (total > 10) {
1508
+ lines.push(chalk.gray(` ... (${total - 10} more lines)`));
1509
+ }
1510
+ }
1511
+ return lines.join('\n');
1512
+ }
1513
+
1514
+ /**
1515
+ * Show a progress indicator: [3/10] style.
1516
+ */
1517
+ function progress(current, total, label) {
1518
+ const bar = '='.repeat(Math.round((current / total) * 20)).padEnd(20, ' ');
1519
+ return ` [${bar}] ${current}/${total} ${label}`;
1520
+ }
1521
+
1522
+ /**
1523
+ * Check if a file is safe to modify (exists, under size limit).
1524
+ */
1525
+ function isSafeToModify(fullPath) {
1526
+ try {
1527
+ const stat = statSync(fullPath);
1528
+ if (stat.size > MAX_FILE_SIZE) {
1529
+ return { safe: false, reason: `file exceeds ${MAX_FILE_SIZE / 1024}KB limit (${Math.round(stat.size / 1024)}KB)` };
1530
+ }
1531
+ return { safe: true };
1532
+ } catch {
1533
+ return { safe: false, reason: 'file not found' };
1534
+ }
1535
+ }
1536
+
1537
+ /**
1538
+ * After applying a fix, re-read the file and verify the triggering pattern is gone.
1539
+ */
1540
+ function verifyFix(fullPath, fix) {
1541
+ if (fix.type === 'create') {
1542
+ return existsSync(join(fullPath, '..', fix.filePath)) || existsSync(fix.filePath);
1543
+ }
1544
+ if (fix.type === 'replace' && fix.old) {
1545
+ try {
1546
+ const content = readFileSync(fullPath, 'utf-8');
1547
+ return !content.includes(fix.old);
1548
+ } catch {
1549
+ return false;
1550
+ }
1551
+ }
1552
+ return true; // inserts are assumed verified
1553
+ }
1554
+
1555
+ /**
1556
+ * Count total lines changed across all applied fixes.
1557
+ */
1558
+ function countLinesChanged(appliedFixes) {
1559
+ let added = 0;
1560
+ let removed = 0;
1561
+ for (const fix of appliedFixes) {
1562
+ if (fix.type === 'replace') {
1563
+ const oldText = fix.old || '';
1564
+ const newText = fix.new || '';
1565
+ removed += oldText === '' ? 0 : oldText.split('\n').length;
1566
+ added += newText === '' ? 0 : newText.split('\n').length;
1567
+ } else if (fix.type === 'insert') {
1568
+ const text = fix.content || '';
1569
+ added += text === '' ? 0 : text.split('\n').length;
1570
+ } else if (fix.type === 'create') {
1571
+ const text = fix.content || '';
1572
+ added += text === '' ? 0 : text.split('\n').length;
1573
+ }
1574
+ }
1575
+ return { added, removed };
1576
+ }
1577
+
1578
+ /**
1579
+ * Create a git stash backup before applying fixes.
1580
+ * Returns true if stash was created.
1581
+ */
1582
+ function createBackup(targetPath) {
1583
+ try {
1584
+ const before = execSync('git stash list', { cwd: targetPath, stdio: 'pipe' }).toString();
1585
+ execSync('git stash push -m "doorman-backup-before-fix"', {
1586
+ cwd: targetPath,
1587
+ stdio: 'pipe',
1588
+ });
1589
+ const after = execSync('git stash list', { cwd: targetPath, stdio: 'pipe' }).toString();
1590
+ // If stash list didn't change, there was nothing to stash
1591
+ return before !== after;
1592
+ } catch {
1593
+ return false;
1594
+ }
1595
+ }
1596
+
1597
+ /**
1598
+ * Pop the most recent doorman backup from git stash.
1599
+ */
1600
+ function rollback(targetPath, silent) {
1601
+ try {
1602
+ const list = execSync('git stash list', { cwd: targetPath, stdio: 'pipe' }).toString();
1603
+ const match = list.match(/stash@\{(\d+)\}.*doorman-backup-before-fix/);
1604
+ if (!match) {
1605
+ if (!silent) {
1606
+ console.log(chalk.yellow(' No doorman backup found in git stash.'));
1607
+ }
1608
+ return false;
1609
+ }
1610
+ execSync(`git stash pop stash@{${match[1]}}`, {
1611
+ cwd: targetPath,
1612
+ stdio: 'pipe',
1613
+ });
1614
+ if (!silent) {
1615
+ console.log(chalk.green(' Rolled back to doorman backup.'));
1616
+ }
1617
+ return true;
1618
+ } catch (e) {
1619
+ if (!silent) {
1620
+ console.log(chalk.red(` Rollback failed: ${e.message}`));
1621
+ }
1622
+ return false;
1623
+ }
1624
+ }
1625
+
1626
+ /**
1627
+ * Apply a single fix to file content. Returns updated content or null on failure.
1628
+ */
1629
+ function applyFix(content, fix) {
1630
+ if (fix.type === 'replace' && fix.old && fix.new) {
1631
+ if (content.includes(fix.old)) {
1632
+ return content.replace(fix.old, fix.new);
1633
+ }
1634
+ return null;
1635
+ }
1636
+ if (fix.type === 'insert' && fix.content && fix.position !== undefined) {
1637
+ const lines = content.split('\n');
1638
+ lines.splice(fix.position, 0, fix.content);
1639
+ return lines.join('\n');
1640
+ }
1641
+ return null;
1642
+ }
1643
+
1644
+ /**
1645
+ * Tiered auto-fix system for Doorman.
1646
+ *
1647
+ * Tiers:
1648
+ * 1 (safe) — Config changes, headers, .gitignore, security files. Auto-applied.
1649
+ * 2 (review) — Code changes (parameterized queries, bcrypt). Shows diff, requires confirm.
1650
+ * 3 (manual) — Architecture changes. Shows instructions only.
1651
+ *
1652
+ * Usage:
1653
+ * doorman fix — dry-run (default), shows what would change
1654
+ * doorman fix --apply — actually apply fixes
1655
+ * doorman fix --undo — rollback the last backup
1656
+ *
1657
+ * @param {string} targetPath - Root path of the project to fix.
1658
+ * @param {object} options
1659
+ * @param {boolean} options.dryRun - Explicit dry-run flag (default behavior).
1660
+ * @param {boolean} options.apply - Actually apply fixes.
1661
+ * @param {boolean} options.undo - Rollback the last doorman stash backup.
1662
+ * @param {boolean} options.silent - Suppress output.
1663
+ */
1664
+ export async function fix(targetPath, options = {}) {
1665
+ const silent = options.silent || false;
1666
+ const shouldApply = options.apply === true;
1667
+ const isDryRun = !shouldApply;
1668
+
1669
+ if (!silent) {
1670
+ console.log('');
1671
+ console.log(chalk.bold.cyan(' Doorman — Tiered Auto-Fix'));
1672
+ console.log('');
1673
+ }
1674
+
1675
+ // Handle rollback
1676
+ if (options.undo) {
1677
+ if (!silent) console.log(chalk.gray(' Rolling back last doorman backup...'));
1678
+ const ok = rollback(targetPath, silent);
1679
+ if (!silent) console.log('');
1680
+ return { rolledBack: ok };
1681
+ }
1682
+
1683
+ // Load cached scan results if available (instant), otherwise scan
1684
+ if (!silent) console.log(chalk.gray(' Loading scan results...'));
1685
+ let result;
1686
+ try {
1687
+ const { loadScanCache } = await import('./scan-cache.js');
1688
+ const cache = loadScanCache(targetPath);
1689
+ if (cache && cache.findings && cache.findings.length > 0) {
1690
+ result = { findings: cache.findings, score: cache.score, stack: cache.stack };
1691
+ if (!silent) console.log(chalk.gray(` Using cached scan (${cache.findings.length} findings from ${cache.timestamp.split('T')[0]})`));
1692
+ }
1693
+ } catch { /* ignore cache errors */ }
1694
+
1695
+ if (!result) {
1696
+ if (!silent) console.log(chalk.gray(' No cached scan found. Running scan...'));
1697
+ result = await check(targetPath, { ...options, silent: true });
1698
+ }
1699
+
1700
+ const fixable = result.findings.filter(f => f.fix);
1701
+
1702
+ // Load rules for verification (re-check findings after fix)
1703
+ let allRules = [];
1704
+ try {
1705
+ const { loadRules } = await import('./rules/index.js');
1706
+ allRules = loadRules({ category: 'security,bugs' });
1707
+ } catch { /* rules not available — skip verification */ }
1708
+
1709
+ if (fixable.length === 0) {
1710
+ if (!silent) {
1711
+ console.log(chalk.green.bold(' No auto-fixable issues found.'));
1712
+ console.log('');
1713
+ }
1714
+ return { fixed: 0, failed: 0, skipped: 0, findings: [] };
1715
+ }
1716
+
1717
+ // Classify by tier
1718
+ let tier1 = fixable.filter(f => getTier(f) === 1);
1719
+ let tier2 = fixable.filter(f => getTier(f) === 2);
1720
+ const tier3 = fixable.filter(f => getTier(f) === 3);
1721
+
1722
+ if (!silent) {
1723
+ console.log(chalk.bold(` Found ${fixable.length} fixable issue${fixable.length === 1 ? '' : 's'}`));
1724
+ if (tier3.length) console.log(chalk.gray(` + ${tier3.length} need manual changes`));
1725
+ console.log('');
1726
+ }
1727
+
1728
+ // Dry-run mode (default): show what would change
1729
+ if (isDryRun) {
1730
+ if (!silent) {
1731
+ console.log(chalk.gray(' Dry-run mode (default). Use --apply to apply fixes.'));
1732
+ console.log('');
1733
+
1734
+ for (const finding of tier1) {
1735
+ console.log(chalk.green(` [safe] Would auto-fix: ${finding.title}`));
1736
+ if (finding.file) console.log(chalk.gray(` in ${finding.file}`));
1737
+ }
1738
+ for (const finding of tier2) {
1739
+ console.log(chalk.yellow(` [review] Would fix (with confirmation): ${finding.title}`));
1740
+ if (finding.file) console.log(chalk.gray(` in ${finding.file}`));
1741
+ if (finding.fix) console.log(formatDiff(finding.fix, finding.file || finding.fix.filePath || ''));
1742
+ }
1743
+ for (const finding of tier3) {
1744
+ console.log(chalk.red(` [manual] Requires manual change: ${finding.title}`));
1745
+ if (finding.fix?.instructions) {
1746
+ console.log(chalk.gray(` ${finding.fix.instructions}`));
1747
+ }
1748
+ }
1749
+
1750
+ console.log('');
1751
+ }
1752
+ return { dryRun: true, fixable: fixable.length, tier1: tier1.length, tier2: tier2.length, tier3: tier3.length };
1753
+ }
1754
+
1755
+ // --- Apply mode ---
1756
+
1757
+ // Collect unique files to be modified
1758
+ const filesToModify = new Set();
1759
+ for (const f of [...tier1, ...tier2]) {
1760
+ if (f.file) filesToModify.add(f.file);
1761
+ }
1762
+
1763
+ // Safety: max file count
1764
+ if (filesToModify.size > MAX_FILES_PER_RUN) {
1765
+ if (!silent) {
1766
+ console.log(chalk.red(` Aborted: would modify ${filesToModify.size} files (limit is ${MAX_FILES_PER_RUN}).`));
1767
+ console.log('');
1768
+ }
1769
+ return { fixed: 0, failed: 0, aborted: true, reason: 'too many files' };
1770
+ }
1771
+
1772
+ // Safety: check file sizes
1773
+ for (const file of filesToModify) {
1774
+ const fullPath = join(targetPath, file);
1775
+ const check = isSafeToModify(fullPath);
1776
+ if (!check.safe) {
1777
+ if (!silent) {
1778
+ console.log(chalk.red(` Skipping ${file}: ${check.reason}`));
1779
+ }
1780
+ }
1781
+ }
1782
+
1783
+ // Create git stash backup
1784
+ const backedUp = createBackup(targetPath);
1785
+ if (!silent) {
1786
+ if (backedUp) {
1787
+ console.log(chalk.green(' ✓ Backup created. Run `npx getdoorman fix --undo` if anything breaks.'));
1788
+ }
1789
+ console.log('');
1790
+ }
1791
+
1792
+ let fixedCount = 0;
1793
+ let failedCount = 0;
1794
+ let skippedCount = 0;
1795
+ let verifiedCount = 0;
1796
+ const appliedFixes = [];
1797
+ const allFindings = [...tier1, ...tier2];
1798
+ const total = allFindings.length;
1799
+ let current = 0;
1800
+ const maxFixes = options.maxFixes || Infinity;
1801
+ let creditLimitHit = false;
1802
+
1803
+ // --- Apply registry-based fixes per file ---
1804
+ // Group all fixable findings by file
1805
+ const findingsByFile = {};
1806
+ const findingsNoFile = [];
1807
+ for (const finding of allFindings) {
1808
+ if (finding.file) {
1809
+ if (!findingsByFile[finding.file]) findingsByFile[finding.file] = [];
1810
+ findingsByFile[finding.file].push(finding);
1811
+ } else {
1812
+ findingsNoFile.push(finding);
1813
+ }
1814
+ }
1815
+
1816
+ if (!silent && allFindings.length > 0) {
1817
+ console.log(chalk.green.bold(' Applying fixes:'));
1818
+ }
1819
+
1820
+ for (const [file, findings] of Object.entries(findingsByFile)) {
1821
+ const fullPath = join(targetPath, file);
1822
+ const sizeCheck = isSafeToModify(fullPath);
1823
+ if (!sizeCheck.safe) {
1824
+ skippedCount += findings.length;
1825
+ current += findings.length;
1826
+ continue;
1827
+ }
1828
+
1829
+ try {
1830
+ let content = readFileSync(fullPath, 'utf-8');
1831
+ const original = content;
1832
+
1833
+ // For each finding, try to fix it and verify the fix works
1834
+ for (const finding of findings) {
1835
+ current++;
1836
+ if (fixedCount >= maxFixes) { creditLimitHit = true; skippedCount++; continue; }
1837
+
1838
+ const beforeFix = content;
1839
+
1840
+ // Try 1: finding-level fix (type: replace with old/new)
1841
+ let attempted = false;
1842
+ if (finding.fix && typeof finding.fix === 'object' && finding.fix.type === 'replace') {
1843
+ const updated = applyFix(content, finding.fix);
1844
+ if (updated !== null) { content = updated; attempted = true; }
1845
+ }
1846
+
1847
+ // Try 2: registry pattern fix (run all, take the diff)
1848
+ if (!attempted) {
1849
+ const { content: regFixed, applied } = applyAllRegistryFixes(content, file, registryEntries);
1850
+ if (applied.length > 0) { content = regFixed; attempted = true; }
1851
+ }
1852
+
1853
+ // Try 3: inline pattern fix
1854
+ if (!attempted) {
1855
+ const fixId = finding.fix && typeof finding.fix === 'string' ? finding.fix : null;
1856
+ const pfn = fixId ? patternFixes[fixId] : null;
1857
+ if (pfn) {
1858
+ const updated = pfn.fn(content);
1859
+ if (updated !== null) { content = updated; attempted = true; }
1860
+ }
1861
+ }
1862
+
1863
+ // Verify: did the fix actually resolve the finding?
1864
+ if (attempted && content !== beforeFix) {
1865
+ // Re-run the specific rule on the new content to verify
1866
+ let stillDetected = false;
1867
+ if (finding.ruleId) {
1868
+ try {
1869
+ const tempFiles = new Map([[file, content]]);
1870
+ const rule = allRules.find(r => r.id === finding.ruleId);
1871
+ if (rule) {
1872
+ const recheck = rule.check({ files: tempFiles, stack: result.stack || {}, silent: true });
1873
+ const recheckArr = Array.isArray(recheck) ? recheck : [];
1874
+ // Check if same finding still exists on same line
1875
+ stillDetected = recheckArr.some(f =>
1876
+ f.file === file && (!finding.line || f.line === finding.line)
1877
+ );
1878
+ }
1879
+ } catch { /* rule threw — assume fixed */ }
1880
+ }
1881
+
1882
+ if (!stillDetected) {
1883
+ fixedCount++;
1884
+ if (!silent) {
1885
+ console.log(chalk.green(` ✓ ${finding.title}`));
1886
+ console.log(chalk.gray(` in ${file}`));
1887
+ }
1888
+ } else {
1889
+ // Fix changed the file but didn't resolve the finding — rollback
1890
+ content = beforeFix;
1891
+ }
1892
+ } else if (attempted) {
1893
+ content = beforeFix;
1894
+ }
1895
+ }
1896
+
1897
+ // Write if content changed — with syntax verification
1898
+ if (content !== original) {
1899
+ // Verify the fixed code still parses before writing
1900
+ if (isSyntaxValid(fullPath, content)) {
1901
+ writeFileSync(fullPath, content, 'utf-8');
1902
+ } else {
1903
+ // Syntax broken — rollback this file, don't count as fixed
1904
+ if (!silent) {
1905
+ console.log(chalk.red(` ✗ Fix broke syntax in ${file} — rolled back`));
1906
+ }
1907
+ fixedCount -= findings.filter(f => f._fixed).length;
1908
+ content = original; // don't write
1909
+ }
1910
+ }
1911
+
1912
+ // Verify fixes
1913
+ for (const finding of findings) {
1914
+ if (finding.fix && typeof finding.fix === 'object') {
1915
+ if (verifyFix(fullPath, finding.fix)) verifiedCount++;
1916
+ }
1917
+ }
1918
+ } catch (e) {
1919
+ failedCount += findings.length;
1920
+ current += findings.length;
1921
+ if (!silent) console.log(chalk.red(` ✗ Failed to apply fixes for ${relativePath}: ${e.message}`));
1922
+ }
1923
+ }
1924
+
1925
+ // File-creation fixes (no source file)
1926
+ for (const finding of findingsNoFile) {
1927
+ current++;
1928
+ if (finding.fix?.type === 'create' && finding.fix.filePath && finding.fix.content) {
1929
+ try {
1930
+ const createPath = join(targetPath, finding.fix.filePath);
1931
+ writeFileSync(createPath, finding.fix.content, 'utf-8');
1932
+ appliedFixes.push(finding.fix);
1933
+ fixedCount++;
1934
+ if (!silent) console.log(chalk.green(` ${progress(current, total, '')} ✓ ${finding.title} (created ${finding.fix.filePath})`));
1935
+ verifiedCount++;
1936
+ } catch {
1937
+ failedCount++;
1938
+ if (!silent) console.log(chalk.red(` ${progress(current, total, '')} ✗ ${finding.title} (create failed)`));
1939
+ }
1940
+ }
1941
+ }
1942
+
1943
+ // --- AI Fix: try AI for findings regex couldn't fix ---
1944
+ const hasApiKey = process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY;
1945
+ if (hasApiKey && shouldApply && failedCount > 0 && fixedCount < maxFixes) {
1946
+ try {
1947
+ const { aiFixAll } = await import('./ai-fixer.js');
1948
+ const { resolve: resolvePath } = await import('path');
1949
+ const resolvedPath = resolvePath(targetPath);
1950
+
1951
+ // Collect unfixed findings that have files
1952
+ const unfixed = allFindings.filter(f => f.file && !f._regexFixed).slice(0, Math.min(10, maxFixes - fixedCount));
1953
+
1954
+ if (unfixed.length > 0) {
1955
+ if (!silent) {
1956
+ console.log('');
1957
+ console.log(chalk.cyan(' Trying AI fixes for remaining issues...'));
1958
+ }
1959
+
1960
+ // Build file content map
1961
+ const fileContentMap = {};
1962
+ for (const f of unfixed) {
1963
+ if (f.file && !fileContentMap[f.file]) {
1964
+ try { fileContentMap[f.file] = readFileSync(join(targetPath, f.file), 'utf-8'); } catch {}
1965
+ }
1966
+ }
1967
+
1968
+ const aiResults = await aiFixAll(unfixed, fileContentMap, { cwd: resolvedPath });
1969
+
1970
+ for (const { finding, fix: aiFix } of aiResults) {
1971
+ if (fixedCount >= maxFixes) break;
1972
+ if (!aiFix || !aiFix.old || !aiFix.new || !finding.file) continue;
1973
+
1974
+ const fullPath = join(targetPath, finding.file);
1975
+ try {
1976
+ let content = readFileSync(fullPath, 'utf-8');
1977
+ if (content.includes(aiFix.old)) {
1978
+ const newContent = content.replace(aiFix.old, aiFix.new);
1979
+ if (isSyntaxValid(fullPath, newContent)) {
1980
+ writeFileSync(fullPath, newContent, 'utf-8');
1981
+ fixedCount++;
1982
+ if (!silent) {
1983
+ console.log(chalk.green(` ✓ ${finding.title}`));
1984
+ console.log(chalk.gray(` in ${finding.file} (AI fix)`));
1985
+ }
1986
+ }
1987
+ }
1988
+ } catch {}
1989
+ }
1990
+ }
1991
+ } catch {
1992
+ // AI fixer not available or failed — continue silently
1993
+ }
1994
+ }
1995
+
1996
+ // --- Tier 3: manual instructions only ---
1997
+ if (tier3.length > 0 && !silent) {
1998
+ console.log('');
1999
+ console.log(chalk.red.bold(' Tier 3 — Manual changes required:'));
2000
+ for (const finding of tier3) {
2001
+ console.log('');
2002
+ console.log(chalk.red(` ⚠ ${finding.title}`));
2003
+ if (finding.file) console.log(chalk.gray(` File: ${finding.file}`));
2004
+ if (finding.fix?.instructions) {
2005
+ console.log(chalk.white(` Instructions: ${finding.fix.instructions}`));
2006
+ }
2007
+ }
2008
+ skippedCount += tier3.length;
2009
+ }
2010
+
2011
+ // --- Summary ---
2012
+ const { added, removed } = countLinesChanged(appliedFixes);
2013
+
2014
+ // Estimate new score from cached findings minus what we fixed
2015
+ let postScore = null;
2016
+ let remainingFixable = 0;
2017
+ if (fixedCount > 0 && shouldApply) {
2018
+ try {
2019
+ // Load the cached scan and remove findings we fixed
2020
+ const { loadScanCache, saveScanCache } = await import('./scan-cache.js');
2021
+ const cache = loadScanCache(targetPath);
2022
+ if (cache && cache.findings) {
2023
+ // Remove fixed findings (match by ruleId + file)
2024
+ const fixedKeys = new Set();
2025
+ for (const [file, findings] of Object.entries(findingsByFile)) {
2026
+ for (const f of findings) {
2027
+ fixedKeys.add(`${f.ruleId}:${f.file}:${f.line}`);
2028
+ }
2029
+ }
2030
+ const remaining = cache.findings.filter(f => {
2031
+ const key = `${f.ruleId}:${f.file}:${f.line}`;
2032
+ return !fixedKeys.has(key);
2033
+ });
2034
+
2035
+ postScore = (await import('./reporter.js')).calculateScore(remaining);
2036
+ remainingFixable = remaining.filter(f => f.fix).length;
2037
+
2038
+ // Update cache with remaining findings
2039
+ saveScanCache(targetPath, remaining, cache.stack, postScore);
2040
+ }
2041
+ } catch {
2042
+ // Non-critical
2043
+ }
2044
+ }
2045
+
2046
+ if (!silent) {
2047
+ console.log('');
2048
+ console.log(chalk.bold(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
2049
+ console.log('');
2050
+
2051
+ if (fixedCount > 0) {
2052
+ console.log(chalk.green.bold(` ✓ ${fixedCount} issue${fixedCount === 1 ? '' : 's'} fixed`));
2053
+ if (postScore !== null) {
2054
+ const scoreColor = postScore >= 70 ? chalk.green.bold : postScore >= 40 ? chalk.yellow.bold : chalk.red.bold;
2055
+ console.log(` Score: ${scoreColor(`${postScore}/100`)}`);
2056
+ }
2057
+ } else {
2058
+ console.log(chalk.dim(' No fixes applied this run.'));
2059
+ }
2060
+
2061
+ if (remainingFixable > 0) {
2062
+ console.log('');
2063
+ console.log(chalk.cyan(` ${remainingFixable} more issue${remainingFixable === 1 ? '' : 's'} can be fixed with AI Fix (Pro plan).`));
2064
+ console.log(chalk.gray(' Upgrade at https://doorman.sh/pro'));
2065
+ }
2066
+
2067
+ if (tier3.length > 0) {
2068
+ console.log('');
2069
+ console.log(chalk.dim(` ${tier3.length} issue${tier3.length === 1 ? '' : 's'} need manual review (run --detail to see).`));
2070
+ }
2071
+
2072
+ if (backedUp) {
2073
+ console.log('');
2074
+ console.log(chalk.gray(' Backup saved. Run `npx getdoorman fix --undo` to rollback.'));
2075
+ }
2076
+
2077
+ console.log('');
2078
+ console.log(chalk.bold(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
2079
+ console.log('');
2080
+ }
2081
+
2082
+ recordFix(targetPath, { applied: fixedCount, offered: fixable.length, accepted: fixedCount });
2083
+
2084
+ // Generate unified diff output for piping to git apply
2085
+ if (isDryRun && options.diff && appliedFixes.length > 0) {
2086
+ console.log('');
2087
+ console.log(chalk.bold(' Unified diff (pipe to `git apply`):'));
2088
+ console.log('');
2089
+ for (const fix of appliedFixes) {
2090
+ if (fix.original && fix.modified && fix.filePath) {
2091
+ const orig = fix.original.split('\n');
2092
+ const mod = fix.modified.split('\n');
2093
+ console.log(`--- a/${fix.filePath}`);
2094
+ console.log(`+++ b/${fix.filePath}`);
2095
+ // Simple line-by-line diff
2096
+ const maxLen = Math.max(orig.length, mod.length);
2097
+ let hunkStart = -1;
2098
+ for (let i = 0; i < maxLen; i++) {
2099
+ if (orig[i] !== mod[i]) {
2100
+ if (hunkStart < 0) {
2101
+ hunkStart = Math.max(0, i - 2);
2102
+ console.log(`@@ -${hunkStart + 1},${orig.length} +${hunkStart + 1},${mod.length} @@`);
2103
+ }
2104
+ if (i < orig.length && orig[i] !== undefined) console.log(`-${orig[i]}`);
2105
+ if (i < mod.length && mod[i] !== undefined) console.log(`+${mod[i]}`);
2106
+ }
2107
+ }
2108
+ }
2109
+ }
2110
+ }
2111
+
2112
+ return { fixed: fixedCount, failed: failedCount, skipped: skippedCount, verified: verifiedCount, linesAdded: added, linesRemoved: removed };
2113
+ }