@qodfy/core 0.2.7 → 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 +797 -19
- package/package.json +3 -2
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",
|
|
@@ -919,10 +943,30 @@ function analyzeApiHandler({
|
|
|
919
943
|
const webhookRouteInfo = getWebhookRouteInfo(relativeFile, handlerContent);
|
|
920
944
|
const webhookProvider = webhookRouteInfo?.provider ?? getWebhookProvider(`${normalizedFile}
|
|
921
945
|
${handlerContent.toLowerCase()}`);
|
|
922
|
-
const
|
|
923
|
-
|
|
946
|
+
const protectionAnalysis = analyzeHandlerProtection({
|
|
947
|
+
handlerBody: handlerContent,
|
|
948
|
+
fullFileContent: content
|
|
949
|
+
});
|
|
950
|
+
const hasWeakAuthSignal = hasWeakAuthRelatedSignal(handlerContent);
|
|
951
|
+
const hasStrongProtection = hasStrongProtectionCallBeforeSensitiveWork(
|
|
952
|
+
handlerContent,
|
|
953
|
+
protectionAnalysis.sensitiveOperation
|
|
954
|
+
);
|
|
955
|
+
const hasAuth = protectionAnalysis.hasAccessControlGuard || hasStrongProtection;
|
|
956
|
+
const secretProtectionAnalysis = analyzeSecretProtectionGuardBeforeSensitiveWork(
|
|
957
|
+
handlerContent,
|
|
958
|
+
content,
|
|
959
|
+
protectionAnalysis.sensitiveOperation
|
|
960
|
+
);
|
|
961
|
+
const hasSecretProtection = secretProtectionAnalysis.hasSecretProtectionGuard;
|
|
962
|
+
const hasWeakSecretSignal = hasWeakSecretProtectionSignal(handlerContent);
|
|
963
|
+
const adminAuthorizationAnalysis = analyzeAdminAuthorization(handlerContent);
|
|
964
|
+
const hasAdminAuthorization = adminAuthorizationAnalysis.hasAdminAuthorization;
|
|
924
965
|
const hasRateLimit = hasRateLimitSignal(handlerContent);
|
|
925
|
-
const
|
|
966
|
+
const hasBasicValidation = hasBasicValidationSignal(handlerContent);
|
|
967
|
+
const hasSchemaValidation = hasSchemaValidationSignal(handlerContent);
|
|
968
|
+
const hasValidation = hasBasicValidation || hasSchemaValidation;
|
|
969
|
+
const hasSpamProtection = hasSpamProtectionSignal(handlerContent);
|
|
926
970
|
const hasCacheHeaders = hasCacheHeaderSignal(handlerContent);
|
|
927
971
|
const hasMethodBlocking = hasMethodBlockingSignal(handlerContent);
|
|
928
972
|
const hasWebhookVerification = hasWebhookSignatureVerification(handlerContent, webhookProvider);
|
|
@@ -942,7 +986,7 @@ ${handlerContent.toLowerCase()}`);
|
|
|
942
986
|
evidence.push({ label: "path contains", detail: internalPathMatch });
|
|
943
987
|
} else if (method === "POST" && formPathMatch) {
|
|
944
988
|
intent = "public-form";
|
|
945
|
-
evidence.push({ label: "
|
|
989
|
+
evidence.push({ label: "public submission endpoint detected", detail: formPathMatch });
|
|
946
990
|
} else if (method === "GET" && publicContentPathMatch && !sensitivePathMatch && !internalPathMatch) {
|
|
947
991
|
intent = "public-read";
|
|
948
992
|
evidence.push({ label: "public read path detected", detail: publicContentPathMatch });
|
|
@@ -950,13 +994,43 @@ ${handlerContent.toLowerCase()}`);
|
|
|
950
994
|
intent = "sensitive-mutation";
|
|
951
995
|
evidence.push({ label: "path contains", detail: sensitivePathMatch });
|
|
952
996
|
}
|
|
953
|
-
if (
|
|
954
|
-
evidence.push({
|
|
997
|
+
if (protectionAnalysis.sensitiveOperation) {
|
|
998
|
+
evidence.push({
|
|
999
|
+
label: intent === "public-form" ? `${getPublicFormSideEffectLabel(protectionAnalysis.sensitiveOperation.label)} side effect detected` : `sensitive side effect ${protectionAnalysis.sensitiveOperation.label} detected`,
|
|
1000
|
+
detail: `${method} handler`
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
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) {
|
|
1011
|
+
evidence.push(...protectionAnalysis.evidence);
|
|
1012
|
+
} else if (hasStrongProtection) {
|
|
1013
|
+
evidence.push({
|
|
1014
|
+
label: "strong protection call detected before sensitive work",
|
|
1015
|
+
detail: `${method} handler`
|
|
1016
|
+
});
|
|
1017
|
+
} else if (hasWeakAuthSignal) {
|
|
1018
|
+
evidence.push({
|
|
1019
|
+
label: "possible auth-related signal detected",
|
|
1020
|
+
detail: `${method} handler`
|
|
1021
|
+
});
|
|
1022
|
+
} else if (protectionAnalysis.sensitiveOperation) {
|
|
1023
|
+
evidence.push({ label: `no access-control guard detected before sensitive side effect`, detail: `${method} handler` });
|
|
955
1024
|
} else {
|
|
956
1025
|
evidence.push({ label: `no auth/session check detected in ${method} handler` });
|
|
957
1026
|
}
|
|
958
1027
|
if (hasSecretProtection) {
|
|
959
|
-
evidence.push(
|
|
1028
|
+
evidence.push(...secretProtectionAnalysis.evidence);
|
|
1029
|
+
} else if (hasWeakSecretSignal) {
|
|
1030
|
+
evidence.push({ label: `possible secret/token signal detected`, detail: `${method} handler` });
|
|
1031
|
+
}
|
|
1032
|
+
if (hasAdminAuthorization) {
|
|
1033
|
+
evidence.push(...adminAuthorizationAnalysis.evidence);
|
|
960
1034
|
}
|
|
961
1035
|
if (intent === "public-form") {
|
|
962
1036
|
evidence.push({
|
|
@@ -964,7 +1038,11 @@ ${handlerContent.toLowerCase()}`);
|
|
|
964
1038
|
detail: `${method} handler`
|
|
965
1039
|
});
|
|
966
1040
|
evidence.push({
|
|
967
|
-
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",
|
|
968
1046
|
detail: `${method} handler`
|
|
969
1047
|
});
|
|
970
1048
|
}
|
|
@@ -996,13 +1074,23 @@ ${handlerContent.toLowerCase()}`);
|
|
|
996
1074
|
evidence,
|
|
997
1075
|
hasAuth,
|
|
998
1076
|
hasSecretProtection,
|
|
1077
|
+
hasAdminAuthorization,
|
|
999
1078
|
hasRateLimit,
|
|
1079
|
+
hasBasicValidation,
|
|
1080
|
+
hasSchemaValidation,
|
|
1000
1081
|
hasValidation,
|
|
1082
|
+
hasSpamProtection,
|
|
1001
1083
|
hasCacheHeaders,
|
|
1002
1084
|
hasMethodBlocking,
|
|
1003
1085
|
hasWebhookVerification
|
|
1004
1086
|
};
|
|
1005
1087
|
}
|
|
1088
|
+
function getPublicFormSideEffectLabel(sideEffectLabel) {
|
|
1089
|
+
if (sideEffectLabel === "send") {
|
|
1090
|
+
return "email/send";
|
|
1091
|
+
}
|
|
1092
|
+
return sideEffectLabel;
|
|
1093
|
+
}
|
|
1006
1094
|
function getHandlerContext(handler, handlers) {
|
|
1007
1095
|
const context = [];
|
|
1008
1096
|
for (const otherHandler of handlers) {
|
|
@@ -1089,6 +1177,9 @@ function getRoutePathMatch(normalizedFile, terms) {
|
|
|
1089
1177
|
return new RegExp(`(^|[\\/._\\[\\]-])${escapedTerm}([\\/._\\[\\]-]|$)`).test(normalizedFile);
|
|
1090
1178
|
});
|
|
1091
1179
|
}
|
|
1180
|
+
function getAdminRoutePathMatch(relativeFile) {
|
|
1181
|
+
return getRoutePathMatch(relativeFile.toLowerCase(), ["admin", "debug", "private", "staff", "manager"]);
|
|
1182
|
+
}
|
|
1092
1183
|
function getExportedHttpMethods(content) {
|
|
1093
1184
|
return getExportedRouteHandlers(content).map((handler) => handler.method);
|
|
1094
1185
|
}
|
|
@@ -1097,15 +1188,31 @@ function getExportedRouteHandlers(content) {
|
|
|
1097
1188
|
const functionExportPattern = /\bexport\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\s*\(/g;
|
|
1098
1189
|
const constExportPattern = /\bexport\s+const\s+(GET|POST|PUT|PATCH|DELETE)\s*=/g;
|
|
1099
1190
|
for (const match of content.matchAll(functionExportPattern)) {
|
|
1191
|
+
if (isCommentedMatch(content, match.index ?? 0)) {
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1100
1194
|
handlers.push(extractRouteHandlerBody(content, match.index ?? 0, match[1], "function"));
|
|
1101
1195
|
}
|
|
1102
1196
|
for (const match of content.matchAll(constExportPattern)) {
|
|
1197
|
+
if (isCommentedMatch(content, match.index ?? 0)) {
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1103
1200
|
handlers.push(extractRouteHandlerBody(content, match.index ?? 0, match[1], "const"));
|
|
1104
1201
|
}
|
|
1105
1202
|
return handlers.sort(
|
|
1106
1203
|
(leftHandler, rightHandler) => getMethodRank(leftHandler.method) - getMethodRank(rightHandler.method)
|
|
1107
1204
|
);
|
|
1108
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
|
+
}
|
|
1109
1216
|
function extractRouteHandlerBody(content, exportIndex, method, exportKind) {
|
|
1110
1217
|
const nextExportIndex = findNextRouteHandlerExport(content, exportIndex + 1);
|
|
1111
1218
|
const handlerEnd = nextExportIndex === -1 ? content.length : nextExportIndex;
|
|
@@ -1166,6 +1273,33 @@ function findMatchingParen(content, openParenIndex) {
|
|
|
1166
1273
|
}
|
|
1167
1274
|
return -1;
|
|
1168
1275
|
}
|
|
1276
|
+
function getIfStatements(content, startIndex = 0, endIndex = content.length) {
|
|
1277
|
+
const statements = [];
|
|
1278
|
+
const ifPattern = /\bif\s*\(/g;
|
|
1279
|
+
ifPattern.lastIndex = startIndex;
|
|
1280
|
+
let match;
|
|
1281
|
+
while ((match = ifPattern.exec(content)) !== null) {
|
|
1282
|
+
const matchIndex = match.index;
|
|
1283
|
+
if (matchIndex > endIndex) {
|
|
1284
|
+
break;
|
|
1285
|
+
}
|
|
1286
|
+
const openParenIndex = content.indexOf("(", matchIndex);
|
|
1287
|
+
if (openParenIndex === -1 || openParenIndex > endIndex) {
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
const closeParenIndex = findMatchingParen(content, openParenIndex);
|
|
1291
|
+
if (closeParenIndex === -1 || closeParenIndex > endIndex) {
|
|
1292
|
+
continue;
|
|
1293
|
+
}
|
|
1294
|
+
statements.push({
|
|
1295
|
+
index: matchIndex,
|
|
1296
|
+
condition: content.slice(openParenIndex + 1, closeParenIndex),
|
|
1297
|
+
branchStartIndex: closeParenIndex + 1
|
|
1298
|
+
});
|
|
1299
|
+
ifPattern.lastIndex = closeParenIndex + 1;
|
|
1300
|
+
}
|
|
1301
|
+
return statements;
|
|
1302
|
+
}
|
|
1169
1303
|
function findMatchingBrace(content, openBraceIndex) {
|
|
1170
1304
|
let depth = 0;
|
|
1171
1305
|
for (let index = openBraceIndex; index < content.length; index++) {
|
|
@@ -1200,21 +1334,641 @@ function getMethodRank(method) {
|
|
|
1200
1334
|
function isMutationMethod(method) {
|
|
1201
1335
|
return method !== "GET";
|
|
1202
1336
|
}
|
|
1203
|
-
function
|
|
1204
|
-
|
|
1205
|
-
|
|
1337
|
+
function analyzeHandlerProtection({
|
|
1338
|
+
handlerBody,
|
|
1339
|
+
fullFileContent
|
|
1340
|
+
}) {
|
|
1341
|
+
const imports = getImportInfos(fullFileContent);
|
|
1342
|
+
const inputParsingOperation = getFirstInputParsingOperation(handlerBody);
|
|
1343
|
+
const sensitiveOperation = getFirstSensitiveOperation(handlerBody);
|
|
1344
|
+
const localHelpers = getLocalHelperInfos(fullFileContent);
|
|
1345
|
+
const helperAssignments = getHelperAssignments(handlerBody);
|
|
1346
|
+
for (const assignment of helperAssignments) {
|
|
1347
|
+
if (isRawRequestAccessorHelper(assignment.helperName)) {
|
|
1348
|
+
continue;
|
|
1349
|
+
}
|
|
1350
|
+
const localHelper = getLocalHelperInfo(localHelpers, assignment.helperName);
|
|
1351
|
+
if (localHelper && !isLocalProtectionHelper(localHelper.body)) {
|
|
1352
|
+
continue;
|
|
1353
|
+
}
|
|
1354
|
+
if (!isGuardHelperAssignmentNearTop(assignment.index, handlerBody, sensitiveOperation)) {
|
|
1355
|
+
continue;
|
|
1356
|
+
}
|
|
1357
|
+
const guard = findAccessControlGuardForVariable({
|
|
1358
|
+
handlerBody,
|
|
1359
|
+
variableName: assignment.variableName,
|
|
1360
|
+
startIndex: assignment.endIndex
|
|
1361
|
+
});
|
|
1362
|
+
if (!guard) {
|
|
1363
|
+
continue;
|
|
1364
|
+
}
|
|
1365
|
+
if (sensitiveOperation && guard.endIndex > sensitiveOperation.index) {
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1368
|
+
const importInfo = getImportInfoForHelper(imports, assignment.helperName);
|
|
1369
|
+
const evidence = [
|
|
1370
|
+
{ label: "access-control guard detected" },
|
|
1371
|
+
{ label: "helper call assigned to variable", detail: assignment.variableName },
|
|
1372
|
+
{ label: "guard checks variable", detail: assignment.variableName },
|
|
1373
|
+
{ label: guard.returnSignal }
|
|
1374
|
+
];
|
|
1375
|
+
if (sensitiveOperation) {
|
|
1376
|
+
evidence.push({ label: "guard appears before sensitive operation", detail: sensitiveOperation.label });
|
|
1377
|
+
}
|
|
1378
|
+
if (importInfo?.isProtectionSource) {
|
|
1379
|
+
evidence.push({ label: "helper imported from protection module", detail: importInfo.source });
|
|
1380
|
+
}
|
|
1381
|
+
if (localHelper) {
|
|
1382
|
+
evidence.push({ label: "local protection helper detected", detail: localHelper.name });
|
|
1383
|
+
}
|
|
1384
|
+
return {
|
|
1385
|
+
hasAccessControlGuard: true,
|
|
1386
|
+
evidence,
|
|
1387
|
+
inputParsingOperation,
|
|
1388
|
+
sensitiveOperation
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
return {
|
|
1392
|
+
hasAccessControlGuard: false,
|
|
1393
|
+
evidence: [],
|
|
1394
|
+
inputParsingOperation,
|
|
1395
|
+
sensitiveOperation
|
|
1396
|
+
};
|
|
1206
1397
|
}
|
|
1207
|
-
function
|
|
1398
|
+
function getImportInfos(content) {
|
|
1399
|
+
const imports = [];
|
|
1400
|
+
const importPattern = /import\s+(?:type\s+)?([\s\S]*?)\s+from\s+["']([^"']+)["'];?/g;
|
|
1401
|
+
for (const match of content.matchAll(importPattern)) {
|
|
1402
|
+
const importClause = match[1]?.trim();
|
|
1403
|
+
const source = match[2];
|
|
1404
|
+
if (!importClause || !source) {
|
|
1405
|
+
continue;
|
|
1406
|
+
}
|
|
1407
|
+
const isProtectionSource = isProtectionImportSource(source);
|
|
1408
|
+
const namedImportMatch = importClause.match(/\{([\s\S]*?)\}/);
|
|
1409
|
+
if (namedImportMatch?.[1]) {
|
|
1410
|
+
const namedImports = namedImportMatch[1].split(",");
|
|
1411
|
+
for (const namedImport of namedImports) {
|
|
1412
|
+
const parts = namedImport.trim().split(/\s+as\s+/i);
|
|
1413
|
+
const importedName = parts[0]?.trim();
|
|
1414
|
+
const localName = parts[1]?.trim() ?? importedName;
|
|
1415
|
+
if (importedName && localName) {
|
|
1416
|
+
imports.push({
|
|
1417
|
+
localName,
|
|
1418
|
+
importedName,
|
|
1419
|
+
source,
|
|
1420
|
+
isProtectionSource
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
const clauseBeforeNamedImports = importClause.split("{")[0]?.replace(/,\s*$/, "").trim();
|
|
1426
|
+
if (clauseBeforeNamedImports && !clauseBeforeNamedImports.startsWith("*")) {
|
|
1427
|
+
const defaultImport = clauseBeforeNamedImports.split(",")[0]?.trim();
|
|
1428
|
+
if (defaultImport && /^[A-Za-z_$][\w$]*$/.test(defaultImport)) {
|
|
1429
|
+
imports.push({
|
|
1430
|
+
localName: defaultImport,
|
|
1431
|
+
importedName: "default",
|
|
1432
|
+
source,
|
|
1433
|
+
isProtectionSource
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
const namespaceImportMatch = importClause.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/);
|
|
1438
|
+
if (namespaceImportMatch?.[1]) {
|
|
1439
|
+
imports.push({
|
|
1440
|
+
localName: namespaceImportMatch[1],
|
|
1441
|
+
importedName: "*",
|
|
1442
|
+
source,
|
|
1443
|
+
isProtectionSource
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
return imports;
|
|
1448
|
+
}
|
|
1449
|
+
function isProtectionImportSource(source) {
|
|
1450
|
+
const normalizedSource = source.toLowerCase();
|
|
1451
|
+
const protectionSourceTerms = [
|
|
1452
|
+
"auth",
|
|
1453
|
+
"session",
|
|
1454
|
+
"sessions",
|
|
1455
|
+
"staff",
|
|
1456
|
+
"permission",
|
|
1457
|
+
"permissions",
|
|
1458
|
+
"access",
|
|
1459
|
+
"access-control",
|
|
1460
|
+
"security",
|
|
1461
|
+
"user",
|
|
1462
|
+
"users"
|
|
1463
|
+
];
|
|
1464
|
+
return protectionSourceTerms.some((term) => normalizedSource.includes(term));
|
|
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
|
+
}
|
|
1557
|
+
function getHelperAssignments(handlerBody) {
|
|
1558
|
+
const assignments = [];
|
|
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;
|
|
1561
|
+
for (const match of handlerBody.matchAll(assignmentPattern)) {
|
|
1562
|
+
const variableName = match[1];
|
|
1563
|
+
const helperName = match[2];
|
|
1564
|
+
const index = match.index ?? 0;
|
|
1565
|
+
if (!variableName || !helperName) {
|
|
1566
|
+
continue;
|
|
1567
|
+
}
|
|
1568
|
+
assignments.push({
|
|
1569
|
+
variableName,
|
|
1570
|
+
helperName,
|
|
1571
|
+
index,
|
|
1572
|
+
endIndex: index + match[0].length
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
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;
|
|
1610
|
+
}
|
|
1611
|
+
function isRawRequestAccessorHelper(helperName) {
|
|
1612
|
+
const normalizedHelperName = helperName.toLowerCase();
|
|
1613
|
+
const rawAccessorHelpers = [
|
|
1614
|
+
"request.headers.get",
|
|
1615
|
+
"req.headers.get",
|
|
1616
|
+
"headers.get",
|
|
1617
|
+
"request.cookies.get",
|
|
1618
|
+
"req.cookies.get",
|
|
1619
|
+
"request.nexturl.searchparams.get",
|
|
1620
|
+
"req.nexturl.searchparams.get",
|
|
1621
|
+
"searchparams.get",
|
|
1622
|
+
"cookies"
|
|
1623
|
+
];
|
|
1624
|
+
return rawAccessorHelpers.includes(normalizedHelperName);
|
|
1625
|
+
}
|
|
1626
|
+
function isGuardHelperAssignmentNearTop(assignmentIndex, handlerBody, sensitiveOperation) {
|
|
1627
|
+
if (sensitiveOperation) {
|
|
1628
|
+
return assignmentIndex < sensitiveOperation.index;
|
|
1629
|
+
}
|
|
1630
|
+
return assignmentIndex < Math.min(1500, handlerBody.length);
|
|
1631
|
+
}
|
|
1632
|
+
function findAccessControlGuardForVariable({
|
|
1633
|
+
handlerBody,
|
|
1634
|
+
variableName,
|
|
1635
|
+
startIndex
|
|
1636
|
+
}) {
|
|
1637
|
+
const guardSearchEnd = Math.min(handlerBody.length, startIndex + 2500);
|
|
1638
|
+
const ifStatements = getIfStatements(handlerBody, startIndex, guardSearchEnd);
|
|
1639
|
+
for (const ifStatement of ifStatements) {
|
|
1640
|
+
if (!doesConditionBlockWhenVariableMissing(ifStatement.condition, variableName)) {
|
|
1641
|
+
continue;
|
|
1642
|
+
}
|
|
1643
|
+
const branch = getIfFailureBranch(handlerBody, ifStatement.branchStartIndex);
|
|
1644
|
+
const returnSignal = getAccessDeniedReturnSignal(branch.text);
|
|
1645
|
+
if (!returnSignal) {
|
|
1646
|
+
continue;
|
|
1647
|
+
}
|
|
1648
|
+
return {
|
|
1649
|
+
endIndex: branch.endIndex,
|
|
1650
|
+
returnSignal
|
|
1651
|
+
};
|
|
1652
|
+
}
|
|
1653
|
+
return null;
|
|
1654
|
+
}
|
|
1655
|
+
function doesConditionBlockWhenVariableMissing(condition, variableName) {
|
|
1656
|
+
const escapedVariableName = escapeRegExp(variableName);
|
|
1657
|
+
return new RegExp(`!\\s*${escapedVariableName}(?:\\b|\\?\\.|\\.)`).test(condition) || new RegExp(`\\b${escapedVariableName}\\s*(?:={2,3})\\s*(?:null|undefined|false)\\b`).test(condition) || new RegExp(`\\b(?:null|undefined|false)\\s*(?:={2,3})\\s*${escapedVariableName}\\b`).test(condition) || new RegExp(`!\\s*${escapedVariableName}(?:\\?\\.)?\\.[A-Za-z_$][\\w$]*`).test(condition) || new RegExp(`\\b${escapedVariableName}(?:\\?\\.)?\\.[A-Za-z_$][\\w$]*\\s*(?:={2,3})\\s*false\\b`).test(condition) || new RegExp(`\\bfalse\\s*(?:={2,3})\\s*${escapedVariableName}(?:\\?\\.)?\\.[A-Za-z_$][\\w$]*\\b`).test(condition);
|
|
1658
|
+
}
|
|
1659
|
+
function getIfFailureBranch(handlerBody, branchStartIndex) {
|
|
1660
|
+
let index = branchStartIndex;
|
|
1661
|
+
while (/\s/.test(handlerBody[index] ?? "")) {
|
|
1662
|
+
index++;
|
|
1663
|
+
}
|
|
1664
|
+
if (handlerBody[index] === "{") {
|
|
1665
|
+
const closeBraceIndex = findMatchingBrace(handlerBody, index);
|
|
1666
|
+
if (closeBraceIndex !== -1) {
|
|
1667
|
+
return {
|
|
1668
|
+
text: handlerBody.slice(index, closeBraceIndex + 1),
|
|
1669
|
+
endIndex: closeBraceIndex
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
const semicolonIndex = handlerBody.indexOf(";", index);
|
|
1674
|
+
const newlineIndex = handlerBody.indexOf("\n", index);
|
|
1675
|
+
const branchEndIndex = [semicolonIndex, newlineIndex].filter((candidateIndex) => candidateIndex !== -1).sort((leftIndex, rightIndex) => leftIndex - rightIndex)[0] ?? Math.min(handlerBody.length, index + 300);
|
|
1676
|
+
return {
|
|
1677
|
+
text: handlerBody.slice(index, branchEndIndex + 1),
|
|
1678
|
+
endIndex: branchEndIndex
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
function getAccessDeniedReturnSignal(branchText) {
|
|
1682
|
+
const normalizedBranch = branchText.toLowerCase();
|
|
1683
|
+
if (!normalizedBranch.includes("return") && !normalizedBranch.includes("redirect(")) {
|
|
1684
|
+
return null;
|
|
1685
|
+
}
|
|
1686
|
+
if (/\bstatus\s*:\s*401\b/.test(normalizedBranch) || /\b401\b/.test(normalizedBranch)) {
|
|
1687
|
+
return "guard returns 401";
|
|
1688
|
+
}
|
|
1689
|
+
if (/\bstatus\s*:\s*403\b/.test(normalizedBranch) || /\b403\b/.test(normalizedBranch)) {
|
|
1690
|
+
return "guard returns 403";
|
|
1691
|
+
}
|
|
1692
|
+
if (normalizedBranch.includes("unauthorized")) {
|
|
1693
|
+
return "guard returns Unauthorized";
|
|
1694
|
+
}
|
|
1695
|
+
if (normalizedBranch.includes("forbidden")) {
|
|
1696
|
+
return "guard returns Forbidden";
|
|
1697
|
+
}
|
|
1698
|
+
if (/redirect\s*\(\s*["']\/(?:login|sign-in|signin|auth)/.test(normalizedBranch)) {
|
|
1699
|
+
return "guard redirects to login";
|
|
1700
|
+
}
|
|
1701
|
+
return null;
|
|
1702
|
+
}
|
|
1703
|
+
function getImportInfoForHelper(imports, helperName) {
|
|
1704
|
+
const helperRootName = getHelperRootName(helperName);
|
|
1705
|
+
return imports.find((importInfo) => importInfo.localName === helperRootName);
|
|
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
|
+
}
|
|
1726
|
+
function getFirstSensitiveOperation(handlerBody) {
|
|
1727
|
+
const sensitiveOperationPatterns = [
|
|
1728
|
+
{ label: "file.arrayBuffer", pattern: /\b[A-Za-z_$][\w$]*\.arrayBuffer\s*\(/i },
|
|
1729
|
+
{ label: "Buffer.from", pattern: /\bBuffer\.from\s*\(/ },
|
|
1730
|
+
{ label: "uploadToR2", pattern: /\buploadToR2\s*\(/i },
|
|
1731
|
+
{ label: "upload", pattern: /\bupload[A-Za-z0-9_$]*\s*\(/i },
|
|
1732
|
+
{ label: "putObject", pattern: /\bputObject\s*\(/i },
|
|
1733
|
+
{ label: "revalidatePath", pattern: /\brevalidatePath\s*\(/i },
|
|
1734
|
+
{ label: "revalidateTag", pattern: /\brevalidateTag\s*\(/i },
|
|
1735
|
+
{ label: "storage", pattern: /\bstorage\b/i },
|
|
1736
|
+
{ label: "write", pattern: /\bwrite[A-Za-z0-9_$]*\s*\(/i },
|
|
1737
|
+
{ label: "create", pattern: /\bcreate[A-Za-z0-9_$]*\s*\(/i },
|
|
1738
|
+
{ label: "update", pattern: /\bupdate[A-Za-z0-9_$]*\s*\(/i },
|
|
1739
|
+
{ label: "delete", pattern: /\bdelete[A-Za-z0-9_$]*\s*\(/i },
|
|
1740
|
+
{ label: "insert", pattern: /\binsert[A-Za-z0-9_$]*\s*\(/i },
|
|
1741
|
+
{ label: "cleanup", pattern: /\bcleanup[A-Za-z0-9_$]*\s*\(/i },
|
|
1742
|
+
{ label: "revalidate", pattern: /\brevalidate[A-Za-z0-9_$]*\s*\(/i },
|
|
1743
|
+
{ label: "prisma", pattern: /\bprisma\./i },
|
|
1744
|
+
{ label: "db", pattern: /\bdb\./i },
|
|
1745
|
+
{ label: "checkout", pattern: /\bcheckout\b/i },
|
|
1746
|
+
{ label: "payment", pattern: /\bpayment\b/i },
|
|
1747
|
+
{ label: "send", pattern: /\bsend[A-Za-z0-9_$]*\s*\(/i },
|
|
1748
|
+
{ label: "mutation", pattern: /\bmutation\b/i }
|
|
1749
|
+
];
|
|
1750
|
+
const matches = [];
|
|
1751
|
+
for (const sensitiveOperationPattern of sensitiveOperationPatterns) {
|
|
1752
|
+
const match = sensitiveOperationPattern.pattern.exec(handlerBody);
|
|
1753
|
+
if (match?.index !== void 0) {
|
|
1754
|
+
if (isBroadMutationOperationLabel(sensitiveOperationPattern.label) && isBenignHelperFunctionCall(match[0] ?? "")) {
|
|
1755
|
+
continue;
|
|
1756
|
+
}
|
|
1757
|
+
matches.push({
|
|
1758
|
+
label: sensitiveOperationPattern.label,
|
|
1759
|
+
index: match.index
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
return matches.sort((leftMatch, rightMatch) => leftMatch.index - rightMatch.index)[0];
|
|
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
|
+
}
|
|
1776
|
+
function escapeRegExp(value) {
|
|
1777
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1778
|
+
}
|
|
1779
|
+
function hasWeakAuthRelatedSignal(content) {
|
|
1780
|
+
const normalizedContent = stripStrongProtectionIdentifiers(content).toLowerCase();
|
|
1781
|
+
const weakSignals = [
|
|
1782
|
+
"session",
|
|
1783
|
+
"authorization",
|
|
1784
|
+
"bearer",
|
|
1785
|
+
"cookies()",
|
|
1786
|
+
"request.cookies",
|
|
1787
|
+
"middleware",
|
|
1788
|
+
"getuser",
|
|
1789
|
+
"jwt",
|
|
1790
|
+
"auth(",
|
|
1791
|
+
"getserversession",
|
|
1792
|
+
"currentuser",
|
|
1793
|
+
"clerkclient"
|
|
1794
|
+
];
|
|
1795
|
+
return weakSignals.some((signal) => normalizedContent.includes(signal));
|
|
1796
|
+
}
|
|
1797
|
+
function stripStrongProtectionIdentifiers(content) {
|
|
1798
|
+
return content.replace(/\b(?:await\s+)?require(?:Auth|User|Admin)\s*\([^)]*\)/gi, "").replace(/\bauth\.protect\s*\([^)]*\)/gi, "").replace(/\b(?:await\s+)?verifySession\s*\([^)]*\)/gi, "");
|
|
1799
|
+
}
|
|
1800
|
+
function hasStrongProtectionCallBeforeSensitiveWork(handlerBody, sensitiveOperation) {
|
|
1801
|
+
const protectionCutoffIndex = sensitiveOperation?.index ?? Math.min(2e3, handlerBody.length);
|
|
1802
|
+
const strongProtectionPatterns = [
|
|
1803
|
+
/\b(?:await\s+)?requireAuth\s*\(/gi,
|
|
1804
|
+
/\b(?:await\s+)?requireUser\s*\(/gi,
|
|
1805
|
+
/\b(?:await\s+)?requireAdmin\s*\(/gi,
|
|
1806
|
+
/\bauth\.protect\s*\(/gi,
|
|
1807
|
+
/(?:^|[;{\n]\s*)(?:await\s+)?verifySession\s*\(/gi
|
|
1808
|
+
];
|
|
1809
|
+
for (const pattern of strongProtectionPatterns) {
|
|
1810
|
+
pattern.lastIndex = 0;
|
|
1811
|
+
for (const match of handlerBody.matchAll(pattern)) {
|
|
1812
|
+
if ((match.index ?? Number.POSITIVE_INFINITY) < protectionCutoffIndex) {
|
|
1813
|
+
return true;
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
return hasAuthGuardRedirectBeforeIndex(handlerBody, protectionCutoffIndex) || hasThrowBasedAuthGuardBeforeIndex(handlerBody, protectionCutoffIndex);
|
|
1818
|
+
}
|
|
1819
|
+
function hasAuthGuardRedirectBeforeIndex(handlerBody, cutoffIndex) {
|
|
1820
|
+
const redirectPattern = /\bif\s*\(([\s\S]*?)\)\s*(?:return\s+)?redirect\s*\(\s*["']\/(?:login|sign-in|signin|auth)/gi;
|
|
1821
|
+
for (const match of handlerBody.matchAll(redirectPattern)) {
|
|
1822
|
+
const condition = match[1] ?? "";
|
|
1823
|
+
if ((match.index ?? Number.POSITIVE_INFINITY) < cutoffIndex && /\!/.test(condition)) {
|
|
1824
|
+
return true;
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
return false;
|
|
1828
|
+
}
|
|
1829
|
+
function hasThrowBasedAuthGuardBeforeIndex(handlerBody, cutoffIndex) {
|
|
1830
|
+
const throwGuardPattern = /\bif\s*\(([\s\S]*?)\)\s*(?:throw\s+new\s+\w+|throw\s+[^;]*(?:Unauthorized|Forbidden|401|403))/gi;
|
|
1831
|
+
for (const match of handlerBody.matchAll(throwGuardPattern)) {
|
|
1832
|
+
const condition = match[1] ?? "";
|
|
1833
|
+
const guardText = match[0] ?? "";
|
|
1834
|
+
if ((match.index ?? Number.POSITIVE_INFINITY) < cutoffIndex && /\!/.test(condition) && /Unauthorized|Forbidden|401|403/i.test(guardText)) {
|
|
1835
|
+
return true;
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
return false;
|
|
1839
|
+
}
|
|
1840
|
+
function hasWeakSecretProtectionSignal(content) {
|
|
1208
1841
|
const normalizedContent = content.toLowerCase();
|
|
1209
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");
|
|
1210
1843
|
}
|
|
1844
|
+
function analyzeSecretProtectionGuardBeforeSensitiveWork(handlerBody, fullFileContent, sensitiveOperation) {
|
|
1845
|
+
const protectionCutoffIndex = sensitiveOperation?.index ?? Math.min(2e3, handlerBody.length);
|
|
1846
|
+
const ifStatements = getIfStatements(handlerBody, 0, protectionCutoffIndex);
|
|
1847
|
+
const localSecretHelpers = getLocalHelperInfos(fullFileContent).filter(
|
|
1848
|
+
(helper) => isSecretValidationExpression(helper.body)
|
|
1849
|
+
);
|
|
1850
|
+
for (const ifStatement of ifStatements) {
|
|
1851
|
+
const branch = getIfFailureBranch(handlerBody, ifStatement.branchStartIndex);
|
|
1852
|
+
const returnSignal = getAccessDeniedReturnSignal(branch.text);
|
|
1853
|
+
if (!returnSignal) {
|
|
1854
|
+
continue;
|
|
1855
|
+
}
|
|
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
|
+
};
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
return {
|
|
1893
|
+
hasSecretProtectionGuard: false,
|
|
1894
|
+
evidence: []
|
|
1895
|
+
};
|
|
1896
|
+
}
|
|
1897
|
+
function isSecretTokenGuardCondition(condition) {
|
|
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);
|
|
1906
|
+
return hasSecretInput && (hasExpectedSecret || hasSecretValidationCall) && hasComparison;
|
|
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
|
+
}
|
|
1211
1957
|
function hasRateLimitSignal(content) {
|
|
1212
1958
|
const normalizedContent = content.toLowerCase();
|
|
1213
|
-
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);
|
|
1960
|
+
}
|
|
1961
|
+
function hasBasicValidationSignal(content) {
|
|
1962
|
+
const normalizedContent = content.toLowerCase();
|
|
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");
|
|
1214
1968
|
}
|
|
1215
|
-
function
|
|
1969
|
+
function hasSpamProtectionSignal(content) {
|
|
1216
1970
|
const normalizedContent = content.toLowerCase();
|
|
1217
|
-
return normalizedContent.includes("
|
|
1971
|
+
return normalizedContent.includes("captcha") || normalizedContent.includes("recaptcha") || normalizedContent.includes("hcaptcha") || normalizedContent.includes("turnstile") || normalizedContent.includes("honeypot") || normalizedContent.includes("bot protection");
|
|
1218
1972
|
}
|
|
1219
1973
|
function hasCacheHeaderSignal(content) {
|
|
1220
1974
|
const normalizedContent = content.toLowerCase();
|
|
@@ -1392,7 +2146,8 @@ Determine whether the ${method} handler should be protected.
|
|
|
1392
2146
|
Instructions:
|
|
1393
2147
|
- Inspect each exported HTTP handler separately.
|
|
1394
2148
|
- Do not add authentication to a GET handler if it is intentionally public.
|
|
1395
|
-
- If the ${method} handler
|
|
2149
|
+
- If the ${method} handler already has a guard that returns 401/403 before sensitive logic, explain where it happens and do not add duplicate auth.
|
|
2150
|
+
- If the ${method} handler handles file uploads, private data, storage writes, payments, account changes, or user-specific actions, add the existing project auth/session check before sensitive logic.
|
|
1396
2151
|
- Also verify protections such as input validation, file size limits for uploads, storage path safety, and rate limiting where relevant.
|
|
1397
2152
|
- Do not introduce a new auth provider.
|
|
1398
2153
|
- Do not refactor unrelated code.
|
|
@@ -1513,6 +2268,29 @@ Return:
|
|
|
1513
2268
|
- The safest minimal change if protection is missing.
|
|
1514
2269
|
- Any edge cases I should test.`;
|
|
1515
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
|
+
}
|
|
1516
2294
|
function createMissingEnvVariableFixPrompt(variableName, files) {
|
|
1517
2295
|
return `Update the environment documentation for this project.
|
|
1518
2296
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qodfy/core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
4
4
|
"description": "Scanner engine for Qodfy, an open-source launch readiness scanner for AI-built apps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"qodfy",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
56
|
"build": "tsup src/index.ts --format esm --dts --clean --tsconfig tsconfig.json",
|
|
57
|
-
"dev": "tsx src/index.ts"
|
|
57
|
+
"dev": "tsx src/index.ts",
|
|
58
|
+
"test": "node --import tsx --test src/protection.test.ts"
|
|
58
59
|
}
|
|
59
60
|
}
|