@qodfy/core 0.2.6 → 0.2.8

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.d.ts CHANGED
@@ -20,6 +20,7 @@ type Issue = {
20
20
  suggestion?: string;
21
21
  fixPrompt?: string;
22
22
  evidence?: IssueEvidence[];
23
+ context?: IssueEvidence[];
23
24
  };
24
25
  type ScanReport = {
25
26
  projectPath: string;
package/dist/index.js CHANGED
@@ -354,18 +354,10 @@ async function scanProject(input) {
354
354
  relativeFile,
355
355
  content
356
356
  }) : null;
357
- if (runWebhookChecks && apiRouteAnalysis?.intent === "webhook" && !apiRouteAnalysis.hasWebhookVerification) {
358
- addIssue({
359
- ruleId: "webhook-missing-signature-verification",
360
- category: "webhook",
361
- severity: apiRouteAnalysis.confidence === "high" ? "critical" : "warning",
362
- confidence: apiRouteAnalysis.confidence,
363
- title: "Webhook route may be missing signature verification",
364
- message: "This webhook route appears to handle external events, but Qodfy could not find signature verification before the event is handled.",
365
- file: relativeFile,
366
- suggestion: getWebhookSignatureSuggestion(apiRouteAnalysis.webhookProvider),
367
- fixPrompt: createWebhookSignatureFixPrompt(relativeFile),
368
- evidence: apiRouteAnalysis.evidence
357
+ if (runWebhookChecks && apiRouteAnalysis) {
358
+ addWebhookSignatureIssues({
359
+ addIssue,
360
+ analysis: apiRouteAnalysis
369
361
  });
370
362
  }
371
363
  if (runSecurityChecks) {
@@ -726,95 +718,125 @@ function isApiRoute(filePath) {
726
718
  const sourceFileExtension = "(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)";
727
719
  return new RegExp(`/app/api(?:/.+)?/route\\.${sourceFileExtension}$`).test(normalizedFile) || new RegExp(`/pages/api/.+\\.${sourceFileExtension}$`).test(normalizedFile);
728
720
  }
721
+ function addWebhookSignatureIssues({
722
+ addIssue,
723
+ analysis
724
+ }) {
725
+ for (const handler of analysis.handlers) {
726
+ if (handler.intent !== "webhook" || handler.hasWebhookVerification) {
727
+ continue;
728
+ }
729
+ addIssue({
730
+ ruleId: "webhook-missing-signature-verification",
731
+ category: "webhook",
732
+ severity: handler.confidence === "high" ? "critical" : "warning",
733
+ confidence: handler.confidence,
734
+ title: `Webhook ${handler.method} handler may be missing signature verification`,
735
+ message: `The ${handler.method} handler in this route appears to handle external events, but Qodfy could not find signature verification before the event is handled.`,
736
+ file: analysis.relativeFile,
737
+ suggestion: getWebhookSignatureSuggestion(analysis.webhookProvider),
738
+ fixPrompt: createWebhookSignatureFixPrompt(analysis.relativeFile, handler.method),
739
+ evidence: handler.evidence,
740
+ context: handler.context
741
+ });
742
+ }
743
+ }
729
744
  function addApiRouteProtectionIssues({
730
745
  addIssue,
731
746
  includeLowConfidence,
732
747
  analysis
733
748
  }) {
734
- if (analysis.intent === "webhook") {
735
- return;
736
- }
737
- if (analysis.intent === "public-read") {
738
- if (includeLowConfidence) {
739
- addIssue({
740
- ruleId: "api-public-read-route",
741
- category: "api",
742
- severity: "info",
743
- confidence: analysis.confidence,
744
- title: "Public read API route detected",
745
- message: "This route appears intentionally public. Authentication may not be required.",
746
- file: analysis.relativeFile,
747
- suggestion: "Verify that it only exposes public or published data and has appropriate validation, caching, and abuse protection.",
748
- fixPrompt: createPublicReadRouteFixPrompt(analysis.relativeFile),
749
- evidence: analysis.evidence
750
- });
749
+ for (const handler of analysis.handlers) {
750
+ if (handler.intent === "webhook") {
751
+ continue;
751
752
  }
752
- return;
753
- }
754
- if (analysis.intent === "public-form") {
755
- if (!analysis.hasRateLimit && !analysis.hasValidation) {
756
- addIssue({
757
- ruleId: "public-form-missing-abuse-protection",
758
- category: "api",
759
- severity: "warning",
760
- confidence: analysis.confidence,
761
- title: "Public form route may be missing abuse protection",
762
- message: "This route appears to accept public submissions. Consider adding rate limiting, validation, or spam protection.",
763
- file: analysis.relativeFile,
764
- suggestion: "Check for rate limiting, validation, captcha, Turnstile, reCAPTCHA, hCaptcha, or another spam protection pattern.",
765
- fixPrompt: createPublicFormProtectionFixPrompt(analysis.relativeFile),
766
- evidence: analysis.evidence
767
- });
753
+ if (handler.intent === "public-read") {
754
+ if (includeLowConfidence) {
755
+ addIssue({
756
+ ruleId: "api-public-read-route",
757
+ category: "api",
758
+ severity: "info",
759
+ confidence: handler.confidence,
760
+ title: `Public ${handler.method} handler detected`,
761
+ message: `The ${handler.method} handler in this route appears intentionally public. Authentication may not be required.`,
762
+ file: analysis.relativeFile,
763
+ suggestion: "Verify that it only exposes public or published data and has appropriate validation, caching, and abuse protection.",
764
+ fixPrompt: createPublicReadRouteFixPrompt(analysis.relativeFile, handler.method),
765
+ evidence: handler.evidence,
766
+ context: handler.context
767
+ });
768
+ }
769
+ continue;
768
770
  }
769
- return;
770
- }
771
- if (analysis.intent === "internal") {
772
- if (!analysis.hasAuth && !analysis.hasSecretProtection) {
773
- addIssue({
774
- ruleId: "internal-route-missing-protection",
775
- category: "security",
776
- severity: "warning",
777
- confidence: analysis.confidence,
778
- title: "Internal API route may be missing protection",
779
- message: "This route appears internal or operational. Confirm it is protected by auth, a secret token, or server-only access.",
780
- file: analysis.relativeFile,
781
- suggestion: "Use the project's existing auth pattern or a secret token check for operational routes such as cron, cleanup, or revalidation.",
782
- fixPrompt: createInternalRouteProtectionFixPrompt(analysis.relativeFile),
783
- evidence: analysis.evidence
784
- });
771
+ if (handler.intent === "public-form") {
772
+ if (!handler.hasRateLimit && !handler.hasValidation) {
773
+ addIssue({
774
+ ruleId: "public-form-missing-abuse-protection",
775
+ category: "api",
776
+ severity: "warning",
777
+ confidence: handler.confidence,
778
+ title: `Public form ${handler.method} handler may be missing abuse protection`,
779
+ message: `The ${handler.method} handler in this route appears to accept public submissions. Consider adding rate limiting, validation, or spam protection.`,
780
+ file: analysis.relativeFile,
781
+ suggestion: "Check for rate limiting, validation, captcha, Turnstile, reCAPTCHA, hCaptcha, or another spam protection pattern.",
782
+ fixPrompt: createPublicFormProtectionFixPrompt(analysis.relativeFile, handler.method),
783
+ evidence: handler.evidence,
784
+ context: handler.context
785
+ });
786
+ }
787
+ continue;
785
788
  }
786
- return;
787
- }
788
- if (analysis.intent === "sensitive-mutation") {
789
- if (!analysis.hasAuth) {
789
+ if (handler.intent === "internal") {
790
+ if (!handler.hasAuth && !handler.hasSecretProtection) {
791
+ addIssue({
792
+ ruleId: "internal-route-missing-protection",
793
+ category: "security",
794
+ severity: "warning",
795
+ confidence: handler.confidence,
796
+ title: `Internal ${handler.method} handler may be missing protection`,
797
+ message: `The ${handler.method} handler in this route appears internal or operational. Confirm it is protected by auth, a secret token, or server-only access.`,
798
+ file: analysis.relativeFile,
799
+ suggestion: "Use the project's existing auth pattern or a secret token check for operational handlers such as cron, cleanup, or revalidation.",
800
+ fixPrompt: createInternalRouteProtectionFixPrompt(analysis.relativeFile, handler.method),
801
+ evidence: handler.evidence,
802
+ context: handler.context
803
+ });
804
+ }
805
+ continue;
806
+ }
807
+ if (handler.intent === "sensitive-mutation") {
808
+ if (!handler.hasAuth) {
809
+ addIssue({
810
+ ruleId: "sensitive-api-route-missing-auth",
811
+ category: "security",
812
+ severity: "warning",
813
+ confidence: handler.confidence,
814
+ title: getSensitiveHandlerTitle(handler),
815
+ message: `The ${handler.method} handler in this route appears to handle uploads or sensitive operations. Confirm it is protected before launch.`,
816
+ file: analysis.relativeFile,
817
+ suggestion: "Review the existing project auth/session pattern and apply it if this handler processes private data, uploads, payments, or account changes.",
818
+ fixPrompt: createApiAuthFixPrompt(analysis.relativeFile, handler.method, handler.intent),
819
+ evidence: handler.evidence,
820
+ context: handler.context
821
+ });
822
+ }
823
+ continue;
824
+ }
825
+ if (handler.authExpected === "review" && !handler.hasAuth) {
790
826
  addIssue({
791
- ruleId: "sensitive-api-route-missing-auth",
792
- category: "security",
827
+ ruleId: "api-mutation-route-review-auth",
828
+ category: "api",
793
829
  severity: "warning",
794
- confidence: analysis.confidence,
795
- title: "Sensitive API route may be missing authentication",
796
- message: "This route appears to handle user-specific or sensitive operations. Confirm it is protected before launch.",
830
+ confidence: handler.confidence,
831
+ title: "API mutation handler should be reviewed for authentication",
832
+ message: `The ${handler.method} handler mutates data or handles requests, but Qodfy could not determine whether authentication is required.`,
797
833
  file: analysis.relativeFile,
798
- suggestion: "Review the existing project auth/session pattern and apply it if this route handles private data, uploads, payments, or account changes.",
799
- fixPrompt: createApiAuthFixPrompt(analysis.relativeFile),
800
- evidence: analysis.evidence
834
+ suggestion: "Confirm the handler is intentionally public, or add the existing project auth/session check before handling private data.",
835
+ fixPrompt: createApiAuthFixPrompt(analysis.relativeFile, handler.method, handler.intent),
836
+ evidence: handler.evidence,
837
+ context: handler.context
801
838
  });
802
839
  }
803
- return;
804
- }
805
- if (analysis.authExpected === "review" && !analysis.hasAuth) {
806
- addIssue({
807
- ruleId: "api-mutation-route-review-auth",
808
- category: "api",
809
- severity: "warning",
810
- confidence: analysis.confidence,
811
- title: "API mutation route should be reviewed for authentication",
812
- message: "This route mutates data or handles requests, but Qodfy could not determine whether authentication is required.",
813
- file: analysis.relativeFile,
814
- suggestion: "Confirm the route is intentionally public, or add the existing project auth/session check before handling private data.",
815
- fixPrompt: createApiAuthFixPrompt(analysis.relativeFile),
816
- evidence: analysis.evidence
817
- });
818
840
  }
819
841
  }
820
842
  function analyzeApiRoute({
@@ -822,25 +844,44 @@ function analyzeApiRoute({
822
844
  relativeFile,
823
845
  content
824
846
  }) {
825
- const normalizedFile = relativeFile.toLowerCase();
826
- const methods = getRouteHttpMethods(content);
827
- const evidence = [];
828
- const webhookRouteInfo = getWebhookRouteInfo(relativeFile, content);
829
- const hasAuth = hasAuthOrSessionCheck(content);
830
- const hasSecretProtection = hasSecretProtectionSignal(content);
831
- const hasRateLimit = hasRateLimitSignal(content);
832
- const hasValidation = hasValidationSignal(content);
833
- const hasCacheHeaders = hasCacheHeaderSignal(content);
834
- const hasMethodBlocking = hasMethodBlockingSignal(content);
835
- const webhookProvider = webhookRouteInfo?.provider ?? "unknown";
836
- const hasWebhookVerification = hasWebhookSignatureVerification(content, webhookProvider);
837
- if (methods.length > 0) {
838
- for (const method of methods) {
839
- evidence.push({ label: "exports", detail: method });
840
- }
841
- } else {
842
- evidence.push({ label: "no exported HTTP method detected" });
847
+ const exportedHandlers = getExportedRouteHandlers(content);
848
+ const handlersToAnalyze = exportedHandlers.length > 0 ? exportedHandlers : getRouteHttpMethods(content).map((method) => ({
849
+ method,
850
+ body: content,
851
+ usedFallbackBody: true
852
+ }));
853
+ const handlers = handlersToAnalyze.map(
854
+ (handler) => analyzeApiHandler({
855
+ relativeFile,
856
+ content,
857
+ method: handler.method,
858
+ body: handler.body,
859
+ usedFallbackBody: handler.usedFallbackBody
860
+ })
861
+ );
862
+ for (const handler of handlers) {
863
+ handler.context = getHandlerContext(handler, handlers);
843
864
  }
865
+ const methods = handlers.map((handler) => handler.method);
866
+ const webhookProvider = getRouteWebhookProvider(handlers, relativeFile, content);
867
+ return {
868
+ file,
869
+ relativeFile,
870
+ methods,
871
+ handlers,
872
+ routeIntent: getRouteIntent(handlers),
873
+ evidence: methods.map((method) => ({ label: "exports", detail: method })),
874
+ webhookProvider
875
+ };
876
+ }
877
+ function analyzeApiHandler({
878
+ relativeFile,
879
+ content,
880
+ method,
881
+ body,
882
+ usedFallbackBody
883
+ }) {
884
+ const normalizedFile = relativeFile.toLowerCase();
844
885
  const webhookPathMatch = getRoutePathMatch(normalizedFile, ["webhook", "webhooks", "callback"]);
845
886
  const internalPathMatch = getRoutePathMatch(normalizedFile, ["internal", "admin", "cron", "cleanup", "revalidate", "private"]);
846
887
  const formPathMatch = getRoutePathMatch(normalizedFile, ["contact", "subscribe", "newsletter", "lead", "inquiry"]);
@@ -874,7 +915,35 @@ function analyzeApiRoute({
874
915
  "sitemap",
875
916
  "rss"
876
917
  ]);
918
+ const handlerContent = body || content;
919
+ const webhookRouteInfo = getWebhookRouteInfo(relativeFile, handlerContent);
920
+ const webhookProvider = webhookRouteInfo?.provider ?? getWebhookProvider(`${normalizedFile}
921
+ ${handlerContent.toLowerCase()}`);
922
+ const protectionAnalysis = analyzeHandlerProtection({
923
+ handlerBody: handlerContent,
924
+ fullFileContent: content
925
+ });
926
+ const hasWeakAuthSignal = hasWeakAuthRelatedSignal(handlerContent);
927
+ const hasStrongProtection = hasStrongProtectionCallBeforeSensitiveWork(
928
+ handlerContent,
929
+ protectionAnalysis.sensitiveOperation
930
+ );
931
+ const hasAuth = protectionAnalysis.hasAccessControlGuard || hasStrongProtection;
932
+ const hasSecretProtection = hasSecretProtectionGuardBeforeSensitiveWork(
933
+ handlerContent,
934
+ protectionAnalysis.sensitiveOperation
935
+ );
936
+ const hasWeakSecretSignal = hasWeakSecretProtectionSignal(handlerContent);
937
+ const hasRateLimit = hasRateLimitSignal(handlerContent);
938
+ const hasValidation = hasValidationSignal(handlerContent);
939
+ const hasCacheHeaders = hasCacheHeaderSignal(handlerContent);
940
+ const hasMethodBlocking = hasMethodBlockingSignal(handlerContent);
941
+ const hasWebhookVerification = hasWebhookSignatureVerification(handlerContent, webhookProvider);
942
+ const evidence = [{ label: "exports", detail: method }];
877
943
  let intent = "unknown";
944
+ if (usedFallbackBody) {
945
+ evidence.push({ label: "handler body extraction fallback", detail: "using full file" });
946
+ }
878
947
  if (webhookRouteInfo || webhookPathMatch) {
879
948
  intent = "webhook";
880
949
  evidence.push({
@@ -884,54 +953,79 @@ function analyzeApiRoute({
884
953
  } else if (internalPathMatch) {
885
954
  intent = "internal";
886
955
  evidence.push({ label: "path contains", detail: internalPathMatch });
887
- } else if (formPathMatch) {
956
+ } else if (method === "POST" && formPathMatch) {
888
957
  intent = "public-form";
889
958
  evidence.push({ label: "path contains", detail: formPathMatch });
890
- } else if (hasMutationMethod(methods) && sensitivePathMatch) {
959
+ } else if (method === "GET" && publicContentPathMatch && !sensitivePathMatch && !internalPathMatch) {
960
+ intent = "public-read";
961
+ evidence.push({ label: "public read path detected", detail: publicContentPathMatch });
962
+ } else if (isMutationMethod(method) && sensitivePathMatch && !hasMethodBlocking) {
891
963
  intent = "sensitive-mutation";
892
964
  evidence.push({ label: "path contains", detail: sensitivePathMatch });
893
- } else if (methods.includes("GET") && publicContentPathMatch && !sensitivePathMatch && !internalPathMatch) {
894
- intent = "public-read";
895
- evidence.push({ label: "public content route detected", detail: publicContentPathMatch });
896
965
  }
897
- if (hasAuth) {
898
- evidence.push({ label: "auth/session check detected" });
966
+ if (protectionAnalysis.sensitiveOperation) {
967
+ evidence.push({
968
+ label: `sensitive operation ${protectionAnalysis.sensitiveOperation.label} detected`,
969
+ detail: `${method} handler`
970
+ });
971
+ }
972
+ if (protectionAnalysis.hasAccessControlGuard) {
973
+ evidence.push(...protectionAnalysis.evidence);
974
+ } else if (hasStrongProtection) {
975
+ evidence.push({
976
+ label: "strong protection call detected before sensitive work",
977
+ detail: `${method} handler`
978
+ });
979
+ } else if (hasWeakAuthSignal) {
980
+ evidence.push({
981
+ label: "possible auth-related signal detected",
982
+ detail: `${method} handler`
983
+ });
984
+ } else if (protectionAnalysis.sensitiveOperation) {
985
+ evidence.push({ label: `no access-control guard detected before sensitive operation`, detail: `${method} handler` });
899
986
  } else {
900
- evidence.push({ label: "no auth/session check detected" });
987
+ evidence.push({ label: `no auth/session check detected in ${method} handler` });
901
988
  }
902
989
  if (hasSecretProtection) {
903
- evidence.push({ label: "secret token check detected" });
904
- }
905
- if (hasRateLimit) {
906
- evidence.push({ label: "rate limit detected" });
907
- } else if (intent === "public-form") {
908
- evidence.push({ label: "no rate limit detected" });
990
+ evidence.push({ label: `secret-token guard detected before sensitive work`, detail: `${method} handler` });
991
+ } else if (hasWeakSecretSignal) {
992
+ evidence.push({ label: `possible secret/token signal detected`, detail: `${method} handler` });
909
993
  }
910
- if (hasValidation) {
911
- evidence.push({ label: "validation detected" });
912
- } else if (intent === "public-form") {
913
- evidence.push({ label: "no validation detected" });
994
+ if (intent === "public-form") {
995
+ evidence.push({
996
+ label: hasRateLimit ? "rate limit detected" : "no rate limit detected",
997
+ detail: `${method} handler`
998
+ });
999
+ evidence.push({
1000
+ label: hasValidation ? "validation detected" : "no validation detected",
1001
+ detail: `${method} handler`
1002
+ });
914
1003
  }
915
- if (hasCacheHeaders) {
916
- evidence.push({ label: "cache/public-read safety signal detected" });
1004
+ if (intent === "public-read") {
1005
+ if (hasRateLimit) {
1006
+ evidence.push({ label: "rate limit detected", detail: `${method} handler` });
1007
+ }
1008
+ if (hasValidation) {
1009
+ evidence.push({ label: "validation detected", detail: `${method} handler` });
1010
+ }
1011
+ if (hasCacheHeaders) {
1012
+ evidence.push({ label: "cache/public-read safety signal detected", detail: `${method} handler` });
1013
+ }
917
1014
  }
918
1015
  if (hasMethodBlocking) {
919
- evidence.push({ label: "method blocking detected" });
1016
+ evidence.push({ label: "method blocking detected", detail: `${method} handler` });
920
1017
  }
921
1018
  if (intent === "webhook") {
922
- if (hasWebhookVerification) {
923
- evidence.push({ label: "webhook signature verification detected" });
924
- } else {
925
- evidence.push({ label: "no webhook signature verification detected" });
926
- }
1019
+ evidence.push({
1020
+ label: hasWebhookVerification ? "webhook signature verification detected" : "no webhook signature verification detected",
1021
+ detail: `${method} handler`
1022
+ });
927
1023
  }
928
1024
  return {
929
- file,
930
- relativeFile,
931
- methods,
1025
+ method,
932
1026
  intent,
933
- authExpected: getAuthExpectation(intent, methods),
934
- confidence: getApiRouteConfidence(intent, methods, webhookRouteInfo),
1027
+ authExpected: getHandlerAuthExpectation(intent, method, hasMethodBlocking),
1028
+ confidence: getApiHandlerConfidence(intent, method, webhookRouteInfo, hasMethodBlocking),
935
1029
  evidence,
936
1030
  hasAuth,
937
1031
  hasSecretProtection,
@@ -939,31 +1033,89 @@ function analyzeApiRoute({
939
1033
  hasValidation,
940
1034
  hasCacheHeaders,
941
1035
  hasMethodBlocking,
942
- hasWebhookVerification,
943
- webhookProvider
1036
+ hasWebhookVerification
944
1037
  };
945
1038
  }
946
- function getAuthExpectation(intent, methods) {
1039
+ function getHandlerContext(handler, handlers) {
1040
+ const context = [];
1041
+ for (const otherHandler of handlers) {
1042
+ if (otherHandler.method === handler.method) {
1043
+ continue;
1044
+ }
1045
+ context.push({ label: "route also exports", detail: otherHandler.method });
1046
+ if (otherHandler.intent === "public-read") {
1047
+ context.push({ label: "public read handler detected", detail: otherHandler.method });
1048
+ }
1049
+ if (otherHandler.hasCacheHeaders) {
1050
+ context.push({ label: "cache/public-read safety signal detected", detail: `${otherHandler.method} handler` });
1051
+ }
1052
+ if (otherHandler.hasMethodBlocking) {
1053
+ context.push({ label: "method blocking detected", detail: `${otherHandler.method} handler` });
1054
+ }
1055
+ }
1056
+ return context.length > 0 ? context : void 0;
1057
+ }
1058
+ function getRouteIntent(handlers) {
1059
+ const intentPriority = [
1060
+ "webhook",
1061
+ "internal",
1062
+ "sensitive-mutation",
1063
+ "public-form",
1064
+ "public-read",
1065
+ "unknown"
1066
+ ];
1067
+ return intentPriority.find(
1068
+ (intent) => handlers.some((handler) => handler.intent === intent)
1069
+ ) ?? "unknown";
1070
+ }
1071
+ function getRouteWebhookProvider(handlers, relativeFile, content) {
1072
+ const routeWebhookInfo = getWebhookRouteInfo(relativeFile, content);
1073
+ if (routeWebhookInfo?.provider && routeWebhookInfo.provider !== "unknown") {
1074
+ return routeWebhookInfo.provider;
1075
+ }
1076
+ const webhookHandler = handlers.find((handler) => handler.intent === "webhook");
1077
+ if (!webhookHandler) {
1078
+ return "unknown";
1079
+ }
1080
+ const providerFromEvidence = webhookHandler.evidence.find(
1081
+ (item) => item.label === "webhook content detected" && item.detail && item.detail !== "unknown"
1082
+ );
1083
+ return providerFromEvidence?.detail ?? "unknown";
1084
+ }
1085
+ function getHandlerAuthExpectation(intent, method, hasMethodBlocking) {
1086
+ if (hasMethodBlocking || intent === "public-read" || intent === "public-form" || intent === "webhook") {
1087
+ return false;
1088
+ }
947
1089
  if (intent === "internal" || intent === "sensitive-mutation") {
948
1090
  return true;
949
1091
  }
950
- if (intent === "unknown" && hasMutationMethod(methods)) {
1092
+ if (isMutationMethod(method)) {
951
1093
  return "review";
952
1094
  }
953
1095
  return false;
954
1096
  }
955
- function getApiRouteConfidence(intent, methods, webhookRouteInfo) {
1097
+ function getApiHandlerConfidence(intent, method, webhookRouteInfo, hasMethodBlocking) {
1098
+ if (hasMethodBlocking) {
1099
+ return "low";
1100
+ }
956
1101
  if (intent === "sensitive-mutation" || intent === "internal") {
957
1102
  return "high";
958
1103
  }
959
1104
  if (intent === "webhook") {
960
1105
  return webhookRouteInfo?.confidence === "high" ? "high" : "medium";
961
1106
  }
962
- if (intent === "public-form" || intent === "unknown" && hasMutationMethod(methods)) {
1107
+ if (intent === "public-form" || intent === "unknown" && isMutationMethod(method)) {
963
1108
  return "medium";
964
1109
  }
965
1110
  return "low";
966
1111
  }
1112
+ function getSensitiveHandlerTitle(handler) {
1113
+ const pathSignal = handler.evidence.find((item) => item.label === "path contains")?.detail;
1114
+ if (handler.method === "POST" && pathSignal === "upload") {
1115
+ return "Upload POST handler may be missing authentication";
1116
+ }
1117
+ return `Sensitive ${handler.method} handler may be missing authentication`;
1118
+ }
967
1119
  function getRoutePathMatch(normalizedFile, terms) {
968
1120
  return terms.find((term) => {
969
1121
  const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -971,16 +1123,123 @@ function getRoutePathMatch(normalizedFile, terms) {
971
1123
  });
972
1124
  }
973
1125
  function getExportedHttpMethods(content) {
974
- const methods = /* @__PURE__ */ new Set();
975
- const functionExportPattern = /\bexport\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\b/g;
976
- const constExportPattern = /\bexport\s+const\s+(GET|POST|PUT|PATCH|DELETE)\b/g;
1126
+ return getExportedRouteHandlers(content).map((handler) => handler.method);
1127
+ }
1128
+ function getExportedRouteHandlers(content) {
1129
+ const handlers = [];
1130
+ const functionExportPattern = /\bexport\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\s*\(/g;
1131
+ const constExportPattern = /\bexport\s+const\s+(GET|POST|PUT|PATCH|DELETE)\s*=/g;
977
1132
  for (const match of content.matchAll(functionExportPattern)) {
978
- methods.add(match[1]);
1133
+ handlers.push(extractRouteHandlerBody(content, match.index ?? 0, match[1], "function"));
979
1134
  }
980
1135
  for (const match of content.matchAll(constExportPattern)) {
981
- methods.add(match[1]);
1136
+ handlers.push(extractRouteHandlerBody(content, match.index ?? 0, match[1], "const"));
982
1137
  }
983
- return [...methods];
1138
+ return handlers.sort(
1139
+ (leftHandler, rightHandler) => getMethodRank(leftHandler.method) - getMethodRank(rightHandler.method)
1140
+ );
1141
+ }
1142
+ function extractRouteHandlerBody(content, exportIndex, method, exportKind) {
1143
+ const nextExportIndex = findNextRouteHandlerExport(content, exportIndex + 1);
1144
+ const handlerEnd = nextExportIndex === -1 ? content.length : nextExportIndex;
1145
+ const openBraceIndex = getRouteHandlerBodyOpenBrace(content, exportIndex, handlerEnd, exportKind);
1146
+ if (openBraceIndex !== -1 && openBraceIndex < handlerEnd) {
1147
+ const closeBraceIndex = findMatchingBrace(content, openBraceIndex);
1148
+ if (closeBraceIndex !== -1) {
1149
+ return {
1150
+ method,
1151
+ body: content.slice(openBraceIndex, closeBraceIndex + 1),
1152
+ usedFallbackBody: false
1153
+ };
1154
+ }
1155
+ }
1156
+ return {
1157
+ method,
1158
+ body: content.slice(exportIndex, handlerEnd),
1159
+ usedFallbackBody: true
1160
+ };
1161
+ }
1162
+ function getRouteHandlerBodyOpenBrace(content, exportIndex, handlerEnd, exportKind) {
1163
+ if (exportKind === "function") {
1164
+ const openParenIndex = content.indexOf("(", exportIndex);
1165
+ if (openParenIndex !== -1 && openParenIndex < handlerEnd) {
1166
+ const closeParenIndex = findMatchingParen(content, openParenIndex);
1167
+ if (closeParenIndex !== -1 && closeParenIndex < handlerEnd) {
1168
+ return content.indexOf("{", closeParenIndex);
1169
+ }
1170
+ }
1171
+ }
1172
+ const equalsIndex = content.indexOf("=", exportIndex);
1173
+ if (equalsIndex !== -1 && equalsIndex < handlerEnd) {
1174
+ const arrowIndex = content.indexOf("=>", equalsIndex);
1175
+ if (arrowIndex !== -1 && arrowIndex < handlerEnd) {
1176
+ return content.indexOf("{", arrowIndex);
1177
+ }
1178
+ }
1179
+ return content.indexOf("{", exportIndex);
1180
+ }
1181
+ function findNextRouteHandlerExport(content, startIndex) {
1182
+ const nextExportPattern = /\bexport\s+(?:async\s+)?(?:function|const)\s+(GET|POST|PUT|PATCH|DELETE)\b/g;
1183
+ nextExportPattern.lastIndex = startIndex;
1184
+ const match = nextExportPattern.exec(content);
1185
+ return match?.index ?? -1;
1186
+ }
1187
+ function findMatchingParen(content, openParenIndex) {
1188
+ let depth = 0;
1189
+ for (let index = openParenIndex; index < content.length; index++) {
1190
+ const character = content[index];
1191
+ if (character === "(") {
1192
+ depth++;
1193
+ } else if (character === ")") {
1194
+ depth--;
1195
+ if (depth === 0) {
1196
+ return index;
1197
+ }
1198
+ }
1199
+ }
1200
+ return -1;
1201
+ }
1202
+ function getIfStatements(content, startIndex = 0, endIndex = content.length) {
1203
+ const statements = [];
1204
+ const ifPattern = /\bif\s*\(/g;
1205
+ ifPattern.lastIndex = startIndex;
1206
+ let match;
1207
+ while ((match = ifPattern.exec(content)) !== null) {
1208
+ const matchIndex = match.index;
1209
+ if (matchIndex > endIndex) {
1210
+ break;
1211
+ }
1212
+ const openParenIndex = content.indexOf("(", matchIndex);
1213
+ if (openParenIndex === -1 || openParenIndex > endIndex) {
1214
+ continue;
1215
+ }
1216
+ const closeParenIndex = findMatchingParen(content, openParenIndex);
1217
+ if (closeParenIndex === -1 || closeParenIndex > endIndex) {
1218
+ continue;
1219
+ }
1220
+ statements.push({
1221
+ index: matchIndex,
1222
+ condition: content.slice(openParenIndex + 1, closeParenIndex),
1223
+ branchStartIndex: closeParenIndex + 1
1224
+ });
1225
+ ifPattern.lastIndex = closeParenIndex + 1;
1226
+ }
1227
+ return statements;
1228
+ }
1229
+ function findMatchingBrace(content, openBraceIndex) {
1230
+ let depth = 0;
1231
+ for (let index = openBraceIndex; index < content.length; index++) {
1232
+ const character = content[index];
1233
+ if (character === "{") {
1234
+ depth++;
1235
+ } else if (character === "}") {
1236
+ depth--;
1237
+ if (depth === 0) {
1238
+ return index;
1239
+ }
1240
+ }
1241
+ }
1242
+ return -1;
984
1243
  }
985
1244
  function getRouteHttpMethods(content) {
986
1245
  const methods = new Set(getExportedHttpMethods(content));
@@ -994,19 +1253,372 @@ function getRouteHttpMethods(content) {
994
1253
  }
995
1254
  return [...methods];
996
1255
  }
997
- function hasMutationMethod(methods) {
998
- return ["POST", "PUT", "PATCH", "DELETE"].some(
999
- (method) => methods.includes(method)
1000
- );
1256
+ function getMethodRank(method) {
1257
+ const methodOrder = ["GET", "POST", "PUT", "PATCH", "DELETE"];
1258
+ return methodOrder.indexOf(method);
1001
1259
  }
1002
- function hasAuthOrSessionCheck(content) {
1003
- const normalizedContent = content.toLowerCase();
1004
- 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");
1260
+ function isMutationMethod(method) {
1261
+ return method !== "GET";
1262
+ }
1263
+ function analyzeHandlerProtection({
1264
+ handlerBody,
1265
+ fullFileContent
1266
+ }) {
1267
+ const imports = getImportInfos(fullFileContent);
1268
+ const sensitiveOperation = getFirstSensitiveOperation(handlerBody);
1269
+ const helperAssignments = getHelperAssignments(handlerBody);
1270
+ for (const assignment of helperAssignments) {
1271
+ if (isRawRequestAccessorHelper(assignment.helperName)) {
1272
+ continue;
1273
+ }
1274
+ if (!isGuardHelperAssignmentNearTop(assignment.index, handlerBody, sensitiveOperation)) {
1275
+ continue;
1276
+ }
1277
+ const guard = findAccessControlGuardForVariable({
1278
+ handlerBody,
1279
+ variableName: assignment.variableName,
1280
+ startIndex: assignment.endIndex
1281
+ });
1282
+ if (!guard) {
1283
+ continue;
1284
+ }
1285
+ if (sensitiveOperation && guard.endIndex > sensitiveOperation.index) {
1286
+ continue;
1287
+ }
1288
+ const importInfo = getImportInfoForHelper(imports, assignment.helperName);
1289
+ const evidence = [
1290
+ { label: "access-control guard detected" },
1291
+ { label: "helper call assigned to variable", detail: assignment.variableName },
1292
+ { label: "guard checks variable", detail: assignment.variableName },
1293
+ { label: guard.returnSignal }
1294
+ ];
1295
+ if (sensitiveOperation) {
1296
+ evidence.push({ label: "guard appears before sensitive operation", detail: sensitiveOperation.label });
1297
+ }
1298
+ if (importInfo?.isProtectionSource) {
1299
+ evidence.push({ label: "helper imported from protection module", detail: importInfo.source });
1300
+ }
1301
+ return {
1302
+ hasAccessControlGuard: true,
1303
+ evidence,
1304
+ sensitiveOperation
1305
+ };
1306
+ }
1307
+ return {
1308
+ hasAccessControlGuard: false,
1309
+ evidence: [],
1310
+ sensitiveOperation
1311
+ };
1312
+ }
1313
+ function getImportInfos(content) {
1314
+ const imports = [];
1315
+ const importPattern = /import\s+(?:type\s+)?([\s\S]*?)\s+from\s+["']([^"']+)["'];?/g;
1316
+ for (const match of content.matchAll(importPattern)) {
1317
+ const importClause = match[1]?.trim();
1318
+ const source = match[2];
1319
+ if (!importClause || !source) {
1320
+ continue;
1321
+ }
1322
+ const isProtectionSource = isProtectionImportSource(source);
1323
+ const namedImportMatch = importClause.match(/\{([\s\S]*?)\}/);
1324
+ if (namedImportMatch?.[1]) {
1325
+ const namedImports = namedImportMatch[1].split(",");
1326
+ for (const namedImport of namedImports) {
1327
+ const parts = namedImport.trim().split(/\s+as\s+/i);
1328
+ const importedName = parts[0]?.trim();
1329
+ const localName = parts[1]?.trim() ?? importedName;
1330
+ if (importedName && localName) {
1331
+ imports.push({
1332
+ localName,
1333
+ importedName,
1334
+ source,
1335
+ isProtectionSource
1336
+ });
1337
+ }
1338
+ }
1339
+ }
1340
+ const clauseBeforeNamedImports = importClause.split("{")[0]?.replace(/,\s*$/, "").trim();
1341
+ if (clauseBeforeNamedImports && !clauseBeforeNamedImports.startsWith("*")) {
1342
+ const defaultImport = clauseBeforeNamedImports.split(",")[0]?.trim();
1343
+ if (defaultImport && /^[A-Za-z_$][\w$]*$/.test(defaultImport)) {
1344
+ imports.push({
1345
+ localName: defaultImport,
1346
+ importedName: "default",
1347
+ source,
1348
+ isProtectionSource
1349
+ });
1350
+ }
1351
+ }
1352
+ const namespaceImportMatch = importClause.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/);
1353
+ if (namespaceImportMatch?.[1]) {
1354
+ imports.push({
1355
+ localName: namespaceImportMatch[1],
1356
+ importedName: "*",
1357
+ source,
1358
+ isProtectionSource
1359
+ });
1360
+ }
1361
+ }
1362
+ return imports;
1363
+ }
1364
+ function isProtectionImportSource(source) {
1365
+ const normalizedSource = source.toLowerCase();
1366
+ const protectionSourceTerms = [
1367
+ "auth",
1368
+ "session",
1369
+ "sessions",
1370
+ "staff",
1371
+ "permission",
1372
+ "permissions",
1373
+ "access",
1374
+ "access-control",
1375
+ "security",
1376
+ "user",
1377
+ "users"
1378
+ ];
1379
+ return protectionSourceTerms.some((term) => normalizedSource.includes(term));
1380
+ }
1381
+ function getHelperAssignments(handlerBody) {
1382
+ const assignments = [];
1383
+ 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;
1384
+ for (const match of handlerBody.matchAll(assignmentPattern)) {
1385
+ const variableName = match[1];
1386
+ const helperName = match[2];
1387
+ const index = match.index ?? 0;
1388
+ if (!variableName || !helperName) {
1389
+ continue;
1390
+ }
1391
+ assignments.push({
1392
+ variableName,
1393
+ helperName,
1394
+ index,
1395
+ endIndex: index + match[0].length
1396
+ });
1397
+ }
1398
+ return assignments;
1399
+ }
1400
+ function isRawRequestAccessorHelper(helperName) {
1401
+ const normalizedHelperName = helperName.toLowerCase();
1402
+ const rawAccessorHelpers = [
1403
+ "request.headers.get",
1404
+ "req.headers.get",
1405
+ "headers.get",
1406
+ "request.cookies.get",
1407
+ "req.cookies.get",
1408
+ "request.nexturl.searchparams.get",
1409
+ "req.nexturl.searchparams.get",
1410
+ "searchparams.get",
1411
+ "cookies"
1412
+ ];
1413
+ return rawAccessorHelpers.includes(normalizedHelperName);
1414
+ }
1415
+ function isGuardHelperAssignmentNearTop(assignmentIndex, handlerBody, sensitiveOperation) {
1416
+ if (sensitiveOperation) {
1417
+ return assignmentIndex < sensitiveOperation.index;
1418
+ }
1419
+ return assignmentIndex < Math.min(1500, handlerBody.length);
1420
+ }
1421
+ function findAccessControlGuardForVariable({
1422
+ handlerBody,
1423
+ variableName,
1424
+ startIndex
1425
+ }) {
1426
+ const guardSearchEnd = Math.min(handlerBody.length, startIndex + 2500);
1427
+ const ifStatements = getIfStatements(handlerBody, startIndex, guardSearchEnd);
1428
+ for (const ifStatement of ifStatements) {
1429
+ if (!doesConditionBlockWhenVariableMissing(ifStatement.condition, variableName)) {
1430
+ continue;
1431
+ }
1432
+ const branch = getIfFailureBranch(handlerBody, ifStatement.branchStartIndex);
1433
+ const returnSignal = getAccessDeniedReturnSignal(branch.text);
1434
+ if (!returnSignal) {
1435
+ continue;
1436
+ }
1437
+ return {
1438
+ endIndex: branch.endIndex,
1439
+ returnSignal
1440
+ };
1441
+ }
1442
+ return null;
1005
1443
  }
1006
- function hasSecretProtectionSignal(content) {
1444
+ function doesConditionBlockWhenVariableMissing(condition, variableName) {
1445
+ const escapedVariableName = escapeRegExp(variableName);
1446
+ 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);
1447
+ }
1448
+ function getIfFailureBranch(handlerBody, branchStartIndex) {
1449
+ let index = branchStartIndex;
1450
+ while (/\s/.test(handlerBody[index] ?? "")) {
1451
+ index++;
1452
+ }
1453
+ if (handlerBody[index] === "{") {
1454
+ const closeBraceIndex = findMatchingBrace(handlerBody, index);
1455
+ if (closeBraceIndex !== -1) {
1456
+ return {
1457
+ text: handlerBody.slice(index, closeBraceIndex + 1),
1458
+ endIndex: closeBraceIndex
1459
+ };
1460
+ }
1461
+ }
1462
+ const semicolonIndex = handlerBody.indexOf(";", index);
1463
+ const newlineIndex = handlerBody.indexOf("\n", index);
1464
+ const branchEndIndex = [semicolonIndex, newlineIndex].filter((candidateIndex) => candidateIndex !== -1).sort((leftIndex, rightIndex) => leftIndex - rightIndex)[0] ?? Math.min(handlerBody.length, index + 300);
1465
+ return {
1466
+ text: handlerBody.slice(index, branchEndIndex + 1),
1467
+ endIndex: branchEndIndex
1468
+ };
1469
+ }
1470
+ function getAccessDeniedReturnSignal(branchText) {
1471
+ const normalizedBranch = branchText.toLowerCase();
1472
+ if (!normalizedBranch.includes("return") && !normalizedBranch.includes("redirect(")) {
1473
+ return null;
1474
+ }
1475
+ if (/\bstatus\s*:\s*401\b/.test(normalizedBranch) || /\b401\b/.test(normalizedBranch)) {
1476
+ return "guard returns 401";
1477
+ }
1478
+ if (/\bstatus\s*:\s*403\b/.test(normalizedBranch) || /\b403\b/.test(normalizedBranch)) {
1479
+ return "guard returns 403";
1480
+ }
1481
+ if (normalizedBranch.includes("unauthorized")) {
1482
+ return "guard returns Unauthorized";
1483
+ }
1484
+ if (normalizedBranch.includes("forbidden")) {
1485
+ return "guard returns Forbidden";
1486
+ }
1487
+ if (/redirect\s*\(\s*["']\/(?:login|sign-in|signin|auth)/.test(normalizedBranch)) {
1488
+ return "guard redirects to login";
1489
+ }
1490
+ return null;
1491
+ }
1492
+ function getImportInfoForHelper(imports, helperName) {
1493
+ const helperRootName = helperName.split(".")[0];
1494
+ return imports.find((importInfo) => importInfo.localName === helperRootName);
1495
+ }
1496
+ function getFirstSensitiveOperation(handlerBody) {
1497
+ const sensitiveOperationPatterns = [
1498
+ { label: "request.formData", pattern: /\brequest\.formData\s*\(/i },
1499
+ { label: "request.json", pattern: /\brequest\.json\s*\(/i },
1500
+ { label: "file.arrayBuffer", pattern: /\b[A-Za-z_$][\w$]*\.arrayBuffer\s*\(/i },
1501
+ { label: "Buffer.from", pattern: /\bBuffer\.from\s*\(/ },
1502
+ { label: "uploadToR2", pattern: /\buploadToR2\s*\(/i },
1503
+ { label: "upload", pattern: /\bupload[A-Za-z0-9_$]*\s*\(/i },
1504
+ { label: "putObject", pattern: /\bputObject\s*\(/i },
1505
+ { label: "storage", pattern: /\bstorage\b/i },
1506
+ { label: "write", pattern: /\bwrite[A-Za-z0-9_$]*\s*\(/i },
1507
+ { label: "create", pattern: /\bcreate[A-Za-z0-9_$]*\s*\(/i },
1508
+ { label: "update", pattern: /\bupdate[A-Za-z0-9_$]*\s*\(/i },
1509
+ { label: "delete", pattern: /\bdelete[A-Za-z0-9_$]*\s*\(/i },
1510
+ { label: "insert", pattern: /\binsert[A-Za-z0-9_$]*\s*\(/i },
1511
+ { label: "cleanup", pattern: /\bcleanup[A-Za-z0-9_$]*\s*\(/i },
1512
+ { label: "revalidate", pattern: /\brevalidate[A-Za-z0-9_$]*\s*\(/i },
1513
+ { label: "prisma", pattern: /\bprisma\./i },
1514
+ { label: "db", pattern: /\bdb\./i },
1515
+ { label: "checkout", pattern: /\bcheckout\b/i },
1516
+ { label: "payment", pattern: /\bpayment\b/i },
1517
+ { label: "send", pattern: /\bsend[A-Za-z0-9_$]*\s*\(/i },
1518
+ { label: "mutation", pattern: /\bmutation\b/i }
1519
+ ];
1520
+ const matches = [];
1521
+ for (const sensitiveOperationPattern of sensitiveOperationPatterns) {
1522
+ const match = sensitiveOperationPattern.pattern.exec(handlerBody);
1523
+ if (match?.index !== void 0) {
1524
+ matches.push({
1525
+ label: sensitiveOperationPattern.label,
1526
+ index: match.index
1527
+ });
1528
+ }
1529
+ }
1530
+ return matches.sort((leftMatch, rightMatch) => leftMatch.index - rightMatch.index)[0];
1531
+ }
1532
+ function escapeRegExp(value) {
1533
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1534
+ }
1535
+ function hasWeakAuthRelatedSignal(content) {
1536
+ const normalizedContent = stripStrongProtectionIdentifiers(content).toLowerCase();
1537
+ const weakSignals = [
1538
+ "session",
1539
+ "authorization",
1540
+ "bearer",
1541
+ "cookies()",
1542
+ "request.cookies",
1543
+ "middleware",
1544
+ "getuser",
1545
+ "jwt",
1546
+ "auth(",
1547
+ "getserversession",
1548
+ "currentuser",
1549
+ "clerkclient"
1550
+ ];
1551
+ return weakSignals.some((signal) => normalizedContent.includes(signal));
1552
+ }
1553
+ function stripStrongProtectionIdentifiers(content) {
1554
+ return content.replace(/\b(?:await\s+)?require(?:Auth|User|Admin)\s*\([^)]*\)/gi, "").replace(/\bauth\.protect\s*\([^)]*\)/gi, "").replace(/\b(?:await\s+)?verifySession\s*\([^)]*\)/gi, "");
1555
+ }
1556
+ function hasStrongProtectionCallBeforeSensitiveWork(handlerBody, sensitiveOperation) {
1557
+ const protectionCutoffIndex = sensitiveOperation?.index ?? Math.min(2e3, handlerBody.length);
1558
+ const strongProtectionPatterns = [
1559
+ /\b(?:await\s+)?requireAuth\s*\(/gi,
1560
+ /\b(?:await\s+)?requireUser\s*\(/gi,
1561
+ /\b(?:await\s+)?requireAdmin\s*\(/gi,
1562
+ /\bauth\.protect\s*\(/gi,
1563
+ /(?:^|[;{\n]\s*)(?:await\s+)?verifySession\s*\(/gi
1564
+ ];
1565
+ for (const pattern of strongProtectionPatterns) {
1566
+ pattern.lastIndex = 0;
1567
+ for (const match of handlerBody.matchAll(pattern)) {
1568
+ if ((match.index ?? Number.POSITIVE_INFINITY) < protectionCutoffIndex) {
1569
+ return true;
1570
+ }
1571
+ }
1572
+ }
1573
+ return hasAuthGuardRedirectBeforeIndex(handlerBody, protectionCutoffIndex) || hasThrowBasedAuthGuardBeforeIndex(handlerBody, protectionCutoffIndex);
1574
+ }
1575
+ function hasAuthGuardRedirectBeforeIndex(handlerBody, cutoffIndex) {
1576
+ const redirectPattern = /\bif\s*\(([\s\S]*?)\)\s*(?:return\s+)?redirect\s*\(\s*["']\/(?:login|sign-in|signin|auth)/gi;
1577
+ for (const match of handlerBody.matchAll(redirectPattern)) {
1578
+ const condition = match[1] ?? "";
1579
+ if ((match.index ?? Number.POSITIVE_INFINITY) < cutoffIndex && /\!/.test(condition)) {
1580
+ return true;
1581
+ }
1582
+ }
1583
+ return false;
1584
+ }
1585
+ function hasThrowBasedAuthGuardBeforeIndex(handlerBody, cutoffIndex) {
1586
+ const throwGuardPattern = /\bif\s*\(([\s\S]*?)\)\s*(?:throw\s+new\s+\w+|throw\s+[^;]*(?:Unauthorized|Forbidden|401|403))/gi;
1587
+ for (const match of handlerBody.matchAll(throwGuardPattern)) {
1588
+ const condition = match[1] ?? "";
1589
+ const guardText = match[0] ?? "";
1590
+ if ((match.index ?? Number.POSITIVE_INFINITY) < cutoffIndex && /\!/.test(condition) && /Unauthorized|Forbidden|401|403/i.test(guardText)) {
1591
+ return true;
1592
+ }
1593
+ }
1594
+ return false;
1595
+ }
1596
+ function hasWeakSecretProtectionSignal(content) {
1007
1597
  const normalizedContent = content.toLowerCase();
1008
1598
  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");
1009
1599
  }
1600
+ function hasSecretProtectionGuardBeforeSensitiveWork(handlerBody, sensitiveOperation) {
1601
+ const protectionCutoffIndex = sensitiveOperation?.index ?? Math.min(2e3, handlerBody.length);
1602
+ const ifStatements = getIfStatements(handlerBody, 0, protectionCutoffIndex);
1603
+ for (const ifStatement of ifStatements) {
1604
+ if (!isSecretTokenGuardCondition(ifStatement.condition)) {
1605
+ continue;
1606
+ }
1607
+ const branch = getIfFailureBranch(handlerBody, ifStatement.branchStartIndex);
1608
+ if (getAccessDeniedReturnSignal(branch.text)) {
1609
+ return true;
1610
+ }
1611
+ }
1612
+ return false;
1613
+ }
1614
+ 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);
1620
+ return hasSecretInput && (hasExpectedSecret || hasSecretValidationCall) && hasComparison;
1621
+ }
1010
1622
  function hasRateLimitSignal(content) {
1011
1623
  const normalizedContent = content.toLowerCase();
1012
1624
  return normalizedContent.includes("ratelimit") || normalizedContent.includes("rate limit") || normalizedContent.includes("limiter") || normalizedContent.includes("upstash") || normalizedContent.includes("throttle");
@@ -1017,7 +1629,7 @@ function hasValidationSignal(content) {
1017
1629
  }
1018
1630
  function hasCacheHeaderSignal(content) {
1019
1631
  const normalizedContent = content.toLowerCase();
1020
- return normalizedContent.includes("cache-control") || normalizedContent.includes("s-maxage") || normalizedContent.includes("stale-while-revalidate") || normalizedContent.includes("public") || normalizedContent.includes("published") || normalizedContent.includes("status");
1632
+ return normalizedContent.includes("cache-control") || normalizedContent.includes("s-maxage") || normalizedContent.includes("stale-while-revalidate") || normalizedContent.includes("public") || normalizedContent.includes("published");
1021
1633
  }
1022
1634
  function hasMethodBlockingSignal(content) {
1023
1635
  const normalizedContent = content.toLowerCase();
@@ -1179,7 +1791,54 @@ function getLargeFileIssueCopy(kind) {
1179
1791
  suggestion: "Review whether this file mixes unrelated responsibilities. If so, split it into smaller modules while preserving behavior."
1180
1792
  };
1181
1793
  }
1182
- function createApiAuthFixPrompt(file) {
1794
+ function createApiAuthFixPrompt(file, method, intent = "unknown") {
1795
+ if (method && intent === "sensitive-mutation") {
1796
+ return `Review the API route at ${file}.
1797
+
1798
+ Qodfy detected a possible issue in the ${method} handler.
1799
+
1800
+ Goal:
1801
+ Determine whether the ${method} handler should be protected.
1802
+
1803
+ Instructions:
1804
+ - Inspect each exported HTTP handler separately.
1805
+ - Do not add authentication to a GET handler if it is intentionally public.
1806
+ - 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.
1807
+ - 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.
1808
+ - Also verify protections such as input validation, file size limits for uploads, storage path safety, and rate limiting where relevant.
1809
+ - Do not introduce a new auth provider.
1810
+ - Do not refactor unrelated code.
1811
+ - Keep existing response formats unchanged.
1812
+ - If the ${method} handler is intentionally public, add a short comment explaining why and confirm abuse protection exists.
1813
+
1814
+ Return:
1815
+ - Whether each handler is public or protected.
1816
+ - Whether the ${method} handler should be protected.
1817
+ - The safest code change.
1818
+ - Edge cases to test.`;
1819
+ }
1820
+ if (method && intent === "unknown") {
1821
+ return `Review the API route at ${file}.
1822
+
1823
+ Qodfy detected a mutation handler that should be reviewed: ${method}.
1824
+
1825
+ Goal:
1826
+ Determine whether the ${method} handler should be public or protected.
1827
+
1828
+ Instructions:
1829
+ - Inspect each exported HTTP handler separately.
1830
+ - Check what data the ${method} handler reads, writes, or returns.
1831
+ - If it handles private data, user-specific data, writes, uploads, or admin actions, add the existing project auth/session check.
1832
+ - If it is intentionally public, document why and confirm validation and abuse protection exist.
1833
+ - Do not introduce a new auth provider.
1834
+ - Do not refactor unrelated code.
1835
+ - Keep existing response formats unchanged.
1836
+
1837
+ Return:
1838
+ - Whether the ${method} handler should be protected.
1839
+ - The safest code change, if any.
1840
+ - Edge cases to test.`;
1841
+ }
1183
1842
  return `Review the API route at ${file}.
1184
1843
 
1185
1844
  Goal:
@@ -1198,34 +1857,40 @@ Return:
1198
1857
  - The updated code.
1199
1858
  - Any edge cases I should test.`;
1200
1859
  }
1201
- function createPublicReadRouteFixPrompt(file) {
1860
+ function createPublicReadRouteFixPrompt(file, method = "GET") {
1202
1861
  return `Review the public read API route at ${file}.
1203
1862
 
1863
+ Qodfy detected this as a likely public ${method} handler.
1864
+
1204
1865
  Goal:
1205
- Verify that this route is safe to remain public.
1866
+ Verify that the ${method} handler is safe to remain public.
1206
1867
 
1207
1868
  Instructions:
1208
- - Confirm it only exposes published, public, or non-sensitive data.
1869
+ - Inspect each exported HTTP handler separately.
1870
+ - Confirm the ${method} handler only exposes published, public, or non-sensitive data.
1209
1871
  - Check that route params and query values are validated or sanitized.
1210
1872
  - Check for appropriate cache headers where useful.
1211
1873
  - Check for abuse protection if the route can be called heavily.
1212
- - Do not add user authentication unless the route should be private.
1874
+ - Do not add user authentication to the ${method} handler unless it should be private.
1213
1875
  - Do not refactor unrelated code.
1214
1876
 
1215
1877
  Return:
1216
- - Whether this route appears intentionally public.
1878
+ - Whether the ${method} handler appears intentionally public.
1217
1879
  - Any low-risk safety improvements.
1218
1880
  - Any edge cases I should test.`;
1219
1881
  }
1220
- function createPublicFormProtectionFixPrompt(file) {
1882
+ function createPublicFormProtectionFixPrompt(file, method = "POST") {
1221
1883
  return `Review the public form API route at ${file}.
1222
1884
 
1885
+ Qodfy detected this as a likely public ${method} handler.
1886
+
1223
1887
  Goal:
1224
1888
  Verify validation, rate limiting, and spam protection.
1225
1889
 
1226
1890
  Instructions:
1227
- - Confirm submitted input is validated before it is used.
1228
- - Check for rate limiting or another abuse protection pattern.
1891
+ - Inspect each exported HTTP handler separately.
1892
+ - Confirm submitted input in the ${method} handler is validated before it is used.
1893
+ - Check for rate limiting or another abuse protection pattern on the ${method} handler.
1229
1894
  - Check whether captcha, Turnstile, reCAPTCHA, or hCaptcha is appropriate.
1230
1895
  - Do not add user authentication unless the form should be private.
1231
1896
  - Do not introduce a new service unless necessary.
@@ -1236,18 +1901,24 @@ Return:
1236
1901
  - The safest minimal change if protection is missing.
1237
1902
  - Any edge cases I should test.`;
1238
1903
  }
1239
- function createInternalRouteProtectionFixPrompt(file) {
1904
+ function createInternalRouteProtectionFixPrompt(file, method) {
1905
+ const handlerLine = method ? `
1906
+ Qodfy detected this as a likely internal ${method} handler.
1907
+ ` : "";
1908
+ const handlerReference = method ? `the ${method} handler` : "the route";
1240
1909
  return `Review the internal API route at ${file}.
1910
+ ${handlerLine}
1241
1911
 
1242
1912
  Goal:
1243
1913
  Confirm this operational route is protected before launch.
1244
1914
 
1245
1915
  Instructions:
1246
- - Check whether the route is protected by existing auth, a secret token, or server-only access.
1916
+ - Inspect each exported HTTP handler separately.
1917
+ - Check whether ${handlerReference} is protected by existing auth, a secret token, or server-only access.
1247
1918
  - For cron, cleanup, revalidation, or admin routes, prefer the existing project protection pattern.
1248
1919
  - Do not introduce a new auth provider.
1249
1920
  - Do not change route behavior unless protection is missing.
1250
- - If the route is intentionally reachable, add a short comment explaining the protection boundary.
1921
+ - If ${handlerReference} is intentionally reachable, add a short comment explaining the protection boundary.
1251
1922
 
1252
1923
  Return:
1253
1924
  - Whether protection already exists.
@@ -1301,7 +1972,7 @@ Create a safe refactor plan without changing behavior.
1301
1972
 
1302
1973
  Instructions:
1303
1974
  - Identify the main responsibilities inside this module.
1304
- - Check whether the file mixes unrelated concerns such as data fetching, filtering, mapping, sorting, constants, types, validation, or business rules.
1975
+ - Check whether the module mixes unrelated concerns such as data access, filtering, mapping, sorting, constants, types, validation, or business rules.
1305
1976
  - Suggest smaller TypeScript modules that could be extracted safely.
1306
1977
  - Do not rewrite the whole file at once.
1307
1978
  - Do not change business logic.
@@ -1467,8 +2138,13 @@ Return:
1467
2138
  - The updated code.
1468
2139
  - Any environment variables required.`;
1469
2140
  }
1470
- function createWebhookSignatureFixPrompt(file) {
2141
+ function createWebhookSignatureFixPrompt(file, method) {
2142
+ const handlerLine = method ? `
2143
+ Qodfy detected a possible issue in the ${method} webhook handler.
2144
+ ` : "";
2145
+ const handlerReference = method ? `the ${method} handler` : "the webhook route";
1471
2146
  return `Review the webhook API route at ${file}.
2147
+ ${handlerLine}
1472
2148
 
1473
2149
  Goal:
1474
2150
  Verify that webhook signature validation happens before the event is handled.
@@ -1476,7 +2152,8 @@ Verify that webhook signature validation happens before the event is handled.
1476
2152
  Instructions:
1477
2153
  - Detect which provider this webhook belongs to based on imports, headers, and environment variables.
1478
2154
  - Use the provider's existing verification pattern if already present.
1479
- - Do not process the webhook event before verification unless required by the provider.
2155
+ - Verify that ${handlerReference} validates the provider signature before processing the event unless the provider requires a different order.
2156
+ - Do not add user authentication unless the webhook provider requires it.
1480
2157
  - Do not introduce unrelated changes.
1481
2158
  - If verification already exists, explain where it happens.
1482
2159
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qodfy/core",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
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
  }