@qodfy/core 0.2.8 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +395 -29
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -124,6 +124,7 @@ var issueIdPrefixes = {
|
|
|
124
124
|
"api-public-read-route": "api-public-read-route",
|
|
125
125
|
"public-form-missing-abuse-protection": "public-form-missing-abuse-protection",
|
|
126
126
|
"internal-route-missing-protection": "internal-route-missing-protection",
|
|
127
|
+
"admin-route-missing-authorization": "admin-route-authorization",
|
|
127
128
|
"api-mutation-route-review-auth": "api-mutation-route-review-auth",
|
|
128
129
|
"ai-route-missing-rate-limit": "ai-route-rate-limit",
|
|
129
130
|
"maintainability-large-file": "maintainability-large-file",
|
|
@@ -723,7 +724,7 @@ function addWebhookSignatureIssues({
|
|
|
723
724
|
analysis
|
|
724
725
|
}) {
|
|
725
726
|
for (const handler of analysis.handlers) {
|
|
726
|
-
if (handler.intent !== "webhook" || handler.hasWebhookVerification) {
|
|
727
|
+
if (handler.intent !== "webhook" || handler.hasWebhookVerification || handler.hasMethodBlocking) {
|
|
727
728
|
continue;
|
|
728
729
|
}
|
|
729
730
|
addIssue({
|
|
@@ -750,6 +751,29 @@ function addApiRouteProtectionIssues({
|
|
|
750
751
|
if (handler.intent === "webhook") {
|
|
751
752
|
continue;
|
|
752
753
|
}
|
|
754
|
+
if (handler.hasMethodBlocking) {
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
const adminPathMatch = getAdminRoutePathMatch(analysis.relativeFile);
|
|
758
|
+
if (adminPathMatch && handler.hasAuth && !handler.hasAdminAuthorization) {
|
|
759
|
+
addIssue({
|
|
760
|
+
ruleId: "admin-route-missing-authorization",
|
|
761
|
+
category: "security",
|
|
762
|
+
severity: "warning",
|
|
763
|
+
confidence: "medium",
|
|
764
|
+
title: `Admin ${handler.method} handler may be missing admin authorization`,
|
|
765
|
+
message: `The ${handler.method} handler is authenticated, but this route appears to expose admin, private, or debug functionality and Qodfy could not find a role, staff, or permission check.`,
|
|
766
|
+
file: analysis.relativeFile,
|
|
767
|
+
suggestion: "Confirm this route is restricted to admins/staff, or remove it before production if it is only for debugging.",
|
|
768
|
+
fixPrompt: createAdminAuthorizationFixPrompt(analysis.relativeFile, handler.method),
|
|
769
|
+
evidence: [
|
|
770
|
+
{ label: "path contains", detail: adminPathMatch },
|
|
771
|
+
{ label: "auth guard detected", detail: `${handler.method} handler` },
|
|
772
|
+
{ label: "no admin/staff/role/permission check detected", detail: `${handler.method} handler` }
|
|
773
|
+
],
|
|
774
|
+
context: handler.evidence
|
|
775
|
+
});
|
|
776
|
+
}
|
|
753
777
|
if (handler.intent === "public-read") {
|
|
754
778
|
if (includeLowConfidence) {
|
|
755
779
|
addIssue({
|
|
@@ -769,7 +793,7 @@ function addApiRouteProtectionIssues({
|
|
|
769
793
|
continue;
|
|
770
794
|
}
|
|
771
795
|
if (handler.intent === "public-form") {
|
|
772
|
-
if (!handler.
|
|
796
|
+
if (!(handler.hasValidation && handler.hasRateLimit)) {
|
|
773
797
|
addIssue({
|
|
774
798
|
ruleId: "public-form-missing-abuse-protection",
|
|
775
799
|
category: "api",
|
|
@@ -883,7 +907,7 @@ function analyzeApiHandler({
|
|
|
883
907
|
}) {
|
|
884
908
|
const normalizedFile = relativeFile.toLowerCase();
|
|
885
909
|
const webhookPathMatch = getRoutePathMatch(normalizedFile, ["webhook", "webhooks", "callback"]);
|
|
886
|
-
const internalPathMatch = getRoutePathMatch(normalizedFile, ["internal", "admin", "cron", "cleanup", "revalidate", "private"]);
|
|
910
|
+
const internalPathMatch = getRoutePathMatch(normalizedFile, ["internal", "admin", "cron", "cleanup", "revalidate", "private", "debug", "staff", "manager"]);
|
|
887
911
|
const formPathMatch = getRoutePathMatch(normalizedFile, ["contact", "subscribe", "newsletter", "lead", "inquiry"]);
|
|
888
912
|
const sensitivePathMatch = getRoutePathMatch(normalizedFile, [
|
|
889
913
|
"upload",
|
|
@@ -929,13 +953,20 @@ ${handlerContent.toLowerCase()}`);
|
|
|
929
953
|
protectionAnalysis.sensitiveOperation
|
|
930
954
|
);
|
|
931
955
|
const hasAuth = protectionAnalysis.hasAccessControlGuard || hasStrongProtection;
|
|
932
|
-
const
|
|
956
|
+
const secretProtectionAnalysis = analyzeSecretProtectionGuardBeforeSensitiveWork(
|
|
933
957
|
handlerContent,
|
|
958
|
+
content,
|
|
934
959
|
protectionAnalysis.sensitiveOperation
|
|
935
960
|
);
|
|
961
|
+
const hasSecretProtection = secretProtectionAnalysis.hasSecretProtectionGuard;
|
|
936
962
|
const hasWeakSecretSignal = hasWeakSecretProtectionSignal(handlerContent);
|
|
963
|
+
const adminAuthorizationAnalysis = analyzeAdminAuthorization(handlerContent);
|
|
964
|
+
const hasAdminAuthorization = adminAuthorizationAnalysis.hasAdminAuthorization;
|
|
937
965
|
const hasRateLimit = hasRateLimitSignal(handlerContent);
|
|
938
|
-
const
|
|
966
|
+
const hasBasicValidation = hasBasicValidationSignal(handlerContent);
|
|
967
|
+
const hasSchemaValidation = hasSchemaValidationSignal(handlerContent);
|
|
968
|
+
const hasValidation = hasBasicValidation || hasSchemaValidation;
|
|
969
|
+
const hasSpamProtection = hasSpamProtectionSignal(handlerContent);
|
|
939
970
|
const hasCacheHeaders = hasCacheHeaderSignal(handlerContent);
|
|
940
971
|
const hasMethodBlocking = hasMethodBlockingSignal(handlerContent);
|
|
941
972
|
const hasWebhookVerification = hasWebhookSignatureVerification(handlerContent, webhookProvider);
|
|
@@ -955,7 +986,7 @@ ${handlerContent.toLowerCase()}`);
|
|
|
955
986
|
evidence.push({ label: "path contains", detail: internalPathMatch });
|
|
956
987
|
} else if (method === "POST" && formPathMatch) {
|
|
957
988
|
intent = "public-form";
|
|
958
|
-
evidence.push({ label: "
|
|
989
|
+
evidence.push({ label: "public submission endpoint detected", detail: formPathMatch });
|
|
959
990
|
} else if (method === "GET" && publicContentPathMatch && !sensitivePathMatch && !internalPathMatch) {
|
|
960
991
|
intent = "public-read";
|
|
961
992
|
evidence.push({ label: "public read path detected", detail: publicContentPathMatch });
|
|
@@ -965,11 +996,18 @@ ${handlerContent.toLowerCase()}`);
|
|
|
965
996
|
}
|
|
966
997
|
if (protectionAnalysis.sensitiveOperation) {
|
|
967
998
|
evidence.push({
|
|
968
|
-
label: `sensitive
|
|
999
|
+
label: intent === "public-form" ? `${getPublicFormSideEffectLabel(protectionAnalysis.sensitiveOperation.label)} side effect detected` : `sensitive side effect ${protectionAnalysis.sensitiveOperation.label} detected`,
|
|
969
1000
|
detail: `${method} handler`
|
|
970
1001
|
});
|
|
971
1002
|
}
|
|
972
|
-
if (protectionAnalysis.
|
|
1003
|
+
if (protectionAnalysis.inputParsingOperation && intent !== "public-form") {
|
|
1004
|
+
evidence.push({
|
|
1005
|
+
label: `input parsing ${protectionAnalysis.inputParsingOperation.label} detected`,
|
|
1006
|
+
detail: `${method} handler`
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
if (intent === "public-form") {
|
|
1010
|
+
} else if (protectionAnalysis.hasAccessControlGuard) {
|
|
973
1011
|
evidence.push(...protectionAnalysis.evidence);
|
|
974
1012
|
} else if (hasStrongProtection) {
|
|
975
1013
|
evidence.push({
|
|
@@ -982,22 +1020,29 @@ ${handlerContent.toLowerCase()}`);
|
|
|
982
1020
|
detail: `${method} handler`
|
|
983
1021
|
});
|
|
984
1022
|
} else if (protectionAnalysis.sensitiveOperation) {
|
|
985
|
-
evidence.push({ label: `no access-control guard detected before sensitive
|
|
1023
|
+
evidence.push({ label: `no access-control guard detected before sensitive side effect`, detail: `${method} handler` });
|
|
986
1024
|
} else {
|
|
987
1025
|
evidence.push({ label: `no auth/session check detected in ${method} handler` });
|
|
988
1026
|
}
|
|
989
1027
|
if (hasSecretProtection) {
|
|
990
|
-
evidence.push(
|
|
1028
|
+
evidence.push(...secretProtectionAnalysis.evidence);
|
|
991
1029
|
} else if (hasWeakSecretSignal) {
|
|
992
1030
|
evidence.push({ label: `possible secret/token signal detected`, detail: `${method} handler` });
|
|
993
1031
|
}
|
|
1032
|
+
if (hasAdminAuthorization) {
|
|
1033
|
+
evidence.push(...adminAuthorizationAnalysis.evidence);
|
|
1034
|
+
}
|
|
994
1035
|
if (intent === "public-form") {
|
|
995
1036
|
evidence.push({
|
|
996
1037
|
label: hasRateLimit ? "rate limit detected" : "no rate limit detected",
|
|
997
1038
|
detail: `${method} handler`
|
|
998
1039
|
});
|
|
999
1040
|
evidence.push({
|
|
1000
|
-
label:
|
|
1041
|
+
label: hasSchemaValidation ? "schema validation detected" : hasBasicValidation ? "basic validation detected" : "no validation detected",
|
|
1042
|
+
detail: `${method} handler`
|
|
1043
|
+
});
|
|
1044
|
+
evidence.push({
|
|
1045
|
+
label: hasSpamProtection ? "spam/bot protection detected" : "no spam/bot protection detected",
|
|
1001
1046
|
detail: `${method} handler`
|
|
1002
1047
|
});
|
|
1003
1048
|
}
|
|
@@ -1029,13 +1074,23 @@ ${handlerContent.toLowerCase()}`);
|
|
|
1029
1074
|
evidence,
|
|
1030
1075
|
hasAuth,
|
|
1031
1076
|
hasSecretProtection,
|
|
1077
|
+
hasAdminAuthorization,
|
|
1032
1078
|
hasRateLimit,
|
|
1079
|
+
hasBasicValidation,
|
|
1080
|
+
hasSchemaValidation,
|
|
1033
1081
|
hasValidation,
|
|
1082
|
+
hasSpamProtection,
|
|
1034
1083
|
hasCacheHeaders,
|
|
1035
1084
|
hasMethodBlocking,
|
|
1036
1085
|
hasWebhookVerification
|
|
1037
1086
|
};
|
|
1038
1087
|
}
|
|
1088
|
+
function getPublicFormSideEffectLabel(sideEffectLabel) {
|
|
1089
|
+
if (sideEffectLabel === "send") {
|
|
1090
|
+
return "email/send";
|
|
1091
|
+
}
|
|
1092
|
+
return sideEffectLabel;
|
|
1093
|
+
}
|
|
1039
1094
|
function getHandlerContext(handler, handlers) {
|
|
1040
1095
|
const context = [];
|
|
1041
1096
|
for (const otherHandler of handlers) {
|
|
@@ -1122,6 +1177,9 @@ function getRoutePathMatch(normalizedFile, terms) {
|
|
|
1122
1177
|
return new RegExp(`(^|[\\/._\\[\\]-])${escapedTerm}([\\/._\\[\\]-]|$)`).test(normalizedFile);
|
|
1123
1178
|
});
|
|
1124
1179
|
}
|
|
1180
|
+
function getAdminRoutePathMatch(relativeFile) {
|
|
1181
|
+
return getRoutePathMatch(relativeFile.toLowerCase(), ["admin", "debug", "private", "staff", "manager"]);
|
|
1182
|
+
}
|
|
1125
1183
|
function getExportedHttpMethods(content) {
|
|
1126
1184
|
return getExportedRouteHandlers(content).map((handler) => handler.method);
|
|
1127
1185
|
}
|
|
@@ -1130,15 +1188,31 @@ function getExportedRouteHandlers(content) {
|
|
|
1130
1188
|
const functionExportPattern = /\bexport\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\s*\(/g;
|
|
1131
1189
|
const constExportPattern = /\bexport\s+const\s+(GET|POST|PUT|PATCH|DELETE)\s*=/g;
|
|
1132
1190
|
for (const match of content.matchAll(functionExportPattern)) {
|
|
1191
|
+
if (isCommentedMatch(content, match.index ?? 0)) {
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1133
1194
|
handlers.push(extractRouteHandlerBody(content, match.index ?? 0, match[1], "function"));
|
|
1134
1195
|
}
|
|
1135
1196
|
for (const match of content.matchAll(constExportPattern)) {
|
|
1197
|
+
if (isCommentedMatch(content, match.index ?? 0)) {
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1136
1200
|
handlers.push(extractRouteHandlerBody(content, match.index ?? 0, match[1], "const"));
|
|
1137
1201
|
}
|
|
1138
1202
|
return handlers.sort(
|
|
1139
1203
|
(leftHandler, rightHandler) => getMethodRank(leftHandler.method) - getMethodRank(rightHandler.method)
|
|
1140
1204
|
);
|
|
1141
1205
|
}
|
|
1206
|
+
function isCommentedMatch(content, matchIndex) {
|
|
1207
|
+
const lineStart = content.lastIndexOf("\n", matchIndex) + 1;
|
|
1208
|
+
const linePrefix = content.slice(lineStart, matchIndex).trim();
|
|
1209
|
+
if (linePrefix.startsWith("//") || linePrefix.startsWith("*")) {
|
|
1210
|
+
return true;
|
|
1211
|
+
}
|
|
1212
|
+
const previousBlockStart = content.lastIndexOf("/*", matchIndex);
|
|
1213
|
+
const previousBlockEnd = content.lastIndexOf("*/", matchIndex);
|
|
1214
|
+
return previousBlockStart !== -1 && previousBlockStart > previousBlockEnd;
|
|
1215
|
+
}
|
|
1142
1216
|
function extractRouteHandlerBody(content, exportIndex, method, exportKind) {
|
|
1143
1217
|
const nextExportIndex = findNextRouteHandlerExport(content, exportIndex + 1);
|
|
1144
1218
|
const handlerEnd = nextExportIndex === -1 ? content.length : nextExportIndex;
|
|
@@ -1265,12 +1339,18 @@ function analyzeHandlerProtection({
|
|
|
1265
1339
|
fullFileContent
|
|
1266
1340
|
}) {
|
|
1267
1341
|
const imports = getImportInfos(fullFileContent);
|
|
1342
|
+
const inputParsingOperation = getFirstInputParsingOperation(handlerBody);
|
|
1268
1343
|
const sensitiveOperation = getFirstSensitiveOperation(handlerBody);
|
|
1344
|
+
const localHelpers = getLocalHelperInfos(fullFileContent);
|
|
1269
1345
|
const helperAssignments = getHelperAssignments(handlerBody);
|
|
1270
1346
|
for (const assignment of helperAssignments) {
|
|
1271
1347
|
if (isRawRequestAccessorHelper(assignment.helperName)) {
|
|
1272
1348
|
continue;
|
|
1273
1349
|
}
|
|
1350
|
+
const localHelper = getLocalHelperInfo(localHelpers, assignment.helperName);
|
|
1351
|
+
if (localHelper && !isLocalProtectionHelper(localHelper.body)) {
|
|
1352
|
+
continue;
|
|
1353
|
+
}
|
|
1274
1354
|
if (!isGuardHelperAssignmentNearTop(assignment.index, handlerBody, sensitiveOperation)) {
|
|
1275
1355
|
continue;
|
|
1276
1356
|
}
|
|
@@ -1298,15 +1378,20 @@ function analyzeHandlerProtection({
|
|
|
1298
1378
|
if (importInfo?.isProtectionSource) {
|
|
1299
1379
|
evidence.push({ label: "helper imported from protection module", detail: importInfo.source });
|
|
1300
1380
|
}
|
|
1381
|
+
if (localHelper) {
|
|
1382
|
+
evidence.push({ label: "local protection helper detected", detail: localHelper.name });
|
|
1383
|
+
}
|
|
1301
1384
|
return {
|
|
1302
1385
|
hasAccessControlGuard: true,
|
|
1303
1386
|
evidence,
|
|
1387
|
+
inputParsingOperation,
|
|
1304
1388
|
sensitiveOperation
|
|
1305
1389
|
};
|
|
1306
1390
|
}
|
|
1307
1391
|
return {
|
|
1308
1392
|
hasAccessControlGuard: false,
|
|
1309
1393
|
evidence: [],
|
|
1394
|
+
inputParsingOperation,
|
|
1310
1395
|
sensitiveOperation
|
|
1311
1396
|
};
|
|
1312
1397
|
}
|
|
@@ -1378,9 +1463,101 @@ function isProtectionImportSource(source) {
|
|
|
1378
1463
|
];
|
|
1379
1464
|
return protectionSourceTerms.some((term) => normalizedSource.includes(term));
|
|
1380
1465
|
}
|
|
1466
|
+
function getLocalHelperInfos(content) {
|
|
1467
|
+
return [
|
|
1468
|
+
...getLocalFunctionDeclarationHelpers(content),
|
|
1469
|
+
...getLocalConstFunctionHelpers(content)
|
|
1470
|
+
];
|
|
1471
|
+
}
|
|
1472
|
+
function getLocalFunctionDeclarationHelpers(content) {
|
|
1473
|
+
const helpers = [];
|
|
1474
|
+
const functionPattern = /\bfunction\s+([A-Za-z_$][\w$]*)\s*\(/g;
|
|
1475
|
+
for (const match of content.matchAll(functionPattern)) {
|
|
1476
|
+
const helperName = match[1];
|
|
1477
|
+
const matchIndex = match.index ?? 0;
|
|
1478
|
+
if (!helperName) {
|
|
1479
|
+
continue;
|
|
1480
|
+
}
|
|
1481
|
+
const openParenIndex = content.indexOf("(", matchIndex);
|
|
1482
|
+
const closeParenIndex = openParenIndex === -1 ? -1 : findMatchingParen(content, openParenIndex);
|
|
1483
|
+
const openBraceIndex = closeParenIndex === -1 ? -1 : content.indexOf("{", closeParenIndex);
|
|
1484
|
+
if (openBraceIndex === -1) {
|
|
1485
|
+
continue;
|
|
1486
|
+
}
|
|
1487
|
+
const closeBraceIndex = findMatchingBrace(content, openBraceIndex);
|
|
1488
|
+
if (closeBraceIndex === -1) {
|
|
1489
|
+
continue;
|
|
1490
|
+
}
|
|
1491
|
+
helpers.push({
|
|
1492
|
+
name: helperName,
|
|
1493
|
+
body: content.slice(openBraceIndex, closeBraceIndex + 1)
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
return helpers;
|
|
1497
|
+
}
|
|
1498
|
+
function getLocalConstFunctionHelpers(content) {
|
|
1499
|
+
const helpers = [];
|
|
1500
|
+
const constFunctionPattern = /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:function\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/g;
|
|
1501
|
+
for (const match of content.matchAll(constFunctionPattern)) {
|
|
1502
|
+
const helperName = match[1];
|
|
1503
|
+
const matchIndex = match.index ?? 0;
|
|
1504
|
+
if (!helperName) {
|
|
1505
|
+
continue;
|
|
1506
|
+
}
|
|
1507
|
+
const equalsIndex = content.indexOf("=", matchIndex);
|
|
1508
|
+
if (equalsIndex === -1) {
|
|
1509
|
+
continue;
|
|
1510
|
+
}
|
|
1511
|
+
const arrowIndex = content.indexOf("=>", equalsIndex);
|
|
1512
|
+
const functionIndex = content.indexOf("function", equalsIndex);
|
|
1513
|
+
const bodyStartSearchIndex = arrowIndex !== -1 && (functionIndex === -1 || arrowIndex < functionIndex) ? arrowIndex + 2 : functionIndex;
|
|
1514
|
+
if (bodyStartSearchIndex === -1) {
|
|
1515
|
+
continue;
|
|
1516
|
+
}
|
|
1517
|
+
let bodyStartIndex = bodyStartSearchIndex;
|
|
1518
|
+
while (/\s/.test(content[bodyStartIndex] ?? "")) {
|
|
1519
|
+
bodyStartIndex++;
|
|
1520
|
+
}
|
|
1521
|
+
if (content[bodyStartIndex] === "{") {
|
|
1522
|
+
const closeBraceIndex = findMatchingBrace(content, bodyStartIndex);
|
|
1523
|
+
if (closeBraceIndex !== -1) {
|
|
1524
|
+
helpers.push({
|
|
1525
|
+
name: helperName,
|
|
1526
|
+
body: content.slice(bodyStartIndex, closeBraceIndex + 1)
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
continue;
|
|
1530
|
+
}
|
|
1531
|
+
const expressionEndIndex = findExpressionEnd(content, bodyStartIndex);
|
|
1532
|
+
helpers.push({
|
|
1533
|
+
name: helperName,
|
|
1534
|
+
body: content.slice(bodyStartIndex, expressionEndIndex)
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1537
|
+
return helpers;
|
|
1538
|
+
}
|
|
1539
|
+
function findExpressionEnd(content, startIndex) {
|
|
1540
|
+
const semicolonIndex = content.indexOf(";", startIndex);
|
|
1541
|
+
const newlineIndex = content.indexOf("\n", startIndex);
|
|
1542
|
+
return [semicolonIndex, newlineIndex].filter((candidateIndex) => candidateIndex !== -1).sort((leftIndex, rightIndex) => leftIndex - rightIndex)[0] ?? content.length;
|
|
1543
|
+
}
|
|
1544
|
+
function getLocalHelperInfo(localHelpers, helperName) {
|
|
1545
|
+
const helperRootName = getHelperRootName(helperName);
|
|
1546
|
+
return localHelpers.find((helper) => helper.name === helperRootName);
|
|
1547
|
+
}
|
|
1548
|
+
function getHelperRootName(helperName) {
|
|
1549
|
+
return helperName.split(".")[0] ?? helperName;
|
|
1550
|
+
}
|
|
1551
|
+
function isLocalProtectionHelper(helperBody) {
|
|
1552
|
+
return isSecretValidationExpression(helperBody) || hasStrongAuthProviderSignal(helperBody) || analyzeAdminAuthorization(helperBody).hasAdminAuthorization;
|
|
1553
|
+
}
|
|
1554
|
+
function hasStrongAuthProviderSignal(content) {
|
|
1555
|
+
return /\bauth\s*\(/i.test(content) || /\bgetServerSession\s*\(/.test(content) || /\bgetSession\s*\(/.test(content) || /\bcurrentUser\s*\(/.test(content) || /\bgetUser\s*\(/.test(content) || /\bauth\.getUser\s*\(/i.test(content);
|
|
1556
|
+
}
|
|
1381
1557
|
function getHelperAssignments(handlerBody) {
|
|
1382
1558
|
const assignments = [];
|
|
1383
1559
|
const assignmentPattern = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)\s*\(/g;
|
|
1560
|
+
const destructuredAssignmentPattern = /\b(?:const|let|var)\s*\{([^}]+)\}\s*=\s*(?:await\s+)?([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)\s*\(/g;
|
|
1384
1561
|
for (const match of handlerBody.matchAll(assignmentPattern)) {
|
|
1385
1562
|
const variableName = match[1];
|
|
1386
1563
|
const helperName = match[2];
|
|
@@ -1395,7 +1572,41 @@ function getHelperAssignments(handlerBody) {
|
|
|
1395
1572
|
endIndex: index + match[0].length
|
|
1396
1573
|
});
|
|
1397
1574
|
}
|
|
1398
|
-
|
|
1575
|
+
for (const match of handlerBody.matchAll(destructuredAssignmentPattern)) {
|
|
1576
|
+
const destructuredContent = match[1];
|
|
1577
|
+
const helperName = match[2];
|
|
1578
|
+
const index = match.index ?? 0;
|
|
1579
|
+
if (!destructuredContent || !helperName) {
|
|
1580
|
+
continue;
|
|
1581
|
+
}
|
|
1582
|
+
for (const variableName of parseDestructuredBindingNames(destructuredContent)) {
|
|
1583
|
+
assignments.push({
|
|
1584
|
+
variableName,
|
|
1585
|
+
helperName,
|
|
1586
|
+
index,
|
|
1587
|
+
endIndex: index + match[0].length
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
return assignments.sort(
|
|
1592
|
+
(leftAssignment, rightAssignment) => leftAssignment.index - rightAssignment.index
|
|
1593
|
+
);
|
|
1594
|
+
}
|
|
1595
|
+
function parseDestructuredBindingNames(destructuredContent) {
|
|
1596
|
+
const variableNames = [];
|
|
1597
|
+
for (const part of destructuredContent.split(",")) {
|
|
1598
|
+
const trimmedPart = part.trim();
|
|
1599
|
+
if (!trimmedPart) {
|
|
1600
|
+
continue;
|
|
1601
|
+
}
|
|
1602
|
+
const withoutDefault = trimmedPart.split("=")[0]?.trim() ?? "";
|
|
1603
|
+
const aliasParts = withoutDefault.split(":");
|
|
1604
|
+
const variableName = (aliasParts[1] ?? aliasParts[0])?.trim();
|
|
1605
|
+
if (variableName && /^[A-Za-z_$][\w$]*$/.test(variableName)) {
|
|
1606
|
+
variableNames.push(variableName);
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
return variableNames;
|
|
1399
1610
|
}
|
|
1400
1611
|
function isRawRequestAccessorHelper(helperName) {
|
|
1401
1612
|
const normalizedHelperName = helperName.toLowerCase();
|
|
@@ -1490,18 +1701,37 @@ function getAccessDeniedReturnSignal(branchText) {
|
|
|
1490
1701
|
return null;
|
|
1491
1702
|
}
|
|
1492
1703
|
function getImportInfoForHelper(imports, helperName) {
|
|
1493
|
-
const helperRootName = helperName
|
|
1704
|
+
const helperRootName = getHelperRootName(helperName);
|
|
1494
1705
|
return imports.find((importInfo) => importInfo.localName === helperRootName);
|
|
1495
1706
|
}
|
|
1707
|
+
function getFirstInputParsingOperation(handlerBody) {
|
|
1708
|
+
const inputParsingPatterns = [
|
|
1709
|
+
{ label: "request.json", pattern: /\brequest\.json\s*\(/i },
|
|
1710
|
+
{ label: "request.formData", pattern: /\brequest\.formData\s*\(/i },
|
|
1711
|
+
{ label: "request.text", pattern: /\brequest\.text\s*\(/i },
|
|
1712
|
+
{ label: "searchParams.get", pattern: /\b(?:request\.nextUrl\.)?searchParams\.get\s*\(/i }
|
|
1713
|
+
];
|
|
1714
|
+
const matches = [];
|
|
1715
|
+
for (const inputParsingPattern of inputParsingPatterns) {
|
|
1716
|
+
const match = inputParsingPattern.pattern.exec(handlerBody);
|
|
1717
|
+
if (match?.index !== void 0) {
|
|
1718
|
+
matches.push({
|
|
1719
|
+
label: inputParsingPattern.label,
|
|
1720
|
+
index: match.index
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
return matches.sort((leftMatch, rightMatch) => leftMatch.index - rightMatch.index)[0];
|
|
1725
|
+
}
|
|
1496
1726
|
function getFirstSensitiveOperation(handlerBody) {
|
|
1497
1727
|
const sensitiveOperationPatterns = [
|
|
1498
|
-
{ label: "request.formData", pattern: /\brequest\.formData\s*\(/i },
|
|
1499
|
-
{ label: "request.json", pattern: /\brequest\.json\s*\(/i },
|
|
1500
1728
|
{ label: "file.arrayBuffer", pattern: /\b[A-Za-z_$][\w$]*\.arrayBuffer\s*\(/i },
|
|
1501
1729
|
{ label: "Buffer.from", pattern: /\bBuffer\.from\s*\(/ },
|
|
1502
1730
|
{ label: "uploadToR2", pattern: /\buploadToR2\s*\(/i },
|
|
1503
1731
|
{ label: "upload", pattern: /\bupload[A-Za-z0-9_$]*\s*\(/i },
|
|
1504
1732
|
{ label: "putObject", pattern: /\bputObject\s*\(/i },
|
|
1733
|
+
{ label: "revalidatePath", pattern: /\brevalidatePath\s*\(/i },
|
|
1734
|
+
{ label: "revalidateTag", pattern: /\brevalidateTag\s*\(/i },
|
|
1505
1735
|
{ label: "storage", pattern: /\bstorage\b/i },
|
|
1506
1736
|
{ label: "write", pattern: /\bwrite[A-Za-z0-9_$]*\s*\(/i },
|
|
1507
1737
|
{ label: "create", pattern: /\bcreate[A-Za-z0-9_$]*\s*\(/i },
|
|
@@ -1521,6 +1751,9 @@ function getFirstSensitiveOperation(handlerBody) {
|
|
|
1521
1751
|
for (const sensitiveOperationPattern of sensitiveOperationPatterns) {
|
|
1522
1752
|
const match = sensitiveOperationPattern.pattern.exec(handlerBody);
|
|
1523
1753
|
if (match?.index !== void 0) {
|
|
1754
|
+
if (isBroadMutationOperationLabel(sensitiveOperationPattern.label) && isBenignHelperFunctionCall(match[0] ?? "")) {
|
|
1755
|
+
continue;
|
|
1756
|
+
}
|
|
1524
1757
|
matches.push({
|
|
1525
1758
|
label: sensitiveOperationPattern.label,
|
|
1526
1759
|
index: match.index
|
|
@@ -1529,6 +1762,17 @@ function getFirstSensitiveOperation(handlerBody) {
|
|
|
1529
1762
|
}
|
|
1530
1763
|
return matches.sort((leftMatch, rightMatch) => leftMatch.index - rightMatch.index)[0];
|
|
1531
1764
|
}
|
|
1765
|
+
function isBroadMutationOperationLabel(label) {
|
|
1766
|
+
return ["create", "update", "delete", "insert", "write"].includes(label);
|
|
1767
|
+
}
|
|
1768
|
+
function isBenignHelperFunctionCall(callExpression) {
|
|
1769
|
+
const functionName = callExpression.match(/\b([A-Za-z_$][\w$]*)\s*\(/)?.[1];
|
|
1770
|
+
if (!functionName) {
|
|
1771
|
+
return false;
|
|
1772
|
+
}
|
|
1773
|
+
const normalizedFunctionName = functionName.toLowerCase();
|
|
1774
|
+
return normalizedFunctionName.includes("ratelimit") || normalizedFunctionName.includes("header") || normalizedFunctionName.includes("response");
|
|
1775
|
+
}
|
|
1532
1776
|
function escapeRegExp(value) {
|
|
1533
1777
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1534
1778
|
}
|
|
@@ -1597,35 +1841,134 @@ function hasWeakSecretProtectionSignal(content) {
|
|
|
1597
1841
|
const normalizedContent = content.toLowerCase();
|
|
1598
1842
|
return /\bprocess\.env\.[A-Za-z0-9_]*SECRET\b/.test(content) || /\bprocess\.env\[['"`][A-Za-z0-9_]*SECRET['"`]\]/.test(content) || normalizedContent.includes("cron_secret") || normalizedContent.includes("revalidate_secret") || normalizedContent.includes("authorization") || normalizedContent.includes("bearer") || normalizedContent.includes("token");
|
|
1599
1843
|
}
|
|
1600
|
-
function
|
|
1844
|
+
function analyzeSecretProtectionGuardBeforeSensitiveWork(handlerBody, fullFileContent, sensitiveOperation) {
|
|
1601
1845
|
const protectionCutoffIndex = sensitiveOperation?.index ?? Math.min(2e3, handlerBody.length);
|
|
1602
1846
|
const ifStatements = getIfStatements(handlerBody, 0, protectionCutoffIndex);
|
|
1847
|
+
const localSecretHelpers = getLocalHelperInfos(fullFileContent).filter(
|
|
1848
|
+
(helper) => isSecretValidationExpression(helper.body)
|
|
1849
|
+
);
|
|
1603
1850
|
for (const ifStatement of ifStatements) {
|
|
1604
|
-
|
|
1851
|
+
const branch = getIfFailureBranch(handlerBody, ifStatement.branchStartIndex);
|
|
1852
|
+
const returnSignal = getAccessDeniedReturnSignal(branch.text);
|
|
1853
|
+
if (!returnSignal) {
|
|
1605
1854
|
continue;
|
|
1606
1855
|
}
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1856
|
+
if (isSecretTokenGuardCondition(ifStatement.condition)) {
|
|
1857
|
+
const evidence = [
|
|
1858
|
+
{ label: "secret-token guard detected before sensitive work" },
|
|
1859
|
+
{ label: "secret/token comparison detected", detail: "handler guard condition" },
|
|
1860
|
+
{ label: returnSignal }
|
|
1861
|
+
];
|
|
1862
|
+
if (sensitiveOperation) {
|
|
1863
|
+
evidence.push({
|
|
1864
|
+
label: `secret guard appears before sensitive side effect`,
|
|
1865
|
+
detail: sensitiveOperation.label
|
|
1866
|
+
});
|
|
1867
|
+
}
|
|
1868
|
+
return {
|
|
1869
|
+
hasSecretProtectionGuard: true,
|
|
1870
|
+
evidence
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
const localHelper = getLocalSecretHelperCalledInGuard(ifStatement.condition, localSecretHelpers);
|
|
1874
|
+
if (localHelper) {
|
|
1875
|
+
const evidence = [
|
|
1876
|
+
{ label: "secret-token guard detected before sensitive work" },
|
|
1877
|
+
{ label: "local secret validation helper detected", detail: localHelper.name },
|
|
1878
|
+
{ label: returnSignal }
|
|
1879
|
+
];
|
|
1880
|
+
if (sensitiveOperation) {
|
|
1881
|
+
evidence.push({
|
|
1882
|
+
label: `secret guard appears before sensitive side effect`,
|
|
1883
|
+
detail: sensitiveOperation.label
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
return {
|
|
1887
|
+
hasSecretProtectionGuard: true,
|
|
1888
|
+
evidence
|
|
1889
|
+
};
|
|
1610
1890
|
}
|
|
1611
1891
|
}
|
|
1612
|
-
return
|
|
1892
|
+
return {
|
|
1893
|
+
hasSecretProtectionGuard: false,
|
|
1894
|
+
evidence: []
|
|
1895
|
+
};
|
|
1613
1896
|
}
|
|
1614
1897
|
function isSecretTokenGuardCondition(condition) {
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
const
|
|
1619
|
-
const
|
|
1898
|
+
return isSecretValidationExpression(condition);
|
|
1899
|
+
}
|
|
1900
|
+
function isSecretValidationExpression(expression) {
|
|
1901
|
+
const normalizedExpression = expression.toLowerCase();
|
|
1902
|
+
const hasSecretInput = /\b(?:token|secret|authorization|bearer)\b/i.test(expression) || /\bheaders\.get\s*\(\s*["'][^"']*(?:authorization|secret|token|key)[^"']*["']\s*\)/i.test(expression) || /\bsearchParams\.get\s*\(\s*["'](?:secret|token|key)["']\s*\)/i.test(expression) || /\bcookies?(?:\(\))?\.get\s*\(\s*["'][^"']*(?:secret|token|key|session)[^"']*["']\s*\)/i.test(expression);
|
|
1903
|
+
const hasExpectedSecret = /\bprocess\.env\.[A-Za-z0-9_]*(?:SECRET|TOKEN|KEY)\b/.test(expression) || /\bprocess\.env\[['"`][A-Za-z0-9_]*(?:SECRET|TOKEN|KEY)['"`]\]/.test(expression) || /\b(?:expected|valid|required|cron|revalidate|internal)[A-Za-z0-9_]*(?:Secret|Token|Key)\b/.test(expression) || /\b(?:expected|valid|required|cron|revalidate|internal)[a-z0-9_]*(?:secret|token|key)\b/.test(normalizedExpression);
|
|
1904
|
+
const hasSecretValidationCall = /\b(?:isValid|verify|validate|compare)[A-Za-z0-9_]*(?:Secret|Token|Key)?\s*\(/.test(expression) || /\btimingSafeEqual\s*\(/.test(expression);
|
|
1905
|
+
const hasComparison = /!==|!=|===|==/.test(expression) || /\b(?:timingSafeEqual|compare|verify|isValid|validate)[A-Za-z0-9_]*\s*\(/.test(expression);
|
|
1620
1906
|
return hasSecretInput && (hasExpectedSecret || hasSecretValidationCall) && hasComparison;
|
|
1621
1907
|
}
|
|
1908
|
+
function getLocalSecretHelperCalledInGuard(condition, localSecretHelpers) {
|
|
1909
|
+
return localSecretHelpers.find((helper) => {
|
|
1910
|
+
const escapedHelperName = escapeRegExp(helper.name);
|
|
1911
|
+
return new RegExp(`!\\s*(?:await\\s+)?${escapedHelperName}\\s*\\(`).test(condition) || new RegExp(`\\b${escapedHelperName}\\s*\\([^)]*\\)\\s*(?:={2,3})\\s*false\\b`).test(condition) || new RegExp(`\\bfalse\\s*(?:={2,3})\\s*${escapedHelperName}\\s*\\(`).test(condition) || new RegExp(`\\b${escapedHelperName}\\s*\\([^)]*\\)\\s*!={1,2}\\s*true\\b`).test(condition);
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
function analyzeAdminAuthorization(content) {
|
|
1915
|
+
const evidence = [];
|
|
1916
|
+
if (/\brequireAdmin\s*\(/.test(content)) {
|
|
1917
|
+
evidence.push({ label: "admin authorization check detected", detail: "requireAdmin" });
|
|
1918
|
+
}
|
|
1919
|
+
if (/\brequireStaff\s*\(/.test(content)) {
|
|
1920
|
+
evidence.push({ label: "staff authorization check detected", detail: "requireStaff" });
|
|
1921
|
+
}
|
|
1922
|
+
if (/\brequireRole\s*\(/.test(content) || /\brequirePermission\s*\(/.test(content)) {
|
|
1923
|
+
evidence.push({ label: "role/permission check detected", detail: "requireRole/requirePermission" });
|
|
1924
|
+
}
|
|
1925
|
+
if (/\b(?:hasPermission|checkPermission)\s*\(/.test(content)) {
|
|
1926
|
+
evidence.push({ label: "permission check detected", detail: "hasPermission/checkPermission" });
|
|
1927
|
+
}
|
|
1928
|
+
if (/\b(?:isAdmin|isStaff)\b/.test(content)) {
|
|
1929
|
+
evidence.push({ label: "admin/staff flag detected", detail: "isAdmin/isStaff" });
|
|
1930
|
+
}
|
|
1931
|
+
if (/\brole\s*(?:={2,3}|!={1,2})\s*["'](?:ADMIN|admin|STAFF|staff|MANAGER|manager)["']/.test(content)) {
|
|
1932
|
+
evidence.push({ label: "role comparison detected" });
|
|
1933
|
+
}
|
|
1934
|
+
if (/\b(?:allowedRoles|roles)\.includes\s*\(/.test(content) || /\.includes\s*\(\s*(?:user|staff|session|account)\.role\s*\)/.test(content)) {
|
|
1935
|
+
evidence.push({ label: "allowed roles check detected" });
|
|
1936
|
+
}
|
|
1937
|
+
if (hasRoleGuardReturningForbidden(content)) {
|
|
1938
|
+
evidence.push({ label: "role guard returns 403/Forbidden" });
|
|
1939
|
+
}
|
|
1940
|
+
return {
|
|
1941
|
+
hasAdminAuthorization: evidence.length > 0,
|
|
1942
|
+
evidence
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1945
|
+
function hasRoleGuardReturningForbidden(content) {
|
|
1946
|
+
for (const ifStatement of getIfStatements(content)) {
|
|
1947
|
+
if (!/\b(?:role|permission|admin|staff|manager|allowedRoles|isAdmin|isStaff)\b/i.test(ifStatement.condition)) {
|
|
1948
|
+
continue;
|
|
1949
|
+
}
|
|
1950
|
+
const branch = getIfFailureBranch(content, ifStatement.branchStartIndex);
|
|
1951
|
+
if (/\bstatus\s*:\s*403\b|\b403\b|Forbidden/i.test(branch.text)) {
|
|
1952
|
+
return true;
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
return false;
|
|
1956
|
+
}
|
|
1622
1957
|
function hasRateLimitSignal(content) {
|
|
1623
1958
|
const normalizedContent = content.toLowerCase();
|
|
1624
|
-
return normalizedContent.includes("ratelimit") || normalizedContent.includes("rate limit") || normalizedContent.includes("limiter") || normalizedContent.includes("upstash") || normalizedContent.includes("throttle");
|
|
1959
|
+
return normalizedContent.includes("ratelimit") || normalizedContent.includes("rate limit") || normalizedContent.includes("limiter") || normalizedContent.includes("upstash") || normalizedContent.includes("throttle") || normalizedContent.includes("too many requests") || /\bstatus\s*:\s*429\b/.test(normalizedContent) || /\b429\b/.test(normalizedContent);
|
|
1625
1960
|
}
|
|
1626
|
-
function
|
|
1961
|
+
function hasBasicValidationSignal(content) {
|
|
1627
1962
|
const normalizedContent = content.toLowerCase();
|
|
1628
|
-
return
|
|
1963
|
+
return /\bif\s*\([^)]*!\s*(?:email|name|message|value|body|payload|input|content|phone|subject)\b/i.test(content) || /\btypeof\s+\w+\s*!==\s*["']string["']/.test(content) || /\btypeof\s+\w+\s*===\s*["']string["']/.test(content) || /\.\s*trim\s*\(/.test(content) || /\.\s*length\b/.test(content) || normalizedContent.includes("validate") || normalizedContent.includes("validation") || normalizedContent.includes("sanitize") || normalizedContent.includes("slugregex") || normalizedContent.includes("isvalid") || /\bstatus\s*:\s*400\b/.test(normalizedContent) && /\b(?:email|name|message|value|body|payload|input|content|phone|subject)\b/.test(normalizedContent);
|
|
1964
|
+
}
|
|
1965
|
+
function hasSchemaValidationSignal(content) {
|
|
1966
|
+
const normalizedContent = content.toLowerCase();
|
|
1967
|
+
return normalizedContent.includes("safeparse") || /\bparse\s*\(\s*(?:body|payload|input|data|requestbody)\s*\)/i.test(content) || normalizedContent.includes("z.object") || normalizedContent.includes("yup") || normalizedContent.includes("joi") || normalizedContent.includes("valibot") || normalizedContent.includes("arktype");
|
|
1968
|
+
}
|
|
1969
|
+
function hasSpamProtectionSignal(content) {
|
|
1970
|
+
const normalizedContent = content.toLowerCase();
|
|
1971
|
+
return normalizedContent.includes("captcha") || normalizedContent.includes("recaptcha") || normalizedContent.includes("hcaptcha") || normalizedContent.includes("turnstile") || normalizedContent.includes("honeypot") || normalizedContent.includes("bot protection");
|
|
1629
1972
|
}
|
|
1630
1973
|
function hasCacheHeaderSignal(content) {
|
|
1631
1974
|
const normalizedContent = content.toLowerCase();
|
|
@@ -1925,6 +2268,29 @@ Return:
|
|
|
1925
2268
|
- The safest minimal change if protection is missing.
|
|
1926
2269
|
- Any edge cases I should test.`;
|
|
1927
2270
|
}
|
|
2271
|
+
function createAdminAuthorizationFixPrompt(file, method) {
|
|
2272
|
+
return `Review the API route at ${file}.
|
|
2273
|
+
|
|
2274
|
+
Qodfy detected a possible authorization issue in the ${method} handler.
|
|
2275
|
+
|
|
2276
|
+
Goal:
|
|
2277
|
+
Confirm this authenticated handler is restricted to the right admin, staff, role, or permission level.
|
|
2278
|
+
|
|
2279
|
+
Instructions:
|
|
2280
|
+
- Inspect each exported HTTP handler separately.
|
|
2281
|
+
- Do not add a new auth provider.
|
|
2282
|
+
- Do not duplicate authentication if the handler already has a 401/403 guard.
|
|
2283
|
+
- Check the existing project pattern for admin, staff, role, or permission checks.
|
|
2284
|
+
- If this handler exposes admin, private, staff, manager, or debug functionality, confirm it has an authorization check beyond basic login.
|
|
2285
|
+
- If this route is only for debugging, remove it or make sure it cannot run in production.
|
|
2286
|
+
- Keep existing response formats unchanged.
|
|
2287
|
+
|
|
2288
|
+
Return:
|
|
2289
|
+
- Where authentication currently happens.
|
|
2290
|
+
- Whether admin/staff/role/permission authorization exists.
|
|
2291
|
+
- The safest minimal change if authorization is missing.
|
|
2292
|
+
- Edge cases to test.`;
|
|
2293
|
+
}
|
|
1928
2294
|
function createMissingEnvVariableFixPrompt(variableName, files) {
|
|
1929
2295
|
return `Update the environment documentation for this project.
|
|
1930
2296
|
|