@oculum/scanner 1.0.2 → 1.0.4

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 (45) hide show
  1. package/dist/index.d.ts +4 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +60 -5
  4. package/dist/index.js.map +1 -1
  5. package/dist/layer1/entropy.d.ts.map +1 -1
  6. package/dist/layer1/entropy.js +6 -4
  7. package/dist/layer1/entropy.js.map +1 -1
  8. package/dist/layer1/index.d.ts +2 -2
  9. package/dist/layer1/index.d.ts.map +1 -1
  10. package/dist/layer1/index.js +14 -5
  11. package/dist/layer1/index.js.map +1 -1
  12. package/dist/layer2/dangerous-functions.d.ts.map +1 -1
  13. package/dist/layer2/dangerous-functions.js +319 -11
  14. package/dist/layer2/dangerous-functions.js.map +1 -1
  15. package/dist/layer2/index.d.ts +2 -2
  16. package/dist/layer2/index.d.ts.map +1 -1
  17. package/dist/layer2/index.js +14 -5
  18. package/dist/layer2/index.js.map +1 -1
  19. package/dist/layer3/anthropic.d.ts +5 -1
  20. package/dist/layer3/anthropic.d.ts.map +1 -1
  21. package/dist/layer3/anthropic.js +175 -30
  22. package/dist/layer3/anthropic.js.map +1 -1
  23. package/dist/layer3/index.d.ts +3 -1
  24. package/dist/layer3/index.d.ts.map +1 -1
  25. package/dist/layer3/index.js +21 -0
  26. package/dist/layer3/index.js.map +1 -1
  27. package/dist/types.d.ts +25 -0
  28. package/dist/types.d.ts.map +1 -1
  29. package/dist/types.js +40 -0
  30. package/dist/types.js.map +1 -1
  31. package/dist/utils/context-helpers.d.ts +12 -0
  32. package/dist/utils/context-helpers.d.ts.map +1 -1
  33. package/dist/utils/context-helpers.js +40 -0
  34. package/dist/utils/context-helpers.js.map +1 -1
  35. package/package.json +4 -2
  36. package/src/index.ts +75 -5
  37. package/src/layer1/entropy.ts +6 -4
  38. package/src/layer1/index.ts +23 -8
  39. package/src/layer2/__tests__/math-random-enhanced.test.ts +405 -0
  40. package/src/layer2/dangerous-functions.ts +368 -11
  41. package/src/layer2/index.ts +20 -8
  42. package/src/layer3/anthropic.ts +190 -31
  43. package/src/layer3/index.ts +27 -2
  44. package/src/types.ts +59 -0
  45. package/src/utils/context-helpers.ts +40 -0
@@ -8,6 +8,8 @@ import {
8
8
  isComment,
9
9
  isTestOrMockFile,
10
10
  isScannerOrFixtureFile,
11
+ isSeedOrDataGenFile,
12
+ isEducationalVulnerabilityFile,
11
13
  } from '../utils/context-helpers'
12
14
 
13
15
  /**
@@ -807,12 +809,9 @@ function isCosmeticMathRandom(lineContent: string, content: string, lineNumber:
807
809
  /Math\.random.*\*\s*\d+\s*\+\s*\d+.*\bpx\b/i, // Math.random() * 100 + 50 + 'px'
808
810
  /Math\.random.*\*\s*\d+\s*\+\s*\d+.*\bms\b/i, // Math.random() * 1000 + 500 + 'ms'
809
811
  /Math\.random.*\*\s*\d+\s*\+\s*\d+.*\bs\b/i, // Math.random() * 5 + 2 + 's'
810
- // UI identifier generation (short strings for element IDs, keys, etc.)
811
- /Math\.random\(\)\.toString\(36\)\.substring\(/, // .toString(36).substring(2, 9) - short UI IDs
812
- /Math\.random\(\)\.toString\(36\)\.substr\(/, // .substr() variant
813
- /Math\.random\(\)\.toString\(36\)\.slice\(/, // .slice() variant
814
- /Math\.random\(\)\.toString\(16\)\.substring\(/, // .toString(16).substring() - hex UI IDs
815
- /Math\.random\(\)\.toString\(16\)\.slice\(/, // hex slice variant
812
+ // NOTE: toString patterns removed - now handled by analyzeToStringPattern()
813
+ // which provides more granular severity classification (info/low/medium/high)
814
+ // based on truncation length and context
816
815
  ]
817
816
 
818
817
  if (cosmeticLinePatterns.some(p => p.test(lineContent))) {
@@ -879,6 +878,269 @@ function isCosmeticMathRandom(lineContent: string, content: string, lineNumber:
879
878
  return false // Default to flagging if unclear
880
879
  }
881
880
 
881
+ /**
882
+ * Extract function context where Math.random() is being called
883
+ * Looks backwards from the current line to find enclosing function name
884
+ * Returns lowercase function name or null if not found
885
+ */
886
+ function extractFunctionContext(content: string, lineNumber: number): string | null {
887
+ const lines = content.split('\n')
888
+ const start = Math.max(0, lineNumber - 10)
889
+
890
+ // Look backwards for function declaration
891
+ for (let i = lineNumber; i >= start; i--) {
892
+ const line = lines[i]
893
+
894
+ // Match various function declaration patterns
895
+ // 1. function functionName
896
+ // 2. export function functionName
897
+ // 3. const/let functionName = function
898
+ // 4. const/let functionName = (arrow function)
899
+ // 5. export const functionName =
900
+
901
+ // Traditional function declaration
902
+ const funcDeclMatch = line.match(/(?:export\s+)?function\s+(\w+)/i)
903
+ if (funcDeclMatch) {
904
+ return funcDeclMatch[1].toLowerCase()
905
+ }
906
+
907
+ // Arrow function or function expression assignment
908
+ // Only match if there's an equals sign and function-like syntax
909
+ const arrowFuncMatch = line.match(/(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:function|\(|async)/i)
910
+ if (arrowFuncMatch) {
911
+ return arrowFuncMatch[1].toLowerCase()
912
+ }
913
+ }
914
+ return null
915
+ }
916
+
917
+ /**
918
+ * Classify function intent based on function name
919
+ * Used to determine if Math.random() usage is legitimate
920
+ */
921
+ function classifyFunctionIntent(functionName: string | null): 'uuid' | 'captcha' | 'demo' | 'security' | 'unknown' {
922
+ if (!functionName) return 'unknown'
923
+
924
+ const lower = functionName.toLowerCase()
925
+
926
+ // UUID/ID generation (UI correlation, not security)
927
+ // Check for specific UUID patterns and generic ID generation functions
928
+ const uuidPatterns = ['uuid', 'guid', 'uniqueid', 'correlationid']
929
+ const idGenerationPatterns = /^(generate|create|make|build)(id|identifier)$/i
930
+ if (uuidPatterns.some(p => lower.includes(p)) || idGenerationPatterns.test(lower)) {
931
+ return 'uuid'
932
+ }
933
+
934
+ // CAPTCHA/puzzle generation (legitimate non-security)
935
+ const captchaPatterns = ['captcha', 'puzzle', 'mathproblem']
936
+ // Also check for 'challenge' but only if not in security context
937
+ if (captchaPatterns.some(p => lower.includes(p))) return 'captcha'
938
+ if (lower.includes('challenge') && !lower.includes('auth')) return 'captcha'
939
+
940
+ // Demo/seed/fixture data
941
+ const demoPatterns = ['seed', 'fixture', 'demo', 'mock', 'fake']
942
+ if (demoPatterns.some(p => lower.includes(p))) return 'demo'
943
+
944
+ // Security-sensitive (check this after id generation to avoid false positives)
945
+ const securityPatterns = ['token', 'secret', 'key', 'password', 'credential', 'signature']
946
+ // Also match generate/create + security term combinations
947
+ const securityFunctionPattern = /^(generate|create|make)(token|secret|key|session|password|credential)/i
948
+ if (securityPatterns.some(p => lower.includes(p)) || securityFunctionPattern.test(lower)) {
949
+ return 'security'
950
+ }
951
+
952
+ return 'unknown'
953
+ }
954
+
955
+ /**
956
+ * Analyze toString() pattern in Math.random() usage
957
+ * Determines intent based on base and truncation length
958
+ */
959
+ function analyzeToStringPattern(lineContent: string): {
960
+ hasToString: boolean
961
+ base: number | null
962
+ isTruncated: boolean
963
+ truncationLength: number | null
964
+ intent: 'short-ui-id' | 'business-id' | 'full-token' | 'unknown'
965
+ } {
966
+ const toString36Match = lineContent.match(/Math\.random\(\)\.toString\(36\)/)
967
+ const toString16Match = lineContent.match(/Math\.random\(\)\.toString\(16\)/)
968
+
969
+ if (!toString36Match && !toString16Match) {
970
+ return { hasToString: false, base: null, isTruncated: false, truncationLength: null, intent: 'unknown' }
971
+ }
972
+
973
+ const base = toString36Match ? 36 : 16
974
+
975
+ // Check for truncation methods
976
+ const substringMatch = lineContent.match(/\.substring\((\d+)(?:,\s*(\d+))?\)/)
977
+ const sliceMatch = lineContent.match(/\.slice\((\d+)(?:,\s*(\d+))?\)/)
978
+ const substrMatch = lineContent.match(/\.substr\((\d+)(?:,\s*(\d+))?\)/)
979
+
980
+ const truncMatch = substringMatch || sliceMatch || substrMatch
981
+
982
+ if (!truncMatch) {
983
+ return { hasToString: true, base, isTruncated: false, truncationLength: null, intent: 'full-token' }
984
+ }
985
+
986
+ // Calculate truncation length
987
+ const start = parseInt(truncMatch[1])
988
+ const end = truncMatch[2] ? parseInt(truncMatch[2]) : null
989
+ const length = end ? (end - start) : null
990
+
991
+ // Classify intent by length
992
+ // Short (2-9 chars): UI correlation IDs, React keys
993
+ // Medium (10-15 chars): Business IDs, order numbers
994
+ if (length && length <= 9) {
995
+ return { hasToString: true, base, isTruncated: true, truncationLength: length, intent: 'short-ui-id' }
996
+ } else if (length && length <= 15) {
997
+ return { hasToString: true, base, isTruncated: true, truncationLength: length, intent: 'business-id' }
998
+ } else {
999
+ return { hasToString: true, base, isTruncated: true, truncationLength: length, intent: 'business-id' }
1000
+ }
1001
+ }
1002
+
1003
+ /**
1004
+ * Extract variable name from Math.random() assignment
1005
+ * Examples:
1006
+ * const token = Math.random() -> "token"
1007
+ * const businessId = Math.random().toString(36) -> "businessId"
1008
+ * return Math.random() -> null (no variable)
1009
+ */
1010
+ function extractMathRandomVariableName(lineContent: string): string | null {
1011
+ // const/let/var variableName = Math.random...
1012
+ const assignmentMatch = lineContent.match(/(?:const|let|var)\s+(\w+)\s*=.*Math\.random/)
1013
+ if (assignmentMatch) return assignmentMatch[1]
1014
+
1015
+ // object.property = Math.random...
1016
+ const propertyMatch = lineContent.match(/(\w+)\s*[:=]\s*Math\.random/)
1017
+ if (propertyMatch) return propertyMatch[1]
1018
+
1019
+ // function parameter default: functionName(param = Math.random())
1020
+ const paramMatch = lineContent.match(/(\w+)\s*=\s*Math\.random/)
1021
+ if (paramMatch) return paramMatch[1]
1022
+
1023
+ return null // No variable name found
1024
+ }
1025
+
1026
+ /**
1027
+ * Classify variable name security risk based on naming patterns
1028
+ *
1029
+ * High risk: Security-sensitive names (token, secret, key, etc.)
1030
+ * Medium risk: Unclear context
1031
+ * Low risk: Non-security names (id, businessId, orderId, etc.)
1032
+ */
1033
+ function classifyVariableNameRisk(varName: string | null): 'high' | 'medium' | 'low' {
1034
+ if (!varName) return 'medium' // Unknown usage, moderate risk
1035
+
1036
+ const lower = varName.toLowerCase()
1037
+
1038
+ // High risk: security-sensitive variable names
1039
+ const highRiskPatterns = [
1040
+ 'token', 'secret', 'key', 'password', 'credential',
1041
+ 'signature', 'salt', 'nonce', 'session', 'csrf',
1042
+ 'auth', 'apikey', 'accesstoken', 'refreshtoken',
1043
+ 'jwt', 'bearer', 'oauth', 'sessionid'
1044
+ ]
1045
+ if (highRiskPatterns.some(p => lower.includes(p))) {
1046
+ return 'high'
1047
+ }
1048
+
1049
+ // Low risk: clearly non-security contexts
1050
+ const lowRiskPatterns = [
1051
+ // Business identifiers
1052
+ 'id', 'uid', 'guid', 'business', 'order', 'invoice',
1053
+ 'customer', 'user', 'product', 'item', 'transaction',
1054
+ 'request', 'reference', 'tracking', 'confirmation',
1055
+ // Test/demo data
1056
+ 'test', 'mock', 'demo', 'sample', 'example', 'fixture',
1057
+ 'random', 'temp', 'temporary', 'generated', 'dummy',
1058
+ // UI identifiers
1059
+ 'toast', 'notification', 'element', 'component', 'widget',
1060
+ 'modal', 'dialog', 'popup', 'unique', 'react'
1061
+ ]
1062
+ if (lowRiskPatterns.some(p => lower.includes(p))) {
1063
+ return 'low'
1064
+ }
1065
+
1066
+ return 'medium' // Unclear context, moderate risk
1067
+ }
1068
+
1069
+ /**
1070
+ * Analyze surrounding code context for security signals
1071
+ * Returns context type and description for severity classification
1072
+ */
1073
+ function analyzeMathRandomContext(
1074
+ content: string,
1075
+ filePath: string,
1076
+ lineNumber: number
1077
+ ): {
1078
+ inSecurityContext: boolean
1079
+ inTestContext: boolean
1080
+ inUIContext: boolean
1081
+ inBusinessLogicContext: boolean
1082
+ contextDescription: string
1083
+ } {
1084
+ const lines = content.split('\n')
1085
+ const start = Math.max(0, lineNumber - 10)
1086
+ const end = Math.min(lines.length, lineNumber + 5)
1087
+ const context = lines.slice(start, end).join('\n')
1088
+
1089
+ // Security context indicators (functions, imports, comments)
1090
+ const securityPatterns = [
1091
+ /\b(generate|create)(Token|Secret|Key|Password|Nonce|Salt|Session|Signature)/i,
1092
+ /\b(auth|crypto|encrypt|decrypt|hash|sign)\b/i,
1093
+ /function\s+.*(?:token|secret|key|auth|crypto)/i,
1094
+ /\bimport.*(?:crypto|jsonwebtoken|bcrypt|argon2|jose)/i,
1095
+ /\/\*.*(?:security|authentication|cryptograph|authorization)/i,
1096
+ /\/\/.*(?:security|auth|crypto|token|secret)/i,
1097
+ ]
1098
+ const inSecurityContext = securityPatterns.some(p => p.test(context))
1099
+
1100
+ // Test context
1101
+ const testFilePatterns = /\.(test|spec)\.(ts|tsx|js|jsx)$/i
1102
+ const testContextPatterns = [
1103
+ /\b(describe|it|test|expect|mock|jest|vitest|mocha|chai)\b/i,
1104
+ /\b(beforeEach|afterEach|beforeAll|afterAll)\b/i,
1105
+ /\b(fixture|stub|spy)\b/i,
1106
+ ]
1107
+ const inTestContext = testFilePatterns.test(filePath) ||
1108
+ testContextPatterns.some(p => p.test(context))
1109
+
1110
+ // UI/cosmetic context (reuse existing logic)
1111
+ const lineContent = lines[lineNumber]
1112
+ const inUIContext = isCosmeticMathRandom(lineContent, content, lineNumber)
1113
+
1114
+ // Business logic context (non-security ID generation)
1115
+ // Note: UUID/CAPTCHA patterns excluded - handled by functionIntent classification
1116
+ const businessLogicPatterns = [
1117
+ /\b(business|order|invoice|customer|product|transaction)Id\b/i,
1118
+ /\b(reference|tracking|confirmation)Number\b/i,
1119
+ ]
1120
+ const inBusinessLogicContext = businessLogicPatterns.some(p => p.test(context)) &&
1121
+ !inSecurityContext
1122
+
1123
+ // Determine context description
1124
+ let contextDescription = 'unknown context'
1125
+ if (inSecurityContext) {
1126
+ contextDescription = 'security-sensitive function'
1127
+ } else if (inTestContext) {
1128
+ contextDescription = 'test/mock data generation'
1129
+ } else if (inUIContext) {
1130
+ contextDescription = 'UI/cosmetic usage'
1131
+ } else if (inBusinessLogicContext) {
1132
+ contextDescription = 'business identifier generation'
1133
+ }
1134
+
1135
+ return {
1136
+ inSecurityContext,
1137
+ inTestContext,
1138
+ inUIContext,
1139
+ inBusinessLogicContext,
1140
+ contextDescription,
1141
+ }
1142
+ }
1143
+
882
1144
  export function detectDangerousFunctions(
883
1145
  content: string,
884
1146
  filePath: string
@@ -1171,13 +1433,108 @@ export function detectDangerousFunctions(
1171
1433
  }
1172
1434
  }
1173
1435
 
1174
- // Special handling for Math.random() - skip cosmetic/UI uses
1436
+ // Special handling for Math.random() - enhanced context-aware severity classification
1175
1437
  if (funcPattern.name === 'Math.random for security') {
1176
- // Check if this is cosmetic use (CSS, animations, UI variations)
1177
- if (isCosmeticMathRandom(line, content, index)) {
1178
- // Skip entirely - this is not a security concern
1179
- break
1438
+ // Phase 1: File-level exclusions (skip entirely)
1439
+ if (isSeedOrDataGenFile(filePath)) {
1440
+ break // Skip seed/data generation files entirely
1441
+ }
1442
+
1443
+ if (isEducationalVulnerabilityFile(filePath)) {
1444
+ break // Skip intentional vulnerability examples
1445
+ }
1446
+
1447
+ // Phase 2: Context analysis
1448
+ const varName = extractMathRandomVariableName(line)
1449
+ const nameRisk = classifyVariableNameRisk(varName)
1450
+ const context = analyzeMathRandomContext(content, filePath, index)
1451
+ const functionName = extractFunctionContext(content, index)
1452
+ const functionIntent = classifyFunctionIntent(functionName)
1453
+ const toStringPattern = analyzeToStringPattern(line)
1454
+
1455
+ // Phase 3: Skip cosmetic/UI uses
1456
+ if (context.inUIContext) {
1457
+ break // Already working
1458
+ }
1459
+
1460
+ // Phase 4: Skip UUID/CAPTCHA generation functions
1461
+ if (functionIntent === 'uuid' || functionIntent === 'captcha') {
1462
+ break // Legitimate non-security uses
1463
+ }
1464
+
1465
+ // Phase 5: Determine severity
1466
+ let severity: VulnerabilitySeverity = 'medium'
1467
+ let confidence: 'high' | 'medium' | 'low' = 'medium'
1468
+ let explanation = ''
1469
+ let description = funcPattern.description
1470
+ let suggestedFix = funcPattern.suggestedFix
1471
+
1472
+ // Test context - INFO
1473
+ if (context.inTestContext) {
1474
+ severity = 'info'
1475
+ confidence = 'low'
1476
+ explanation = ' (test data generation)'
1477
+ description = 'Math.random() used in test context for generating mock data. Not security-critical, but consider crypto.randomUUID() for better uniqueness in tests.'
1478
+ suggestedFix = 'Consider crypto.randomUUID() for test data uniqueness, though Math.random() is acceptable in tests'
1479
+ }
1480
+ // Seed/demo function context - INFO
1481
+ else if (functionIntent === 'demo') {
1482
+ severity = 'info'
1483
+ confidence = 'low'
1484
+ explanation = ' (seed/demo data generation)'
1485
+ description = 'Math.random() used for generating fixture/seed data. Not security-critical in development contexts.'
1486
+ suggestedFix = 'Acceptable for seed data. Use crypto.randomUUID() if uniqueness guarantees needed.'
1487
+ }
1488
+ // Security context - HIGH
1489
+ else if (nameRisk === 'high' || context.inSecurityContext || functionIntent === 'security') {
1490
+ severity = 'high'
1491
+ confidence = 'high'
1492
+ explanation = ' (security-sensitive context)'
1493
+ description = 'Math.random() is NOT cryptographically secure and MUST NOT be used for tokens, keys, passwords, or session IDs. This can lead to predictable values that attackers can exploit.'
1494
+ suggestedFix = 'Replace with crypto.randomBytes() or crypto.randomUUID() for security-sensitive operations'
1495
+ }
1496
+ // Short UI ID pattern - INFO
1497
+ else if (toStringPattern.intent === 'short-ui-id') {
1498
+ severity = 'info'
1499
+ confidence = 'low'
1500
+ explanation = ' (UI correlation ID)'
1501
+ description = 'Math.random() used for short UI correlation IDs. Not security-critical, but collisions possible in high-volume scenarios.'
1502
+ suggestedFix = 'For UI correlation, crypto.randomUUID() provides better uniqueness guarantees'
1503
+ }
1504
+ // Business ID pattern - LOW
1505
+ else if (nameRisk === 'low' || context.inBusinessLogicContext || toStringPattern.intent === 'business-id') {
1506
+ severity = 'low'
1507
+ confidence = 'low'
1508
+ explanation = ' (business identifier)'
1509
+ description = 'Math.random() is being used for non-security purposes (business IDs, tracking numbers). While not critical, Math.random() can produce collisions in high-volume scenarios.'
1510
+ suggestedFix = 'Consider crypto.randomUUID() for better uniqueness guarantees and collision resistance'
1511
+ }
1512
+ // Unknown context - MEDIUM
1513
+ else {
1514
+ severity = 'medium'
1515
+ confidence = 'medium'
1516
+ explanation = ' (unclear context)'
1517
+ description = 'Math.random() is being used. Verify this is not for security-critical purposes like tokens, session IDs, or cryptographic operations.'
1518
+ suggestedFix = 'If used for security, replace with crypto.randomBytes(). For unique IDs, use crypto.randomUUID()'
1180
1519
  }
1520
+
1521
+ // Update title with context
1522
+ const title = `Math.random() in ${context.contextDescription}${explanation}`
1523
+
1524
+ vulnerabilities.push({
1525
+ id: `dangerous-func-${filePath}-${index + 1}-${funcPattern.name}`,
1526
+ filePath,
1527
+ lineNumber: index + 1,
1528
+ lineContent: line.trim(),
1529
+ severity,
1530
+ category: 'dangerous_function',
1531
+ title,
1532
+ description,
1533
+ suggestedFix,
1534
+ confidence,
1535
+ layer: 2,
1536
+ })
1537
+ break // Only report once per line
1181
1538
  }
1182
1539
 
1183
1540
  // Standard handling for all other patterns
@@ -5,7 +5,7 @@
5
5
  * and AI code fingerprinting
6
6
  */
7
7
 
8
- import type { Vulnerability, ScanFile } from '../types'
8
+ import type { Vulnerability, ScanFile, CancellationToken } from '../types'
9
9
  import type { ProgressCallback } from '../index'
10
10
  import type { MiddlewareAuthConfig } from '../utils/middleware-detector'
11
11
  import { detectAuthHelpers, type AuthHelperContext } from '../utils/auth-helper-detector'
@@ -183,13 +183,16 @@ function processFileLayer2(
183
183
  }
184
184
  }
185
185
 
186
- // Parallel batch size for Layer 2 processing
186
+ // Parallel batch size for Layer 2 processing (larger batches for performance)
187
187
  const LAYER2_PARALLEL_BATCH_SIZE = 50
188
+ // Progress update interval (report every N files for better UX)
189
+ const PROGRESS_UPDATE_INTERVAL = 10
188
190
 
189
191
  export async function runLayer2Scan(
190
192
  files: ScanFile[],
191
193
  options: Layer2Options = {},
192
- onProgress?: ProgressCallback
194
+ onProgress?: ProgressCallback,
195
+ cancellationToken?: CancellationToken
193
196
  ): Promise<Layer2Result> {
194
197
  const startTime = Date.now()
195
198
  const vulnerabilities: Vulnerability[] = []
@@ -210,17 +213,24 @@ export async function runLayer2Scan(
210
213
  endpointProtection: 0,
211
214
  schemaValidation: 0,
212
215
  }
213
-
216
+
214
217
  // Detect auth helpers once for all files (if not already provided)
215
218
  const authHelperContext = options.authHelperContext || detectAuthHelpers(files)
216
219
 
220
+ // Track progress for frequent updates
221
+ let filesProcessed = 0
222
+ let lastProgressUpdate = 0
223
+
217
224
  // Process files in parallel batches for better performance on large codebases
218
225
  for (let i = 0; i < files.length; i += LAYER2_PARALLEL_BATCH_SIZE) {
226
+ // Check for cancellation before processing batch
227
+ if (cancellationToken?.cancelled) break
228
+
219
229
  const batch = files.slice(i, i + LAYER2_PARALLEL_BATCH_SIZE)
220
230
  const results = await Promise.all(
221
231
  batch.map(file => Promise.resolve(processFileLayer2(file, options, authHelperContext)))
222
232
  )
223
-
233
+
224
234
  for (const result of results) {
225
235
  vulnerabilities.push(...result.findings)
226
236
  // Accumulate stats
@@ -229,9 +239,10 @@ export async function runLayer2Scan(
229
239
  }
230
240
  }
231
241
 
232
- // Report progress after each batch
233
- if (onProgress) {
234
- const filesProcessed = Math.min(i + LAYER2_PARALLEL_BATCH_SIZE, files.length)
242
+ filesProcessed = Math.min(i + LAYER2_PARALLEL_BATCH_SIZE, files.length)
243
+
244
+ // Report progress every PROGRESS_UPDATE_INTERVAL files for better UX
245
+ if (onProgress && (filesProcessed - lastProgressUpdate >= PROGRESS_UPDATE_INTERVAL || filesProcessed === files.length)) {
235
246
  onProgress({
236
247
  status: 'layer2',
237
248
  message: 'Running structural scan (variables, logic gates)...',
@@ -239,6 +250,7 @@ export async function runLayer2Scan(
239
250
  totalFiles: files.length,
240
251
  vulnerabilitiesFound: vulnerabilities.length,
241
252
  })
253
+ lastProgressUpdate = filesProcessed
242
254
  }
243
255
  }
244
256