@oculum/scanner 1.0.0

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 (281) hide show
  1. package/dist/formatters/cli-terminal.d.ts +27 -0
  2. package/dist/formatters/cli-terminal.d.ts.map +1 -0
  3. package/dist/formatters/cli-terminal.js +412 -0
  4. package/dist/formatters/cli-terminal.js.map +1 -0
  5. package/dist/formatters/github-comment.d.ts +41 -0
  6. package/dist/formatters/github-comment.d.ts.map +1 -0
  7. package/dist/formatters/github-comment.js +306 -0
  8. package/dist/formatters/github-comment.js.map +1 -0
  9. package/dist/formatters/grouping.d.ts +52 -0
  10. package/dist/formatters/grouping.d.ts.map +1 -0
  11. package/dist/formatters/grouping.js +152 -0
  12. package/dist/formatters/grouping.js.map +1 -0
  13. package/dist/formatters/index.d.ts +9 -0
  14. package/dist/formatters/index.d.ts.map +1 -0
  15. package/dist/formatters/index.js +35 -0
  16. package/dist/formatters/index.js.map +1 -0
  17. package/dist/formatters/vscode-diagnostic.d.ts +103 -0
  18. package/dist/formatters/vscode-diagnostic.d.ts.map +1 -0
  19. package/dist/formatters/vscode-diagnostic.js +151 -0
  20. package/dist/formatters/vscode-diagnostic.js.map +1 -0
  21. package/dist/index.d.ts +52 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +648 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/layer1/comments.d.ts +8 -0
  26. package/dist/layer1/comments.d.ts.map +1 -0
  27. package/dist/layer1/comments.js +203 -0
  28. package/dist/layer1/comments.js.map +1 -0
  29. package/dist/layer1/config-audit.d.ts +8 -0
  30. package/dist/layer1/config-audit.d.ts.map +1 -0
  31. package/dist/layer1/config-audit.js +252 -0
  32. package/dist/layer1/config-audit.js.map +1 -0
  33. package/dist/layer1/entropy.d.ts +8 -0
  34. package/dist/layer1/entropy.d.ts.map +1 -0
  35. package/dist/layer1/entropy.js +500 -0
  36. package/dist/layer1/entropy.js.map +1 -0
  37. package/dist/layer1/file-flags.d.ts +7 -0
  38. package/dist/layer1/file-flags.d.ts.map +1 -0
  39. package/dist/layer1/file-flags.js +112 -0
  40. package/dist/layer1/file-flags.js.map +1 -0
  41. package/dist/layer1/index.d.ts +36 -0
  42. package/dist/layer1/index.d.ts.map +1 -0
  43. package/dist/layer1/index.js +132 -0
  44. package/dist/layer1/index.js.map +1 -0
  45. package/dist/layer1/patterns.d.ts +8 -0
  46. package/dist/layer1/patterns.d.ts.map +1 -0
  47. package/dist/layer1/patterns.js +482 -0
  48. package/dist/layer1/patterns.js.map +1 -0
  49. package/dist/layer1/urls.d.ts +8 -0
  50. package/dist/layer1/urls.d.ts.map +1 -0
  51. package/dist/layer1/urls.js +296 -0
  52. package/dist/layer1/urls.js.map +1 -0
  53. package/dist/layer1/weak-crypto.d.ts +7 -0
  54. package/dist/layer1/weak-crypto.d.ts.map +1 -0
  55. package/dist/layer1/weak-crypto.js +291 -0
  56. package/dist/layer1/weak-crypto.js.map +1 -0
  57. package/dist/layer2/ai-agent-tools.d.ts +19 -0
  58. package/dist/layer2/ai-agent-tools.d.ts.map +1 -0
  59. package/dist/layer2/ai-agent-tools.js +528 -0
  60. package/dist/layer2/ai-agent-tools.js.map +1 -0
  61. package/dist/layer2/ai-endpoint-protection.d.ts +36 -0
  62. package/dist/layer2/ai-endpoint-protection.d.ts.map +1 -0
  63. package/dist/layer2/ai-endpoint-protection.js +332 -0
  64. package/dist/layer2/ai-endpoint-protection.js.map +1 -0
  65. package/dist/layer2/ai-execution-sinks.d.ts +18 -0
  66. package/dist/layer2/ai-execution-sinks.d.ts.map +1 -0
  67. package/dist/layer2/ai-execution-sinks.js +496 -0
  68. package/dist/layer2/ai-execution-sinks.js.map +1 -0
  69. package/dist/layer2/ai-fingerprinting.d.ts +7 -0
  70. package/dist/layer2/ai-fingerprinting.d.ts.map +1 -0
  71. package/dist/layer2/ai-fingerprinting.js +654 -0
  72. package/dist/layer2/ai-fingerprinting.js.map +1 -0
  73. package/dist/layer2/ai-prompt-hygiene.d.ts +19 -0
  74. package/dist/layer2/ai-prompt-hygiene.d.ts.map +1 -0
  75. package/dist/layer2/ai-prompt-hygiene.js +356 -0
  76. package/dist/layer2/ai-prompt-hygiene.js.map +1 -0
  77. package/dist/layer2/ai-rag-safety.d.ts +21 -0
  78. package/dist/layer2/ai-rag-safety.d.ts.map +1 -0
  79. package/dist/layer2/ai-rag-safety.js +459 -0
  80. package/dist/layer2/ai-rag-safety.js.map +1 -0
  81. package/dist/layer2/ai-schema-validation.d.ts +25 -0
  82. package/dist/layer2/ai-schema-validation.d.ts.map +1 -0
  83. package/dist/layer2/ai-schema-validation.js +375 -0
  84. package/dist/layer2/ai-schema-validation.js.map +1 -0
  85. package/dist/layer2/auth-antipatterns.d.ts +20 -0
  86. package/dist/layer2/auth-antipatterns.d.ts.map +1 -0
  87. package/dist/layer2/auth-antipatterns.js +333 -0
  88. package/dist/layer2/auth-antipatterns.js.map +1 -0
  89. package/dist/layer2/byok-patterns.d.ts +12 -0
  90. package/dist/layer2/byok-patterns.d.ts.map +1 -0
  91. package/dist/layer2/byok-patterns.js +299 -0
  92. package/dist/layer2/byok-patterns.js.map +1 -0
  93. package/dist/layer2/dangerous-functions.d.ts +7 -0
  94. package/dist/layer2/dangerous-functions.d.ts.map +1 -0
  95. package/dist/layer2/dangerous-functions.js +1375 -0
  96. package/dist/layer2/dangerous-functions.js.map +1 -0
  97. package/dist/layer2/data-exposure.d.ts +16 -0
  98. package/dist/layer2/data-exposure.d.ts.map +1 -0
  99. package/dist/layer2/data-exposure.js +279 -0
  100. package/dist/layer2/data-exposure.js.map +1 -0
  101. package/dist/layer2/framework-checks.d.ts +7 -0
  102. package/dist/layer2/framework-checks.d.ts.map +1 -0
  103. package/dist/layer2/framework-checks.js +388 -0
  104. package/dist/layer2/framework-checks.js.map +1 -0
  105. package/dist/layer2/index.d.ts +58 -0
  106. package/dist/layer2/index.d.ts.map +1 -0
  107. package/dist/layer2/index.js +380 -0
  108. package/dist/layer2/index.js.map +1 -0
  109. package/dist/layer2/logic-gates.d.ts +7 -0
  110. package/dist/layer2/logic-gates.d.ts.map +1 -0
  111. package/dist/layer2/logic-gates.js +182 -0
  112. package/dist/layer2/logic-gates.js.map +1 -0
  113. package/dist/layer2/risky-imports.d.ts +7 -0
  114. package/dist/layer2/risky-imports.d.ts.map +1 -0
  115. package/dist/layer2/risky-imports.js +161 -0
  116. package/dist/layer2/risky-imports.js.map +1 -0
  117. package/dist/layer2/variables.d.ts +8 -0
  118. package/dist/layer2/variables.d.ts.map +1 -0
  119. package/dist/layer2/variables.js +152 -0
  120. package/dist/layer2/variables.js.map +1 -0
  121. package/dist/layer3/anthropic.d.ts +83 -0
  122. package/dist/layer3/anthropic.d.ts.map +1 -0
  123. package/dist/layer3/anthropic.js +1745 -0
  124. package/dist/layer3/anthropic.js.map +1 -0
  125. package/dist/layer3/index.d.ts +24 -0
  126. package/dist/layer3/index.d.ts.map +1 -0
  127. package/dist/layer3/index.js +119 -0
  128. package/dist/layer3/index.js.map +1 -0
  129. package/dist/layer3/openai.d.ts +25 -0
  130. package/dist/layer3/openai.d.ts.map +1 -0
  131. package/dist/layer3/openai.js +238 -0
  132. package/dist/layer3/openai.js.map +1 -0
  133. package/dist/layer3/package-check.d.ts +63 -0
  134. package/dist/layer3/package-check.d.ts.map +1 -0
  135. package/dist/layer3/package-check.js +508 -0
  136. package/dist/layer3/package-check.js.map +1 -0
  137. package/dist/modes/incremental.d.ts +66 -0
  138. package/dist/modes/incremental.d.ts.map +1 -0
  139. package/dist/modes/incremental.js +200 -0
  140. package/dist/modes/incremental.js.map +1 -0
  141. package/dist/tiers.d.ts +125 -0
  142. package/dist/tiers.d.ts.map +1 -0
  143. package/dist/tiers.js +234 -0
  144. package/dist/tiers.js.map +1 -0
  145. package/dist/types.d.ts +175 -0
  146. package/dist/types.d.ts.map +1 -0
  147. package/dist/types.js +50 -0
  148. package/dist/types.js.map +1 -0
  149. package/dist/utils/auth-helper-detector.d.ts +56 -0
  150. package/dist/utils/auth-helper-detector.d.ts.map +1 -0
  151. package/dist/utils/auth-helper-detector.js +360 -0
  152. package/dist/utils/auth-helper-detector.js.map +1 -0
  153. package/dist/utils/context-helpers.d.ts +96 -0
  154. package/dist/utils/context-helpers.d.ts.map +1 -0
  155. package/dist/utils/context-helpers.js +493 -0
  156. package/dist/utils/context-helpers.js.map +1 -0
  157. package/dist/utils/diff-detector.d.ts +53 -0
  158. package/dist/utils/diff-detector.d.ts.map +1 -0
  159. package/dist/utils/diff-detector.js +104 -0
  160. package/dist/utils/diff-detector.js.map +1 -0
  161. package/dist/utils/diff-parser.d.ts +80 -0
  162. package/dist/utils/diff-parser.d.ts.map +1 -0
  163. package/dist/utils/diff-parser.js +202 -0
  164. package/dist/utils/diff-parser.js.map +1 -0
  165. package/dist/utils/imported-auth-detector.d.ts +37 -0
  166. package/dist/utils/imported-auth-detector.d.ts.map +1 -0
  167. package/dist/utils/imported-auth-detector.js +251 -0
  168. package/dist/utils/imported-auth-detector.js.map +1 -0
  169. package/dist/utils/middleware-detector.d.ts +55 -0
  170. package/dist/utils/middleware-detector.d.ts.map +1 -0
  171. package/dist/utils/middleware-detector.js +260 -0
  172. package/dist/utils/middleware-detector.js.map +1 -0
  173. package/dist/utils/oauth-flow-detector.d.ts +41 -0
  174. package/dist/utils/oauth-flow-detector.d.ts.map +1 -0
  175. package/dist/utils/oauth-flow-detector.js +202 -0
  176. package/dist/utils/oauth-flow-detector.js.map +1 -0
  177. package/dist/utils/path-exclusions.d.ts +55 -0
  178. package/dist/utils/path-exclusions.d.ts.map +1 -0
  179. package/dist/utils/path-exclusions.js +222 -0
  180. package/dist/utils/path-exclusions.js.map +1 -0
  181. package/dist/utils/project-context-builder.d.ts +119 -0
  182. package/dist/utils/project-context-builder.d.ts.map +1 -0
  183. package/dist/utils/project-context-builder.js +534 -0
  184. package/dist/utils/project-context-builder.js.map +1 -0
  185. package/dist/utils/registry-clients.d.ts +93 -0
  186. package/dist/utils/registry-clients.d.ts.map +1 -0
  187. package/dist/utils/registry-clients.js +273 -0
  188. package/dist/utils/registry-clients.js.map +1 -0
  189. package/dist/utils/trpc-analyzer.d.ts +78 -0
  190. package/dist/utils/trpc-analyzer.d.ts.map +1 -0
  191. package/dist/utils/trpc-analyzer.js +297 -0
  192. package/dist/utils/trpc-analyzer.js.map +1 -0
  193. package/package.json +45 -0
  194. package/src/__tests__/benchmark/fixtures/false-positives.ts +227 -0
  195. package/src/__tests__/benchmark/fixtures/index.ts +68 -0
  196. package/src/__tests__/benchmark/fixtures/layer1/config-audit.ts +364 -0
  197. package/src/__tests__/benchmark/fixtures/layer1/hardcoded-secrets.ts +173 -0
  198. package/src/__tests__/benchmark/fixtures/layer1/high-entropy.ts +234 -0
  199. package/src/__tests__/benchmark/fixtures/layer1/index.ts +31 -0
  200. package/src/__tests__/benchmark/fixtures/layer1/sensitive-urls.ts +90 -0
  201. package/src/__tests__/benchmark/fixtures/layer1/weak-crypto.ts +197 -0
  202. package/src/__tests__/benchmark/fixtures/layer2/ai-agent-tools.ts +170 -0
  203. package/src/__tests__/benchmark/fixtures/layer2/ai-endpoint-protection.ts +418 -0
  204. package/src/__tests__/benchmark/fixtures/layer2/ai-execution-sinks.ts +189 -0
  205. package/src/__tests__/benchmark/fixtures/layer2/ai-fingerprinting.ts +316 -0
  206. package/src/__tests__/benchmark/fixtures/layer2/ai-prompt-hygiene.ts +178 -0
  207. package/src/__tests__/benchmark/fixtures/layer2/ai-rag-safety.ts +184 -0
  208. package/src/__tests__/benchmark/fixtures/layer2/ai-schema-validation.ts +434 -0
  209. package/src/__tests__/benchmark/fixtures/layer2/auth-antipatterns.ts +159 -0
  210. package/src/__tests__/benchmark/fixtures/layer2/byok-patterns.ts +112 -0
  211. package/src/__tests__/benchmark/fixtures/layer2/dangerous-functions.ts +246 -0
  212. package/src/__tests__/benchmark/fixtures/layer2/data-exposure.ts +168 -0
  213. package/src/__tests__/benchmark/fixtures/layer2/framework-checks.ts +346 -0
  214. package/src/__tests__/benchmark/fixtures/layer2/index.ts +67 -0
  215. package/src/__tests__/benchmark/fixtures/layer2/injection-vulnerabilities.ts +239 -0
  216. package/src/__tests__/benchmark/fixtures/layer2/logic-gates.ts +246 -0
  217. package/src/__tests__/benchmark/fixtures/layer2/risky-imports.ts +231 -0
  218. package/src/__tests__/benchmark/fixtures/layer2/variables.ts +167 -0
  219. package/src/__tests__/benchmark/index.ts +29 -0
  220. package/src/__tests__/benchmark/run-benchmark.ts +144 -0
  221. package/src/__tests__/benchmark/run-depth-validation.ts +206 -0
  222. package/src/__tests__/benchmark/run-real-world-test.ts +243 -0
  223. package/src/__tests__/benchmark/security-benchmark-script.ts +1737 -0
  224. package/src/__tests__/benchmark/tier-integration-script.ts +177 -0
  225. package/src/__tests__/benchmark/types.ts +144 -0
  226. package/src/__tests__/benchmark/utils/test-runner.ts +475 -0
  227. package/src/__tests__/regression/known-false-positives.test.ts +467 -0
  228. package/src/__tests__/snapshots/__snapshots__/scan-depth.test.ts.snap +178 -0
  229. package/src/__tests__/snapshots/scan-depth.test.ts +258 -0
  230. package/src/__tests__/validation/analyze-results.ts +542 -0
  231. package/src/__tests__/validation/extract-for-triage.ts +146 -0
  232. package/src/__tests__/validation/fp-deep-analysis.ts +327 -0
  233. package/src/__tests__/validation/run-validation.ts +364 -0
  234. package/src/__tests__/validation/triage-template.md +132 -0
  235. package/src/formatters/cli-terminal.ts +446 -0
  236. package/src/formatters/github-comment.ts +382 -0
  237. package/src/formatters/grouping.ts +190 -0
  238. package/src/formatters/index.ts +47 -0
  239. package/src/formatters/vscode-diagnostic.ts +243 -0
  240. package/src/index.ts +823 -0
  241. package/src/layer1/comments.ts +218 -0
  242. package/src/layer1/config-audit.ts +289 -0
  243. package/src/layer1/entropy.ts +583 -0
  244. package/src/layer1/file-flags.ts +127 -0
  245. package/src/layer1/index.ts +181 -0
  246. package/src/layer1/patterns.ts +516 -0
  247. package/src/layer1/urls.ts +334 -0
  248. package/src/layer1/weak-crypto.ts +328 -0
  249. package/src/layer2/ai-agent-tools.ts +601 -0
  250. package/src/layer2/ai-endpoint-protection.ts +387 -0
  251. package/src/layer2/ai-execution-sinks.ts +580 -0
  252. package/src/layer2/ai-fingerprinting.ts +758 -0
  253. package/src/layer2/ai-prompt-hygiene.ts +411 -0
  254. package/src/layer2/ai-rag-safety.ts +511 -0
  255. package/src/layer2/ai-schema-validation.ts +421 -0
  256. package/src/layer2/auth-antipatterns.ts +394 -0
  257. package/src/layer2/byok-patterns.ts +336 -0
  258. package/src/layer2/dangerous-functions.ts +1563 -0
  259. package/src/layer2/data-exposure.ts +315 -0
  260. package/src/layer2/framework-checks.ts +433 -0
  261. package/src/layer2/index.ts +473 -0
  262. package/src/layer2/logic-gates.ts +206 -0
  263. package/src/layer2/risky-imports.ts +186 -0
  264. package/src/layer2/variables.ts +166 -0
  265. package/src/layer3/anthropic.ts +2030 -0
  266. package/src/layer3/index.ts +130 -0
  267. package/src/layer3/package-check.ts +604 -0
  268. package/src/modes/incremental.ts +293 -0
  269. package/src/tiers.ts +318 -0
  270. package/src/types.ts +284 -0
  271. package/src/utils/auth-helper-detector.ts +443 -0
  272. package/src/utils/context-helpers.ts +535 -0
  273. package/src/utils/diff-detector.ts +135 -0
  274. package/src/utils/diff-parser.ts +272 -0
  275. package/src/utils/imported-auth-detector.ts +320 -0
  276. package/src/utils/middleware-detector.ts +333 -0
  277. package/src/utils/oauth-flow-detector.ts +246 -0
  278. package/src/utils/path-exclusions.ts +266 -0
  279. package/src/utils/project-context-builder.ts +707 -0
  280. package/src/utils/registry-clients.ts +351 -0
  281. package/src/utils/trpc-analyzer.ts +382 -0
package/src/index.ts ADDED
@@ -0,0 +1,823 @@
1
+ /**
2
+ * Scanner Orchestrator
3
+ * Coordinates all three scanning layers and produces final results
4
+ */
5
+
6
+ import type {
7
+ ScanFile,
8
+ ScanResult,
9
+ Vulnerability,
10
+ SeverityCounts,
11
+ CategoryCounts,
12
+ VulnerabilityCategory,
13
+ ScanMode,
14
+ ScanModeConfig,
15
+ ScanDepth,
16
+ } from './types'
17
+ import { SCANNABLE_EXTENSIONS, SPECIAL_FILES, MAX_FILE_SIZE, SCAN_MODE_DEFAULTS } from './types'
18
+ import { runLayer1Scan } from './layer1'
19
+ import { runLayer2Scan } from './layer2'
20
+ import { runLayer3Scan } from './layer3'
21
+ import { validateFindingsWithAI, applyAutoDismissRules, type ValidationStats } from './layer3/anthropic'
22
+ import { aggregateLocalhostFindings } from './layer1/urls'
23
+ import { detectGlobalAuthMiddleware, type MiddlewareAuthConfig, getRoutePathFromFile, isRouteProtectedByMiddleware } from './utils/middleware-detector'
24
+ import { detectAuthHelpers } from './utils/auth-helper-detector'
25
+ import { buildFileAuthImports } from './utils/imported-auth-detector'
26
+ // Tier system imports for filtering by scan depth
27
+ import {
28
+ type DetectorTier,
29
+ type TierStats,
30
+ getTierForCategory,
31
+ isTierVisibleAtDepth,
32
+ shouldValidateWithAI,
33
+ computeTierStats,
34
+ formatTierStats,
35
+ } from './tiers'
36
+
37
+ // Maximum candidates per file to send to AI validation (cost control)
38
+ const MAX_VALIDATION_CANDIDATES_PER_FILE = 10
39
+
40
+ /**
41
+ * Classify vulnerabilities into tier groups for filtering
42
+ */
43
+ interface TierClassification {
44
+ /** Tier A (core): High-precision, always visible */
45
+ core: Vulnerability[]
46
+ /** Tier B (ai_assisted): Context-heavy, needs AI validation */
47
+ ai_assisted: Vulnerability[]
48
+ /** Tier C (experimental): Hidden from users, internal use only */
49
+ experimental: Vulnerability[]
50
+ }
51
+
52
+ /**
53
+ * Classify vulnerabilities by their detector tier
54
+ */
55
+ function classifyByTier(vulnerabilities: Vulnerability[]): TierClassification {
56
+ const result: TierClassification = {
57
+ core: [],
58
+ ai_assisted: [],
59
+ experimental: [],
60
+ }
61
+
62
+ for (const vuln of vulnerabilities) {
63
+ const tier = getTierForCategory(vuln.category, vuln.layer)
64
+ result[tier].push(vuln)
65
+ }
66
+
67
+ return result
68
+ }
69
+
70
+ /**
71
+ * Filter vulnerabilities based on scan depth and tier
72
+ *
73
+ * - cheap: Only Tier A (core) findings are surfaced
74
+ * - validated: Tier A visible, Tier B goes through AI validation
75
+ * - deep: Same as validated, plus Layer 3 semantic analysis
76
+ *
77
+ * Tier C (experimental) is NEVER surfaced to users, regardless of depth.
78
+ */
79
+ function filterByTierAndDepth(
80
+ vulnerabilities: Vulnerability[],
81
+ depth: ScanDepth
82
+ ): {
83
+ /** Findings to surface directly (no AI validation needed) */
84
+ toSurface: Vulnerability[]
85
+ /** Findings to send through AI validation */
86
+ toValidate: Vulnerability[]
87
+ /** Findings hidden from users (Tier C) */
88
+ hidden: Vulnerability[]
89
+ /** Tier stats for logging */
90
+ tierStats: TierStats
91
+ } {
92
+ const classified = classifyByTier(vulnerabilities)
93
+ const tierStats = computeTierStats(
94
+ vulnerabilities.map(v => ({ category: v.category, layer: v.layer }))
95
+ )
96
+
97
+ switch (depth) {
98
+ case 'cheap':
99
+ // Only Tier A is visible, no AI validation
100
+ return {
101
+ toSurface: classified.core,
102
+ toValidate: [],
103
+ hidden: [...classified.ai_assisted, ...classified.experimental],
104
+ tierStats,
105
+ }
106
+
107
+ case 'validated':
108
+ case 'deep':
109
+ // Tier A always surfaces, Tier B goes to AI validation
110
+ // For findings that already require AI validation, only include Tier A/B
111
+ const coreRequiringValidation = classified.core.filter(v =>
112
+ v.requiresAIValidation ||
113
+ v.category === 'high_entropy_string' ||
114
+ v.category === 'hardcoded_secret' ||
115
+ (v.category === 'sensitive_url' && v.severity !== 'info')
116
+ )
117
+ const coreNotRequiringValidation = classified.core.filter(v =>
118
+ !coreRequiringValidation.includes(v)
119
+ )
120
+
121
+ return {
122
+ toSurface: coreNotRequiringValidation,
123
+ toValidate: [...coreRequiringValidation, ...classified.ai_assisted],
124
+ hidden: classified.experimental,
125
+ tierStats,
126
+ }
127
+ }
128
+ }
129
+
130
+ export interface ScanOptions {
131
+ /** Enable AI-powered validation and analysis */
132
+ enableAI?: boolean
133
+ /** Maximum files to scan */
134
+ maxFiles?: number
135
+ /** Branch being scanned */
136
+ branch?: string
137
+ /** Scan mode configuration (full vs incremental) */
138
+ scanMode?: ScanMode | ScanModeConfig
139
+ /** Scan depth (cheap/validated/deep) - controls AI usage */
140
+ scanDepth?: ScanDepth
141
+ }
142
+
143
+ export interface ScanProgress {
144
+ status: 'fetching' | 'layer1' | 'layer2' | 'layer3' | 'validating' | 'complete' | 'failed'
145
+ message: string
146
+ filesProcessed: number
147
+ totalFiles: number
148
+ vulnerabilitiesFound: number
149
+ }
150
+
151
+ export type ProgressCallback = (progress: ScanProgress) => void
152
+
153
+ /**
154
+ * Resolve scan mode configuration from options
155
+ *
156
+ * Handles both scan mode (full/incremental) and depth (cheap/validated/deep).
157
+ * Depth controls AI usage:
158
+ * - cheap: skip AI validation + skip Layer 3
159
+ * - validated: run AI validation, skip Layer 3
160
+ * - deep: run AI validation + Layer 3
161
+ */
162
+ function resolveScanModeConfig(options: ScanOptions): ScanModeConfig {
163
+ const scanModeOption = options.scanMode
164
+
165
+ // Determine base mode
166
+ const mode: ScanMode = !scanModeOption ? 'full'
167
+ : typeof scanModeOption === 'string' ? scanModeOption
168
+ : scanModeOption.mode
169
+
170
+ const defaults = SCAN_MODE_DEFAULTS[mode]
171
+
172
+ // Build config from defaults + explicit overrides
173
+ let config: ScanModeConfig = {
174
+ ...defaults,
175
+ mode,
176
+ ...(typeof scanModeOption === 'object' ? scanModeOption : {}),
177
+ }
178
+
179
+ // Apply depth mode (default: 'cheap')
180
+ const depth = options.scanDepth ?? 'cheap'
181
+ config.scanDepth = depth
182
+
183
+ // Map depth to skipAIValidation/skipLayer3 unless explicitly overridden
184
+ const hasExplicitSkipAI = typeof scanModeOption === 'object' && scanModeOption.skipAIValidation !== undefined
185
+ const hasExplicitSkipL3 = typeof scanModeOption === 'object' && scanModeOption.skipLayer3 !== undefined
186
+
187
+ if (!hasExplicitSkipAI) {
188
+ config.skipAIValidation = depth === 'cheap'
189
+ }
190
+ if (!hasExplicitSkipL3) {
191
+ config.skipLayer3 = depth !== 'deep'
192
+ }
193
+
194
+ return config
195
+ }
196
+
197
+ /**
198
+ * Run a complete security scan on the provided files
199
+ *
200
+ * Supports two scan modes:
201
+ * - full: Complete scan with AI validation on all files (initial onboarding, deep audits)
202
+ * - incremental: Focused scan on changed files only (CI/CD, fast feedback)
203
+ */
204
+ export async function runScan(
205
+ files: ScanFile[],
206
+ repoInfo: { name: string; url: string; branch: string },
207
+ options: ScanOptions = {},
208
+ onProgress?: ProgressCallback
209
+ ): Promise<ScanResult> {
210
+ const startTime = Date.now()
211
+ const allVulnerabilities: Vulnerability[] = []
212
+
213
+ // Resolve scan mode configuration
214
+ const scanModeConfig = resolveScanModeConfig(options)
215
+ const isIncremental = scanModeConfig.mode === 'incremental'
216
+ const depth = scanModeConfig.scanDepth || 'cheap'
217
+
218
+ console.log(`[Scanner] repo=${repoInfo.name} mode=${scanModeConfig.mode} depth=${depth} files=${files.length}`)
219
+ if (isIncremental && scanModeConfig.changedFiles) {
220
+ console.log(`[Scanner] repo=${repoInfo.name} incremental_files=${scanModeConfig.changedFiles.length}`)
221
+ }
222
+
223
+ // Report progress helper
224
+ const reportProgress = (
225
+ status: ScanProgress['status'],
226
+ message: string,
227
+ vulnerabilitiesFound: number = allVulnerabilities.length
228
+ ) => {
229
+ if (onProgress) {
230
+ onProgress({
231
+ status,
232
+ message,
233
+ filesProcessed: files.length,
234
+ totalFiles: files.length,
235
+ vulnerabilitiesFound,
236
+ })
237
+ }
238
+ }
239
+
240
+ // For incremental scans, filter to only changed files for AI-heavy operations
241
+ // but still run static analysis on all files for context
242
+ const filesForAI = isIncremental && scanModeConfig.changedFiles
243
+ ? files.filter(f => scanModeConfig.changedFiles!.some(cf => f.path.endsWith(cf) || f.path.includes(cf)))
244
+ : files
245
+
246
+ try {
247
+ // Detect global auth middleware before scanning (always on all files for context)
248
+ const middlewareConfig = detectGlobalAuthMiddleware(files)
249
+ if (middlewareConfig.hasAuthMiddleware) {
250
+ console.log(`[Scanner] repo=${repoInfo.name} auth_middleware=${middlewareConfig.authType || 'unknown'} file=${middlewareConfig.middlewareFile}`)
251
+ }
252
+
253
+ // Build imported auth registry for cross-file middleware detection
254
+ const fileAuthImports = buildFileAuthImports(files)
255
+ const filesWithImportedAuth = Array.from(fileAuthImports.values()).filter(f => f.usesImportedAuth).length
256
+ if (filesWithImportedAuth > 0) {
257
+ console.log(`[Scanner] repo=${repoInfo.name} files_with_imported_auth=${filesWithImportedAuth}`)
258
+ }
259
+
260
+ // Layer 1: Surface Scan
261
+ reportProgress('layer1', 'Running surface scan (patterns, entropy, config)...')
262
+ let layer1Result = await runLayer1Scan(files)
263
+
264
+ // Aggregate repeated localhost findings
265
+ const layer1RawCount = layer1Result.vulnerabilities.length
266
+ layer1Result = {
267
+ ...layer1Result,
268
+ vulnerabilities: aggregateLocalhostFindings(layer1Result.vulnerabilities)
269
+ }
270
+ console.log(`[Layer1] repo=${repoInfo.name} findings_raw=${layer1RawCount} findings_deduped=${layer1Result.vulnerabilities.length}`)
271
+
272
+ // Layer 2: Structural Scan
273
+ reportProgress('layer2', 'Running structural scan (variables, logic gates)...', layer1Result.vulnerabilities.length)
274
+ const layer2Result = await runLayer2Scan(files, { middlewareConfig, fileAuthImports })
275
+
276
+ // Format heuristic breakdown for logging
277
+ const heuristicBreakdown = Object.entries(layer2Result.stats.raw)
278
+ .filter(([, count]) => count > 0)
279
+ .map(([name, count]) => `${name}:${count}`)
280
+ .join(',')
281
+ console.log(`[Layer2] repo=${repoInfo.name} findings_raw=${Object.values(layer2Result.stats.raw).reduce((a, b) => a + b, 0)} findings_deduped=${layer2Result.vulnerabilities.length} heuristic_breakdown={${heuristicBreakdown}}`)
282
+
283
+ // Combine Layer 1 and Layer 2 findings
284
+ const layer12Findings = [...layer1Result.vulnerabilities, ...layer2Result.vulnerabilities]
285
+
286
+ // Aggregate noisy findings BEFORE tier filtering to reduce noise
287
+ const beforeAggregationCount = layer12Findings.length
288
+ const aggregatedFindings = aggregateNoisyFindings(layer12Findings)
289
+ const aggregatedCount = beforeAggregationCount - aggregatedFindings.length
290
+
291
+ // Apply tier-based filtering based on scan depth
292
+ // This is the key integration point for the detector tier system
293
+ const tierFiltered = filterByTierAndDepth(aggregatedFindings, depth)
294
+
295
+ // Log tier breakdown
296
+ console.log(`[Scanner] repo=${repoInfo.name} tier_breakdown=${formatTierStats(tierFiltered.tierStats)}`)
297
+ console.log(`[Scanner] repo=${repoInfo.name} depth=${depth} tier_routing: surface=${tierFiltered.toSurface.length} validate=${tierFiltered.toValidate.length} hidden=${tierFiltered.hidden.length}`)
298
+
299
+ // For cheap scans: Tier A surfaces directly, Tier B/C are hidden
300
+ // For validated/deep: Tier A surfaces, Tier B goes through AI validation, Tier C hidden
301
+
302
+ // Some Tier A findings still need AI validation (entropy, secrets, AI-specific)
303
+ const additionalValidation = tierFiltered.toSurface.filter(v =>
304
+ v.requiresAIValidation ||
305
+ v.category === 'ai_prompt_injection' || // Story B1: Prompt hygiene
306
+ v.category === 'ai_unsafe_execution' || // Story B2: LLM output execution
307
+ v.category === 'ai_overpermissive_tool' // Story B4: Agent tool permissions
308
+ )
309
+
310
+ // Surface findings that don't need validation (excluding those that do)
311
+ const noValidationNeeded = tierFiltered.toSurface.filter(v => !additionalValidation.includes(v))
312
+
313
+ // Combine tier-filtered validation candidates with additional ones
314
+ const requiresValidation = [...tierFiltered.toValidate, ...additionalValidation]
315
+
316
+ // Apply smart auto-dismiss rules BEFORE AI validation (saves API costs)
317
+ const { toValidate: afterAutoDismiss, dismissed: autoDismissed } = applyAutoDismissRules(requiresValidation)
318
+
319
+ // Track auto-dismiss by severity for logging
320
+ const autoDismissBySeverity: Record<string, number> = { info: 0, low: 0, medium: 0, high: 0, critical: 0 }
321
+ for (const d of autoDismissed) {
322
+ autoDismissBySeverity[d.finding.severity] = (autoDismissBySeverity[d.finding.severity] || 0) + 1
323
+ }
324
+ if (autoDismissed.length > 0) {
325
+ console.log(`[Layer2] repo=${repoInfo.name} auto_dismissed_total=${autoDismissed.length} by_severity={info:${autoDismissBySeverity.info},low:${autoDismissBySeverity.low},medium:${autoDismissBySeverity.medium},high:${autoDismissBySeverity.high}}`)
326
+ }
327
+
328
+ // Apply per-file cap to validation candidates (cost control)
329
+ // Use scan mode config for max files
330
+ const maxValidationFiles = scanModeConfig.maxAIValidationFiles || MAX_VALIDATION_CANDIDATES_PER_FILE
331
+ const cappedValidation = capValidationCandidatesPerFile(afterAutoDismiss, maxValidationFiles)
332
+
333
+ // AI Validation of selected findings (if AI is enabled and not skipped by scan mode)
334
+ let validatedFindings = cappedValidation
335
+ let capturedValidationStats: ValidationStats | undefined = undefined
336
+ const shouldValidate = options.enableAI !== false && !scanModeConfig.skipAIValidation && cappedValidation.length > 0
337
+
338
+ if (shouldValidate) {
339
+ reportProgress('validating', 'AI validating findings (entropy, secrets, AI patterns)...', cappedValidation.length)
340
+
341
+ // For incremental scans, only validate findings in changed files
342
+ const findingsToValidate = isIncremental && scanModeConfig.changedFiles
343
+ ? cappedValidation.filter(v => scanModeConfig.changedFiles!.some(cf => v.filePath.endsWith(cf) || v.filePath.includes(cf)))
344
+ : cappedValidation
345
+
346
+ if (findingsToValidate.length > 0) {
347
+ const validationResult = await validateFindingsWithAI(findingsToValidate, filesForAI)
348
+ validatedFindings = validationResult.vulnerabilities
349
+ const { stats: validationStats } = validationResult
350
+ capturedValidationStats = validationStats // Capture for return
351
+
352
+ console.log(`[AI Validation] repo=${repoInfo.name} depth=${depth} candidates=${findingsToValidate.length} capped_from=${requiresValidation.length} auto_dismissed=${autoDismissed.length} kept=${validationStats.confirmedFindings} rejected=${validationStats.dismissedFindings} downgraded=${validationStats.downgradedFindings}`)
353
+ console.log(`[AI Validation] cost_estimate: input_tokens=${validationStats.estimatedInputTokens} output_tokens=${validationStats.estimatedOutputTokens} cost=$${validationStats.estimatedCost.toFixed(4)} api_calls=${validationStats.apiCalls}`)
354
+
355
+ // Add back findings that weren't validated (not in changed files)
356
+ const notValidated = cappedValidation.filter(v => !findingsToValidate.includes(v))
357
+ validatedFindings.push(...notValidated)
358
+ }
359
+ } else if (scanModeConfig.skipAIValidation) {
360
+ console.log(`[AI Validation] repo=${repoInfo.name} depth=${depth} skipped=true reason=scan_mode_config`)
361
+ }
362
+
363
+ // Combine validated and non-validated findings
364
+ allVulnerabilities.push(...validatedFindings, ...noValidationNeeded)
365
+
366
+ // Layer 3: AI Semantic Analysis (new findings)
367
+ // Skip for incremental scans by default (configurable)
368
+ const shouldRunLayer3 = options.enableAI !== false && !scanModeConfig.skipLayer3
369
+
370
+ if (shouldRunLayer3) {
371
+ reportProgress('layer3', 'Running AI semantic analysis...', allVulnerabilities.length)
372
+
373
+ // For incremental scans, only analyze changed files
374
+ const filesToAnalyze = isIncremental ? filesForAI : files
375
+ const maxLayer3Files = scanModeConfig.maxLayer3Files || 10
376
+
377
+ // Detect auth helpers for Layer 3 context
378
+ const authHelperContext = detectAuthHelpers(files)
379
+
380
+ const layer3Result = await runLayer3Scan(filesToAnalyze, {
381
+ enableAI: options.enableAI,
382
+ maxFiles: maxLayer3Files,
383
+ projectContext: {
384
+ middlewareConfig: middlewareConfig.hasAuthMiddleware ? {
385
+ hasAuthMiddleware: true,
386
+ authType: middlewareConfig.authType,
387
+ protectedPaths: middlewareConfig.protectedPaths,
388
+ } : undefined,
389
+ authHelpers: authHelperContext.hasThrowingHelpers ? {
390
+ hasThrowingHelpers: true,
391
+ summary: authHelperContext.summary,
392
+ } : undefined,
393
+ },
394
+ })
395
+ allVulnerabilities.push(...layer3Result.vulnerabilities)
396
+ console.log(`[Layer3] repo=${repoInfo.name} depth=${depth} files_analyzed=${layer3Result.aiAnalyzed} findings=${layer3Result.vulnerabilities.length}`)
397
+ } else if (scanModeConfig.skipLayer3) {
398
+ console.log(`[Layer3] repo=${repoInfo.name} depth=${depth} skipped=true reason=scan_mode_config`)
399
+ }
400
+
401
+ // Deduplicate vulnerabilities
402
+ const uniqueVulnerabilities = deduplicateVulnerabilities(allVulnerabilities)
403
+
404
+ // Resolve contradictions (e.g., middleware-protected INFO vs missing-auth CRITICAL on same route)
405
+ const resolvedVulnerabilities = resolveContradictions(uniqueVulnerabilities, middlewareConfig)
406
+
407
+ // Sort by severity
408
+ const sortedVulnerabilities = sortBySeverity(resolvedVulnerabilities)
409
+
410
+ // Compute issue-mix counts
411
+ const severityCounts = computeSeverityCounts(sortedVulnerabilities)
412
+ const categoryCounts = computeCategoryCounts(sortedVulnerabilities)
413
+ const hasBlockingIssues = severityCounts.critical > 0 || severityCounts.high > 0
414
+
415
+ reportProgress('complete', 'Scan complete!', sortedVulnerabilities.length)
416
+
417
+ return {
418
+ repoName: repoInfo.name,
419
+ repoUrl: repoInfo.url,
420
+ branch: repoInfo.branch,
421
+ filesScanned: files.length,
422
+ filesSkipped: 0, // TODO: track skipped files
423
+ vulnerabilities: sortedVulnerabilities,
424
+ severityCounts,
425
+ categoryCounts,
426
+ hasBlockingIssues,
427
+ scanDuration: Date.now() - startTime,
428
+ timestamp: new Date().toISOString(),
429
+ validationStats: capturedValidationStats,
430
+ }
431
+ } catch (error) {
432
+ reportProgress('failed', `Scan failed: ${error}`)
433
+ throw error
434
+ }
435
+ }
436
+
437
+ /**
438
+ * Aggregate noisy findings in the same file to reduce clutter
439
+ * Groups repeated findings with same filePath + category + title
440
+ * Especially useful for AI pattern spam
441
+ */
442
+ function aggregateNoisyFindings(vulnerabilities: Vulnerability[]): Vulnerability[] {
443
+ // Group findings by file + category + title
444
+ const groups = new Map<string, Vulnerability[]>()
445
+
446
+ for (const vuln of vulnerabilities) {
447
+ // Create grouping key: same file, category, and base title (without line-specific info)
448
+ const baseTitle = vuln.title.replace(/\s*\(\d+ instances?\)/, '').trim()
449
+ const key = `${vuln.filePath}|${vuln.category}|${baseTitle}`
450
+
451
+ const existing = groups.get(key) || []
452
+ existing.push(vuln)
453
+ groups.set(key, existing)
454
+ }
455
+
456
+ const result: Vulnerability[] = []
457
+
458
+ for (const [key, group] of groups) {
459
+ // If only 1-2 findings, keep them as-is
460
+ if (group.length <= 2) {
461
+ result.push(...group)
462
+ continue
463
+ }
464
+
465
+ // For 3+ similar findings in same file, aggregate into one
466
+ const first = group[0]
467
+ const lineNumbers = group.map(v => v.lineNumber).sort((a, b) => a - b)
468
+ const uniqueLines = [...new Set(lineNumbers)]
469
+
470
+ // Format line numbers nicely (show first few, then "...")
471
+ const lineDisplay = uniqueLines.length > 5
472
+ ? `${uniqueLines.slice(0, 5).join(', ')}... (${uniqueLines.length} total)`
473
+ : uniqueLines.join(', ')
474
+
475
+ // Keep highest severity from the group
476
+ const highestSeverity = group.reduce((max, v) =>
477
+ severityRank(v.severity) > severityRank(max.severity) ? v : max
478
+ , group[0]).severity
479
+
480
+ // Create aggregated finding
481
+ const aggregated: Vulnerability = {
482
+ id: `${first.id}-aggregated`,
483
+ filePath: first.filePath,
484
+ lineNumber: uniqueLines[0], // First occurrence
485
+ lineContent: `${group.length} instances across this file`,
486
+ severity: highestSeverity,
487
+ category: first.category,
488
+ title: `${first.title.replace(/\s*\(\d+ instances?\)/, '')} (${group.length} instances)`,
489
+ description: `${first.description}\n\nFound ${group.length} occurrences at lines: ${lineDisplay}`,
490
+ suggestedFix: first.suggestedFix,
491
+ confidence: first.confidence,
492
+ layer: first.layer,
493
+ requiresAIValidation: first.requiresAIValidation,
494
+ }
495
+
496
+ result.push(aggregated)
497
+ }
498
+
499
+ return result
500
+ }
501
+
502
+ /**
503
+ * Cap validation candidates per file to control AI costs
504
+ * Prioritizes by:
505
+ * 1. Tier (ai_assisted findings need AI most, so they get priority)
506
+ * 2. Severity (critical > high > medium > low > info)
507
+ * 3. Category importance (secrets/URLs/auth before cosmetic patterns)
508
+ */
509
+ function capValidationCandidatesPerFile(
510
+ vulnerabilities: Vulnerability[],
511
+ maxPerFile: number = MAX_VALIDATION_CANDIDATES_PER_FILE
512
+ ): Vulnerability[] {
513
+ // Group by file
514
+ const byFile = new Map<string, Vulnerability[]>()
515
+ for (const vuln of vulnerabilities) {
516
+ const existing = byFile.get(vuln.filePath) || []
517
+ existing.push(vuln)
518
+ byFile.set(vuln.filePath, existing)
519
+ }
520
+
521
+ const result: Vulnerability[] = []
522
+
523
+ for (const [filePath, fileVulns] of byFile) {
524
+ // Sort by priority: tier first (ai_assisted needs AI most), then severity, then category
525
+ const sorted = [...fileVulns].sort((a, b) => {
526
+ // Tier comparison: ai_assisted (needs AI) > core (high precision) > experimental (hidden anyway)
527
+ const tierPriority = (v: Vulnerability): number => {
528
+ const tier = getTierForCategory(v.category, v.layer)
529
+ if (tier === 'ai_assisted') return 10 // Highest priority - these NEED AI validation
530
+ if (tier === 'core') return 5 // High precision, but AI can still help
531
+ return 1 // Experimental - shouldn't even be here
532
+ }
533
+ const tierDiff = tierPriority(b) - tierPriority(a)
534
+ if (tierDiff !== 0) return tierDiff
535
+
536
+ // Severity comparison (higher severity = higher priority)
537
+ const severityDiff = severityRank(b.severity) - severityRank(a.severity)
538
+ if (severityDiff !== 0) return severityDiff
539
+
540
+ // Category importance (secrets/URLs/auth before AI patterns)
541
+ const categoryPriority = (v: Vulnerability): number => {
542
+ if (v.category === 'hardcoded_secret') return 10
543
+ if (v.category === 'high_entropy_string') return 9
544
+ if (v.category === 'sensitive_url') return 8
545
+ if (v.category === 'missing_auth') return 7
546
+ if (v.category === 'ai_pattern') return 3
547
+ return 5
548
+ }
549
+ return categoryPriority(b) - categoryPriority(a)
550
+ })
551
+
552
+ // Take top N per file
553
+ const capped = sorted.slice(0, maxPerFile)
554
+ result.push(...capped)
555
+
556
+ if (sorted.length > maxPerFile) {
557
+ console.log(`[Scanner] Capped ${filePath}: ${sorted.length} → ${maxPerFile} validation candidates`)
558
+ }
559
+ }
560
+
561
+ return result
562
+ }
563
+
564
+ /**
565
+ * Remove duplicate vulnerabilities (same file, line, category)
566
+ * Also handles cross-layer URL duplicates (sensitive_url + ai_pattern)
567
+ */
568
+ function deduplicateVulnerabilities(vulnerabilities: Vulnerability[]): Vulnerability[] {
569
+ const seen = new Map<string, Vulnerability>()
570
+ const urlDedupMap = new Map<string, Vulnerability>()
571
+
572
+ for (const vuln of vulnerabilities) {
573
+ // Special handling for URL duplicates across layers
574
+ // (e.g., Layer 1 detects as sensitive_url, Layer 2 detects as ai_pattern)
575
+ if ((vuln.category === 'sensitive_url' || vuln.category === 'ai_pattern') &&
576
+ /url|localhost|127\.0\.0\.1|http/i.test(vuln.lineContent)) {
577
+
578
+ // Create compound key that ignores category differences for URLs
579
+ const urlKey = `${vuln.filePath}:${vuln.lineNumber}:url_finding`
580
+ const existing = urlDedupMap.get(urlKey)
581
+
582
+ if (!existing) {
583
+ urlDedupMap.set(urlKey, vuln)
584
+ } else {
585
+ // Keep Layer 1 (more specific) over Layer 2 AI pattern
586
+ // Or keep higher severity
587
+ if (vuln.layer < existing.layer ||
588
+ severityRank(vuln.severity) > severityRank(existing.severity)) {
589
+ urlDedupMap.set(urlKey, vuln)
590
+ }
591
+ }
592
+ continue
593
+ }
594
+
595
+ // Standard deduplication for non-URL findings
596
+ const key = `${vuln.filePath}:${vuln.lineNumber}:${vuln.category}`
597
+ const existing = seen.get(key)
598
+
599
+ // Keep the higher severity or higher confidence finding
600
+ if (!existing) {
601
+ seen.set(key, vuln)
602
+ } else if (severityRank(vuln.severity) > severityRank(existing.severity)) {
603
+ seen.set(key, vuln)
604
+ } else if (
605
+ severityRank(vuln.severity) === severityRank(existing.severity) &&
606
+ confidenceRank(vuln.confidence) > confidenceRank(existing.confidence)
607
+ ) {
608
+ seen.set(key, vuln)
609
+ }
610
+ }
611
+
612
+ // Combine URL and non-URL findings
613
+ return [...Array.from(seen.values()), ...Array.from(urlDedupMap.values())]
614
+ }
615
+
616
+ /**
617
+ * Resolve contradictions in findings
618
+ *
619
+ * Key contradiction types:
620
+ * 1. Same route has both "protected by middleware" (info) AND "missing auth" (high/critical)
621
+ * → Keep only the info-level "protected by middleware" finding
622
+ * 2. Same file has BYOK "transient use" (info) AND "key stored insecurely" (high)
623
+ * → Keep only the most accurate one based on context
624
+ * 3. Same line has conflicting severities from different layers
625
+ * → Prefer the lower severity if one explicitly notes protection
626
+ */
627
+ function resolveContradictions(
628
+ vulnerabilities: Vulnerability[],
629
+ middlewareConfig?: MiddlewareAuthConfig
630
+ ): Vulnerability[] {
631
+ // Group findings by file path for contradiction analysis
632
+ const byFile = new Map<string, Vulnerability[]>()
633
+ for (const vuln of vulnerabilities) {
634
+ const existing = byFile.get(vuln.filePath) || []
635
+ existing.push(vuln)
636
+ byFile.set(vuln.filePath, existing)
637
+ }
638
+
639
+ const result: Vulnerability[] = []
640
+
641
+ for (const [filePath, fileVulns] of byFile) {
642
+ // Check for auth contradictions in this file
643
+ const authFindings = fileVulns.filter(v => v.category === 'missing_auth')
644
+ const otherFindings = fileVulns.filter(v => v.category !== 'missing_auth')
645
+
646
+ // Identify protected routes (middleware or auth helper)
647
+ const protectedInfos = authFindings.filter(v =>
648
+ v.severity === 'info' &&
649
+ (v.validationNotes === 'MIDDLEWARE_PROTECTED' ||
650
+ v.validationNotes === 'AUTH_HELPER_PROTECTED' ||
651
+ v.title.includes('protected by middleware') ||
652
+ v.title.includes('uses auth helper'))
653
+ )
654
+
655
+ // NEW: Check if this file's route is protected by middleware (even without explicit info finding)
656
+ // This catches Layer 3 findings that don't have the Layer 2 protection info
657
+ const routePath = getRoutePathFromFile(filePath)
658
+ const isAPIRouteProtected = routePath && middlewareConfig?.hasAuthMiddleware
659
+ ? isRouteProtectedByMiddleware(routePath, middlewareConfig).isProtected
660
+ : false
661
+
662
+ // Also check if file is a client component calling protected API routes
663
+ // Client components (components/, app/ without route.ts) calling /api/** are safe
664
+ const isClientCallingProtectedAPI =
665
+ middlewareConfig?.hasAuthMiddleware &&
666
+ (filePath.includes('/components/') ||
667
+ (filePath.includes('/app/') && !filePath.includes('route.ts')))
668
+
669
+ // If we have protected route info findings OR the route is protected by middleware
670
+ if (protectedInfos.length > 0 || isAPIRouteProtected || isClientCallingProtectedAPI) {
671
+ // Keep the protected info findings
672
+ result.push(...protectedInfos)
673
+
674
+ // For other auth findings on same file, either drop or downgrade to info
675
+ const otherAuthFindings = authFindings.filter(v => !protectedInfos.includes(v))
676
+
677
+ for (const vuln of otherAuthFindings) {
678
+ // If it's high/critical missing auth on a protected route, drop it entirely
679
+ // (the middleware/helper protection supersedes)
680
+ if (vuln.severity === 'critical' || vuln.severity === 'high') {
681
+ const reason = isAPIRouteProtected
682
+ ? 'API route protected by middleware'
683
+ : isClientCallingProtectedAPI
684
+ ? 'client component calling protected API'
685
+ : 'route is protected'
686
+ console.log(`[Contradiction] Dropping "${vuln.title}" (${vuln.severity}) - ${reason}`)
687
+ continue // Skip this finding
688
+ }
689
+
690
+ // Keep lower severity auth findings as-is
691
+ result.push(vuln)
692
+ }
693
+ } else {
694
+ // No protection detected - keep all auth findings as-is
695
+ result.push(...authFindings)
696
+ }
697
+
698
+ // Handle BYOK contradictions
699
+ const byokFindings = otherFindings.filter(v =>
700
+ v.category === 'ai_pattern' && v.title.toLowerCase().includes('byok')
701
+ )
702
+ const nonByokFindings = otherFindings.filter(v =>
703
+ !(v.category === 'ai_pattern' && v.title.toLowerCase().includes('byok'))
704
+ )
705
+
706
+ if (byokFindings.length > 0) {
707
+ // Check if we have both transient (low) and storage (high) BYOK findings
708
+ const transientByok = byokFindings.filter(v =>
709
+ v.severity === 'low' || v.severity === 'info'
710
+ )
711
+ const storageByok = byokFindings.filter(v =>
712
+ v.severity === 'high' || v.severity === 'medium'
713
+ )
714
+
715
+ if (transientByok.length > 0 && storageByok.length > 0) {
716
+ // If we detected transient usage, prefer the lower severity
717
+ // The higher severity ones may be false positives
718
+ result.push(...transientByok)
719
+ // Mark high-severity BYOK for review
720
+ for (const vuln of storageByok) {
721
+ result.push({
722
+ ...vuln,
723
+ severity: 'low',
724
+ validationNotes: `${vuln.validationNotes || ''} (downgraded: transient BYOK usage detected in same file)`.trim(),
725
+ })
726
+ }
727
+ } else {
728
+ // Keep all BYOK findings as-is
729
+ result.push(...byokFindings)
730
+ }
731
+ }
732
+
733
+ // Add non-BYOK other findings
734
+ result.push(...nonByokFindings)
735
+ }
736
+
737
+ return result
738
+ }
739
+
740
+ /**
741
+ * Sort vulnerabilities by severity (critical first)
742
+ */
743
+ function sortBySeverity(vulnerabilities: Vulnerability[]): Vulnerability[] {
744
+ return [...vulnerabilities].sort((a, b) => {
745
+ const severityDiff = severityRank(b.severity) - severityRank(a.severity)
746
+ if (severityDiff !== 0) return severityDiff
747
+
748
+ // Secondary sort by confidence
749
+ return confidenceRank(b.confidence) - confidenceRank(a.confidence)
750
+ })
751
+ }
752
+
753
+ function severityRank(severity: string): number {
754
+ const ranks: Record<string, number> = {
755
+ critical: 5,
756
+ high: 4,
757
+ medium: 3,
758
+ low: 2,
759
+ info: 1,
760
+ }
761
+ return ranks[severity] || 0
762
+ }
763
+
764
+ function confidenceRank(confidence: string): number {
765
+ const ranks: Record<string, number> = {
766
+ high: 3,
767
+ medium: 2,
768
+ low: 1,
769
+ }
770
+ return ranks[confidence] || 0
771
+ }
772
+
773
+ /**
774
+ * Compute severity counts from vulnerabilities
775
+ */
776
+ function computeSeverityCounts(vulnerabilities: Vulnerability[]): SeverityCounts {
777
+ const counts: SeverityCounts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 }
778
+
779
+ for (const vuln of vulnerabilities) {
780
+ if (vuln.severity in counts) {
781
+ counts[vuln.severity]++
782
+ }
783
+ }
784
+
785
+ return counts
786
+ }
787
+
788
+ /**
789
+ * Compute category counts from vulnerabilities
790
+ */
791
+ function computeCategoryCounts(vulnerabilities: Vulnerability[]): CategoryCounts {
792
+ const counts: CategoryCounts = {}
793
+
794
+ for (const vuln of vulnerabilities) {
795
+ const category = vuln.category as VulnerabilityCategory
796
+ counts[category] = (counts[category] || 0) + 1
797
+ }
798
+
799
+ return counts
800
+ }
801
+
802
+ /**
803
+ * Helper to compute counts from vulnerabilities (for backfilling legacy scans)
804
+ */
805
+ export function computeIssueMixFromVulnerabilities(vulnerabilities: Vulnerability[]): {
806
+ severityCounts: SeverityCounts
807
+ categoryCounts: CategoryCounts
808
+ hasBlockingIssues: boolean
809
+ } {
810
+ const severityCounts = computeSeverityCounts(vulnerabilities)
811
+ const categoryCounts = computeCategoryCounts(vulnerabilities)
812
+ const hasBlockingIssues = severityCounts.critical > 0 || severityCounts.high > 0
813
+
814
+ return { severityCounts, categoryCounts, hasBlockingIssues }
815
+ }
816
+
817
+ // Re-export types and utilities
818
+ export * from './types'
819
+ export { runLayer1Scan } from './layer1'
820
+ export { runLayer2Scan } from './layer2'
821
+ export { runLayer3Scan } from './layer3'
822
+ export { buildProjectContext, type ProjectContext } from './utils/project-context-builder'
823
+ export { validateFindingsWithAI, type ValidationStats, type AIValidationResult } from './layer3/anthropic'