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/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]{
|
|
719
|
-
{ name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{
|
|
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 =
|
|
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
|
|
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
|
|
4279
|
-
/
|
|
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
|
|
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
|
-
|
|
4291
|
-
if (!
|
|
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
|
-
|
|
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:
|
|
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",
|
|
4570
|
-
return make("robots_txt", "robots.txt", "robots.txt exists and is accessible",
|
|
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",
|
|
4584
|
-
return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives",
|
|
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",
|
|
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",
|
|
4590
|
-
return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt",
|
|
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",
|
|
4596
|
-
return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header",
|
|
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",
|
|
4600
|
-
return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation",
|
|
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",
|
|
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",
|
|
4607
|
-
return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery",
|
|
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",
|
|
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",
|
|
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",
|
|
4616
|
-
return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
4631
|
-
return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup",
|
|
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",
|
|
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",
|
|
4638
|
-
if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
4639
|
-
return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
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",
|
|
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",
|
|
4645
|
-
if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
4646
|
-
return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
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",
|
|
4653
|
-
if (hasDirectory) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures",
|
|
4654
|
-
return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures",
|
|
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",
|
|
4658
|
-
if (ctx.loadTimeMs > 5e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4659
|
-
if (ctx.loadTimeMs > 2e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4660
|
-
return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
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
|
|
4864
|
+
const r = await safeFetch(url, timeout, c.signal);
|
|
4724
4865
|
clearTimeout(t);
|
|
4725
|
-
if (r
|
|
4726
|
-
return
|
|
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
|
|
4876
|
+
const r = await safeFetch(url, timeout, c.signal);
|
|
4736
4877
|
clearTimeout(t);
|
|
4737
|
-
if (
|
|
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
|
|
4753
|
-
const html = await r.text();
|
|
4893
|
+
const r = await safeFetch(url, timeout, c.signal);
|
|
4754
4894
|
clearTimeout(t);
|
|
4755
|
-
if (r
|
|
4756
|
-
|
|
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
|
}
|