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/mcp.js
CHANGED
|
@@ -5,7 +5,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { stat as stat2 } from "fs/promises";
|
|
8
|
-
import { resolve as resolve4 } from "path";
|
|
8
|
+
import { resolve as resolve4, sep as sep2 } from "path";
|
|
9
9
|
|
|
10
10
|
// src/utils/file-walker.ts
|
|
11
11
|
import fg from "fast-glob";
|
|
@@ -719,8 +719,8 @@ function summarizeFindings(findings) {
|
|
|
719
719
|
|
|
720
720
|
// src/rules/secrets.ts
|
|
721
721
|
var SECRET_PATTERNS = [
|
|
722
|
-
{ name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{
|
|
723
|
-
{ name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{
|
|
722
|
+
{ name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{8,}/ },
|
|
723
|
+
{ name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{8,}/ },
|
|
724
724
|
{ name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/ },
|
|
725
725
|
{ name: "AWS secret key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/ },
|
|
726
726
|
{ name: "Supabase service role key", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/ },
|
|
@@ -753,7 +753,8 @@ var secretsRule = {
|
|
|
753
753
|
column: match.index + 1,
|
|
754
754
|
message: `Hardcoded ${name} detected`,
|
|
755
755
|
severity: "critical",
|
|
756
|
-
category: "security"
|
|
756
|
+
category: "security",
|
|
757
|
+
fix: "Move the secret to an environment variable and access via process.env.SECRET_NAME"
|
|
757
758
|
});
|
|
758
759
|
}
|
|
759
760
|
}
|
|
@@ -837,7 +838,8 @@ var hallucinatedImportsRule = {
|
|
|
837
838
|
column: 1,
|
|
838
839
|
message: `Package "${pkgName}" is imported but not in package.json`,
|
|
839
840
|
severity: isNonProd ? "warning" : "critical",
|
|
840
|
-
category: "reliability"
|
|
841
|
+
category: "reliability",
|
|
842
|
+
fix: "Verify the package exists on npm. The AI may have invented this package name."
|
|
841
843
|
});
|
|
842
844
|
}
|
|
843
845
|
return findings;
|
|
@@ -865,7 +867,8 @@ var hallucinatedImportsRule = {
|
|
|
865
867
|
column: match.index + 1,
|
|
866
868
|
message: `Package "${pkgName}" is imported but not in package.json`,
|
|
867
869
|
severity: isNonProd ? "warning" : "critical",
|
|
868
|
-
category: "reliability"
|
|
870
|
+
category: "reliability",
|
|
871
|
+
fix: "Verify the package exists on npm. The AI may have invented this package name."
|
|
869
872
|
});
|
|
870
873
|
}
|
|
871
874
|
}
|
|
@@ -948,7 +951,8 @@ var authChecksRule = {
|
|
|
948
951
|
column: 1,
|
|
949
952
|
message,
|
|
950
953
|
severity,
|
|
951
|
-
category: "security"
|
|
954
|
+
category: "security",
|
|
955
|
+
fix: "Add authentication check: const session = await auth(); if (!session) return new Response('Unauthorized', { status: 401 })"
|
|
952
956
|
}];
|
|
953
957
|
}
|
|
954
958
|
};
|
|
@@ -987,7 +991,8 @@ var envExposureRule = {
|
|
|
987
991
|
column: 1,
|
|
988
992
|
message: ".env is not listed in .gitignore \u2014 secrets may be committed",
|
|
989
993
|
severity: "critical",
|
|
990
|
-
category: "security"
|
|
994
|
+
category: "security",
|
|
995
|
+
fix: "Add .env to .gitignore to prevent committing secrets"
|
|
991
996
|
});
|
|
992
997
|
}
|
|
993
998
|
return findings;
|
|
@@ -1009,7 +1014,8 @@ var envExposureRule = {
|
|
|
1009
1014
|
column: match.index + 1,
|
|
1010
1015
|
message: isSensitive ? `Sensitive server env var "${envName}" used in client component` : `Server env var "${envName}" used in client component (will be undefined at runtime)`,
|
|
1011
1016
|
severity: isSensitive ? "critical" : "warning",
|
|
1012
|
-
category: "security"
|
|
1017
|
+
category: "security",
|
|
1018
|
+
fix: "Move to server-only file or use NEXT_PUBLIC_ prefix only for non-sensitive values"
|
|
1013
1019
|
});
|
|
1014
1020
|
}
|
|
1015
1021
|
}
|
|
@@ -1047,7 +1053,8 @@ var errorHandlingRule = {
|
|
|
1047
1053
|
column: 1,
|
|
1048
1054
|
message: "API route handler has no try/catch block",
|
|
1049
1055
|
severity: "warning",
|
|
1050
|
-
category: "reliability"
|
|
1056
|
+
category: "reliability",
|
|
1057
|
+
fix: "Wrap the handler body in try/catch and return appropriate error responses"
|
|
1051
1058
|
}];
|
|
1052
1059
|
}
|
|
1053
1060
|
};
|
|
@@ -1105,7 +1112,8 @@ var inputValidationRule = {
|
|
|
1105
1112
|
column: 1,
|
|
1106
1113
|
message: "Request body accessed without validation (consider using Zod, Yup, or similar)",
|
|
1107
1114
|
severity: "warning",
|
|
1108
|
-
category: "security"
|
|
1115
|
+
category: "security",
|
|
1116
|
+
fix: "Validate input with Zod or a similar schema library before using it"
|
|
1109
1117
|
}];
|
|
1110
1118
|
}
|
|
1111
1119
|
};
|
|
@@ -1162,12 +1170,22 @@ var rateLimitingRule = {
|
|
|
1162
1170
|
column: 1,
|
|
1163
1171
|
message: "No rate limiting \u2014 anyone could spam this endpoint and run up your API costs",
|
|
1164
1172
|
severity: "info",
|
|
1165
|
-
category: "security"
|
|
1173
|
+
category: "security",
|
|
1174
|
+
fix: "Add rate limiting: import { Ratelimit } from '@upstash/ratelimit' or use express-rate-limit"
|
|
1166
1175
|
}];
|
|
1167
1176
|
}
|
|
1168
1177
|
};
|
|
1169
1178
|
|
|
1170
1179
|
// src/rules/cors-config.ts
|
|
1180
|
+
function hasCredentialsNearby(lines, startLine, commentMap) {
|
|
1181
|
+
const end = Math.min(lines.length, startLine + 8);
|
|
1182
|
+
for (let j = startLine; j < end; j++) {
|
|
1183
|
+
if (commentMap[j]) continue;
|
|
1184
|
+
if (/credentials\s*:\s*true/.test(lines[j])) return true;
|
|
1185
|
+
if (/Access-Control-Allow-Credentials['"]\s*[,:]\s*['"]true['"]/.test(lines[j])) return true;
|
|
1186
|
+
}
|
|
1187
|
+
return false;
|
|
1188
|
+
}
|
|
1171
1189
|
var corsConfigRule = {
|
|
1172
1190
|
id: "cors-config",
|
|
1173
1191
|
name: "Permissive CORS",
|
|
@@ -1181,14 +1199,16 @@ var corsConfigRule = {
|
|
|
1181
1199
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1182
1200
|
const line = file.lines[i];
|
|
1183
1201
|
if (/['"]Access-Control-Allow-Origin['"]\s*[,:]\s*['"]\*['"]/.test(line)) {
|
|
1202
|
+
const withCreds = hasCredentialsNearby(file.lines, i, file.commentMap);
|
|
1184
1203
|
findings.push({
|
|
1185
1204
|
ruleId: "cors-config",
|
|
1186
1205
|
file: file.relativePath,
|
|
1187
1206
|
line: i + 1,
|
|
1188
1207
|
column: line.indexOf("Access-Control") + 1,
|
|
1189
|
-
message: 'Access-Control-Allow-Origin set to "*" allows any domain',
|
|
1190
|
-
severity: "warning",
|
|
1191
|
-
category: "security"
|
|
1208
|
+
message: withCreds ? "CORS wildcard with credentials allows any site to make authenticated requests" : 'Access-Control-Allow-Origin set to "*" allows any domain',
|
|
1209
|
+
severity: withCreds ? "critical" : "warning",
|
|
1210
|
+
category: "security",
|
|
1211
|
+
fix: withCreds ? "Never use credentials with wildcard origin. Set origin to specific trusted domains." : "Restrict origin to specific domains: origin: ['https://yourdomain.com']"
|
|
1192
1212
|
});
|
|
1193
1213
|
}
|
|
1194
1214
|
if (/cors\(\s*\)/.test(line)) {
|
|
@@ -1199,18 +1219,21 @@ var corsConfigRule = {
|
|
|
1199
1219
|
column: line.indexOf("cors(") + 1,
|
|
1200
1220
|
message: "cors() called without config allows all origins",
|
|
1201
1221
|
severity: "warning",
|
|
1202
|
-
category: "security"
|
|
1222
|
+
category: "security",
|
|
1223
|
+
fix: "Pass explicit options: cors({ origin: 'https://yourdomain.com' })"
|
|
1203
1224
|
});
|
|
1204
1225
|
}
|
|
1205
1226
|
if (/origin\s*:\s*['"]\*['"]/.test(line)) {
|
|
1227
|
+
const withCreds = hasCredentialsNearby(file.lines, i, file.commentMap);
|
|
1206
1228
|
findings.push({
|
|
1207
1229
|
ruleId: "cors-config",
|
|
1208
1230
|
file: file.relativePath,
|
|
1209
1231
|
line: i + 1,
|
|
1210
1232
|
column: line.indexOf("origin") + 1,
|
|
1211
|
-
message: 'CORS origin set to "*" allows any domain',
|
|
1212
|
-
severity: "warning",
|
|
1213
|
-
category: "security"
|
|
1233
|
+
message: withCreds ? "CORS wildcard with credentials allows any site to make authenticated requests" : 'CORS origin set to "*" allows any domain',
|
|
1234
|
+
severity: withCreds ? "critical" : "warning",
|
|
1235
|
+
category: "security",
|
|
1236
|
+
fix: withCreds ? "Never use credentials with wildcard origin. Set origin to specific trusted domains." : "Restrict origin to specific domains: origin: ['https://yourdomain.com']"
|
|
1214
1237
|
});
|
|
1215
1238
|
}
|
|
1216
1239
|
if (/origin\s*:\s*true/.test(line)) {
|
|
@@ -1221,7 +1244,8 @@ var corsConfigRule = {
|
|
|
1221
1244
|
column: line.indexOf("origin") + 1,
|
|
1222
1245
|
message: "CORS origin set to true mirrors any requesting origin",
|
|
1223
1246
|
severity: "warning",
|
|
1224
|
-
category: "security"
|
|
1247
|
+
category: "security",
|
|
1248
|
+
fix: "Set origin to specific domains instead of reflecting the request origin"
|
|
1225
1249
|
});
|
|
1226
1250
|
}
|
|
1227
1251
|
}
|
|
@@ -1230,7 +1254,7 @@ var corsConfigRule = {
|
|
|
1230
1254
|
};
|
|
1231
1255
|
|
|
1232
1256
|
// src/rules/ai-smells.ts
|
|
1233
|
-
var CONSOLE_LOG_THRESHOLD =
|
|
1257
|
+
var CONSOLE_LOG_THRESHOLD = 3;
|
|
1234
1258
|
var ANY_TYPE_THRESHOLD = 5;
|
|
1235
1259
|
var COMMENTED_CODE_THRESHOLD = 3;
|
|
1236
1260
|
var aiSmellsRule = {
|
|
@@ -1264,7 +1288,8 @@ var aiSmellsRule = {
|
|
|
1264
1288
|
column: line.indexOf(todoMatch[1]) + 1,
|
|
1265
1289
|
message: `${todoMatch[1]} comment: ${todoMatch[2].trim() || "(no description)"}`,
|
|
1266
1290
|
severity: "info",
|
|
1267
|
-
category: "ai-quality"
|
|
1291
|
+
category: "ai-quality",
|
|
1292
|
+
fix: "Resolve the TODO/FIXME before shipping to production"
|
|
1268
1293
|
});
|
|
1269
1294
|
}
|
|
1270
1295
|
const commentContent = trimmed.slice(2).trim();
|
|
@@ -1279,7 +1304,8 @@ var aiSmellsRule = {
|
|
|
1279
1304
|
column: 1,
|
|
1280
1305
|
message: `${COMMENTED_CODE_THRESHOLD}+ consecutive lines of commented-out code`,
|
|
1281
1306
|
severity: "info",
|
|
1282
|
-
category: "ai-quality"
|
|
1307
|
+
category: "ai-quality",
|
|
1308
|
+
fix: "Remove commented-out code \u2014 use version control to recover old code if needed"
|
|
1283
1309
|
});
|
|
1284
1310
|
}
|
|
1285
1311
|
} else {
|
|
@@ -1296,7 +1322,8 @@ var aiSmellsRule = {
|
|
|
1296
1322
|
column: 1,
|
|
1297
1323
|
message: 'Placeholder "not implemented" function',
|
|
1298
1324
|
severity: "warning",
|
|
1299
|
-
category: "ai-quality"
|
|
1325
|
+
category: "ai-quality",
|
|
1326
|
+
fix: "Replace with a production-ready implementation or remove the function"
|
|
1300
1327
|
});
|
|
1301
1328
|
}
|
|
1302
1329
|
if (/console\.log\s*\(/.test(line)) {
|
|
@@ -1314,7 +1341,8 @@ var aiSmellsRule = {
|
|
|
1314
1341
|
column: 1,
|
|
1315
1342
|
message: `${consoleLogCount} console.log statements (consider a proper logger)`,
|
|
1316
1343
|
severity: "warning",
|
|
1317
|
-
category: "ai-quality"
|
|
1344
|
+
category: "ai-quality",
|
|
1345
|
+
fix: "Replace console.log with a structured logger (e.g., pino, winston) or remove debug logs"
|
|
1318
1346
|
});
|
|
1319
1347
|
}
|
|
1320
1348
|
if (anyTypeCount > ANY_TYPE_THRESHOLD) {
|
|
@@ -1325,7 +1353,8 @@ var aiSmellsRule = {
|
|
|
1325
1353
|
column: 1,
|
|
1326
1354
|
message: `${anyTypeCount} uses of "any" type (consider proper typing)`,
|
|
1327
1355
|
severity: "warning",
|
|
1328
|
-
category: "ai-quality"
|
|
1356
|
+
category: "ai-quality",
|
|
1357
|
+
fix: 'Replace "any" with specific types or use "unknown" with type narrowing'
|
|
1329
1358
|
});
|
|
1330
1359
|
}
|
|
1331
1360
|
return findings;
|
|
@@ -1367,7 +1396,8 @@ var unsafeHtmlRule = {
|
|
|
1367
1396
|
column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
|
|
1368
1397
|
message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
|
|
1369
1398
|
severity: "critical",
|
|
1370
|
-
category: "security"
|
|
1399
|
+
category: "security",
|
|
1400
|
+
fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
|
|
1371
1401
|
});
|
|
1372
1402
|
}
|
|
1373
1403
|
}
|
|
@@ -1393,7 +1423,8 @@ var unsafeHtmlRule = {
|
|
|
1393
1423
|
column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
|
|
1394
1424
|
message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
|
|
1395
1425
|
severity: "critical",
|
|
1396
|
-
category: "security"
|
|
1426
|
+
category: "security",
|
|
1427
|
+
fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
|
|
1397
1428
|
});
|
|
1398
1429
|
}
|
|
1399
1430
|
}
|
|
@@ -1408,7 +1439,8 @@ var unsafeHtmlRule = {
|
|
|
1408
1439
|
column: file.lines[line - 1].indexOf(".innerHTML") + 1,
|
|
1409
1440
|
message: "Direct innerHTML assignment is an XSS risk",
|
|
1410
1441
|
severity: "critical",
|
|
1411
|
-
category: "security"
|
|
1442
|
+
category: "security",
|
|
1443
|
+
fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
|
|
1412
1444
|
});
|
|
1413
1445
|
}
|
|
1414
1446
|
}
|
|
@@ -1436,7 +1468,8 @@ var unsafeHtmlRule = {
|
|
|
1436
1468
|
column: line.indexOf("dangerouslySetInnerHTML") + 1,
|
|
1437
1469
|
message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
|
|
1438
1470
|
severity: "critical",
|
|
1439
|
-
category: "security"
|
|
1471
|
+
category: "security",
|
|
1472
|
+
fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
|
|
1440
1473
|
});
|
|
1441
1474
|
}
|
|
1442
1475
|
if (/\w\.innerHTML\s*=/.test(line)) {
|
|
@@ -1447,7 +1480,8 @@ var unsafeHtmlRule = {
|
|
|
1447
1480
|
column: line.indexOf(".innerHTML") + 1,
|
|
1448
1481
|
message: "Direct innerHTML assignment is an XSS risk",
|
|
1449
1482
|
severity: "critical",
|
|
1450
|
-
category: "security"
|
|
1483
|
+
category: "security",
|
|
1484
|
+
fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
|
|
1451
1485
|
});
|
|
1452
1486
|
}
|
|
1453
1487
|
}
|
|
@@ -1509,7 +1543,8 @@ var sqlInjectionRule = {
|
|
|
1509
1543
|
column: 1,
|
|
1510
1544
|
message,
|
|
1511
1545
|
severity,
|
|
1512
|
-
category: "security"
|
|
1546
|
+
category: "security",
|
|
1547
|
+
fix: "Use parameterized queries: db.query('SELECT * FROM users WHERE id = $1', [id])"
|
|
1513
1548
|
});
|
|
1514
1549
|
break;
|
|
1515
1550
|
}
|
|
@@ -1557,7 +1592,8 @@ var placeholderContentRule = {
|
|
|
1557
1592
|
column: match.index + 1,
|
|
1558
1593
|
message: label,
|
|
1559
1594
|
severity: "info",
|
|
1560
|
-
category: "ai-quality"
|
|
1595
|
+
category: "ai-quality",
|
|
1596
|
+
fix: "Replace placeholder content with real production values before deploying"
|
|
1561
1597
|
});
|
|
1562
1598
|
break;
|
|
1563
1599
|
}
|
|
@@ -1603,7 +1639,8 @@ var staleFallbackRule = {
|
|
|
1603
1639
|
column: match.index + 1,
|
|
1604
1640
|
message: `${label} \u2014 use environment variable instead`,
|
|
1605
1641
|
severity: "warning",
|
|
1606
|
-
category: "ai-quality"
|
|
1642
|
+
category: "ai-quality",
|
|
1643
|
+
fix: "Replace hardcoded URL with an environment variable: process.env.DATABASE_URL or similar"
|
|
1607
1644
|
});
|
|
1608
1645
|
break;
|
|
1609
1646
|
}
|
|
@@ -1649,7 +1686,8 @@ var hallucinatedApiRule = {
|
|
|
1649
1686
|
column: match.index + 1,
|
|
1650
1687
|
message: fix,
|
|
1651
1688
|
severity: "warning",
|
|
1652
|
-
category: "ai-quality"
|
|
1689
|
+
category: "ai-quality",
|
|
1690
|
+
fix
|
|
1653
1691
|
});
|
|
1654
1692
|
}
|
|
1655
1693
|
}
|
|
@@ -1731,7 +1769,8 @@ var openRedirectRule = {
|
|
|
1731
1769
|
column: col,
|
|
1732
1770
|
message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
|
|
1733
1771
|
severity: "warning",
|
|
1734
|
-
category: "security"
|
|
1772
|
+
category: "security",
|
|
1773
|
+
fix: "Validate the redirect URL against an allowlist of trusted domains"
|
|
1735
1774
|
});
|
|
1736
1775
|
return;
|
|
1737
1776
|
}
|
|
@@ -1743,7 +1782,8 @@ var openRedirectRule = {
|
|
|
1743
1782
|
column: col,
|
|
1744
1783
|
message: "Possible user input in redirect \u2014 verify the URL is validated before use",
|
|
1745
1784
|
severity: "warning",
|
|
1746
|
-
category: "security"
|
|
1785
|
+
category: "security",
|
|
1786
|
+
fix: "Validate the redirect URL against an allowlist of trusted domains"
|
|
1747
1787
|
});
|
|
1748
1788
|
}
|
|
1749
1789
|
});
|
|
@@ -1764,7 +1804,8 @@ var openRedirectRule = {
|
|
|
1764
1804
|
column: match.index + 1,
|
|
1765
1805
|
message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
|
|
1766
1806
|
severity: "warning",
|
|
1767
|
-
category: "security"
|
|
1807
|
+
category: "security",
|
|
1808
|
+
fix: "Validate the redirect URL against an allowlist of trusted domains"
|
|
1768
1809
|
});
|
|
1769
1810
|
break;
|
|
1770
1811
|
}
|
|
@@ -1780,7 +1821,8 @@ var openRedirectRule = {
|
|
|
1780
1821
|
column: match.index + 1,
|
|
1781
1822
|
message: "Possible user input in redirect \u2014 verify the URL is validated before use",
|
|
1782
1823
|
severity: "warning",
|
|
1783
|
-
category: "security"
|
|
1824
|
+
category: "security",
|
|
1825
|
+
fix: "Validate the redirect URL against an allowlist of trusted domains"
|
|
1784
1826
|
});
|
|
1785
1827
|
break;
|
|
1786
1828
|
}
|
|
@@ -1818,7 +1860,8 @@ var noSyncFsRule = {
|
|
|
1818
1860
|
column: match.index + 1,
|
|
1819
1861
|
message: `Synchronous ${fnName}() blocks the event loop \u2014 use async alternative`,
|
|
1820
1862
|
severity,
|
|
1821
|
-
category: "performance"
|
|
1863
|
+
category: "performance",
|
|
1864
|
+
fix: `Use the async version: fs.promises.${fnName.replace("Sync", "")}() instead of ${fnName}()`
|
|
1822
1865
|
});
|
|
1823
1866
|
}
|
|
1824
1867
|
}
|
|
@@ -1876,7 +1919,8 @@ var noNPlusOneRule = {
|
|
|
1876
1919
|
column: match.index + 1,
|
|
1877
1920
|
message: "Database/fetch call inside loop \u2014 potential N+1 query, consider batching",
|
|
1878
1921
|
severity: "warning",
|
|
1879
|
-
category: "performance"
|
|
1922
|
+
category: "performance",
|
|
1923
|
+
fix: "Use eager loading (include/join) or batch the queries outside the loop"
|
|
1880
1924
|
});
|
|
1881
1925
|
break;
|
|
1882
1926
|
}
|
|
@@ -1912,7 +1956,8 @@ var noDynamicImportLoopRule = {
|
|
|
1912
1956
|
column: match.index + 1,
|
|
1913
1957
|
message: "Dynamic import() inside loop \u2014 move import outside or use Promise.all",
|
|
1914
1958
|
severity: "warning",
|
|
1915
|
-
category: "performance"
|
|
1959
|
+
category: "performance",
|
|
1960
|
+
fix: "Move the dynamic import outside the loop and call it once"
|
|
1916
1961
|
});
|
|
1917
1962
|
}
|
|
1918
1963
|
}
|
|
@@ -1944,7 +1989,8 @@ var noUnboundedQueryRule = {
|
|
|
1944
1989
|
column: line.indexOf(".findMany") + 1,
|
|
1945
1990
|
message: ".findMany() without take/limit \u2014 query may return unbounded results",
|
|
1946
1991
|
severity: "warning",
|
|
1947
|
-
category: "performance"
|
|
1992
|
+
category: "performance",
|
|
1993
|
+
fix: "Add a LIMIT clause or use pagination to prevent unbounded result sets"
|
|
1948
1994
|
});
|
|
1949
1995
|
continue;
|
|
1950
1996
|
}
|
|
@@ -1958,7 +2004,8 @@ var noUnboundedQueryRule = {
|
|
|
1958
2004
|
column: line.indexOf(".findMany") + 1,
|
|
1959
2005
|
message: ".findMany() without take \u2014 add pagination or limit",
|
|
1960
2006
|
severity: "warning",
|
|
1961
|
-
category: "performance"
|
|
2007
|
+
category: "performance",
|
|
2008
|
+
fix: "Add a LIMIT clause or use pagination to prevent unbounded result sets"
|
|
1962
2009
|
});
|
|
1963
2010
|
}
|
|
1964
2011
|
continue;
|
|
@@ -1974,7 +2021,8 @@ var noUnboundedQueryRule = {
|
|
|
1974
2021
|
column: line.indexOf(".select") + 1,
|
|
1975
2022
|
message: ".select('*') without .limit() or filter \u2014 add pagination or a where clause",
|
|
1976
2023
|
severity: "warning",
|
|
1977
|
-
category: "performance"
|
|
2024
|
+
category: "performance",
|
|
2025
|
+
fix: "Add a LIMIT clause or use pagination to prevent unbounded result sets"
|
|
1978
2026
|
});
|
|
1979
2027
|
}
|
|
1980
2028
|
}
|
|
@@ -2053,7 +2101,8 @@ var unhandledPromiseRule = {
|
|
|
2053
2101
|
column: col ? col.index + 1 : 1,
|
|
2054
2102
|
message: "Async call without await, return, or assignment \u2014 promise result is lost",
|
|
2055
2103
|
severity: "warning",
|
|
2056
|
-
category: "reliability"
|
|
2104
|
+
category: "reliability",
|
|
2105
|
+
fix: "Add .catch() handler or use try/catch with await"
|
|
2057
2106
|
});
|
|
2058
2107
|
}
|
|
2059
2108
|
return findings;
|
|
@@ -2085,7 +2134,8 @@ var missingLoadingStateRule = {
|
|
|
2085
2134
|
column: 1,
|
|
2086
2135
|
message: "useEffect with fetch but no loading/pending state \u2014 users see empty content during load",
|
|
2087
2136
|
severity: "info",
|
|
2088
|
-
category: "reliability"
|
|
2137
|
+
category: "reliability",
|
|
2138
|
+
fix: "Add a loading state: const [loading, setLoading] = useState(true) and show a spinner/skeleton while loading"
|
|
2089
2139
|
}];
|
|
2090
2140
|
}
|
|
2091
2141
|
}
|
|
@@ -2116,7 +2166,8 @@ var missingErrorBoundaryRule = {
|
|
|
2116
2166
|
column: 1,
|
|
2117
2167
|
message: `Layout without error.tsx \u2014 errors in ${dir} will bubble up to parent error boundary`,
|
|
2118
2168
|
severity: "info",
|
|
2119
|
-
category: "reliability"
|
|
2169
|
+
category: "reliability",
|
|
2170
|
+
fix: "Add an error.tsx file in the same route segment to catch rendering errors"
|
|
2120
2171
|
}];
|
|
2121
2172
|
}
|
|
2122
2173
|
};
|
|
@@ -2254,7 +2305,8 @@ var codebaseConsistencyRule = {
|
|
|
2254
2305
|
column: 1,
|
|
2255
2306
|
message: `${dim.label}: ${consistency}% consistent \u2014 dominant: ${dominant} (${dominantCount} files), minority: ${minorities.join(", ")}`,
|
|
2256
2307
|
severity: consistency < 60 ? "warning" : "info",
|
|
2257
|
-
category: "ai-quality"
|
|
2308
|
+
category: "ai-quality",
|
|
2309
|
+
fix: "Standardize on one pattern across the codebase for consistency"
|
|
2258
2310
|
});
|
|
2259
2311
|
}
|
|
2260
2312
|
return findings;
|
|
@@ -2383,7 +2435,8 @@ var deadExportsRule = {
|
|
|
2383
2435
|
column: 1,
|
|
2384
2436
|
message: `${totalDead} exported symbols never imported \u2014 dead exports pollute AI context. Top files: ${topFiles.join(", ")}`,
|
|
2385
2437
|
severity: totalDead > 20 ? "warning" : "info",
|
|
2386
|
-
category: "ai-quality"
|
|
2438
|
+
category: "ai-quality",
|
|
2439
|
+
fix: "Remove the unused export or add a consumer"
|
|
2387
2440
|
});
|
|
2388
2441
|
}
|
|
2389
2442
|
return findings;
|
|
@@ -2447,7 +2500,8 @@ var shallowCatchRule = {
|
|
|
2447
2500
|
column: file.lines[catchLine].indexOf("catch") + 1,
|
|
2448
2501
|
message: score === 0 ? "Empty catch block \u2014 errors are silently swallowed" : `Decorative error handler: ${label}`,
|
|
2449
2502
|
severity: score === 0 ? "warning" : "info",
|
|
2450
|
-
category: "reliability"
|
|
2503
|
+
category: "reliability",
|
|
2504
|
+
fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
|
|
2451
2505
|
});
|
|
2452
2506
|
}
|
|
2453
2507
|
});
|
|
@@ -2515,7 +2569,8 @@ var shallowCatchRule = {
|
|
|
2515
2569
|
column: file.lines[i].indexOf("catch") + 1,
|
|
2516
2570
|
message: score === 0 ? "Empty catch block \u2014 errors are silently swallowed" : `Decorative error handler: ${label}`,
|
|
2517
2571
|
severity: score === 0 ? "warning" : "info",
|
|
2518
|
-
category: "reliability"
|
|
2572
|
+
category: "reliability",
|
|
2573
|
+
fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
|
|
2519
2574
|
});
|
|
2520
2575
|
}
|
|
2521
2576
|
i = bodyEnd;
|
|
@@ -2567,7 +2622,8 @@ var comprehensionDebtRule = {
|
|
|
2567
2622
|
column: 1,
|
|
2568
2623
|
message: `${fnName}() has ${params} parameters (max ${MAX_PARAMS}) \u2014 hard to call correctly`,
|
|
2569
2624
|
severity: "info",
|
|
2570
|
-
category: "ai-quality"
|
|
2625
|
+
category: "ai-quality",
|
|
2626
|
+
fix: "Group related parameters into an options object"
|
|
2571
2627
|
});
|
|
2572
2628
|
}
|
|
2573
2629
|
}
|
|
@@ -2594,7 +2650,8 @@ var comprehensionDebtRule = {
|
|
|
2594
2650
|
column: 1,
|
|
2595
2651
|
message: `${fnName}() is ${length} lines long (max ${MAX_FUNCTION_LENGTH}) \u2014 consider splitting`,
|
|
2596
2652
|
severity: "info",
|
|
2597
|
-
category: "ai-quality"
|
|
2653
|
+
category: "ai-quality",
|
|
2654
|
+
fix: "Break this into smaller, focused functions with clear names"
|
|
2598
2655
|
});
|
|
2599
2656
|
}
|
|
2600
2657
|
if (maxDepthInFn > MAX_NESTING_DEPTH) {
|
|
@@ -2605,7 +2662,8 @@ var comprehensionDebtRule = {
|
|
|
2605
2662
|
column: 1,
|
|
2606
2663
|
message: `${fnName}() has nesting depth ${maxDepthInFn} (max ${MAX_NESTING_DEPTH}) \u2014 flatten with early returns`,
|
|
2607
2664
|
severity: "info",
|
|
2608
|
-
category: "ai-quality"
|
|
2665
|
+
category: "ai-quality",
|
|
2666
|
+
fix: "Reduce nesting with early returns, guard clauses, or extracted helper functions"
|
|
2609
2667
|
});
|
|
2610
2668
|
}
|
|
2611
2669
|
inFunction = false;
|
|
@@ -2712,7 +2770,8 @@ var phantomDependencyRule = {
|
|
|
2712
2770
|
column: 1,
|
|
2713
2771
|
message: `"${name}" is a commonly hallucinated package name \u2014 verify it exists and is the correct package`,
|
|
2714
2772
|
severity: "warning",
|
|
2715
|
-
category: "security"
|
|
2773
|
+
category: "security",
|
|
2774
|
+
fix: "Add the missing package to dependencies in package.json, or remove the import if the package is hallucinated"
|
|
2716
2775
|
});
|
|
2717
2776
|
}
|
|
2718
2777
|
for (const pattern of SUSPICIOUS_PATTERNS) {
|
|
@@ -2724,7 +2783,8 @@ var phantomDependencyRule = {
|
|
|
2724
2783
|
column: 1,
|
|
2725
2784
|
message: `"${name}" has a suspicious package name pattern \u2014 verify it's legitimate`,
|
|
2726
2785
|
severity: "info",
|
|
2727
|
-
category: "security"
|
|
2786
|
+
category: "security",
|
|
2787
|
+
fix: "Verify the package on npm \u2014 suspicious names may indicate typosquatting or hallucination"
|
|
2728
2788
|
});
|
|
2729
2789
|
break;
|
|
2730
2790
|
}
|
|
@@ -3234,6 +3294,7 @@ var useClientOveruseRule = {
|
|
|
3234
3294
|
// src/rules/env-fallback-secret.ts
|
|
3235
3295
|
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;
|
|
3236
3296
|
var ENV_FALLBACK = /process\.env\.\w*(SECRET|KEY|PASSWORD|TOKEN)\w*\s*(?:\|\||\?\?)\s*['"`]/i;
|
|
3297
|
+
var CONN_STRING_FALLBACK = /process\.env\.\w+\s*(?:\|\||\?\?)\s*['"`](?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|mssql):\/\//i;
|
|
3237
3298
|
var envFallbackSecretRule = {
|
|
3238
3299
|
id: "env-fallback-secret",
|
|
3239
3300
|
name: "Secret with Fallback Value",
|
|
@@ -3261,6 +3322,20 @@ var envFallbackSecretRule = {
|
|
|
3261
3322
|
});
|
|
3262
3323
|
continue;
|
|
3263
3324
|
}
|
|
3325
|
+
const connMatch = CONN_STRING_FALLBACK.exec(line);
|
|
3326
|
+
if (connMatch) {
|
|
3327
|
+
findings.push({
|
|
3328
|
+
ruleId: "env-fallback-secret",
|
|
3329
|
+
file: file.relativePath,
|
|
3330
|
+
line: i + 1,
|
|
3331
|
+
column: connMatch.index + 1,
|
|
3332
|
+
message: "Connection string with credentials used as fallback \u2014 hardcoded DB/service URL becomes the production connection when env var is missing",
|
|
3333
|
+
severity: "warning",
|
|
3334
|
+
category: "security",
|
|
3335
|
+
fix: "Fail fast when required env vars are missing instead of falling back to a default value"
|
|
3336
|
+
});
|
|
3337
|
+
continue;
|
|
3338
|
+
}
|
|
3264
3339
|
const genericMatch = ENV_FALLBACK.exec(line);
|
|
3265
3340
|
if (genericMatch && !isConfigFile(file.relativePath)) {
|
|
3266
3341
|
findings.push({
|
|
@@ -3477,7 +3552,8 @@ var evalInjectionRule = {
|
|
|
3477
3552
|
column: match.index + 1,
|
|
3478
3553
|
message: msg,
|
|
3479
3554
|
severity: "critical",
|
|
3480
|
-
category: "security"
|
|
3555
|
+
category: "security",
|
|
3556
|
+
fix: "Replace eval/Function constructor with a safe alternative like JSON.parse() or a sandboxed interpreter"
|
|
3481
3557
|
});
|
|
3482
3558
|
break;
|
|
3483
3559
|
}
|
|
@@ -4274,44 +4350,57 @@ var clientSideAuthOnlyRule = {
|
|
|
4274
4350
|
};
|
|
4275
4351
|
|
|
4276
4352
|
// src/rules/missing-abort-controller.ts
|
|
4277
|
-
var
|
|
4353
|
+
var HTTP_CALL = /\b(fetch|axios)\s*[.(]/;
|
|
4278
4354
|
var HAS_TIMEOUT = [
|
|
4279
4355
|
/AbortController/,
|
|
4280
4356
|
/abort/i,
|
|
4281
4357
|
/signal\s*:/,
|
|
4282
|
-
/timeout
|
|
4283
|
-
/
|
|
4358
|
+
/timeout\s*:/,
|
|
4359
|
+
/timeout\s*=/,
|
|
4360
|
+
/setTimeout.*abort/s,
|
|
4361
|
+
/axios\.create\s*\([^)]*timeout/s
|
|
4284
4362
|
];
|
|
4363
|
+
var BACKGROUND_PATTERN = /\b(setInterval|cron|schedule|createWorker|bullmq|agenda|queue\.process)\b/;
|
|
4364
|
+
function isBackgroundFile(file) {
|
|
4365
|
+
if (BACKGROUND_PATTERN.test(file.content)) return true;
|
|
4366
|
+
const p = file.relativePath.toLowerCase();
|
|
4367
|
+
return /\b(cron|jobs?|workers?|queues?|tasks?|background)\b/.test(p);
|
|
4368
|
+
}
|
|
4285
4369
|
var missingAbortControllerRule = {
|
|
4286
4370
|
id: "missing-abort-controller",
|
|
4287
4371
|
name: "Missing Abort Controller",
|
|
4288
|
-
description: "Detects fetch calls
|
|
4372
|
+
description: "Detects fetch/axios calls without timeout or AbortController \u2014 requests can hang indefinitely",
|
|
4289
4373
|
category: "performance",
|
|
4290
4374
|
severity: "info",
|
|
4291
4375
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
4292
4376
|
check(file, _project) {
|
|
4293
4377
|
if (isTestFile(file.relativePath)) return [];
|
|
4294
|
-
|
|
4295
|
-
if (!
|
|
4378
|
+
const isRelevant = isApiRoute(file.relativePath) || /['"]use server['"]/.test(file.content) || isBackgroundFile(file);
|
|
4379
|
+
if (!isRelevant) return [];
|
|
4380
|
+
if (!HTTP_CALL.test(file.content)) return [];
|
|
4296
4381
|
const hasTimeout = HAS_TIMEOUT.some((p) => p.test(file.content));
|
|
4297
4382
|
if (hasTimeout) return [];
|
|
4298
4383
|
let reportLine = 1;
|
|
4384
|
+
let matchedCall = "fetch";
|
|
4299
4385
|
for (let i = 0; i < file.lines.length; i++) {
|
|
4300
4386
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
4301
|
-
|
|
4387
|
+
const m = file.lines[i].match(HTTP_CALL);
|
|
4388
|
+
if (m) {
|
|
4302
4389
|
reportLine = i + 1;
|
|
4390
|
+
matchedCall = m[1];
|
|
4303
4391
|
break;
|
|
4304
4392
|
}
|
|
4305
4393
|
}
|
|
4394
|
+
const isFetch = matchedCall === "fetch";
|
|
4306
4395
|
return [{
|
|
4307
4396
|
ruleId: "missing-abort-controller",
|
|
4308
4397
|
file: file.relativePath,
|
|
4309
4398
|
line: reportLine,
|
|
4310
4399
|
column: 1,
|
|
4311
|
-
message:
|
|
4400
|
+
message: `${matchedCall}() without timeout or AbortController \u2014 request will hang indefinitely if the upstream server doesn't respond`,
|
|
4312
4401
|
severity: "info",
|
|
4313
4402
|
category: "performance",
|
|
4314
|
-
fix: "Add a timeout: const controller = new AbortController(); setTimeout(() => controller.abort(), 10000); fetch(url, { signal: controller.signal })"
|
|
4403
|
+
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 })"
|
|
4315
4404
|
}];
|
|
4316
4405
|
}
|
|
4317
4406
|
};
|
|
@@ -4422,111 +4511,113 @@ async function scan(options) {
|
|
|
4422
4511
|
}
|
|
4423
4512
|
|
|
4424
4513
|
// src/web-scanner/checks.ts
|
|
4425
|
-
function make(id, name, description, maxPoints, severity, status, details) {
|
|
4514
|
+
function make(id, name, description, maxPoints, severity, maturity, status, details) {
|
|
4426
4515
|
return {
|
|
4427
4516
|
id,
|
|
4428
4517
|
name,
|
|
4429
4518
|
description,
|
|
4430
4519
|
status,
|
|
4431
4520
|
severity,
|
|
4521
|
+
maturity,
|
|
4432
4522
|
details,
|
|
4433
4523
|
points: status === "pass" ? maxPoints : status === "warn" ? Math.floor(maxPoints / 2) : 0,
|
|
4434
4524
|
maxPoints
|
|
4435
4525
|
};
|
|
4436
4526
|
}
|
|
4437
4527
|
function checkRobotsTxt(ctx) {
|
|
4438
|
-
if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible",
|
|
4439
|
-
return make("robots_txt", "robots.txt", "robots.txt exists and is accessible",
|
|
4528
|
+
if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 7, "medium", "established", "fail", "No robots.txt found.");
|
|
4529
|
+
return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 7, "medium", "established", "pass", "robots.txt found.");
|
|
4440
4530
|
}
|
|
4441
4531
|
function checkRobotsAiDirectives(ctx) {
|
|
4442
|
-
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.");
|
|
4443
|
-
const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai"];
|
|
4532
|
+
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.");
|
|
4533
|
+
const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai", "Applebot-Extended", "Grok"];
|
|
4444
4534
|
const found = agents.filter((a) => ctx.robotsTxt.toLowerCase().includes(a.toLowerCase()));
|
|
4445
|
-
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.");
|
|
4446
|
-
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(", ")}.`);
|
|
4447
|
-
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(", ")}.`);
|
|
4535
|
+
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.");
|
|
4536
|
+
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(", ")}.`);
|
|
4537
|
+
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(", ")}.`);
|
|
4448
4538
|
}
|
|
4449
4539
|
function checkContentUsage(ctx) {
|
|
4450
4540
|
const hasHeader = ctx.headers["content-usage"] != null;
|
|
4451
4541
|
const hasInRobots = ctx.robotsTxt?.toLowerCase().includes("content-usage") ?? false;
|
|
4452
|
-
if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives",
|
|
4453
|
-
return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives",
|
|
4542
|
+
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.");
|
|
4543
|
+
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.");
|
|
4454
4544
|
}
|
|
4455
4545
|
function checkLlmsTxt(ctx) {
|
|
4456
|
-
if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt",
|
|
4457
|
-
const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0);
|
|
4458
|
-
if (lines.length < 3) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt",
|
|
4459
|
-
return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt",
|
|
4546
|
+
if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 6, "high", "emerging", "fail", "No llms.txt found.");
|
|
4547
|
+
const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
|
|
4548
|
+
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.");
|
|
4549
|
+
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.`);
|
|
4460
4550
|
}
|
|
4461
4551
|
function checkTdmRep(ctx) {
|
|
4462
4552
|
const hasWK = ctx.tdmRep != null;
|
|
4463
4553
|
const hasHeader = ctx.headers["tdm-reservation"] != null;
|
|
4464
|
-
if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header",
|
|
4465
|
-
return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header",
|
|
4554
|
+
if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 4, "medium", "emerging", "fail", "No TDMRep configuration found.");
|
|
4555
|
+
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"]}`);
|
|
4466
4556
|
}
|
|
4467
4557
|
function checkAiDisclosure(ctx) {
|
|
4468
|
-
if (!ctx.headers["ai-disclosure"]) return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation",
|
|
4469
|
-
return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation",
|
|
4558
|
+
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.");
|
|
4559
|
+
return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 2, "low", "emerging", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
|
|
4470
4560
|
}
|
|
4471
4561
|
function checkAgentCard(ctx) {
|
|
4472
|
-
if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery",
|
|
4562
|
+
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.");
|
|
4473
4563
|
try {
|
|
4474
4564
|
const card = JSON.parse(ctx.agentCard);
|
|
4475
|
-
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",
|
|
4476
|
-
return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery",
|
|
4565
|
+
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.");
|
|
4566
|
+
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).`);
|
|
4477
4567
|
} catch {
|
|
4478
|
-
return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery",
|
|
4568
|
+
return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 5, "high", "emerging", "warn", "AgentCard found but contains invalid JSON.");
|
|
4479
4569
|
}
|
|
4480
4570
|
}
|
|
4481
4571
|
function checkAiTxt(ctx) {
|
|
4482
|
-
if (!ctx.aiTxt) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec",
|
|
4572
|
+
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.");
|
|
4483
4573
|
const lines = ctx.aiTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
|
|
4484
|
-
if (lines.length < 2) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec",
|
|
4485
|
-
return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec",
|
|
4574
|
+
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.");
|
|
4575
|
+
return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 3, "medium", "emerging", "pass", `ai.txt found with ${lines.length} directive(s).`);
|
|
4486
4576
|
}
|
|
4487
4577
|
function checkWebMCP(ctx) {
|
|
4488
|
-
if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration",
|
|
4578
|
+
if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 5, "high", "emerging", "info", "Could not check for WebMCP tools.");
|
|
4489
4579
|
const hasToolname = /toolname=/i.test(ctx.html);
|
|
4490
4580
|
const hasModelContext = /navigator\.modelContext/i.test(ctx.html) || /registerTool/i.test(ctx.html);
|
|
4491
|
-
if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration",
|
|
4581
|
+
if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 5, "high", "emerging", "fail", "No WebMCP tools detected.");
|
|
4492
4582
|
const count = (ctx.html.match(/toolname=/gi) || []).length;
|
|
4493
|
-
return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration",
|
|
4583
|
+
return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 5, "high", "emerging", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
|
|
4494
4584
|
}
|
|
4495
4585
|
function checkStructuredData(ctx) {
|
|
4496
|
-
if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup",
|
|
4586
|
+
if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 12, "medium", "established", "info", "Could not fetch page HTML.");
|
|
4497
4587
|
const jsonLd = ctx.html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>/gi);
|
|
4498
4588
|
const hasMicrodata = ctx.html.includes("itemscope") && ctx.html.includes("itemtype");
|
|
4499
|
-
if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup",
|
|
4500
|
-
return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup",
|
|
4589
|
+
if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 12, "medium", "established", "fail", "No structured data found.");
|
|
4590
|
+
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).`);
|
|
4501
4591
|
}
|
|
4502
4592
|
function checkOpenGraph(ctx) {
|
|
4503
|
-
if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
4593
|
+
if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "info", "Could not fetch HTML.");
|
|
4504
4594
|
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)];
|
|
4505
4595
|
const passed = checks.filter(Boolean).length;
|
|
4506
|
-
if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
4507
|
-
if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
4508
|
-
return make("opengraph", "OpenGraph & Meta", "OG tags and meta description",
|
|
4596
|
+
if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "fail", "No OpenGraph tags or meta description.");
|
|
4597
|
+
if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "warn", `Found ${passed}/4 meta tags.`);
|
|
4598
|
+
if (passed < 4) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "pass", `Found ${passed}/4 key meta tags.`);
|
|
4599
|
+
return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "pass", "All 4 key meta tags present.");
|
|
4509
4600
|
}
|
|
4510
4601
|
function checkSitemap(ctx) {
|
|
4511
|
-
if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
4602
|
+
if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "fail", "No sitemap.xml found.");
|
|
4512
4603
|
const count = Math.max((ctx.sitemapXml.match(/<url>/gi) || []).length, (ctx.sitemapXml.match(/<loc>/gi) || []).length);
|
|
4513
|
-
if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
4514
|
-
if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
4515
|
-
return make("sitemap", "sitemap.xml", "Sitemap exists and is valid",
|
|
4604
|
+
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.");
|
|
4605
|
+
if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "warn", "sitemap.xml found but appears empty.");
|
|
4606
|
+
return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "pass", `sitemap.xml found with ${count} URL(s).`);
|
|
4516
4607
|
}
|
|
4517
4608
|
function checkHttpSignatures(ctx) {
|
|
4518
4609
|
const hasDirectory = ctx.httpSigDirectory != null;
|
|
4519
4610
|
const hasSignatureHeader = ctx.headers["signature"] != null || ctx.headers["signature-input"] != null;
|
|
4520
4611
|
const hasSignatureAgent = ctx.headers["signature-agent"] != null;
|
|
4521
|
-
if (!hasDirectory && !hasSignatureHeader && !hasSignatureAgent) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures",
|
|
4522
|
-
if (hasDirectory) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures",
|
|
4523
|
-
return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures",
|
|
4612
|
+
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.");
|
|
4613
|
+
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.");
|
|
4614
|
+
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.");
|
|
4524
4615
|
}
|
|
4525
4616
|
function checkPageSpeed(ctx) {
|
|
4526
|
-
if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4527
|
-
if (ctx.loadTimeMs > 5e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4528
|
-
if (ctx.loadTimeMs > 2e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4529
|
-
return make("page_speed", "Page Load Time", "Response time for AI agent interactions",
|
|
4617
|
+
if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "info", "Could not measure.");
|
|
4618
|
+
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.`);
|
|
4619
|
+
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.`);
|
|
4620
|
+
return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
|
|
4530
4621
|
}
|
|
4531
4622
|
var allChecks = [
|
|
4532
4623
|
checkRobotsTxt,
|
|
@@ -4565,6 +4656,29 @@ function isPrivateHost(hostname) {
|
|
|
4565
4656
|
if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) return true;
|
|
4566
4657
|
const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
4567
4658
|
if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || h === "::") return true;
|
|
4659
|
+
const v4mapped = h.match(/^(?:::ffff:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
4660
|
+
if (v4mapped) {
|
|
4661
|
+
const [, a, b] = v4mapped.map(Number);
|
|
4662
|
+
if (a === 10 || a === 127 || a === 0) return true;
|
|
4663
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
4664
|
+
if (a === 192 && b === 168) return true;
|
|
4665
|
+
if (a === 169 && b === 254) return true;
|
|
4666
|
+
return false;
|
|
4667
|
+
}
|
|
4668
|
+
if (/^(0[xX][0-9a-fA-F]+|0[0-7]+)(\.|$)/.test(hostname)) return true;
|
|
4669
|
+
if (/^\d+$/.test(hostname)) {
|
|
4670
|
+
const dec = parseInt(hostname, 10);
|
|
4671
|
+
if (dec >= 0 && dec <= 4294967295) {
|
|
4672
|
+
const a = dec >>> 24 & 255;
|
|
4673
|
+
const b = dec >>> 16 & 255;
|
|
4674
|
+
if (a === 10 || a === 127 || a === 0) return true;
|
|
4675
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
4676
|
+
if (a === 192 && b === 168) return true;
|
|
4677
|
+
if (a === 169 && b === 254) return true;
|
|
4678
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
4679
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
4680
|
+
}
|
|
4681
|
+
}
|
|
4568
4682
|
const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
4569
4683
|
if (ipv4) {
|
|
4570
4684
|
const [, a, b] = ipv4.map(Number);
|
|
@@ -4577,22 +4691,49 @@ function isPrivateHost(hostname) {
|
|
|
4577
4691
|
}
|
|
4578
4692
|
return false;
|
|
4579
4693
|
}
|
|
4694
|
+
function isAllowedProtocol(url) {
|
|
4695
|
+
try {
|
|
4696
|
+
const p = new URL(url).protocol;
|
|
4697
|
+
return p === "https:" || p === "http:";
|
|
4698
|
+
} catch {
|
|
4699
|
+
return false;
|
|
4700
|
+
}
|
|
4701
|
+
}
|
|
4580
4702
|
function validateRedirectUrl(responseUrl) {
|
|
4581
4703
|
try {
|
|
4582
4704
|
const parsed = new URL(responseUrl);
|
|
4705
|
+
if (!isAllowedProtocol(responseUrl)) return false;
|
|
4583
4706
|
return !isPrivateHost(parsed.hostname);
|
|
4584
4707
|
} catch {
|
|
4585
4708
|
return false;
|
|
4586
4709
|
}
|
|
4587
4710
|
}
|
|
4711
|
+
var MAX_REDIRECTS = 5;
|
|
4712
|
+
async function safeFetch(url, timeout, signal) {
|
|
4713
|
+
let current = url;
|
|
4714
|
+
for (let i = 0; i < MAX_REDIRECTS; i++) {
|
|
4715
|
+
const r = await fetch(current, { signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "manual" });
|
|
4716
|
+
const status = r.status;
|
|
4717
|
+
if (status >= 300 && status < 400) {
|
|
4718
|
+
const location = r.headers.get("location");
|
|
4719
|
+
if (!location) return null;
|
|
4720
|
+
const resolved = new URL(location, current).toString();
|
|
4721
|
+
if (!validateRedirectUrl(resolved)) return null;
|
|
4722
|
+
current = resolved;
|
|
4723
|
+
continue;
|
|
4724
|
+
}
|
|
4725
|
+
return r;
|
|
4726
|
+
}
|
|
4727
|
+
return null;
|
|
4728
|
+
}
|
|
4588
4729
|
async function fetchText(url, timeout = 8e3) {
|
|
4589
4730
|
try {
|
|
4590
4731
|
const c = new AbortController();
|
|
4591
4732
|
const t = setTimeout(() => c.abort(), timeout);
|
|
4592
|
-
const r = await
|
|
4733
|
+
const r = await safeFetch(url, timeout, c.signal);
|
|
4593
4734
|
clearTimeout(t);
|
|
4594
|
-
if (r
|
|
4595
|
-
return
|
|
4735
|
+
if (!r || !r.ok) return null;
|
|
4736
|
+
return await r.text();
|
|
4596
4737
|
} catch {
|
|
4597
4738
|
return null;
|
|
4598
4739
|
}
|
|
@@ -4601,9 +4742,9 @@ async function fetchHeaders(url, timeout = 8e3) {
|
|
|
4601
4742
|
try {
|
|
4602
4743
|
const c = new AbortController();
|
|
4603
4744
|
const t = setTimeout(() => c.abort(), timeout);
|
|
4604
|
-
const r = await
|
|
4745
|
+
const r = await safeFetch(url, timeout, c.signal);
|
|
4605
4746
|
clearTimeout(t);
|
|
4606
|
-
if (
|
|
4747
|
+
if (!r) return {};
|
|
4607
4748
|
const h = {};
|
|
4608
4749
|
r.headers.forEach((v, k) => {
|
|
4609
4750
|
h[k.toLowerCase()] = v;
|
|
@@ -4618,20 +4759,18 @@ async function fetchWithTiming(url, timeout = 15e3) {
|
|
|
4618
4759
|
const c = new AbortController();
|
|
4619
4760
|
const t = setTimeout(() => c.abort(), timeout);
|
|
4620
4761
|
const start = Date.now();
|
|
4621
|
-
const r = await
|
|
4622
|
-
const html = await r.text();
|
|
4762
|
+
const r = await safeFetch(url, timeout, c.signal);
|
|
4623
4763
|
clearTimeout(t);
|
|
4624
|
-
if (r
|
|
4625
|
-
|
|
4764
|
+
if (!r || !r.ok) return { html: null, loadTimeMs: null };
|
|
4765
|
+
const html = await r.text();
|
|
4766
|
+
return { html, loadTimeMs: Date.now() - start };
|
|
4626
4767
|
} catch {
|
|
4627
4768
|
return { html: null, loadTimeMs: null };
|
|
4628
4769
|
}
|
|
4629
4770
|
}
|
|
4630
4771
|
function normalizeUrl(input) {
|
|
4631
4772
|
let url = input.trim();
|
|
4632
|
-
if (!/^https?:\/\//i.test(url)) {
|
|
4633
|
-
url = `https://${url}`;
|
|
4634
|
-
}
|
|
4773
|
+
if (!/^https?:\/\//i.test(url)) url = `https://${url}`;
|
|
4635
4774
|
const parsed = new URL(url);
|
|
4636
4775
|
return parsed.origin;
|
|
4637
4776
|
}
|
|
@@ -4684,7 +4823,9 @@ server.tool(
|
|
|
4684
4823
|
async ({ path, ignore }) => {
|
|
4685
4824
|
const resolved = resolve4(path);
|
|
4686
4825
|
const cwd = process.cwd();
|
|
4687
|
-
|
|
4826
|
+
const normalizedResolved = process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
4827
|
+
const normalizedCwd = process.platform === "win32" ? cwd.toLowerCase() : cwd;
|
|
4828
|
+
if (normalizedResolved !== normalizedCwd && !normalizedResolved.startsWith(normalizedCwd + sep2)) {
|
|
4688
4829
|
return {
|
|
4689
4830
|
content: [{ type: "text", text: `Error: Path must be within the current working directory (${cwd})` }],
|
|
4690
4831
|
isError: true
|