prodlint 0.8.1 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -715,8 +715,8 @@ function summarizeFindings(findings) {
715
715
 
716
716
  // src/rules/secrets.ts
717
717
  var SECRET_PATTERNS = [
718
- { name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{20,}/ },
719
- { name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{20,}/ },
718
+ { name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{8,}/ },
719
+ { name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{8,}/ },
720
720
  { name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/ },
721
721
  { name: "AWS secret key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/ },
722
722
  { name: "Supabase service role key", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/ },
@@ -749,7 +749,8 @@ var secretsRule = {
749
749
  column: match.index + 1,
750
750
  message: `Hardcoded ${name} detected`,
751
751
  severity: "critical",
752
- category: "security"
752
+ category: "security",
753
+ fix: "Move the secret to an environment variable and access via process.env.SECRET_NAME"
753
754
  });
754
755
  }
755
756
  }
@@ -833,7 +834,8 @@ var hallucinatedImportsRule = {
833
834
  column: 1,
834
835
  message: `Package "${pkgName}" is imported but not in package.json`,
835
836
  severity: isNonProd ? "warning" : "critical",
836
- category: "reliability"
837
+ category: "reliability",
838
+ fix: "Verify the package exists on npm. The AI may have invented this package name."
837
839
  });
838
840
  }
839
841
  return findings;
@@ -861,7 +863,8 @@ var hallucinatedImportsRule = {
861
863
  column: match.index + 1,
862
864
  message: `Package "${pkgName}" is imported but not in package.json`,
863
865
  severity: isNonProd ? "warning" : "critical",
864
- category: "reliability"
866
+ category: "reliability",
867
+ fix: "Verify the package exists on npm. The AI may have invented this package name."
865
868
  });
866
869
  }
867
870
  }
@@ -944,7 +947,8 @@ var authChecksRule = {
944
947
  column: 1,
945
948
  message,
946
949
  severity,
947
- category: "security"
950
+ category: "security",
951
+ fix: "Add authentication check: const session = await auth(); if (!session) return new Response('Unauthorized', { status: 401 })"
948
952
  }];
949
953
  }
950
954
  };
@@ -983,7 +987,8 @@ var envExposureRule = {
983
987
  column: 1,
984
988
  message: ".env is not listed in .gitignore \u2014 secrets may be committed",
985
989
  severity: "critical",
986
- category: "security"
990
+ category: "security",
991
+ fix: "Add .env to .gitignore to prevent committing secrets"
987
992
  });
988
993
  }
989
994
  return findings;
@@ -1005,7 +1010,8 @@ var envExposureRule = {
1005
1010
  column: match.index + 1,
1006
1011
  message: isSensitive ? `Sensitive server env var "${envName}" used in client component` : `Server env var "${envName}" used in client component (will be undefined at runtime)`,
1007
1012
  severity: isSensitive ? "critical" : "warning",
1008
- category: "security"
1013
+ category: "security",
1014
+ fix: "Move to server-only file or use NEXT_PUBLIC_ prefix only for non-sensitive values"
1009
1015
  });
1010
1016
  }
1011
1017
  }
@@ -1043,7 +1049,8 @@ var errorHandlingRule = {
1043
1049
  column: 1,
1044
1050
  message: "API route handler has no try/catch block",
1045
1051
  severity: "warning",
1046
- category: "reliability"
1052
+ category: "reliability",
1053
+ fix: "Wrap the handler body in try/catch and return appropriate error responses"
1047
1054
  }];
1048
1055
  }
1049
1056
  };
@@ -1101,7 +1108,8 @@ var inputValidationRule = {
1101
1108
  column: 1,
1102
1109
  message: "Request body accessed without validation (consider using Zod, Yup, or similar)",
1103
1110
  severity: "warning",
1104
- category: "security"
1111
+ category: "security",
1112
+ fix: "Validate input with Zod or a similar schema library before using it"
1105
1113
  }];
1106
1114
  }
1107
1115
  };
@@ -1158,12 +1166,22 @@ var rateLimitingRule = {
1158
1166
  column: 1,
1159
1167
  message: "No rate limiting \u2014 anyone could spam this endpoint and run up your API costs",
1160
1168
  severity: "info",
1161
- category: "security"
1169
+ category: "security",
1170
+ fix: "Add rate limiting: import { Ratelimit } from '@upstash/ratelimit' or use express-rate-limit"
1162
1171
  }];
1163
1172
  }
1164
1173
  };
1165
1174
 
1166
1175
  // src/rules/cors-config.ts
1176
+ function hasCredentialsNearby(lines, startLine, commentMap) {
1177
+ const end = Math.min(lines.length, startLine + 8);
1178
+ for (let j = startLine; j < end; j++) {
1179
+ if (commentMap[j]) continue;
1180
+ if (/credentials\s*:\s*true/.test(lines[j])) return true;
1181
+ if (/Access-Control-Allow-Credentials['"]\s*[,:]\s*['"]true['"]/.test(lines[j])) return true;
1182
+ }
1183
+ return false;
1184
+ }
1167
1185
  var corsConfigRule = {
1168
1186
  id: "cors-config",
1169
1187
  name: "Permissive CORS",
@@ -1177,14 +1195,16 @@ var corsConfigRule = {
1177
1195
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1178
1196
  const line = file.lines[i];
1179
1197
  if (/['"]Access-Control-Allow-Origin['"]\s*[,:]\s*['"]\*['"]/.test(line)) {
1198
+ const withCreds = hasCredentialsNearby(file.lines, i, file.commentMap);
1180
1199
  findings.push({
1181
1200
  ruleId: "cors-config",
1182
1201
  file: file.relativePath,
1183
1202
  line: i + 1,
1184
1203
  column: line.indexOf("Access-Control") + 1,
1185
- message: 'Access-Control-Allow-Origin set to "*" allows any domain',
1186
- severity: "warning",
1187
- category: "security"
1204
+ message: withCreds ? "CORS wildcard with credentials allows any site to make authenticated requests" : 'Access-Control-Allow-Origin set to "*" allows any domain',
1205
+ severity: withCreds ? "critical" : "warning",
1206
+ category: "security",
1207
+ fix: withCreds ? "Never use credentials with wildcard origin. Set origin to specific trusted domains." : "Restrict origin to specific domains: origin: ['https://yourdomain.com']"
1188
1208
  });
1189
1209
  }
1190
1210
  if (/cors\(\s*\)/.test(line)) {
@@ -1195,18 +1215,21 @@ var corsConfigRule = {
1195
1215
  column: line.indexOf("cors(") + 1,
1196
1216
  message: "cors() called without config allows all origins",
1197
1217
  severity: "warning",
1198
- category: "security"
1218
+ category: "security",
1219
+ fix: "Pass explicit options: cors({ origin: 'https://yourdomain.com' })"
1199
1220
  });
1200
1221
  }
1201
1222
  if (/origin\s*:\s*['"]\*['"]/.test(line)) {
1223
+ const withCreds = hasCredentialsNearby(file.lines, i, file.commentMap);
1202
1224
  findings.push({
1203
1225
  ruleId: "cors-config",
1204
1226
  file: file.relativePath,
1205
1227
  line: i + 1,
1206
1228
  column: line.indexOf("origin") + 1,
1207
- message: 'CORS origin set to "*" allows any domain',
1208
- severity: "warning",
1209
- category: "security"
1229
+ message: withCreds ? "CORS wildcard with credentials allows any site to make authenticated requests" : 'CORS origin set to "*" allows any domain',
1230
+ severity: withCreds ? "critical" : "warning",
1231
+ category: "security",
1232
+ fix: withCreds ? "Never use credentials with wildcard origin. Set origin to specific trusted domains." : "Restrict origin to specific domains: origin: ['https://yourdomain.com']"
1210
1233
  });
1211
1234
  }
1212
1235
  if (/origin\s*:\s*true/.test(line)) {
@@ -1217,7 +1240,8 @@ var corsConfigRule = {
1217
1240
  column: line.indexOf("origin") + 1,
1218
1241
  message: "CORS origin set to true mirrors any requesting origin",
1219
1242
  severity: "warning",
1220
- category: "security"
1243
+ category: "security",
1244
+ fix: "Set origin to specific domains instead of reflecting the request origin"
1221
1245
  });
1222
1246
  }
1223
1247
  }
@@ -1226,7 +1250,7 @@ var corsConfigRule = {
1226
1250
  };
1227
1251
 
1228
1252
  // src/rules/ai-smells.ts
1229
- var CONSOLE_LOG_THRESHOLD = 5;
1253
+ var CONSOLE_LOG_THRESHOLD = 3;
1230
1254
  var ANY_TYPE_THRESHOLD = 5;
1231
1255
  var COMMENTED_CODE_THRESHOLD = 3;
1232
1256
  var aiSmellsRule = {
@@ -1260,7 +1284,8 @@ var aiSmellsRule = {
1260
1284
  column: line.indexOf(todoMatch[1]) + 1,
1261
1285
  message: `${todoMatch[1]} comment: ${todoMatch[2].trim() || "(no description)"}`,
1262
1286
  severity: "info",
1263
- category: "ai-quality"
1287
+ category: "ai-quality",
1288
+ fix: "Resolve the TODO/FIXME before shipping to production"
1264
1289
  });
1265
1290
  }
1266
1291
  const commentContent = trimmed.slice(2).trim();
@@ -1275,7 +1300,8 @@ var aiSmellsRule = {
1275
1300
  column: 1,
1276
1301
  message: `${COMMENTED_CODE_THRESHOLD}+ consecutive lines of commented-out code`,
1277
1302
  severity: "info",
1278
- category: "ai-quality"
1303
+ category: "ai-quality",
1304
+ fix: "Remove commented-out code \u2014 use version control to recover old code if needed"
1279
1305
  });
1280
1306
  }
1281
1307
  } else {
@@ -1292,7 +1318,8 @@ var aiSmellsRule = {
1292
1318
  column: 1,
1293
1319
  message: 'Placeholder "not implemented" function',
1294
1320
  severity: "warning",
1295
- category: "ai-quality"
1321
+ category: "ai-quality",
1322
+ fix: "Replace with a production-ready implementation or remove the function"
1296
1323
  });
1297
1324
  }
1298
1325
  if (/console\.log\s*\(/.test(line)) {
@@ -1310,7 +1337,8 @@ var aiSmellsRule = {
1310
1337
  column: 1,
1311
1338
  message: `${consoleLogCount} console.log statements (consider a proper logger)`,
1312
1339
  severity: "warning",
1313
- category: "ai-quality"
1340
+ category: "ai-quality",
1341
+ fix: "Replace console.log with a structured logger (e.g., pino, winston) or remove debug logs"
1314
1342
  });
1315
1343
  }
1316
1344
  if (anyTypeCount > ANY_TYPE_THRESHOLD) {
@@ -1321,7 +1349,8 @@ var aiSmellsRule = {
1321
1349
  column: 1,
1322
1350
  message: `${anyTypeCount} uses of "any" type (consider proper typing)`,
1323
1351
  severity: "warning",
1324
- category: "ai-quality"
1352
+ category: "ai-quality",
1353
+ fix: 'Replace "any" with specific types or use "unknown" with type narrowing'
1325
1354
  });
1326
1355
  }
1327
1356
  return findings;
@@ -1363,7 +1392,8 @@ var unsafeHtmlRule = {
1363
1392
  column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
1364
1393
  message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
1365
1394
  severity: "critical",
1366
- category: "security"
1395
+ category: "security",
1396
+ fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
1367
1397
  });
1368
1398
  }
1369
1399
  }
@@ -1389,7 +1419,8 @@ var unsafeHtmlRule = {
1389
1419
  column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
1390
1420
  message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
1391
1421
  severity: "critical",
1392
- category: "security"
1422
+ category: "security",
1423
+ fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
1393
1424
  });
1394
1425
  }
1395
1426
  }
@@ -1404,7 +1435,8 @@ var unsafeHtmlRule = {
1404
1435
  column: file.lines[line - 1].indexOf(".innerHTML") + 1,
1405
1436
  message: "Direct innerHTML assignment is an XSS risk",
1406
1437
  severity: "critical",
1407
- category: "security"
1438
+ category: "security",
1439
+ fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
1408
1440
  });
1409
1441
  }
1410
1442
  }
@@ -1432,7 +1464,8 @@ var unsafeHtmlRule = {
1432
1464
  column: line.indexOf("dangerouslySetInnerHTML") + 1,
1433
1465
  message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
1434
1466
  severity: "critical",
1435
- category: "security"
1467
+ category: "security",
1468
+ fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
1436
1469
  });
1437
1470
  }
1438
1471
  if (/\w\.innerHTML\s*=/.test(line)) {
@@ -1443,7 +1476,8 @@ var unsafeHtmlRule = {
1443
1476
  column: line.indexOf(".innerHTML") + 1,
1444
1477
  message: "Direct innerHTML assignment is an XSS risk",
1445
1478
  severity: "critical",
1446
- category: "security"
1479
+ category: "security",
1480
+ fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
1447
1481
  });
1448
1482
  }
1449
1483
  }
@@ -1505,7 +1539,8 @@ var sqlInjectionRule = {
1505
1539
  column: 1,
1506
1540
  message,
1507
1541
  severity,
1508
- category: "security"
1542
+ category: "security",
1543
+ fix: "Use parameterized queries: db.query('SELECT * FROM users WHERE id = $1', [id])"
1509
1544
  });
1510
1545
  break;
1511
1546
  }
@@ -1553,7 +1588,8 @@ var placeholderContentRule = {
1553
1588
  column: match.index + 1,
1554
1589
  message: label,
1555
1590
  severity: "info",
1556
- category: "ai-quality"
1591
+ category: "ai-quality",
1592
+ fix: "Replace placeholder content with real production values before deploying"
1557
1593
  });
1558
1594
  break;
1559
1595
  }
@@ -1599,7 +1635,8 @@ var staleFallbackRule = {
1599
1635
  column: match.index + 1,
1600
1636
  message: `${label} \u2014 use environment variable instead`,
1601
1637
  severity: "warning",
1602
- category: "ai-quality"
1638
+ category: "ai-quality",
1639
+ fix: "Replace hardcoded URL with an environment variable: process.env.DATABASE_URL or similar"
1603
1640
  });
1604
1641
  break;
1605
1642
  }
@@ -1645,7 +1682,8 @@ var hallucinatedApiRule = {
1645
1682
  column: match.index + 1,
1646
1683
  message: fix,
1647
1684
  severity: "warning",
1648
- category: "ai-quality"
1685
+ category: "ai-quality",
1686
+ fix
1649
1687
  });
1650
1688
  }
1651
1689
  }
@@ -1727,7 +1765,8 @@ var openRedirectRule = {
1727
1765
  column: col,
1728
1766
  message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
1729
1767
  severity: "warning",
1730
- category: "security"
1768
+ category: "security",
1769
+ fix: "Validate the redirect URL against an allowlist of trusted domains"
1731
1770
  });
1732
1771
  return;
1733
1772
  }
@@ -1739,7 +1778,8 @@ var openRedirectRule = {
1739
1778
  column: col,
1740
1779
  message: "Possible user input in redirect \u2014 verify the URL is validated before use",
1741
1780
  severity: "warning",
1742
- category: "security"
1781
+ category: "security",
1782
+ fix: "Validate the redirect URL against an allowlist of trusted domains"
1743
1783
  });
1744
1784
  }
1745
1785
  });
@@ -1760,7 +1800,8 @@ var openRedirectRule = {
1760
1800
  column: match.index + 1,
1761
1801
  message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
1762
1802
  severity: "warning",
1763
- category: "security"
1803
+ category: "security",
1804
+ fix: "Validate the redirect URL against an allowlist of trusted domains"
1764
1805
  });
1765
1806
  break;
1766
1807
  }
@@ -1776,7 +1817,8 @@ var openRedirectRule = {
1776
1817
  column: match.index + 1,
1777
1818
  message: "Possible user input in redirect \u2014 verify the URL is validated before use",
1778
1819
  severity: "warning",
1779
- category: "security"
1820
+ category: "security",
1821
+ fix: "Validate the redirect URL against an allowlist of trusted domains"
1780
1822
  });
1781
1823
  break;
1782
1824
  }
@@ -1814,7 +1856,8 @@ var noSyncFsRule = {
1814
1856
  column: match.index + 1,
1815
1857
  message: `Synchronous ${fnName}() blocks the event loop \u2014 use async alternative`,
1816
1858
  severity,
1817
- category: "performance"
1859
+ category: "performance",
1860
+ fix: `Use the async version: fs.promises.${fnName.replace("Sync", "")}() instead of ${fnName}()`
1818
1861
  });
1819
1862
  }
1820
1863
  }
@@ -1872,7 +1915,8 @@ var noNPlusOneRule = {
1872
1915
  column: match.index + 1,
1873
1916
  message: "Database/fetch call inside loop \u2014 potential N+1 query, consider batching",
1874
1917
  severity: "warning",
1875
- category: "performance"
1918
+ category: "performance",
1919
+ fix: "Use eager loading (include/join) or batch the queries outside the loop"
1876
1920
  });
1877
1921
  break;
1878
1922
  }
@@ -1908,7 +1952,8 @@ var noDynamicImportLoopRule = {
1908
1952
  column: match.index + 1,
1909
1953
  message: "Dynamic import() inside loop \u2014 move import outside or use Promise.all",
1910
1954
  severity: "warning",
1911
- category: "performance"
1955
+ category: "performance",
1956
+ fix: "Move the dynamic import outside the loop and call it once"
1912
1957
  });
1913
1958
  }
1914
1959
  }
@@ -1940,7 +1985,8 @@ var noUnboundedQueryRule = {
1940
1985
  column: line.indexOf(".findMany") + 1,
1941
1986
  message: ".findMany() without take/limit \u2014 query may return unbounded results",
1942
1987
  severity: "warning",
1943
- category: "performance"
1988
+ category: "performance",
1989
+ fix: "Add a LIMIT clause or use pagination to prevent unbounded result sets"
1944
1990
  });
1945
1991
  continue;
1946
1992
  }
@@ -1954,7 +2000,8 @@ var noUnboundedQueryRule = {
1954
2000
  column: line.indexOf(".findMany") + 1,
1955
2001
  message: ".findMany() without take \u2014 add pagination or limit",
1956
2002
  severity: "warning",
1957
- category: "performance"
2003
+ category: "performance",
2004
+ fix: "Add a LIMIT clause or use pagination to prevent unbounded result sets"
1958
2005
  });
1959
2006
  }
1960
2007
  continue;
@@ -1970,7 +2017,8 @@ var noUnboundedQueryRule = {
1970
2017
  column: line.indexOf(".select") + 1,
1971
2018
  message: ".select('*') without .limit() or filter \u2014 add pagination or a where clause",
1972
2019
  severity: "warning",
1973
- category: "performance"
2020
+ category: "performance",
2021
+ fix: "Add a LIMIT clause or use pagination to prevent unbounded result sets"
1974
2022
  });
1975
2023
  }
1976
2024
  }
@@ -2049,7 +2097,8 @@ var unhandledPromiseRule = {
2049
2097
  column: col ? col.index + 1 : 1,
2050
2098
  message: "Async call without await, return, or assignment \u2014 promise result is lost",
2051
2099
  severity: "warning",
2052
- category: "reliability"
2100
+ category: "reliability",
2101
+ fix: "Add .catch() handler or use try/catch with await"
2053
2102
  });
2054
2103
  }
2055
2104
  return findings;
@@ -2081,7 +2130,8 @@ var missingLoadingStateRule = {
2081
2130
  column: 1,
2082
2131
  message: "useEffect with fetch but no loading/pending state \u2014 users see empty content during load",
2083
2132
  severity: "info",
2084
- category: "reliability"
2133
+ category: "reliability",
2134
+ fix: "Add a loading state: const [loading, setLoading] = useState(true) and show a spinner/skeleton while loading"
2085
2135
  }];
2086
2136
  }
2087
2137
  }
@@ -2112,7 +2162,8 @@ var missingErrorBoundaryRule = {
2112
2162
  column: 1,
2113
2163
  message: `Layout without error.tsx \u2014 errors in ${dir} will bubble up to parent error boundary`,
2114
2164
  severity: "info",
2115
- category: "reliability"
2165
+ category: "reliability",
2166
+ fix: "Add an error.tsx file in the same route segment to catch rendering errors"
2116
2167
  }];
2117
2168
  }
2118
2169
  };
@@ -2250,7 +2301,8 @@ var codebaseConsistencyRule = {
2250
2301
  column: 1,
2251
2302
  message: `${dim.label}: ${consistency}% consistent \u2014 dominant: ${dominant} (${dominantCount} files), minority: ${minorities.join(", ")}`,
2252
2303
  severity: consistency < 60 ? "warning" : "info",
2253
- category: "ai-quality"
2304
+ category: "ai-quality",
2305
+ fix: "Standardize on one pattern across the codebase for consistency"
2254
2306
  });
2255
2307
  }
2256
2308
  return findings;
@@ -2379,7 +2431,8 @@ var deadExportsRule = {
2379
2431
  column: 1,
2380
2432
  message: `${totalDead} exported symbols never imported \u2014 dead exports pollute AI context. Top files: ${topFiles.join(", ")}`,
2381
2433
  severity: totalDead > 20 ? "warning" : "info",
2382
- category: "ai-quality"
2434
+ category: "ai-quality",
2435
+ fix: "Remove the unused export or add a consumer"
2383
2436
  });
2384
2437
  }
2385
2438
  return findings;
@@ -2443,7 +2496,8 @@ var shallowCatchRule = {
2443
2496
  column: file.lines[catchLine].indexOf("catch") + 1,
2444
2497
  message: score === 0 ? "Empty catch block \u2014 errors are silently swallowed" : `Decorative error handler: ${label}`,
2445
2498
  severity: score === 0 ? "warning" : "info",
2446
- category: "reliability"
2499
+ category: "reliability",
2500
+ fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
2447
2501
  });
2448
2502
  }
2449
2503
  });
@@ -2511,7 +2565,8 @@ var shallowCatchRule = {
2511
2565
  column: file.lines[i].indexOf("catch") + 1,
2512
2566
  message: score === 0 ? "Empty catch block \u2014 errors are silently swallowed" : `Decorative error handler: ${label}`,
2513
2567
  severity: score === 0 ? "warning" : "info",
2514
- category: "reliability"
2568
+ category: "reliability",
2569
+ fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
2515
2570
  });
2516
2571
  }
2517
2572
  i = bodyEnd;
@@ -2563,7 +2618,8 @@ var comprehensionDebtRule = {
2563
2618
  column: 1,
2564
2619
  message: `${fnName}() has ${params} parameters (max ${MAX_PARAMS}) \u2014 hard to call correctly`,
2565
2620
  severity: "info",
2566
- category: "ai-quality"
2621
+ category: "ai-quality",
2622
+ fix: "Group related parameters into an options object"
2567
2623
  });
2568
2624
  }
2569
2625
  }
@@ -2590,7 +2646,8 @@ var comprehensionDebtRule = {
2590
2646
  column: 1,
2591
2647
  message: `${fnName}() is ${length} lines long (max ${MAX_FUNCTION_LENGTH}) \u2014 consider splitting`,
2592
2648
  severity: "info",
2593
- category: "ai-quality"
2649
+ category: "ai-quality",
2650
+ fix: "Break this into smaller, focused functions with clear names"
2594
2651
  });
2595
2652
  }
2596
2653
  if (maxDepthInFn > MAX_NESTING_DEPTH) {
@@ -2601,7 +2658,8 @@ var comprehensionDebtRule = {
2601
2658
  column: 1,
2602
2659
  message: `${fnName}() has nesting depth ${maxDepthInFn} (max ${MAX_NESTING_DEPTH}) \u2014 flatten with early returns`,
2603
2660
  severity: "info",
2604
- category: "ai-quality"
2661
+ category: "ai-quality",
2662
+ fix: "Reduce nesting with early returns, guard clauses, or extracted helper functions"
2605
2663
  });
2606
2664
  }
2607
2665
  inFunction = false;
@@ -2708,7 +2766,8 @@ var phantomDependencyRule = {
2708
2766
  column: 1,
2709
2767
  message: `"${name}" is a commonly hallucinated package name \u2014 verify it exists and is the correct package`,
2710
2768
  severity: "warning",
2711
- category: "security"
2769
+ category: "security",
2770
+ fix: "Add the missing package to dependencies in package.json, or remove the import if the package is hallucinated"
2712
2771
  });
2713
2772
  }
2714
2773
  for (const pattern of SUSPICIOUS_PATTERNS) {
@@ -2720,7 +2779,8 @@ var phantomDependencyRule = {
2720
2779
  column: 1,
2721
2780
  message: `"${name}" has a suspicious package name pattern \u2014 verify it's legitimate`,
2722
2781
  severity: "info",
2723
- category: "security"
2782
+ category: "security",
2783
+ fix: "Verify the package on npm \u2014 suspicious names may indicate typosquatting or hallucination"
2724
2784
  });
2725
2785
  break;
2726
2786
  }
@@ -3230,6 +3290,7 @@ var useClientOveruseRule = {
3230
3290
  // src/rules/env-fallback-secret.ts
3231
3291
  var SENSITIVE_ENV = /process\.env\.(JWT_SECRET|SECRET_KEY|AUTH_SECRET|SESSION_SECRET|ENCRYPTION_KEY|API_SECRET|PRIVATE_KEY|SIGNING_KEY|NEXTAUTH_SECRET|TOKEN_SECRET|APP_SECRET|COOKIE_SECRET|HASH_SECRET)\s*(?:\|\||&&|\?\?)\s*['"`]/i;
3232
3292
  var ENV_FALLBACK = /process\.env\.\w*(SECRET|KEY|PASSWORD|TOKEN)\w*\s*(?:\|\||\?\?)\s*['"`]/i;
3293
+ var CONN_STRING_FALLBACK = /process\.env\.\w+\s*(?:\|\||\?\?)\s*['"`](?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|mssql):\/\//i;
3233
3294
  var envFallbackSecretRule = {
3234
3295
  id: "env-fallback-secret",
3235
3296
  name: "Secret with Fallback Value",
@@ -3257,6 +3318,20 @@ var envFallbackSecretRule = {
3257
3318
  });
3258
3319
  continue;
3259
3320
  }
3321
+ const connMatch = CONN_STRING_FALLBACK.exec(line);
3322
+ if (connMatch) {
3323
+ findings.push({
3324
+ ruleId: "env-fallback-secret",
3325
+ file: file.relativePath,
3326
+ line: i + 1,
3327
+ column: connMatch.index + 1,
3328
+ message: "Connection string with credentials used as fallback \u2014 hardcoded DB/service URL becomes the production connection when env var is missing",
3329
+ severity: "warning",
3330
+ category: "security",
3331
+ fix: "Fail fast when required env vars are missing instead of falling back to a default value"
3332
+ });
3333
+ continue;
3334
+ }
3260
3335
  const genericMatch = ENV_FALLBACK.exec(line);
3261
3336
  if (genericMatch && !isConfigFile(file.relativePath)) {
3262
3337
  findings.push({
@@ -3473,7 +3548,8 @@ var evalInjectionRule = {
3473
3548
  column: match.index + 1,
3474
3549
  message: msg,
3475
3550
  severity: "critical",
3476
- category: "security"
3551
+ category: "security",
3552
+ fix: "Replace eval/Function constructor with a safe alternative like JSON.parse() or a sandboxed interpreter"
3477
3553
  });
3478
3554
  break;
3479
3555
  }
@@ -4270,44 +4346,57 @@ var clientSideAuthOnlyRule = {
4270
4346
  };
4271
4347
 
4272
4348
  // src/rules/missing-abort-controller.ts
4273
- var FETCH_CALL = /\bfetch\s*\(/;
4349
+ var HTTP_CALL = /\b(fetch|axios)\s*[.(]/;
4274
4350
  var HAS_TIMEOUT = [
4275
4351
  /AbortController/,
4276
4352
  /abort/i,
4277
4353
  /signal\s*:/,
4278
- /timeout/i,
4279
- /setTimeout.*abort/s
4354
+ /timeout\s*:/,
4355
+ /timeout\s*=/,
4356
+ /setTimeout.*abort/s,
4357
+ /axios\.create\s*\([^)]*timeout/s
4280
4358
  ];
4359
+ var BACKGROUND_PATTERN = /\b(setInterval|cron|schedule|createWorker|bullmq|agenda|queue\.process)\b/;
4360
+ function isBackgroundFile(file) {
4361
+ if (BACKGROUND_PATTERN.test(file.content)) return true;
4362
+ const p = file.relativePath.toLowerCase();
4363
+ return /\b(cron|jobs?|workers?|queues?|tasks?|background)\b/.test(p);
4364
+ }
4281
4365
  var missingAbortControllerRule = {
4282
4366
  id: "missing-abort-controller",
4283
4367
  name: "Missing Abort Controller",
4284
- description: "Detects fetch calls in API routes without timeout or AbortController \u2014 requests can hang indefinitely",
4368
+ description: "Detects fetch/axios calls without timeout or AbortController \u2014 requests can hang indefinitely",
4285
4369
  category: "performance",
4286
4370
  severity: "info",
4287
4371
  fileExtensions: ["ts", "tsx", "js", "jsx"],
4288
4372
  check(file, _project) {
4289
4373
  if (isTestFile(file.relativePath)) return [];
4290
- if (!isApiRoute(file.relativePath) && !/['"]use server['"]/.test(file.content)) return [];
4291
- if (!FETCH_CALL.test(file.content)) return [];
4374
+ const isRelevant = isApiRoute(file.relativePath) || /['"]use server['"]/.test(file.content) || isBackgroundFile(file);
4375
+ if (!isRelevant) return [];
4376
+ if (!HTTP_CALL.test(file.content)) return [];
4292
4377
  const hasTimeout = HAS_TIMEOUT.some((p) => p.test(file.content));
4293
4378
  if (hasTimeout) return [];
4294
4379
  let reportLine = 1;
4380
+ let matchedCall = "fetch";
4295
4381
  for (let i = 0; i < file.lines.length; i++) {
4296
4382
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
4297
- if (FETCH_CALL.test(file.lines[i])) {
4383
+ const m = file.lines[i].match(HTTP_CALL);
4384
+ if (m) {
4298
4385
  reportLine = i + 1;
4386
+ matchedCall = m[1];
4299
4387
  break;
4300
4388
  }
4301
4389
  }
4390
+ const isFetch = matchedCall === "fetch";
4302
4391
  return [{
4303
4392
  ruleId: "missing-abort-controller",
4304
4393
  file: file.relativePath,
4305
4394
  line: reportLine,
4306
4395
  column: 1,
4307
- message: "fetch() without timeout or AbortController \u2014 request will hang indefinitely if the upstream server doesn't respond",
4396
+ message: `${matchedCall}() without timeout or AbortController \u2014 request will hang indefinitely if the upstream server doesn't respond`,
4308
4397
  severity: "info",
4309
4398
  category: "performance",
4310
- fix: "Add a timeout: const controller = new AbortController(); setTimeout(() => controller.abort(), 10000); fetch(url, { signal: controller.signal })"
4399
+ fix: isFetch ? "Add a timeout: const controller = new AbortController(); setTimeout(() => controller.abort(), 10000); fetch(url, { signal: controller.signal })" : "Add a timeout: axios.get(url, { timeout: 10000 }) or configure in axios.create({ timeout: 10000 })"
4311
4400
  }];
4312
4401
  }
4313
4402
  };
@@ -4553,111 +4642,113 @@ function reportWebPretty(result) {
4553
4642
  }
4554
4643
 
4555
4644
  // src/web-scanner/checks.ts
4556
- function make(id, name, description, maxPoints, severity, status, details) {
4645
+ function make(id, name, description, maxPoints, severity, maturity, status, details) {
4557
4646
  return {
4558
4647
  id,
4559
4648
  name,
4560
4649
  description,
4561
4650
  status,
4562
4651
  severity,
4652
+ maturity,
4563
4653
  details,
4564
4654
  points: status === "pass" ? maxPoints : status === "warn" ? Math.floor(maxPoints / 2) : 0,
4565
4655
  maxPoints
4566
4656
  };
4567
4657
  }
4568
4658
  function checkRobotsTxt(ctx) {
4569
- if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "fail", "No robots.txt found.");
4570
- return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "pass", "robots.txt found.");
4659
+ if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 7, "medium", "established", "fail", "No robots.txt found.");
4660
+ return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 7, "medium", "established", "pass", "robots.txt found.");
4571
4661
  }
4572
4662
  function checkRobotsAiDirectives(ctx) {
4573
- if (!ctx.robotsTxt) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "fail", "No robots.txt found. AI bots have no guidance.");
4574
- const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai"];
4663
+ if (!ctx.robotsTxt) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "established", "fail", "No robots.txt found. AI bots have no guidance.");
4664
+ const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai", "Applebot-Extended", "Grok"];
4575
4665
  const found = agents.filter((a) => ctx.robotsTxt.toLowerCase().includes(a.toLowerCase()));
4576
- if (found.length === 0) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "fail", "No AI-specific user-agent directives found.");
4577
- if (found.length < 3) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "warn", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4578
- return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "pass", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4666
+ if (found.length === 0) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "established", "fail", "No AI-specific user-agent directives found.");
4667
+ if (found.length < 3) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "established", "warn", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4668
+ return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "established", "pass", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4579
4669
  }
4580
4670
  function checkContentUsage(ctx) {
4581
4671
  const hasHeader = ctx.headers["content-usage"] != null;
4582
4672
  const hasInRobots = ctx.robotsTxt?.toLowerCase().includes("content-usage") ?? false;
4583
- if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "fail", "No Content-Usage directives found.");
4584
- return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "pass", hasHeader ? `Header: ${ctx.headers["content-usage"]}` : "Found in robots.txt.");
4673
+ if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 5, "high", "emerging", "fail", "No Content-Usage directives found.");
4674
+ return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 5, "high", "emerging", "pass", hasHeader ? `Header: ${ctx.headers["content-usage"]}` : "Found in robots.txt.");
4585
4675
  }
4586
4676
  function checkLlmsTxt(ctx) {
4587
- if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "fail", "No llms.txt found.");
4588
- const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0);
4589
- if (lines.length < 3) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "warn", "llms.txt found but appears minimal.");
4590
- return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "pass", `llms.txt found with ${lines.length} lines.`);
4677
+ if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 6, "high", "emerging", "fail", "No llms.txt found.");
4678
+ const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
4679
+ if (lines.length < 3) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 6, "high", "emerging", "warn", "llms.txt found but appears minimal.");
4680
+ return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 6, "high", "emerging", "pass", `llms.txt found with ${lines.length} content lines.`);
4591
4681
  }
4592
4682
  function checkTdmRep(ctx) {
4593
4683
  const hasWK = ctx.tdmRep != null;
4594
4684
  const hasHeader = ctx.headers["tdm-reservation"] != null;
4595
- if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "fail", "No TDMRep configuration found.");
4596
- return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "pass", hasWK ? "/.well-known/tdmrep.json found." : `TDM-Reservation header: ${ctx.headers["tdm-reservation"]}`);
4685
+ if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 4, "medium", "emerging", "fail", "No TDMRep configuration found.");
4686
+ return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 4, "medium", "emerging", "pass", hasWK ? "/.well-known/tdmrep.json found." : `TDM-Reservation header: ${ctx.headers["tdm-reservation"]}`);
4597
4687
  }
4598
4688
  function checkAiDisclosure(ctx) {
4599
- if (!ctx.headers["ai-disclosure"]) return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "fail", "No AI-Disclosure header found.");
4600
- return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
4689
+ if (!ctx.headers["ai-disclosure"]) return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 2, "low", "emerging", "fail", "No AI-Disclosure header found.");
4690
+ return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 2, "low", "emerging", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
4601
4691
  }
4602
4692
  function checkAgentCard(ctx) {
4603
- if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "fail", "No A2A AgentCard found.");
4693
+ if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 5, "high", "emerging", "fail", "No A2A AgentCard found.");
4604
4694
  try {
4605
4695
  const card = JSON.parse(ctx.agentCard);
4606
- if (!card.name || !Array.isArray(card.skills) || card.skills.length === 0) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but missing name or skills.");
4607
- return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "pass", `AgentCard found with ${card.skills.length} skill(s).`);
4696
+ if (!card.name || !Array.isArray(card.skills) || card.skills.length === 0) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 5, "high", "emerging", "warn", "AgentCard found but missing name or skills.");
4697
+ return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 5, "high", "emerging", "pass", `AgentCard found with ${card.skills.length} skill(s).`);
4608
4698
  } catch {
4609
- return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but contains invalid JSON.");
4699
+ return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 5, "high", "emerging", "warn", "AgentCard found but contains invalid JSON.");
4610
4700
  }
4611
4701
  }
4612
4702
  function checkAiTxt(ctx) {
4613
- if (!ctx.aiTxt) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "fail", "No ai.txt found at site root.");
4703
+ if (!ctx.aiTxt) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 3, "medium", "emerging", "fail", "No ai.txt found at site root.");
4614
4704
  const lines = ctx.aiTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
4615
- if (lines.length < 2) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "warn", "ai.txt found but appears minimal.");
4616
- return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "pass", `ai.txt found with ${lines.length} directive(s).`);
4705
+ if (lines.length < 2) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 3, "medium", "emerging", "warn", "ai.txt found but appears minimal.");
4706
+ return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 3, "medium", "emerging", "pass", `ai.txt found with ${lines.length} directive(s).`);
4617
4707
  }
4618
4708
  function checkWebMCP(ctx) {
4619
- if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "info", "Could not check for WebMCP tools.");
4709
+ if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 5, "high", "emerging", "info", "Could not check for WebMCP tools.");
4620
4710
  const hasToolname = /toolname=/i.test(ctx.html);
4621
4711
  const hasModelContext = /navigator\.modelContext/i.test(ctx.html) || /registerTool/i.test(ctx.html);
4622
- if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "fail", "No WebMCP tools detected.");
4712
+ if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 5, "high", "emerging", "fail", "No WebMCP tools detected.");
4623
4713
  const count = (ctx.html.match(/toolname=/gi) || []).length;
4624
- return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
4714
+ return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 5, "high", "emerging", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
4625
4715
  }
4626
4716
  function checkStructuredData(ctx) {
4627
- if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "info", "Could not fetch page HTML.");
4717
+ if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 12, "medium", "established", "info", "Could not fetch page HTML.");
4628
4718
  const jsonLd = ctx.html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>/gi);
4629
4719
  const hasMicrodata = ctx.html.includes("itemscope") && ctx.html.includes("itemtype");
4630
- if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "fail", "No structured data found.");
4631
- return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "pass", `Found ${(jsonLd?.length || 0) + (hasMicrodata ? 1 : 0)} structured data block(s).`);
4720
+ if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 12, "medium", "established", "fail", "No structured data found.");
4721
+ return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 12, "medium", "established", "pass", `Found ${(jsonLd?.length || 0) + (hasMicrodata ? 1 : 0)} structured data block(s).`);
4632
4722
  }
4633
4723
  function checkOpenGraph(ctx) {
4634
- if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "info", "Could not fetch HTML.");
4724
+ if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "info", "Could not fetch HTML.");
4635
4725
  const checks = [/og:title/i.test(ctx.html), /og:description/i.test(ctx.html), /og:image/i.test(ctx.html), /name=["']description["']/i.test(ctx.html)];
4636
4726
  const passed = checks.filter(Boolean).length;
4637
- if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "fail", "No OpenGraph tags or meta description.");
4638
- if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "warn", `Found ${passed}/4 meta tags.`);
4639
- return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "pass", `All ${passed} key meta tags present.`);
4727
+ if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "fail", "No OpenGraph tags or meta description.");
4728
+ if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "warn", `Found ${passed}/4 meta tags.`);
4729
+ if (passed < 4) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "pass", `Found ${passed}/4 key meta tags.`);
4730
+ return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "pass", "All 4 key meta tags present.");
4640
4731
  }
4641
4732
  function checkSitemap(ctx) {
4642
- if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "fail", "No sitemap.xml found.");
4733
+ if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "fail", "No sitemap.xml found.");
4643
4734
  const count = Math.max((ctx.sitemapXml.match(/<url>/gi) || []).length, (ctx.sitemapXml.match(/<loc>/gi) || []).length);
4644
- if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", "Sitemap index found.");
4645
- if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "warn", "sitemap.xml found but appears empty.");
4646
- return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", `sitemap.xml found with ${count} URL(s).`);
4735
+ if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "pass", "Sitemap index found.");
4736
+ if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "warn", "sitemap.xml found but appears empty.");
4737
+ return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "pass", `sitemap.xml found with ${count} URL(s).`);
4647
4738
  }
4648
4739
  function checkHttpSignatures(ctx) {
4649
4740
  const hasDirectory = ctx.httpSigDirectory != null;
4650
4741
  const hasSignatureHeader = ctx.headers["signature"] != null || ctx.headers["signature-input"] != null;
4651
4742
  const hasSignatureAgent = ctx.headers["signature-agent"] != null;
4652
- if (!hasDirectory && !hasSignatureHeader && !hasSignatureAgent) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 5, "medium", "fail", "No HTTP signature support detected.");
4653
- if (hasDirectory) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 5, "medium", "pass", "/.well-known/http-message-signatures-directory found.");
4654
- return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 5, "medium", "pass", hasSignatureAgent ? "Signature-Agent header detected." : "Signature/Signature-Input headers detected.");
4743
+ if (!hasDirectory && !hasSignatureHeader && !hasSignatureAgent) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 3, "medium", "emerging", "fail", "No HTTP signature support detected.");
4744
+ if (hasDirectory) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 3, "medium", "emerging", "pass", "/.well-known/http-message-signatures-directory found.");
4745
+ return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 3, "medium", "emerging", "pass", hasSignatureAgent ? "Signature-Agent header detected." : "Signature/Signature-Input headers detected.");
4655
4746
  }
4656
4747
  function checkPageSpeed(ctx) {
4657
- if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "info", "Could not measure.");
4658
- if (ctx.loadTimeMs > 5e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "fail", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 agents may time out.`);
4659
- if (ctx.loadTimeMs > 2e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "warn", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 consider optimizing.`);
4660
- return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
4748
+ if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "info", "Could not measure.");
4749
+ if (ctx.loadTimeMs > 5e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "fail", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 agents may time out.`);
4750
+ if (ctx.loadTimeMs > 2e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "warn", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 consider optimizing.`);
4751
+ return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
4661
4752
  }
4662
4753
  var allChecks = [
4663
4754
  checkRobotsTxt,
@@ -4696,6 +4787,29 @@ function isPrivateHost(hostname) {
4696
4787
  if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) return true;
4697
4788
  const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
4698
4789
  if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || h === "::") return true;
4790
+ const v4mapped = h.match(/^(?:::ffff:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
4791
+ if (v4mapped) {
4792
+ const [, a, b] = v4mapped.map(Number);
4793
+ if (a === 10 || a === 127 || a === 0) return true;
4794
+ if (a === 172 && b >= 16 && b <= 31) return true;
4795
+ if (a === 192 && b === 168) return true;
4796
+ if (a === 169 && b === 254) return true;
4797
+ return false;
4798
+ }
4799
+ if (/^(0[xX][0-9a-fA-F]+|0[0-7]+)(\.|$)/.test(hostname)) return true;
4800
+ if (/^\d+$/.test(hostname)) {
4801
+ const dec = parseInt(hostname, 10);
4802
+ if (dec >= 0 && dec <= 4294967295) {
4803
+ const a = dec >>> 24 & 255;
4804
+ const b = dec >>> 16 & 255;
4805
+ if (a === 10 || a === 127 || a === 0) return true;
4806
+ if (a === 172 && b >= 16 && b <= 31) return true;
4807
+ if (a === 192 && b === 168) return true;
4808
+ if (a === 169 && b === 254) return true;
4809
+ if (a === 100 && b >= 64 && b <= 127) return true;
4810
+ if (a === 198 && (b === 18 || b === 19)) return true;
4811
+ }
4812
+ }
4699
4813
  const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
4700
4814
  if (ipv4) {
4701
4815
  const [, a, b] = ipv4.map(Number);
@@ -4708,22 +4822,49 @@ function isPrivateHost(hostname) {
4708
4822
  }
4709
4823
  return false;
4710
4824
  }
4825
+ function isAllowedProtocol(url) {
4826
+ try {
4827
+ const p = new URL(url).protocol;
4828
+ return p === "https:" || p === "http:";
4829
+ } catch {
4830
+ return false;
4831
+ }
4832
+ }
4711
4833
  function validateRedirectUrl(responseUrl) {
4712
4834
  try {
4713
4835
  const parsed = new URL(responseUrl);
4836
+ if (!isAllowedProtocol(responseUrl)) return false;
4714
4837
  return !isPrivateHost(parsed.hostname);
4715
4838
  } catch {
4716
4839
  return false;
4717
4840
  }
4718
4841
  }
4842
+ var MAX_REDIRECTS = 5;
4843
+ async function safeFetch(url, timeout, signal) {
4844
+ let current = url;
4845
+ for (let i = 0; i < MAX_REDIRECTS; i++) {
4846
+ const r = await fetch(current, { signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "manual" });
4847
+ const status = r.status;
4848
+ if (status >= 300 && status < 400) {
4849
+ const location = r.headers.get("location");
4850
+ if (!location) return null;
4851
+ const resolved = new URL(location, current).toString();
4852
+ if (!validateRedirectUrl(resolved)) return null;
4853
+ current = resolved;
4854
+ continue;
4855
+ }
4856
+ return r;
4857
+ }
4858
+ return null;
4859
+ }
4719
4860
  async function fetchText(url, timeout = 8e3) {
4720
4861
  try {
4721
4862
  const c = new AbortController();
4722
4863
  const t = setTimeout(() => c.abort(), timeout);
4723
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4864
+ const r = await safeFetch(url, timeout, c.signal);
4724
4865
  clearTimeout(t);
4725
- if (r.url && !validateRedirectUrl(r.url)) return null;
4726
- return r.ok ? await r.text() : null;
4866
+ if (!r || !r.ok) return null;
4867
+ return await r.text();
4727
4868
  } catch {
4728
4869
  return null;
4729
4870
  }
@@ -4732,9 +4873,9 @@ async function fetchHeaders(url, timeout = 8e3) {
4732
4873
  try {
4733
4874
  const c = new AbortController();
4734
4875
  const t = setTimeout(() => c.abort(), timeout);
4735
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4876
+ const r = await safeFetch(url, timeout, c.signal);
4736
4877
  clearTimeout(t);
4737
- if (r.url && !validateRedirectUrl(r.url)) return {};
4878
+ if (!r) return {};
4738
4879
  const h = {};
4739
4880
  r.headers.forEach((v, k) => {
4740
4881
  h[k.toLowerCase()] = v;
@@ -4749,20 +4890,18 @@ async function fetchWithTiming(url, timeout = 15e3) {
4749
4890
  const c = new AbortController();
4750
4891
  const t = setTimeout(() => c.abort(), timeout);
4751
4892
  const start = Date.now();
4752
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4753
- const html = await r.text();
4893
+ const r = await safeFetch(url, timeout, c.signal);
4754
4894
  clearTimeout(t);
4755
- if (r.url && !validateRedirectUrl(r.url)) return { html: null, loadTimeMs: null };
4756
- return r.ok ? { html, loadTimeMs: Date.now() - start } : { html: null, loadTimeMs: null };
4895
+ if (!r || !r.ok) return { html: null, loadTimeMs: null };
4896
+ const html = await r.text();
4897
+ return { html, loadTimeMs: Date.now() - start };
4757
4898
  } catch {
4758
4899
  return { html: null, loadTimeMs: null };
4759
4900
  }
4760
4901
  }
4761
4902
  function normalizeUrl(input) {
4762
4903
  let url = input.trim();
4763
- if (!/^https?:\/\//i.test(url)) {
4764
- url = `https://${url}`;
4765
- }
4904
+ if (!/^https?:\/\//i.test(url)) url = `https://${url}`;
4766
4905
  const parsed = new URL(url);
4767
4906
  return parsed.origin;
4768
4907
  }