@qodfy/core 0.2.8 → 0.2.9

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