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/README.md +3 -3
- package/dist/cli.js +263 -124
- package/dist/index.d.ts +7 -5
- package/dist/index.js +262 -121
- package/dist/mcp.js +267 -126
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -710,8 +710,8 @@ function summarizeFindings(findings) {
|
|
|
710
710
|
|
|
711
711
|
// src/rules/secrets.ts
|
|
712
712
|
var SECRET_PATTERNS = [
|
|
713
|
-
{ name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{
|
|
714
|
-
{ name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{
|
|
713
|
+
{ name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{8,}/ },
|
|
714
|
+
{ name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{8,}/ },
|
|
715
715
|
{ name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/ },
|
|
716
716
|
{ name: "AWS secret key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/ },
|
|
717
717
|
{ name: "Supabase service role key", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/ },
|
|
@@ -744,7 +744,8 @@ var secretsRule = {
|
|
|
744
744
|
column: match.index + 1,
|
|
745
745
|
message: `Hardcoded ${name} detected`,
|
|
746
746
|
severity: "critical",
|
|
747
|
-
category: "security"
|
|
747
|
+
category: "security",
|
|
748
|
+
fix: "Move the secret to an environment variable and access via process.env.SECRET_NAME"
|
|
748
749
|
});
|
|
749
750
|
}
|
|
750
751
|
}
|
|
@@ -828,7 +829,8 @@ var hallucinatedImportsRule = {
|
|
|
828
829
|
column: 1,
|
|
829
830
|
message: `Package "${pkgName}" is imported but not in package.json`,
|
|
830
831
|
severity: isNonProd ? "warning" : "critical",
|
|
831
|
-
category: "reliability"
|
|
832
|
+
category: "reliability",
|
|
833
|
+
fix: "Verify the package exists on npm. The AI may have invented this package name."
|
|
832
834
|
});
|
|
833
835
|
}
|
|
834
836
|
return findings;
|
|
@@ -856,7 +858,8 @@ var hallucinatedImportsRule = {
|
|
|
856
858
|
column: match.index + 1,
|
|
857
859
|
message: `Package "${pkgName}" is imported but not in package.json`,
|
|
858
860
|
severity: isNonProd ? "warning" : "critical",
|
|
859
|
-
category: "reliability"
|
|
861
|
+
category: "reliability",
|
|
862
|
+
fix: "Verify the package exists on npm. The AI may have invented this package name."
|
|
860
863
|
});
|
|
861
864
|
}
|
|
862
865
|
}
|
|
@@ -939,7 +942,8 @@ var authChecksRule = {
|
|
|
939
942
|
column: 1,
|
|
940
943
|
message,
|
|
941
944
|
severity,
|
|
942
|
-
category: "security"
|
|
945
|
+
category: "security",
|
|
946
|
+
fix: "Add authentication check: const session = await auth(); if (!session) return new Response('Unauthorized', { status: 401 })"
|
|
943
947
|
}];
|
|
944
948
|
}
|
|
945
949
|
};
|
|
@@ -978,7 +982,8 @@ var envExposureRule = {
|
|
|
978
982
|
column: 1,
|
|
979
983
|
message: ".env is not listed in .gitignore \u2014 secrets may be committed",
|
|
980
984
|
severity: "critical",
|
|
981
|
-
category: "security"
|
|
985
|
+
category: "security",
|
|
986
|
+
fix: "Add .env to .gitignore to prevent committing secrets"
|
|
982
987
|
});
|
|
983
988
|
}
|
|
984
989
|
return findings;
|
|
@@ -1000,7 +1005,8 @@ var envExposureRule = {
|
|
|
1000
1005
|
column: match.index + 1,
|
|
1001
1006
|
message: isSensitive ? `Sensitive server env var "${envName}" used in client component` : `Server env var "${envName}" used in client component (will be undefined at runtime)`,
|
|
1002
1007
|
severity: isSensitive ? "critical" : "warning",
|
|
1003
|
-
category: "security"
|
|
1008
|
+
category: "security",
|
|
1009
|
+
fix: "Move to server-only file or use NEXT_PUBLIC_ prefix only for non-sensitive values"
|
|
1004
1010
|
});
|
|
1005
1011
|
}
|
|
1006
1012
|
}
|
|
@@ -1038,7 +1044,8 @@ var errorHandlingRule = {
|
|
|
1038
1044
|
column: 1,
|
|
1039
1045
|
message: "API route handler has no try/catch block",
|
|
1040
1046
|
severity: "warning",
|
|
1041
|
-
category: "reliability"
|
|
1047
|
+
category: "reliability",
|
|
1048
|
+
fix: "Wrap the handler body in try/catch and return appropriate error responses"
|
|
1042
1049
|
}];
|
|
1043
1050
|
}
|
|
1044
1051
|
};
|
|
@@ -1096,7 +1103,8 @@ var inputValidationRule = {
|
|
|
1096
1103
|
column: 1,
|
|
1097
1104
|
message: "Request body accessed without validation (consider using Zod, Yup, or similar)",
|
|
1098
1105
|
severity: "warning",
|
|
1099
|
-
category: "security"
|
|
1106
|
+
category: "security",
|
|
1107
|
+
fix: "Validate input with Zod or a similar schema library before using it"
|
|
1100
1108
|
}];
|
|
1101
1109
|
}
|
|
1102
1110
|
};
|
|
@@ -1153,12 +1161,22 @@ var rateLimitingRule = {
|
|
|
1153
1161
|
column: 1,
|
|
1154
1162
|
message: "No rate limiting \u2014 anyone could spam this endpoint and run up your API costs",
|
|
1155
1163
|
severity: "info",
|
|
1156
|
-
category: "security"
|
|
1164
|
+
category: "security",
|
|
1165
|
+
fix: "Add rate limiting: import { Ratelimit } from '@upstash/ratelimit' or use express-rate-limit"
|
|
1157
1166
|
}];
|
|
1158
1167
|
}
|
|
1159
1168
|
};
|
|
1160
1169
|
|
|
1161
1170
|
// src/rules/cors-config.ts
|
|
1171
|
+
function hasCredentialsNearby(lines, startLine, commentMap) {
|
|
1172
|
+
const end = Math.min(lines.length, startLine + 8);
|
|
1173
|
+
for (let j = startLine; j < end; j++) {
|
|
1174
|
+
if (commentMap[j]) continue;
|
|
1175
|
+
if (/credentials\s*:\s*true/.test(lines[j])) return true;
|
|
1176
|
+
if (/Access-Control-Allow-Credentials['"]\s*[,:]\s*['"]true['"]/.test(lines[j])) return true;
|
|
1177
|
+
}
|
|
1178
|
+
return false;
|
|
1179
|
+
}
|
|
1162
1180
|
var corsConfigRule = {
|
|
1163
1181
|
id: "cors-config",
|
|
1164
1182
|
name: "Permissive CORS",
|
|
@@ -1172,14 +1190,16 @@ var corsConfigRule = {
|
|
|
1172
1190
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1173
1191
|
const line = file.lines[i];
|
|
1174
1192
|
if (/['"]Access-Control-Allow-Origin['"]\s*[,:]\s*['"]\*['"]/.test(line)) {
|
|
1193
|
+
const withCreds = hasCredentialsNearby(file.lines, i, file.commentMap);
|
|
1175
1194
|
findings.push({
|
|
1176
1195
|
ruleId: "cors-config",
|
|
1177
1196
|
file: file.relativePath,
|
|
1178
1197
|
line: i + 1,
|
|
1179
1198
|
column: line.indexOf("Access-Control") + 1,
|
|
1180
|
-
message: 'Access-Control-Allow-Origin set to "*" allows any domain',
|
|
1181
|
-
severity: "warning",
|
|
1182
|
-
category: "security"
|
|
1199
|
+
message: withCreds ? "CORS wildcard with credentials allows any site to make authenticated requests" : 'Access-Control-Allow-Origin set to "*" allows any domain',
|
|
1200
|
+
severity: withCreds ? "critical" : "warning",
|
|
1201
|
+
category: "security",
|
|
1202
|
+
fix: withCreds ? "Never use credentials with wildcard origin. Set origin to specific trusted domains." : "Restrict origin to specific domains: origin: ['https://yourdomain.com']"
|
|
1183
1203
|
});
|
|
1184
1204
|
}
|
|
1185
1205
|
if (/cors\(\s*\)/.test(line)) {
|
|
@@ -1190,18 +1210,21 @@ var corsConfigRule = {
|
|
|
1190
1210
|
column: line.indexOf("cors(") + 1,
|
|
1191
1211
|
message: "cors() called without config allows all origins",
|
|
1192
1212
|
severity: "warning",
|
|
1193
|
-
category: "security"
|
|
1213
|
+
category: "security",
|
|
1214
|
+
fix: "Pass explicit options: cors({ origin: 'https://yourdomain.com' })"
|
|
1194
1215
|
});
|
|
1195
1216
|
}
|
|
1196
1217
|
if (/origin\s*:\s*['"]\*['"]/.test(line)) {
|
|
1218
|
+
const withCreds = hasCredentialsNearby(file.lines, i, file.commentMap);
|
|
1197
1219
|
findings.push({
|
|
1198
1220
|
ruleId: "cors-config",
|
|
1199
1221
|
file: file.relativePath,
|
|
1200
1222
|
line: i + 1,
|
|
1201
1223
|
column: line.indexOf("origin") + 1,
|
|
1202
|
-
message: 'CORS origin set to "*" allows any domain',
|
|
1203
|
-
severity: "warning",
|
|
1204
|
-
category: "security"
|
|
1224
|
+
message: withCreds ? "CORS wildcard with credentials allows any site to make authenticated requests" : 'CORS origin set to "*" allows any domain',
|
|
1225
|
+
severity: withCreds ? "critical" : "warning",
|
|
1226
|
+
category: "security",
|
|
1227
|
+
fix: withCreds ? "Never use credentials with wildcard origin. Set origin to specific trusted domains." : "Restrict origin to specific domains: origin: ['https://yourdomain.com']"
|
|
1205
1228
|
});
|
|
1206
1229
|
}
|
|
1207
1230
|
if (/origin\s*:\s*true/.test(line)) {
|
|
@@ -1212,7 +1235,8 @@ var corsConfigRule = {
|
|
|
1212
1235
|
column: line.indexOf("origin") + 1,
|
|
1213
1236
|
message: "CORS origin set to true mirrors any requesting origin",
|
|
1214
1237
|
severity: "warning",
|
|
1215
|
-
category: "security"
|
|
1238
|
+
category: "security",
|
|
1239
|
+
fix: "Set origin to specific domains instead of reflecting the request origin"
|
|
1216
1240
|
});
|
|
1217
1241
|
}
|
|
1218
1242
|
}
|
|
@@ -1221,7 +1245,7 @@ var corsConfigRule = {
|
|
|
1221
1245
|
};
|
|
1222
1246
|
|
|
1223
1247
|
// src/rules/ai-smells.ts
|
|
1224
|
-
var CONSOLE_LOG_THRESHOLD =
|
|
1248
|
+
var CONSOLE_LOG_THRESHOLD = 3;
|
|
1225
1249
|
var ANY_TYPE_THRESHOLD = 5;
|
|
1226
1250
|
var COMMENTED_CODE_THRESHOLD = 3;
|
|
1227
1251
|
var aiSmellsRule = {
|
|
@@ -1255,7 +1279,8 @@ var aiSmellsRule = {
|
|
|
1255
1279
|
column: line.indexOf(todoMatch[1]) + 1,
|
|
1256
1280
|
message: `${todoMatch[1]} comment: ${todoMatch[2].trim() || "(no description)"}`,
|
|
1257
1281
|
severity: "info",
|
|
1258
|
-
category: "ai-quality"
|
|
1282
|
+
category: "ai-quality",
|
|
1283
|
+
fix: "Resolve the TODO/FIXME before shipping to production"
|
|
1259
1284
|
});
|
|
1260
1285
|
}
|
|
1261
1286
|
const commentContent = trimmed.slice(2).trim();
|
|
@@ -1270,7 +1295,8 @@ var aiSmellsRule = {
|
|
|
1270
1295
|
column: 1,
|
|
1271
1296
|
message: `${COMMENTED_CODE_THRESHOLD}+ consecutive lines of commented-out code`,
|
|
1272
1297
|
severity: "info",
|
|
1273
|
-
category: "ai-quality"
|
|
1298
|
+
category: "ai-quality",
|
|
1299
|
+
fix: "Remove commented-out code \u2014 use version control to recover old code if needed"
|
|
1274
1300
|
});
|
|
1275
1301
|
}
|
|
1276
1302
|
} else {
|
|
@@ -1287,7 +1313,8 @@ var aiSmellsRule = {
|
|
|
1287
1313
|
column: 1,
|
|
1288
1314
|
message: 'Placeholder "not implemented" function',
|
|
1289
1315
|
severity: "warning",
|
|
1290
|
-
category: "ai-quality"
|
|
1316
|
+
category: "ai-quality",
|
|
1317
|
+
fix: "Replace with a production-ready implementation or remove the function"
|
|
1291
1318
|
});
|
|
1292
1319
|
}
|
|
1293
1320
|
if (/console\.log\s*\(/.test(line)) {
|
|
@@ -1305,7 +1332,8 @@ var aiSmellsRule = {
|
|
|
1305
1332
|
column: 1,
|
|
1306
1333
|
message: `${consoleLogCount} console.log statements (consider a proper logger)`,
|
|
1307
1334
|
severity: "warning",
|
|
1308
|
-
category: "ai-quality"
|
|
1335
|
+
category: "ai-quality",
|
|
1336
|
+
fix: "Replace console.log with a structured logger (e.g., pino, winston) or remove debug logs"
|
|
1309
1337
|
});
|
|
1310
1338
|
}
|
|
1311
1339
|
if (anyTypeCount > ANY_TYPE_THRESHOLD) {
|
|
@@ -1316,7 +1344,8 @@ var aiSmellsRule = {
|
|
|
1316
1344
|
column: 1,
|
|
1317
1345
|
message: `${anyTypeCount} uses of "any" type (consider proper typing)`,
|
|
1318
1346
|
severity: "warning",
|
|
1319
|
-
category: "ai-quality"
|
|
1347
|
+
category: "ai-quality",
|
|
1348
|
+
fix: 'Replace "any" with specific types or use "unknown" with type narrowing'
|
|
1320
1349
|
});
|
|
1321
1350
|
}
|
|
1322
1351
|
return findings;
|
|
@@ -1358,7 +1387,8 @@ var unsafeHtmlRule = {
|
|
|
1358
1387
|
column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
|
|
1359
1388
|
message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
|
|
1360
1389
|
severity: "critical",
|
|
1361
|
-
category: "security"
|
|
1390
|
+
category: "security",
|
|
1391
|
+
fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
|
|
1362
1392
|
});
|
|
1363
1393
|
}
|
|
1364
1394
|
}
|
|
@@ -1384,7 +1414,8 @@ var unsafeHtmlRule = {
|
|
|
1384
1414
|
column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
|
|
1385
1415
|
message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
|
|
1386
1416
|
severity: "critical",
|
|
1387
|
-
category: "security"
|
|
1417
|
+
category: "security",
|
|
1418
|
+
fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
|
|
1388
1419
|
});
|
|
1389
1420
|
}
|
|
1390
1421
|
}
|
|
@@ -1399,7 +1430,8 @@ var unsafeHtmlRule = {
|
|
|
1399
1430
|
column: file.lines[line - 1].indexOf(".innerHTML") + 1,
|
|
1400
1431
|
message: "Direct innerHTML assignment is an XSS risk",
|
|
1401
1432
|
severity: "critical",
|
|
1402
|
-
category: "security"
|
|
1433
|
+
category: "security",
|
|
1434
|
+
fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
|
|
1403
1435
|
});
|
|
1404
1436
|
}
|
|
1405
1437
|
}
|
|
@@ -1427,7 +1459,8 @@ var unsafeHtmlRule = {
|
|
|
1427
1459
|
column: line.indexOf("dangerouslySetInnerHTML") + 1,
|
|
1428
1460
|
message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
|
|
1429
1461
|
severity: "critical",
|
|
1430
|
-
category: "security"
|
|
1462
|
+
category: "security",
|
|
1463
|
+
fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
|
|
1431
1464
|
});
|
|
1432
1465
|
}
|
|
1433
1466
|
if (/\w\.innerHTML\s*=/.test(line)) {
|
|
@@ -1438,7 +1471,8 @@ var unsafeHtmlRule = {
|
|
|
1438
1471
|
column: line.indexOf(".innerHTML") + 1,
|
|
1439
1472
|
message: "Direct innerHTML assignment is an XSS risk",
|
|
1440
1473
|
severity: "critical",
|
|
1441
|
-
category: "security"
|
|
1474
|
+
category: "security",
|
|
1475
|
+
fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
|
|
1442
1476
|
});
|
|
1443
1477
|
}
|
|
1444
1478
|
}
|
|
@@ -1500,7 +1534,8 @@ var sqlInjectionRule = {
|
|
|
1500
1534
|
column: 1,
|
|
1501
1535
|
message,
|
|
1502
1536
|
severity,
|
|
1503
|
-
category: "security"
|
|
1537
|
+
category: "security",
|
|
1538
|
+
fix: "Use parameterized queries: db.query('SELECT * FROM users WHERE id = $1', [id])"
|
|
1504
1539
|
});
|
|
1505
1540
|
break;
|
|
1506
1541
|
}
|
|
@@ -1548,7 +1583,8 @@ var placeholderContentRule = {
|
|
|
1548
1583
|
column: match.index + 1,
|
|
1549
1584
|
message: label,
|
|
1550
1585
|
severity: "info",
|
|
1551
|
-
category: "ai-quality"
|
|
1586
|
+
category: "ai-quality",
|
|
1587
|
+
fix: "Replace placeholder content with real production values before deploying"
|
|
1552
1588
|
});
|
|
1553
1589
|
break;
|
|
1554
1590
|
}
|
|
@@ -1594,7 +1630,8 @@ var staleFallbackRule = {
|
|
|
1594
1630
|
column: match.index + 1,
|
|
1595
1631
|
message: `${label} \u2014 use environment variable instead`,
|
|
1596
1632
|
severity: "warning",
|
|
1597
|
-
category: "ai-quality"
|
|
1633
|
+
category: "ai-quality",
|
|
1634
|
+
fix: "Replace hardcoded URL with an environment variable: process.env.DATABASE_URL or similar"
|
|
1598
1635
|
});
|
|
1599
1636
|
break;
|
|
1600
1637
|
}
|
|
@@ -1640,7 +1677,8 @@ var hallucinatedApiRule = {
|
|
|
1640
1677
|
column: match.index + 1,
|
|
1641
1678
|
message: fix,
|
|
1642
1679
|
severity: "warning",
|
|
1643
|
-
category: "ai-quality"
|
|
1680
|
+
category: "ai-quality",
|
|
1681
|
+
fix
|
|
1644
1682
|
});
|
|
1645
1683
|
}
|
|
1646
1684
|
}
|
|
@@ -1722,7 +1760,8 @@ var openRedirectRule = {
|
|
|
1722
1760
|
column: col,
|
|
1723
1761
|
message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
|
|
1724
1762
|
severity: "warning",
|
|
1725
|
-
category: "security"
|
|
1763
|
+
category: "security",
|
|
1764
|
+
fix: "Validate the redirect URL against an allowlist of trusted domains"
|
|
1726
1765
|
});
|
|
1727
1766
|
return;
|
|
1728
1767
|
}
|
|
@@ -1734,7 +1773,8 @@ var openRedirectRule = {
|
|
|
1734
1773
|
column: col,
|
|
1735
1774
|
message: "Possible user input in redirect \u2014 verify the URL is validated before use",
|
|
1736
1775
|
severity: "warning",
|
|
1737
|
-
category: "security"
|
|
1776
|
+
category: "security",
|
|
1777
|
+
fix: "Validate the redirect URL against an allowlist of trusted domains"
|
|
1738
1778
|
});
|
|
1739
1779
|
}
|
|
1740
1780
|
});
|
|
@@ -1755,7 +1795,8 @@ var openRedirectRule = {
|
|
|
1755
1795
|
column: match.index + 1,
|
|
1756
1796
|
message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
|
|
1757
1797
|
severity: "warning",
|
|
1758
|
-
category: "security"
|
|
1798
|
+
category: "security",
|
|
1799
|
+
fix: "Validate the redirect URL against an allowlist of trusted domains"
|
|
1759
1800
|
});
|
|
1760
1801
|
break;
|
|
1761
1802
|
}
|
|
@@ -1771,7 +1812,8 @@ var openRedirectRule = {
|
|
|
1771
1812
|
column: match.index + 1,
|
|
1772
1813
|
message: "Possible user input in redirect \u2014 verify the URL is validated before use",
|
|
1773
1814
|
severity: "warning",
|
|
1774
|
-
category: "security"
|
|
1815
|
+
category: "security",
|
|
1816
|
+
fix: "Validate the redirect URL against an allowlist of trusted domains"
|
|
1775
1817
|
});
|
|
1776
1818
|
break;
|
|
1777
1819
|
}
|
|
@@ -1809,7 +1851,8 @@ var noSyncFsRule = {
|
|
|
1809
1851
|
column: match.index + 1,
|
|
1810
1852
|
message: `Synchronous ${fnName}() blocks the event loop \u2014 use async alternative`,
|
|
1811
1853
|
severity,
|
|
1812
|
-
category: "performance"
|
|
1854
|
+
category: "performance",
|
|
1855
|
+
fix: `Use the async version: fs.promises.${fnName.replace("Sync", "")}() instead of ${fnName}()`
|
|
1813
1856
|
});
|
|
1814
1857
|
}
|
|
1815
1858
|
}
|
|
@@ -1867,7 +1910,8 @@ var noNPlusOneRule = {
|
|
|
1867
1910
|
column: match.index + 1,
|
|
1868
1911
|
message: "Database/fetch call inside loop \u2014 potential N+1 query, consider batching",
|
|
1869
1912
|
severity: "warning",
|
|
1870
|
-
category: "performance"
|
|
1913
|
+
category: "performance",
|
|
1914
|
+
fix: "Use eager loading (include/join) or batch the queries outside the loop"
|
|
1871
1915
|
});
|
|
1872
1916
|
break;
|
|
1873
1917
|
}
|
|
@@ -1903,7 +1947,8 @@ var noDynamicImportLoopRule = {
|
|
|
1903
1947
|
column: match.index + 1,
|
|
1904
1948
|
message: "Dynamic import() inside loop \u2014 move import outside or use Promise.all",
|
|
1905
1949
|
severity: "warning",
|
|
1906
|
-
category: "performance"
|
|
1950
|
+
category: "performance",
|
|
1951
|
+
fix: "Move the dynamic import outside the loop and call it once"
|
|
1907
1952
|
});
|
|
1908
1953
|
}
|
|
1909
1954
|
}
|
|
@@ -1935,7 +1980,8 @@ var noUnboundedQueryRule = {
|
|
|
1935
1980
|
column: line.indexOf(".findMany") + 1,
|
|
1936
1981
|
message: ".findMany() without take/limit \u2014 query may return unbounded results",
|
|
1937
1982
|
severity: "warning",
|
|
1938
|
-
category: "performance"
|
|
1983
|
+
category: "performance",
|
|
1984
|
+
fix: "Add a LIMIT clause or use pagination to prevent unbounded result sets"
|
|
1939
1985
|
});
|
|
1940
1986
|
continue;
|
|
1941
1987
|
}
|
|
@@ -1949,7 +1995,8 @@ var noUnboundedQueryRule = {
|
|
|
1949
1995
|
column: line.indexOf(".findMany") + 1,
|
|
1950
1996
|
message: ".findMany() without take \u2014 add pagination or limit",
|
|
1951
1997
|
severity: "warning",
|
|
1952
|
-
category: "performance"
|
|
1998
|
+
category: "performance",
|
|
1999
|
+
fix: "Add a LIMIT clause or use pagination to prevent unbounded result sets"
|
|
1953
2000
|
});
|
|
1954
2001
|
}
|
|
1955
2002
|
continue;
|
|
@@ -1965,7 +2012,8 @@ var noUnboundedQueryRule = {
|
|
|
1965
2012
|
column: line.indexOf(".select") + 1,
|
|
1966
2013
|
message: ".select('*') without .limit() or filter \u2014 add pagination or a where clause",
|
|
1967
2014
|
severity: "warning",
|
|
1968
|
-
category: "performance"
|
|
2015
|
+
category: "performance",
|
|
2016
|
+
fix: "Add a LIMIT clause or use pagination to prevent unbounded result sets"
|
|
1969
2017
|
});
|
|
1970
2018
|
}
|
|
1971
2019
|
}
|
|
@@ -2044,7 +2092,8 @@ var unhandledPromiseRule = {
|
|
|
2044
2092
|
column: col ? col.index + 1 : 1,
|
|
2045
2093
|
message: "Async call without await, return, or assignment \u2014 promise result is lost",
|
|
2046
2094
|
severity: "warning",
|
|
2047
|
-
category: "reliability"
|
|
2095
|
+
category: "reliability",
|
|
2096
|
+
fix: "Add .catch() handler or use try/catch with await"
|
|
2048
2097
|
});
|
|
2049
2098
|
}
|
|
2050
2099
|
return findings;
|
|
@@ -2076,7 +2125,8 @@ var missingLoadingStateRule = {
|
|
|
2076
2125
|
column: 1,
|
|
2077
2126
|
message: "useEffect with fetch but no loading/pending state \u2014 users see empty content during load",
|
|
2078
2127
|
severity: "info",
|
|
2079
|
-
category: "reliability"
|
|
2128
|
+
category: "reliability",
|
|
2129
|
+
fix: "Add a loading state: const [loading, setLoading] = useState(true) and show a spinner/skeleton while loading"
|
|
2080
2130
|
}];
|
|
2081
2131
|
}
|
|
2082
2132
|
}
|
|
@@ -2107,7 +2157,8 @@ var missingErrorBoundaryRule = {
|
|
|
2107
2157
|
column: 1,
|
|
2108
2158
|
message: `Layout without error.tsx \u2014 errors in ${dir} will bubble up to parent error boundary`,
|
|
2109
2159
|
severity: "info",
|
|
2110
|
-
category: "reliability"
|
|
2160
|
+
category: "reliability",
|
|
2161
|
+
fix: "Add an error.tsx file in the same route segment to catch rendering errors"
|
|
2111
2162
|
}];
|
|
2112
2163
|
}
|
|
2113
2164
|
};
|
|
@@ -2245,7 +2296,8 @@ var codebaseConsistencyRule = {
|
|
|
2245
2296
|
column: 1,
|
|
2246
2297
|
message: `${dim.label}: ${consistency}% consistent \u2014 dominant: ${dominant} (${dominantCount} files), minority: ${minorities.join(", ")}`,
|
|
2247
2298
|
severity: consistency < 60 ? "warning" : "info",
|
|
2248
|
-
category: "ai-quality"
|
|
2299
|
+
category: "ai-quality",
|
|
2300
|
+
fix: "Standardize on one pattern across the codebase for consistency"
|
|
2249
2301
|
});
|
|
2250
2302
|
}
|
|
2251
2303
|
return findings;
|
|
@@ -2374,7 +2426,8 @@ var deadExportsRule = {
|
|
|
2374
2426
|
column: 1,
|
|
2375
2427
|
message: `${totalDead} exported symbols never imported \u2014 dead exports pollute AI context. Top files: ${topFiles.join(", ")}`,
|
|
2376
2428
|
severity: totalDead > 20 ? "warning" : "info",
|
|
2377
|
-
category: "ai-quality"
|
|
2429
|
+
category: "ai-quality",
|
|
2430
|
+
fix: "Remove the unused export or add a consumer"
|
|
2378
2431
|
});
|
|
2379
2432
|
}
|
|
2380
2433
|
return findings;
|
|
@@ -2438,7 +2491,8 @@ var shallowCatchRule = {
|
|
|
2438
2491
|
column: file.lines[catchLine].indexOf("catch") + 1,
|
|
2439
2492
|
message: score === 0 ? "Empty catch block \u2014 errors are silently swallowed" : `Decorative error handler: ${label}`,
|
|
2440
2493
|
severity: score === 0 ? "warning" : "info",
|
|
2441
|
-
category: "reliability"
|
|
2494
|
+
category: "reliability",
|
|
2495
|
+
fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
|
|
2442
2496
|
});
|
|
2443
2497
|
}
|
|
2444
2498
|
});
|
|
@@ -2506,7 +2560,8 @@ var shallowCatchRule = {
|
|
|
2506
2560
|
column: file.lines[i].indexOf("catch") + 1,
|
|
2507
2561
|
message: score === 0 ? "Empty catch block \u2014 errors are silently swallowed" : `Decorative error handler: ${label}`,
|
|
2508
2562
|
severity: score === 0 ? "warning" : "info",
|
|
2509
|
-
category: "reliability"
|
|
2563
|
+
category: "reliability",
|
|
2564
|
+
fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
|
|
2510
2565
|
});
|
|
2511
2566
|
}
|
|
2512
2567
|
i = bodyEnd;
|
|
@@ -2558,7 +2613,8 @@ var comprehensionDebtRule = {
|
|
|
2558
2613
|
column: 1,
|
|
2559
2614
|
message: `${fnName}() has ${params} parameters (max ${MAX_PARAMS}) \u2014 hard to call correctly`,
|
|
2560
2615
|
severity: "info",
|
|
2561
|
-
category: "ai-quality"
|
|
2616
|
+
category: "ai-quality",
|
|
2617
|
+
fix: "Group related parameters into an options object"
|
|
2562
2618
|
});
|
|
2563
2619
|
}
|
|
2564
2620
|
}
|
|
@@ -2585,7 +2641,8 @@ var comprehensionDebtRule = {
|
|
|
2585
2641
|
column: 1,
|
|
2586
2642
|
message: `${fnName}() is ${length} lines long (max ${MAX_FUNCTION_LENGTH}) \u2014 consider splitting`,
|
|
2587
2643
|
severity: "info",
|
|
2588
|
-
category: "ai-quality"
|
|
2644
|
+
category: "ai-quality",
|
|
2645
|
+
fix: "Break this into smaller, focused functions with clear names"
|
|
2589
2646
|
});
|
|
2590
2647
|
}
|
|
2591
2648
|
if (maxDepthInFn > MAX_NESTING_DEPTH) {
|
|
@@ -2596,7 +2653,8 @@ var comprehensionDebtRule = {
|
|
|
2596
2653
|
column: 1,
|
|
2597
2654
|
message: `${fnName}() has nesting depth ${maxDepthInFn} (max ${MAX_NESTING_DEPTH}) \u2014 flatten with early returns`,
|
|
2598
2655
|
severity: "info",
|
|
2599
|
-
category: "ai-quality"
|
|
2656
|
+
category: "ai-quality",
|
|
2657
|
+
fix: "Reduce nesting with early returns, guard clauses, or extracted helper functions"
|
|
2600
2658
|
});
|
|
2601
2659
|
}
|
|
2602
2660
|
inFunction = false;
|
|
@@ -2703,7 +2761,8 @@ var phantomDependencyRule = {
|
|
|
2703
2761
|
column: 1,
|
|
2704
2762
|
message: `"${name}" is a commonly hallucinated package name \u2014 verify it exists and is the correct package`,
|
|
2705
2763
|
severity: "warning",
|
|
2706
|
-
category: "security"
|
|
2764
|
+
category: "security",
|
|
2765
|
+
fix: "Add the missing package to dependencies in package.json, or remove the import if the package is hallucinated"
|
|
2707
2766
|
});
|
|
2708
2767
|
}
|
|
2709
2768
|
for (const pattern of SUSPICIOUS_PATTERNS) {
|
|
@@ -2715,7 +2774,8 @@ var phantomDependencyRule = {
|
|
|
2715
2774
|
column: 1,
|
|
2716
2775
|
message: `"${name}" has a suspicious package name pattern \u2014 verify it's legitimate`,
|
|
2717
2776
|
severity: "info",
|
|
2718
|
-
category: "security"
|
|
2777
|
+
category: "security",
|
|
2778
|
+
fix: "Verify the package on npm \u2014 suspicious names may indicate typosquatting or hallucination"
|
|
2719
2779
|
});
|
|
2720
2780
|
break;
|
|
2721
2781
|
}
|
|
@@ -3225,6 +3285,7 @@ var useClientOveruseRule = {
|
|
|
3225
3285
|
// src/rules/env-fallback-secret.ts
|
|
3226
3286
|
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;
|
|
3227
3287
|
var ENV_FALLBACK = /process\.env\.\w*(SECRET|KEY|PASSWORD|TOKEN)\w*\s*(?:\|\||\?\?)\s*['"`]/i;
|
|
3288
|
+
var CONN_STRING_FALLBACK = /process\.env\.\w+\s*(?:\|\||\?\?)\s*['"`](?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|mssql):\/\//i;
|
|
3228
3289
|
var envFallbackSecretRule = {
|
|
3229
3290
|
id: "env-fallback-secret",
|
|
3230
3291
|
name: "Secret with Fallback Value",
|
|
@@ -3252,6 +3313,20 @@ var envFallbackSecretRule = {
|
|
|
3252
3313
|
});
|
|
3253
3314
|
continue;
|
|
3254
3315
|
}
|
|
3316
|
+
const connMatch = CONN_STRING_FALLBACK.exec(line);
|
|
3317
|
+
if (connMatch) {
|
|
3318
|
+
findings.push({
|
|
3319
|
+
ruleId: "env-fallback-secret",
|
|
3320
|
+
file: file.relativePath,
|
|
3321
|
+
line: i + 1,
|
|
3322
|
+
column: connMatch.index + 1,
|
|
3323
|
+
message: "Connection string with credentials used as fallback \u2014 hardcoded DB/service URL becomes the production connection when env var is missing",
|
|
3324
|
+
severity: "warning",
|
|
3325
|
+
category: "security",
|
|
3326
|
+
fix: "Fail fast when required env vars are missing instead of falling back to a default value"
|
|
3327
|
+
});
|
|
3328
|
+
continue;
|
|
3329
|
+
}
|
|
3255
3330
|
const genericMatch = ENV_FALLBACK.exec(line);
|
|
3256
3331
|
if (genericMatch && !isConfigFile(file.relativePath)) {
|
|
3257
3332
|
findings.push({
|
|
@@ -3468,7 +3543,8 @@ var evalInjectionRule = {
|
|
|
3468
3543
|
column: match.index + 1,
|
|
3469
3544
|
message: msg,
|
|
3470
3545
|
severity: "critical",
|
|
3471
|
-
category: "security"
|
|
3546
|
+
category: "security",
|
|
3547
|
+
fix: "Replace eval/Function constructor with a safe alternative like JSON.parse() or a sandboxed interpreter"
|
|
3472
3548
|
});
|
|
3473
3549
|
break;
|
|
3474
3550
|
}
|
|
@@ -4265,44 +4341,57 @@ var clientSideAuthOnlyRule = {
|
|
|
4265
4341
|
};
|
|
4266
4342
|
|
|
4267
4343
|
// src/rules/missing-abort-controller.ts
|
|
4268
|
-
var
|
|
4344
|
+
var HTTP_CALL = /\b(fetch|axios)\s*[.(]/;
|
|
4269
4345
|
var HAS_TIMEOUT = [
|
|
4270
4346
|
/AbortController/,
|
|
4271
4347
|
/abort/i,
|
|
4272
4348
|
/signal\s*:/,
|
|
4273
|
-
/timeout
|
|
4274
|
-
/
|
|
4349
|
+
/timeout\s*:/,
|
|
4350
|
+
/timeout\s*=/,
|
|
4351
|
+
/setTimeout.*abort/s,
|
|
4352
|
+
/axios\.create\s*\([^)]*timeout/s
|
|
4275
4353
|
];
|
|
4354
|
+
var BACKGROUND_PATTERN = /\b(setInterval|cron|schedule|createWorker|bullmq|agenda|queue\.process)\b/;
|
|
4355
|
+
function isBackgroundFile(file) {
|
|
4356
|
+
if (BACKGROUND_PATTERN.test(file.content)) return true;
|
|
4357
|
+
const p = file.relativePath.toLowerCase();
|
|
4358
|
+
return /\b(cron|jobs?|workers?|queues?|tasks?|background)\b/.test(p);
|
|
4359
|
+
}
|
|
4276
4360
|
var missingAbortControllerRule = {
|
|
4277
4361
|
id: "missing-abort-controller",
|
|
4278
4362
|
name: "Missing Abort Controller",
|
|
4279
|
-
description: "Detects fetch calls
|
|
4363
|
+
description: "Detects fetch/axios calls without timeout or AbortController \u2014 requests can hang indefinitely",
|
|
4280
4364
|
category: "performance",
|
|
4281
4365
|
severity: "info",
|
|
4282
4366
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
4283
4367
|
check(file, _project) {
|
|
4284
4368
|
if (isTestFile(file.relativePath)) return [];
|
|
4285
|
-
|
|
4286
|
-
if (!
|
|
4369
|
+
const isRelevant = isApiRoute(file.relativePath) || /['"]use server['"]/.test(file.content) || isBackgroundFile(file);
|
|
4370
|
+
if (!isRelevant) return [];
|
|
4371
|
+
if (!HTTP_CALL.test(file.content)) return [];
|
|
4287
4372
|
const hasTimeout = HAS_TIMEOUT.some((p) => p.test(file.content));
|
|
4288
4373
|
if (hasTimeout) return [];
|
|
4289
4374
|
let reportLine = 1;
|
|
4375
|
+
let matchedCall = "fetch";
|
|
4290
4376
|
for (let i = 0; i < file.lines.length; i++) {
|
|
4291
4377
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
4292
|
-
|
|
4378
|
+
const m = file.lines[i].match(HTTP_CALL);
|
|
4379
|
+
if (m) {
|
|
4293
4380
|
reportLine = i + 1;
|
|
4381
|
+
matchedCall = m[1];
|
|
4294
4382
|
break;
|
|
4295
4383
|
}
|
|
4296
4384
|
}
|
|
4385
|
+
const isFetch = matchedCall === "fetch";
|
|
4297
4386
|
return [{
|
|
4298
4387
|
ruleId: "missing-abort-controller",
|
|
4299
4388
|
file: file.relativePath,
|
|
4300
4389
|
line: reportLine,
|
|
4301
4390
|
column: 1,
|
|
4302
|
-
message:
|
|
4391
|
+
message: `${matchedCall}() without timeout or AbortController \u2014 request will hang indefinitely if the upstream server doesn't respond`,
|
|
4303
4392
|
severity: "info",
|
|
4304
4393
|
category: "performance",
|
|
4305
|
-
fix: "Add a timeout: const controller = new AbortController(); setTimeout(() => controller.abort(), 10000); fetch(url, { signal: controller.signal })"
|
|
4394
|
+
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 })"
|
|
4306
4395
|
}];
|
|
4307
4396
|
}
|
|
4308
4397
|
};
|
|
@@ -4413,111 +4502,113 @@ async function scan(options) {
|
|
|
4413
4502
|
}
|
|
4414
4503
|
|
|
4415
4504
|
// src/web-scanner/checks.ts
|
|
4416
|
-
function make(id, name, description, maxPoints, severity, status, details) {
|
|
4505
|
+
function make(id, name, description, maxPoints, severity, maturity, status, details) {
|
|
4417
4506
|
return {
|
|
4418
4507
|
id,
|
|
4419
4508
|
name,
|
|
4420
4509
|
description,
|
|
4421
4510
|
status,
|
|
4422
4511
|
severity,
|
|
4512
|
+
maturity,
|
|
4423
4513
|
details,
|
|
4424
4514
|
points: status === "pass" ? maxPoints : status === "warn" ? Math.floor(maxPoints / 2) : 0,
|
|
4425
4515
|
maxPoints
|
|
4426
4516
|
};
|
|
4427
4517
|
}
|
|
4428
4518
|
function checkRobotsTxt(ctx) {
|
|
4429
|
-
if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible",
|
|
4430
|
-
return make("robots_txt", "robots.txt", "robots.txt exists and is accessible",
|
|
4519
|
+
if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 7, "medium", "established", "fail", "No robots.txt found.");
|
|
4520
|
+
return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 7, "medium", "established", "pass", "robots.txt found.");
|
|
4431
4521
|
}
|
|
4432
4522
|
function checkRobotsAiDirectives(ctx) {
|
|
4433
|
-
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.");
|
|
4434
|
-
const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai"];
|
|
4523
|
+
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.");
|
|
4524
|
+
const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai", "Applebot-Extended", "Grok"];
|
|
4435
4525
|
const found = agents.filter((a) => ctx.robotsTxt.toLowerCase().includes(a.toLowerCase()));
|
|
4436
|
-
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.");
|
|
4437
|
-
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(", ")}.`);
|
|
4438
|
-
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(", ")}.`);
|
|
4526
|
+
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.");
|
|
4527
|
+
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(", ")}.`);
|
|
4528
|
+
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(", ")}.`);
|
|
4439
4529
|
}
|
|
4440
4530
|
function checkContentUsage(ctx) {
|
|
4441
4531
|
const hasHeader = ctx.headers["content-usage"] != null;
|
|
4442
4532
|
const hasInRobots = ctx.robotsTxt?.toLowerCase().includes("content-usage") ?? false;
|
|
4443
|
-
if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives",
|
|
4444
|
-
return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives",
|
|
4533
|
+
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.");
|
|
4534
|
+
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.");
|
|
4445
4535
|
}
|
|
4446
4536
|
function checkLlmsTxt(ctx) {
|
|
4447
|
-
if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt",
|
|
4448
|
-
const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0);
|
|
4449
|
-
if (lines.length < 3) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt",
|
|
4450
|
-
return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt",
|
|
4537
|
+
if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 6, "high", "emerging", "fail", "No llms.txt found.");
|
|
4538
|
+
const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
|
|
4539
|
+
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.");
|
|
4540
|
+
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.`);
|
|
4451
4541
|
}
|
|
4452
4542
|
function checkTdmRep(ctx) {
|
|
4453
4543
|
const hasWK = ctx.tdmRep != null;
|
|
4454
4544
|
const hasHeader = ctx.headers["tdm-reservation"] != null;
|
|
4455
|
-
if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header",
|
|
4456
|
-
return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header",
|
|
4545
|
+
if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 4, "medium", "emerging", "fail", "No TDMRep configuration found.");
|
|
4546
|
+
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"]}`);
|
|
4457
4547
|
}
|
|
4458
4548
|
function checkAiDisclosure(ctx) {
|
|
4459
|
-
if (!ctx.headers["ai-disclosure"]) return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation",
|
|
4460
|
-
return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation",
|
|
4549
|
+
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.");
|
|
4550
|
+
return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 2, "low", "emerging", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
|
|
4461
4551
|
}
|
|
4462
4552
|
function checkAgentCard(ctx) {
|
|
4463
|
-
if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery",
|
|
4553
|
+
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.");
|
|
4464
4554
|
try {
|
|
4465
4555
|
const card = JSON.parse(ctx.agentCard);
|
|
4466
|
-
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",
|
|
4467
|
-
return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery",
|
|
4556
|
+
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.");
|
|
4557
|
+
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).`);
|
|
4468
4558
|
} catch {
|
|
4469
|
-
return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery",
|
|
4559
|
+
return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 5, "high", "emerging", "warn", "AgentCard found but contains invalid JSON.");
|
|
4470
4560
|
}
|
|
4471
4561
|
}
|
|
4472
4562
|
function checkAiTxt(ctx) {
|
|
4473
|
-
if (!ctx.aiTxt) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec",
|
|
4563
|
+
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.");
|
|
4474
4564
|
const lines = ctx.aiTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
|
|
4475
|
-
if (lines.length < 2) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec",
|
|
4476
|
-
return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec",
|
|
4565
|
+
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.");
|
|
4566
|
+
return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 3, "medium", "emerging", "pass", `ai.txt found with ${lines.length} directive(s).`);
|
|
4477
4567
|
}
|
|
4478
4568
|
function checkWebMCP(ctx) {
|
|
4479
|
-
if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration",
|
|
4569
|
+
if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 5, "high", "emerging", "info", "Could not check for WebMCP tools.");
|
|
4480
4570
|
const hasToolname = /toolname=/i.test(ctx.html);
|
|
4481
4571
|
const hasModelContext = /navigator\.modelContext/i.test(ctx.html) || /registerTool/i.test(ctx.html);
|
|
4482
|
-
if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration",
|
|
4572
|
+
if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 5, "high", "emerging", "fail", "No WebMCP tools detected.");
|
|
4483
4573
|
const count = (ctx.html.match(/toolname=/gi) || []).length;
|
|
4484
|
-
return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration",
|
|
4574
|
+
return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 5, "high", "emerging", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
|
|
4485
4575
|
}
|
|
4486
4576
|
function checkStructuredData(ctx) {
|
|
4487
|
-
if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup",
|
|
4577
|
+
if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 12, "medium", "established", "info", "Could not fetch page HTML.");
|
|
4488
4578
|
const jsonLd = ctx.html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>/gi);
|
|
4489
4579
|
const hasMicrodata = ctx.html.includes("itemscope") && ctx.html.includes("itemtype");
|
|
4490
|
-
if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup",
|
|
4491
|
-
return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup",
|
|
4580
|
+
if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 12, "medium", "established", "fail", "No structured data found.");
|
|
4581
|
+
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).`);
|
|
4492
4582
|
}
|
|
4493
4583
|
function checkOpenGraph(ctx) {
|
|
4494
|
-
if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
4584
|
+
if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "info", "Could not fetch HTML.");
|
|
4495
4585
|
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)];
|
|
4496
4586
|
const passed = checks.filter(Boolean).length;
|
|
4497
|
-
if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
4498
|
-
if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
4499
|
-
return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
4587
|
+
if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "fail", "No OpenGraph tags or meta description.");
|
|
4588
|
+
if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "warn", `Found ${passed}/4 meta tags.`);
|
|
4589
|
+
if (passed < 4) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "pass", `Found ${passed}/4 key meta tags.`);
|
|
4590
|
+
return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "pass", "All 4 key meta tags present.");
|
|
4500
4591
|
}
|
|
4501
4592
|
function checkSitemap(ctx) {
|
|
4502
|
-
if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
4593
|
+
if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "fail", "No sitemap.xml found.");
|
|
4503
4594
|
const count = Math.max((ctx.sitemapXml.match(/<url>/gi) || []).length, (ctx.sitemapXml.match(/<loc>/gi) || []).length);
|
|
4504
|
-
if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
4505
|
-
if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
4506
|
-
return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
4595
|
+
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.");
|
|
4596
|
+
if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "warn", "sitemap.xml found but appears empty.");
|
|
4597
|
+
return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "pass", `sitemap.xml found with ${count} URL(s).`);
|
|
4507
4598
|
}
|
|
4508
4599
|
function checkHttpSignatures(ctx) {
|
|
4509
4600
|
const hasDirectory = ctx.httpSigDirectory != null;
|
|
4510
4601
|
const hasSignatureHeader = ctx.headers["signature"] != null || ctx.headers["signature-input"] != null;
|
|
4511
4602
|
const hasSignatureAgent = ctx.headers["signature-agent"] != null;
|
|
4512
|
-
if (!hasDirectory && !hasSignatureHeader && !hasSignatureAgent) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures",
|
|
4513
|
-
if (hasDirectory) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures",
|
|
4514
|
-
return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures",
|
|
4603
|
+
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.");
|
|
4604
|
+
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.");
|
|
4605
|
+
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.");
|
|
4515
4606
|
}
|
|
4516
4607
|
function checkPageSpeed(ctx) {
|
|
4517
|
-
if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4518
|
-
if (ctx.loadTimeMs > 5e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4519
|
-
if (ctx.loadTimeMs > 2e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4520
|
-
return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4608
|
+
if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "info", "Could not measure.");
|
|
4609
|
+
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.`);
|
|
4610
|
+
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.`);
|
|
4611
|
+
return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
|
|
4521
4612
|
}
|
|
4522
4613
|
var allChecks = [
|
|
4523
4614
|
checkRobotsTxt,
|
|
@@ -4556,6 +4647,29 @@ function isPrivateHost(hostname) {
|
|
|
4556
4647
|
if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) return true;
|
|
4557
4648
|
const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
4558
4649
|
if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || h === "::") return true;
|
|
4650
|
+
const v4mapped = h.match(/^(?:::ffff:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
4651
|
+
if (v4mapped) {
|
|
4652
|
+
const [, a, b] = v4mapped.map(Number);
|
|
4653
|
+
if (a === 10 || a === 127 || a === 0) return true;
|
|
4654
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
4655
|
+
if (a === 192 && b === 168) return true;
|
|
4656
|
+
if (a === 169 && b === 254) return true;
|
|
4657
|
+
return false;
|
|
4658
|
+
}
|
|
4659
|
+
if (/^(0[xX][0-9a-fA-F]+|0[0-7]+)(\.|$)/.test(hostname)) return true;
|
|
4660
|
+
if (/^\d+$/.test(hostname)) {
|
|
4661
|
+
const dec = parseInt(hostname, 10);
|
|
4662
|
+
if (dec >= 0 && dec <= 4294967295) {
|
|
4663
|
+
const a = dec >>> 24 & 255;
|
|
4664
|
+
const b = dec >>> 16 & 255;
|
|
4665
|
+
if (a === 10 || a === 127 || a === 0) return true;
|
|
4666
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
4667
|
+
if (a === 192 && b === 168) return true;
|
|
4668
|
+
if (a === 169 && b === 254) return true;
|
|
4669
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
4670
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
4671
|
+
}
|
|
4672
|
+
}
|
|
4559
4673
|
const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
4560
4674
|
if (ipv4) {
|
|
4561
4675
|
const [, a, b] = ipv4.map(Number);
|
|
@@ -4568,22 +4682,49 @@ function isPrivateHost(hostname) {
|
|
|
4568
4682
|
}
|
|
4569
4683
|
return false;
|
|
4570
4684
|
}
|
|
4685
|
+
function isAllowedProtocol(url) {
|
|
4686
|
+
try {
|
|
4687
|
+
const p = new URL(url).protocol;
|
|
4688
|
+
return p === "https:" || p === "http:";
|
|
4689
|
+
} catch {
|
|
4690
|
+
return false;
|
|
4691
|
+
}
|
|
4692
|
+
}
|
|
4571
4693
|
function validateRedirectUrl(responseUrl) {
|
|
4572
4694
|
try {
|
|
4573
4695
|
const parsed = new URL(responseUrl);
|
|
4696
|
+
if (!isAllowedProtocol(responseUrl)) return false;
|
|
4574
4697
|
return !isPrivateHost(parsed.hostname);
|
|
4575
4698
|
} catch {
|
|
4576
4699
|
return false;
|
|
4577
4700
|
}
|
|
4578
4701
|
}
|
|
4702
|
+
var MAX_REDIRECTS = 5;
|
|
4703
|
+
async function safeFetch(url, timeout, signal) {
|
|
4704
|
+
let current = url;
|
|
4705
|
+
for (let i = 0; i < MAX_REDIRECTS; i++) {
|
|
4706
|
+
const r = await fetch(current, { signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "manual" });
|
|
4707
|
+
const status = r.status;
|
|
4708
|
+
if (status >= 300 && status < 400) {
|
|
4709
|
+
const location = r.headers.get("location");
|
|
4710
|
+
if (!location) return null;
|
|
4711
|
+
const resolved = new URL(location, current).toString();
|
|
4712
|
+
if (!validateRedirectUrl(resolved)) return null;
|
|
4713
|
+
current = resolved;
|
|
4714
|
+
continue;
|
|
4715
|
+
}
|
|
4716
|
+
return r;
|
|
4717
|
+
}
|
|
4718
|
+
return null;
|
|
4719
|
+
}
|
|
4579
4720
|
async function fetchText(url, timeout = 8e3) {
|
|
4580
4721
|
try {
|
|
4581
4722
|
const c = new AbortController();
|
|
4582
4723
|
const t = setTimeout(() => c.abort(), timeout);
|
|
4583
|
-
const r = await
|
|
4724
|
+
const r = await safeFetch(url, timeout, c.signal);
|
|
4584
4725
|
clearTimeout(t);
|
|
4585
|
-
if (r
|
|
4586
|
-
return
|
|
4726
|
+
if (!r || !r.ok) return null;
|
|
4727
|
+
return await r.text();
|
|
4587
4728
|
} catch {
|
|
4588
4729
|
return null;
|
|
4589
4730
|
}
|
|
@@ -4592,9 +4733,9 @@ async function fetchHeaders(url, timeout = 8e3) {
|
|
|
4592
4733
|
try {
|
|
4593
4734
|
const c = new AbortController();
|
|
4594
4735
|
const t = setTimeout(() => c.abort(), timeout);
|
|
4595
|
-
const r = await
|
|
4736
|
+
const r = await safeFetch(url, timeout, c.signal);
|
|
4596
4737
|
clearTimeout(t);
|
|
4597
|
-
if (
|
|
4738
|
+
if (!r) return {};
|
|
4598
4739
|
const h = {};
|
|
4599
4740
|
r.headers.forEach((v, k) => {
|
|
4600
4741
|
h[k.toLowerCase()] = v;
|
|
@@ -4609,11 +4750,11 @@ async function fetchWithTiming(url, timeout = 15e3) {
|
|
|
4609
4750
|
const c = new AbortController();
|
|
4610
4751
|
const t = setTimeout(() => c.abort(), timeout);
|
|
4611
4752
|
const start = Date.now();
|
|
4612
|
-
const r = await
|
|
4613
|
-
const html = await r.text();
|
|
4753
|
+
const r = await safeFetch(url, timeout, c.signal);
|
|
4614
4754
|
clearTimeout(t);
|
|
4615
|
-
if (r
|
|
4616
|
-
|
|
4755
|
+
if (!r || !r.ok) return { html: null, loadTimeMs: null };
|
|
4756
|
+
const html = await r.text();
|
|
4757
|
+
return { html, loadTimeMs: Date.now() - start };
|
|
4617
4758
|
} catch {
|
|
4618
4759
|
return { html: null, loadTimeMs: null };
|
|
4619
4760
|
}
|