@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.
Files changed (2) hide show
  1. package/dist/index.js +797 -19
  2. 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.hasRateLimit && !handler.hasValidation) {
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 hasAuth = hasAuthOrSessionCheck(handlerContent);
923
- const hasSecretProtection = hasSecretProtectionSignal(handlerContent);
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 hasValidation = hasValidationSignal(handlerContent);
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: "path contains", detail: formPathMatch });
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 (hasAuth) {
954
- evidence.push({ label: `auth/session check detected in ${method} handler` });
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({ label: `secret token check detected in ${method} handler` });
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: hasValidation ? "validation detected" : "no validation detected",
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 hasAuthOrSessionCheck(content) {
1204
- const normalizedContent = content.toLowerCase();
1205
- return normalizedContent.includes("auth(") || normalizedContent.includes("getserversession") || normalizedContent.includes("currentuser") || normalizedContent.includes("clerkclient") || normalizedContent.includes("session") || normalizedContent.includes("requireauth") || normalizedContent.includes("requireuser") || normalizedContent.includes("requireadmin") || normalizedContent.includes("verifysession") || normalizedContent.includes("getuser") || normalizedContent.includes("jwt") || normalizedContent.includes("authorization") || normalizedContent.includes("bearer") || normalizedContent.includes("cookies()") || normalizedContent.includes("request.cookies") || normalizedContent.includes("middleware");
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 hasSecretProtectionSignal(content) {
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 hasValidationSignal(content) {
1969
+ function hasSpamProtectionSignal(content) {
1216
1970
  const normalizedContent = content.toLowerCase();
1217
- return normalizedContent.includes("zod") || normalizedContent.includes("schema") || normalizedContent.includes("validate") || normalizedContent.includes("validation") || normalizedContent.includes("sanitize") || normalizedContent.includes("safeparse") || normalizedContent.includes("parse(") || normalizedContent.includes("slugregex") || normalizedContent.includes("isvalid") || normalizedContent.includes("captcha") || normalizedContent.includes("turnstile") || normalizedContent.includes("recaptcha") || normalizedContent.includes("hcaptcha");
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 handles file uploads, private data, storage writes, payments, account changes, or user-specific actions, add the existing project auth/session check to 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.7",
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
  }