@oculum/cli 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +522 -24
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -11474,6 +11474,34 @@ var require_types = __commonJS({
11474
11474
  "use strict";
11475
11475
  Object.defineProperty(exports2, "__esModule", { value: true });
11476
11476
  exports2.SCAN_MODE_DEFAULTS = exports2.MAX_FILE_SIZE = exports2.SPECIAL_FILES = exports2.SCANNABLE_EXTENSIONS = void 0;
11477
+ exports2.createCancellationToken = createCancellationToken2;
11478
+ function createCancellationToken2() {
11479
+ const cleanupCallbacks = [];
11480
+ const token = {
11481
+ cancelled: false,
11482
+ reason: void 0,
11483
+ cancel(reason) {
11484
+ if (!token.cancelled) {
11485
+ token.cancelled = true;
11486
+ token.reason = reason;
11487
+ cleanupCallbacks.forEach((cb) => {
11488
+ try {
11489
+ cb();
11490
+ } catch (e2) {
11491
+ }
11492
+ });
11493
+ }
11494
+ },
11495
+ onCancel(callback) {
11496
+ if (token.cancelled) {
11497
+ callback();
11498
+ } else {
11499
+ cleanupCallbacks.push(callback);
11500
+ }
11501
+ }
11502
+ };
11503
+ return token;
11504
+ }
11477
11505
  exports2.SCANNABLE_EXTENSIONS = [
11478
11506
  ".js",
11479
11507
  ".jsx",
@@ -14444,7 +14472,8 @@ var require_layer1 = __commonJS({
14444
14472
  };
14445
14473
  }
14446
14474
  var LAYER1_PARALLEL_BATCH_SIZE = 50;
14447
- async function runLayer1Scan(files) {
14475
+ var PROGRESS_UPDATE_INTERVAL = 10;
14476
+ async function runLayer1Scan(files, onProgress, cancellationToken) {
14448
14477
  const startTime = Date.now();
14449
14478
  const vulnerabilities = [];
14450
14479
  const rawStats = {
@@ -14456,7 +14485,11 @@ var require_layer1 = __commonJS({
14456
14485
  file_flags: 0,
14457
14486
  ai_comments: 0
14458
14487
  };
14488
+ let filesProcessed = 0;
14489
+ let lastProgressUpdate = 0;
14459
14490
  for (let i = 0; i < files.length; i += LAYER1_PARALLEL_BATCH_SIZE) {
14491
+ if (cancellationToken?.cancelled)
14492
+ break;
14460
14493
  const batch = files.slice(i, i + LAYER1_PARALLEL_BATCH_SIZE);
14461
14494
  const results = await Promise.all(batch.map((file) => Promise.resolve(processFileLayer1(file))));
14462
14495
  for (const result of results) {
@@ -14465,6 +14498,17 @@ var require_layer1 = __commonJS({
14465
14498
  rawStats[key] += value;
14466
14499
  }
14467
14500
  }
14501
+ filesProcessed = Math.min(i + LAYER1_PARALLEL_BATCH_SIZE, files.length);
14502
+ if (onProgress && (filesProcessed - lastProgressUpdate >= PROGRESS_UPDATE_INTERVAL || filesProcessed === files.length)) {
14503
+ onProgress({
14504
+ status: "layer1",
14505
+ message: "Running surface scan (patterns, entropy, config)...",
14506
+ filesProcessed,
14507
+ totalFiles: files.length,
14508
+ vulnerabilitiesFound: vulnerabilities.length
14509
+ });
14510
+ lastProgressUpdate = filesProcessed;
14511
+ }
14468
14512
  }
14469
14513
  const dedupedVulnerabilities = deduplicateFindings(vulnerabilities);
14470
14514
  const { kept: uniqueVulnerabilities, suppressed } = (0, path_exclusions_1.filterFindingsByPath)(dedupedVulnerabilities);
@@ -15867,6 +15911,139 @@ var require_dangerous_functions = __commonJS({
15867
15911
  }
15868
15912
  return false;
15869
15913
  }
15914
+ function extractMathRandomVariableName(lineContent) {
15915
+ const assignmentMatch = lineContent.match(/(?:const|let|var)\s+(\w+)\s*=.*Math\.random/);
15916
+ if (assignmentMatch)
15917
+ return assignmentMatch[1];
15918
+ const propertyMatch = lineContent.match(/(\w+)\s*[:=]\s*Math\.random/);
15919
+ if (propertyMatch)
15920
+ return propertyMatch[1];
15921
+ const paramMatch = lineContent.match(/(\w+)\s*=\s*Math\.random/);
15922
+ if (paramMatch)
15923
+ return paramMatch[1];
15924
+ return null;
15925
+ }
15926
+ function classifyVariableNameRisk(varName) {
15927
+ if (!varName)
15928
+ return "medium";
15929
+ const lower = varName.toLowerCase();
15930
+ const highRiskPatterns = [
15931
+ "token",
15932
+ "secret",
15933
+ "key",
15934
+ "password",
15935
+ "credential",
15936
+ "signature",
15937
+ "salt",
15938
+ "nonce",
15939
+ "session",
15940
+ "csrf",
15941
+ "auth",
15942
+ "apikey",
15943
+ "accesstoken",
15944
+ "refreshtoken",
15945
+ "jwt",
15946
+ "bearer",
15947
+ "oauth",
15948
+ "sessionid"
15949
+ ];
15950
+ if (highRiskPatterns.some((p2) => lower.includes(p2))) {
15951
+ return "high";
15952
+ }
15953
+ const lowRiskPatterns = [
15954
+ // Business identifiers
15955
+ "id",
15956
+ "uid",
15957
+ "guid",
15958
+ "business",
15959
+ "order",
15960
+ "invoice",
15961
+ "customer",
15962
+ "user",
15963
+ "product",
15964
+ "item",
15965
+ "transaction",
15966
+ "request",
15967
+ "reference",
15968
+ "tracking",
15969
+ "confirmation",
15970
+ // Test/demo data
15971
+ "test",
15972
+ "mock",
15973
+ "demo",
15974
+ "sample",
15975
+ "example",
15976
+ "fixture",
15977
+ "random",
15978
+ "temp",
15979
+ "temporary",
15980
+ "generated",
15981
+ "dummy",
15982
+ // UI identifiers
15983
+ "toast",
15984
+ "notification",
15985
+ "element",
15986
+ "component",
15987
+ "widget",
15988
+ "modal",
15989
+ "dialog",
15990
+ "popup",
15991
+ "unique",
15992
+ "react"
15993
+ ];
15994
+ if (lowRiskPatterns.some((p2) => lower.includes(p2))) {
15995
+ return "low";
15996
+ }
15997
+ return "medium";
15998
+ }
15999
+ function analyzeMathRandomContext(content, filePath, lineNumber) {
16000
+ const lines = content.split("\n");
16001
+ const start = Math.max(0, lineNumber - 10);
16002
+ const end = Math.min(lines.length, lineNumber + 5);
16003
+ const context = lines.slice(start, end).join("\n");
16004
+ const securityPatterns = [
16005
+ /\b(generate|create)(Token|Secret|Key|Password|Nonce|Salt|Session|Signature)/i,
16006
+ /\b(auth|crypto|encrypt|decrypt|hash|sign)\b/i,
16007
+ /function\s+.*(?:token|secret|key|auth|crypto)/i,
16008
+ /\bimport.*(?:crypto|jsonwebtoken|bcrypt|argon2|jose)/i,
16009
+ /\/\*.*(?:security|authentication|cryptograph|authorization)/i,
16010
+ /\/\/.*(?:security|auth|crypto|token|secret)/i
16011
+ ];
16012
+ const inSecurityContext = securityPatterns.some((p2) => p2.test(context));
16013
+ const testFilePatterns = /\.(test|spec)\.(ts|tsx|js|jsx)$/i;
16014
+ const testContextPatterns = [
16015
+ /\b(describe|it|test|expect|mock|jest|vitest|mocha|chai)\b/i,
16016
+ /\b(beforeEach|afterEach|beforeAll|afterAll)\b/i,
16017
+ /\b(fixture|stub|spy)\b/i
16018
+ ];
16019
+ const inTestContext = testFilePatterns.test(filePath) || testContextPatterns.some((p2) => p2.test(context));
16020
+ const lineContent = lines[lineNumber];
16021
+ const inUIContext = isCosmeticMathRandom(lineContent, content, lineNumber);
16022
+ const businessLogicPatterns = [
16023
+ /\b(business|order|invoice|customer|product|transaction)Id\b/i,
16024
+ /\b(reference|tracking|confirmation)Number\b/i,
16025
+ /\bgenerate.*Id\b/i,
16026
+ /\bcreate.*Id\b/i
16027
+ ];
16028
+ const inBusinessLogicContext = businessLogicPatterns.some((p2) => p2.test(context)) && !inSecurityContext;
16029
+ let contextDescription = "unknown context";
16030
+ if (inSecurityContext) {
16031
+ contextDescription = "security-sensitive function";
16032
+ } else if (inTestContext) {
16033
+ contextDescription = "test/mock data generation";
16034
+ } else if (inUIContext) {
16035
+ contextDescription = "UI/cosmetic usage";
16036
+ } else if (inBusinessLogicContext) {
16037
+ contextDescription = "business identifier generation";
16038
+ }
16039
+ return {
16040
+ inSecurityContext,
16041
+ inTestContext,
16042
+ inUIContext,
16043
+ inBusinessLogicContext,
16044
+ contextDescription
16045
+ };
16046
+ }
15870
16047
  function detectDangerousFunctions(content, filePath) {
15871
16048
  const vulnerabilities = [];
15872
16049
  if ((0, context_helpers_1.isScannerOrFixtureFile)(filePath)) {
@@ -16090,9 +16267,57 @@ var require_dangerous_functions = __commonJS({
16090
16267
  }
16091
16268
  }
16092
16269
  if (funcPattern.name === "Math.random for security") {
16093
- if (isCosmeticMathRandom(line, content, index)) {
16270
+ const varName = extractMathRandomVariableName(line);
16271
+ const nameRisk = classifyVariableNameRisk(varName);
16272
+ const context = analyzeMathRandomContext(content, filePath, index);
16273
+ if (context.inUIContext) {
16094
16274
  break;
16095
16275
  }
16276
+ let severity2 = "medium";
16277
+ let confidence2 = "medium";
16278
+ let explanation = "";
16279
+ let description = funcPattern.description;
16280
+ let suggestedFix = funcPattern.suggestedFix;
16281
+ if (context.inTestContext) {
16282
+ severity2 = "info";
16283
+ confidence2 = "low";
16284
+ explanation = " (test data generation)";
16285
+ description = "Math.random() used in test context for generating mock data. Not security-critical, but consider crypto.randomUUID() for better uniqueness in tests.";
16286
+ suggestedFix = "Consider crypto.randomUUID() for test data uniqueness, though Math.random() is acceptable in tests";
16287
+ } else if (nameRisk === "high" || context.inSecurityContext) {
16288
+ severity2 = "high";
16289
+ confidence2 = "high";
16290
+ explanation = " (security-sensitive context)";
16291
+ 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.";
16292
+ suggestedFix = "Replace with crypto.randomBytes() or crypto.randomUUID() for security-sensitive operations";
16293
+ } else if (nameRisk === "low" || context.inBusinessLogicContext) {
16294
+ severity2 = "low";
16295
+ confidence2 = "medium";
16296
+ explanation = " (business identifier)";
16297
+ 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.";
16298
+ suggestedFix = "Consider crypto.randomUUID() for better uniqueness guarantees and collision resistance";
16299
+ } else {
16300
+ severity2 = "medium";
16301
+ confidence2 = "medium";
16302
+ explanation = " (unclear context)";
16303
+ description = "Math.random() is being used. Verify this is not for security-critical purposes like tokens, session IDs, or cryptographic operations.";
16304
+ suggestedFix = "If used for security, replace with crypto.randomBytes(). For unique IDs, use crypto.randomUUID()";
16305
+ }
16306
+ const title = `Math.random() in ${context.contextDescription}${explanation}`;
16307
+ vulnerabilities.push({
16308
+ id: `dangerous-func-${filePath}-${index + 1}-${funcPattern.name}`,
16309
+ filePath,
16310
+ lineNumber: index + 1,
16311
+ lineContent: line.trim(),
16312
+ severity: severity2,
16313
+ category: "dangerous_function",
16314
+ title,
16315
+ description,
16316
+ suggestedFix,
16317
+ confidence: confidence2,
16318
+ layer: 2
16319
+ });
16320
+ break;
16096
16321
  }
16097
16322
  let severity = funcPattern.severity;
16098
16323
  let confidence = "high";
@@ -20768,7 +20993,8 @@ var require_layer2 = __commonJS({
20768
20993
  };
20769
20994
  }
20770
20995
  var LAYER2_PARALLEL_BATCH_SIZE = 50;
20771
- async function runLayer2Scan(files, options = {}) {
20996
+ var PROGRESS_UPDATE_INTERVAL = 10;
20997
+ async function runLayer2Scan(files, options = {}, onProgress, cancellationToken) {
20772
20998
  const startTime = Date.now();
20773
20999
  const vulnerabilities = [];
20774
21000
  const stats = {
@@ -20789,7 +21015,11 @@ var require_layer2 = __commonJS({
20789
21015
  schemaValidation: 0
20790
21016
  };
20791
21017
  const authHelperContext = options.authHelperContext || (0, auth_helper_detector_1.detectAuthHelpers)(files);
21018
+ let filesProcessed = 0;
21019
+ let lastProgressUpdate = 0;
20792
21020
  for (let i = 0; i < files.length; i += LAYER2_PARALLEL_BATCH_SIZE) {
21021
+ if (cancellationToken?.cancelled)
21022
+ break;
20793
21023
  const batch = files.slice(i, i + LAYER2_PARALLEL_BATCH_SIZE);
20794
21024
  const results = await Promise.all(batch.map((file) => Promise.resolve(processFileLayer2(file, options, authHelperContext))));
20795
21025
  for (const result of results) {
@@ -20798,6 +21028,17 @@ var require_layer2 = __commonJS({
20798
21028
  stats[key] += value;
20799
21029
  }
20800
21030
  }
21031
+ filesProcessed = Math.min(i + LAYER2_PARALLEL_BATCH_SIZE, files.length);
21032
+ if (onProgress && (filesProcessed - lastProgressUpdate >= PROGRESS_UPDATE_INTERVAL || filesProcessed === files.length)) {
21033
+ onProgress({
21034
+ status: "layer2",
21035
+ message: "Running structural scan (variables, logic gates)...",
21036
+ filesProcessed,
21037
+ totalFiles: files.length,
21038
+ vulnerabilitiesFound: vulnerabilities.length
21039
+ });
21040
+ lastProgressUpdate = filesProcessed;
21041
+ }
20801
21042
  }
20802
21043
  const dedupedVulnerabilities = deduplicateFindings(vulnerabilities);
20803
21044
  const excludeTestFiles = options.excludeTestFiles !== false;
@@ -37170,7 +37411,7 @@ For each candidate finding, return:
37170
37411
  console.log(` - Estimated cost: $${stats.estimatedCost.toFixed(4)}`);
37171
37412
  return { vulnerabilities: validatedFindings, stats };
37172
37413
  }
37173
- async function validateFindingsWithAI(findings, files, projectContext) {
37414
+ async function validateFindingsWithAI(findings, files, projectContext, onProgress) {
37174
37415
  const stats = {
37175
37416
  totalFindings: findings.length,
37176
37417
  validatedFindings: 0,
@@ -37220,9 +37461,17 @@ For each candidate finding, return:
37220
37461
  let totalApiBatches = 0;
37221
37462
  const totalFileBatches = Math.ceil(fileEntries.length / FILES_PER_API_BATCH);
37222
37463
  console.log(`[AI Validation] Phase 2: Processing ${fileEntries.length} files in ${totalFileBatches} API batch(es) (${FILES_PER_API_BATCH} files/batch)`);
37464
+ let filesValidated = 0;
37223
37465
  for (let batchStart = 0; batchStart < fileEntries.length; batchStart += FILES_PER_API_BATCH) {
37224
37466
  const fileBatch = fileEntries.slice(batchStart, batchStart + FILES_PER_API_BATCH);
37225
37467
  const batchNum = Math.floor(batchStart / FILES_PER_API_BATCH) + 1;
37468
+ if (onProgress) {
37469
+ onProgress({
37470
+ filesProcessed: filesValidated,
37471
+ totalFiles: fileEntries.length,
37472
+ status: `AI validating batch ${batchNum}/${totalFileBatches}`
37473
+ });
37474
+ }
37226
37475
  console.log(`[AI Validation] API Batch ${batchNum}/${totalFileBatches}: ${fileBatch.length} files`);
37227
37476
  const fileDataList = [];
37228
37477
  const filesWithoutContent = [];
@@ -37358,6 +37607,14 @@ For each candidate finding, return:
37358
37607
  }
37359
37608
  const batchDuration = Date.now() - batchStartTime;
37360
37609
  totalBatchWaitTime += batchDuration;
37610
+ filesValidated += fileBatch.length;
37611
+ if (onProgress) {
37612
+ onProgress({
37613
+ filesProcessed: filesValidated,
37614
+ totalFiles: fileEntries.length,
37615
+ status: `AI validation complete for batch ${batchNum}/${totalFileBatches}`
37616
+ });
37617
+ }
37361
37618
  }
37362
37619
  const totalCacheableTokens = stats.cacheCreationTokens + stats.cacheReadTokens;
37363
37620
  stats.cacheHitRate = totalCacheableTokens > 0 ? stats.cacheReadTokens / totalCacheableTokens : 0;
@@ -38431,11 +38688,29 @@ var require_layer3 = __commonJS({
38431
38688
  const vulnerabilities = [];
38432
38689
  let aiAnalyzedCount = 0;
38433
38690
  const maxAIFiles = options.maxFiles ?? MAX_AI_FILES;
38691
+ if (options.cancellationToken?.cancelled) {
38692
+ return {
38693
+ vulnerabilities: [],
38694
+ filesScanned: files.length,
38695
+ duration: Date.now() - startTime,
38696
+ aiAnalyzed: 0
38697
+ };
38698
+ }
38434
38699
  const packageFiles = files.filter((f) => f.path.endsWith("package.json"));
38435
38700
  for (const file of packageFiles) {
38701
+ if (options.cancellationToken?.cancelled)
38702
+ break;
38436
38703
  const packageFindings = await (0, package_check_1.checkPackages)(file.content, file.path);
38437
38704
  vulnerabilities.push(...packageFindings);
38438
38705
  }
38706
+ if (options.cancellationToken?.cancelled) {
38707
+ return {
38708
+ vulnerabilities,
38709
+ filesScanned: files.length,
38710
+ duration: Date.now() - startTime,
38711
+ aiAnalyzed: 0
38712
+ };
38713
+ }
38439
38714
  if (options.enableAI !== false) {
38440
38715
  const aiCandidates = files.filter((f) => {
38441
38716
  const hasPriorityExt = AI_PRIORITY_EXTENSIONS.some((ext2) => f.path.endsWith(ext2));
@@ -38722,7 +38997,7 @@ var require_dist = __commonJS({
38722
38997
  for (var p2 in m2) if (p2 !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p2)) __createBinding(exports3, m2, p2);
38723
38998
  };
38724
38999
  Object.defineProperty(exports2, "__esModule", { value: true });
38725
- exports2.validateFindingsWithAI = exports2.buildProjectContext = exports2.runLayer3Scan = exports2.runLayer2Scan = exports2.runLayer1Scan = void 0;
39000
+ exports2.createCancellationToken = exports2.validateFindingsWithAI = exports2.buildProjectContext = exports2.runLayer3Scan = exports2.runLayer2Scan = exports2.runLayer1Scan = void 0;
38726
39001
  exports2.runScan = runScan2;
38727
39002
  exports2.computeIssueMixFromVulnerabilities = computeIssueMixFromVulnerabilities;
38728
39003
  var types_1 = require_types();
@@ -38799,11 +39074,17 @@ var require_dist = __commonJS({
38799
39074
  const isIncremental = scanModeConfig.mode === "incremental";
38800
39075
  const depth = scanModeConfig.scanDepth || "cheap";
38801
39076
  const quiet = options.quiet ?? false;
39077
+ const cancellationToken = options.cancellationToken;
38802
39078
  const log = (message) => {
38803
39079
  if (!quiet) {
38804
39080
  console.log(message);
38805
39081
  }
38806
39082
  };
39083
+ const checkCancelled = () => {
39084
+ if (cancellationToken?.cancelled) {
39085
+ throw new Error(`Scan cancelled: ${cancellationToken.reason || "user requested"}`);
39086
+ }
39087
+ };
38807
39088
  log(`[Scanner] repo=${repoInfo.name} mode=${scanModeConfig.mode} depth=${depth} files=${files.length}`);
38808
39089
  if (isIncremental && scanModeConfig.changedFiles) {
38809
39090
  log(`[Scanner] repo=${repoInfo.name} incremental_files=${scanModeConfig.changedFiles.length}`);
@@ -38820,8 +39101,11 @@ var require_dist = __commonJS({
38820
39101
  }
38821
39102
  };
38822
39103
  const filesForAI = isIncremental && scanModeConfig.changedFiles ? files.filter((f) => scanModeConfig.changedFiles.some((cf) => f.path.endsWith(cf) || f.path.includes(cf))) : files;
39104
+ let middlewareConfig;
39105
+ let capturedValidationStats;
38823
39106
  try {
38824
- const middlewareConfig = (0, middleware_detector_1.detectGlobalAuthMiddleware)(files);
39107
+ checkCancelled();
39108
+ middlewareConfig = (0, middleware_detector_1.detectGlobalAuthMiddleware)(files);
38825
39109
  if (middlewareConfig.hasAuthMiddleware) {
38826
39110
  log(`[Scanner] repo=${repoInfo.name} auth_middleware=${middlewareConfig.authType || "unknown"} file=${middlewareConfig.middlewareFile}`);
38827
39111
  }
@@ -38830,16 +39114,18 @@ var require_dist = __commonJS({
38830
39114
  if (filesWithImportedAuth > 0) {
38831
39115
  log(`[Scanner] repo=${repoInfo.name} files_with_imported_auth=${filesWithImportedAuth}`);
38832
39116
  }
39117
+ checkCancelled();
38833
39118
  reportProgress("layer1", "Running surface scan (patterns, entropy, config)...");
38834
- let layer1Result = await (0, layer1_1.runLayer1Scan)(files);
39119
+ let layer1Result = await (0, layer1_1.runLayer1Scan)(files, onProgress, cancellationToken);
38835
39120
  const layer1RawCount = layer1Result.vulnerabilities.length;
38836
39121
  layer1Result = {
38837
39122
  ...layer1Result,
38838
39123
  vulnerabilities: (0, urls_1.aggregateLocalhostFindings)(layer1Result.vulnerabilities)
38839
39124
  };
38840
39125
  log(`[Layer1] repo=${repoInfo.name} findings_raw=${layer1RawCount} findings_deduped=${layer1Result.vulnerabilities.length}`);
39126
+ checkCancelled();
38841
39127
  reportProgress("layer2", "Running structural scan (variables, logic gates)...", layer1Result.vulnerabilities.length);
38842
- const layer2Result = await (0, layer2_1.runLayer2Scan)(files, { middlewareConfig, fileAuthImports });
39128
+ const layer2Result = await (0, layer2_1.runLayer2Scan)(files, { middlewareConfig, fileAuthImports }, onProgress, cancellationToken);
38843
39129
  const heuristicBreakdown = Object.entries(layer2Result.stats.raw).filter(([, count]) => count > 0).map(([name, count]) => `${name}:${count}`).join(",");
38844
39130
  log(`[Layer2] repo=${repoInfo.name} findings_raw=${Object.values(layer2Result.stats.raw).reduce((a, b3) => a + b3, 0)} findings_deduped=${layer2Result.vulnerabilities.length} heuristic_breakdown={${heuristicBreakdown}}`);
38845
39131
  const layer12Findings = [...layer1Result.vulnerabilities, ...layer2Result.vulnerabilities];
@@ -38867,17 +39153,33 @@ var require_dist = __commonJS({
38867
39153
  }
38868
39154
  const maxValidationFiles = scanModeConfig.maxAIValidationFiles || MAX_VALIDATION_CANDIDATES_PER_FILE;
38869
39155
  const cappedValidation = capValidationCandidatesPerFile(afterAutoDismiss, maxValidationFiles);
39156
+ checkCancelled();
38870
39157
  let validatedFindings = cappedValidation;
38871
- let capturedValidationStats = void 0;
39158
+ let capturedValidationStats2 = void 0;
38872
39159
  const shouldValidate = options.enableAI !== false && !scanModeConfig.skipAIValidation && cappedValidation.length > 0;
38873
39160
  if (shouldValidate) {
39161
+ checkCancelled();
38874
39162
  reportProgress("validating", "AI validating findings (entropy, secrets, AI patterns)...", cappedValidation.length);
38875
39163
  const findingsToValidate = isIncremental && scanModeConfig.changedFiles ? cappedValidation.filter((v2) => scanModeConfig.changedFiles.some((cf) => v2.filePath.endsWith(cf) || v2.filePath.includes(cf))) : cappedValidation;
38876
39164
  if (findingsToValidate.length > 0) {
38877
- const validationResult = await (0, anthropic_1.validateFindingsWithAI)(findingsToValidate, filesForAI);
39165
+ const validationResult = await (0, anthropic_1.validateFindingsWithAI)(
39166
+ findingsToValidate,
39167
+ filesForAI,
39168
+ void 0,
39169
+ // projectContext (uses default)
39170
+ onProgress ? (progress) => {
39171
+ onProgress({
39172
+ status: "validating",
39173
+ message: progress.status,
39174
+ filesProcessed: progress.filesProcessed,
39175
+ totalFiles: progress.totalFiles,
39176
+ vulnerabilitiesFound: allVulnerabilities.length
39177
+ });
39178
+ } : void 0
39179
+ );
38878
39180
  validatedFindings = validationResult.vulnerabilities;
38879
39181
  const { stats: validationStats } = validationResult;
38880
- capturedValidationStats = validationStats;
39182
+ capturedValidationStats2 = validationStats;
38881
39183
  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}`);
38882
39184
  log(`[AI Validation] cost_estimate: input_tokens=${validationStats.estimatedInputTokens} output_tokens=${validationStats.estimatedOutputTokens} cost=$${validationStats.estimatedCost.toFixed(4)} api_calls=${validationStats.apiCalls}`);
38883
39185
  const notValidated = cappedValidation.filter((v2) => !findingsToValidate.includes(v2));
@@ -38889,6 +39191,7 @@ var require_dist = __commonJS({
38889
39191
  allVulnerabilities.push(...validatedFindings, ...noValidationNeeded);
38890
39192
  const shouldRunLayer3 = options.enableAI !== false && !scanModeConfig.skipLayer3;
38891
39193
  if (shouldRunLayer3) {
39194
+ checkCancelled();
38892
39195
  reportProgress("layer3", "Running AI semantic analysis...", allVulnerabilities.length);
38893
39196
  const filesToAnalyze = isIncremental ? filesForAI : files;
38894
39197
  const maxLayer3Files = scanModeConfig.maxLayer3Files || 10;
@@ -38906,7 +39209,8 @@ var require_dist = __commonJS({
38906
39209
  hasThrowingHelpers: true,
38907
39210
  summary: authHelperContext.summary
38908
39211
  } : void 0
38909
- }
39212
+ },
39213
+ cancellationToken
38910
39214
  });
38911
39215
  allVulnerabilities.push(...layer3Result.vulnerabilities);
38912
39216
  log(`[Layer3] repo=${repoInfo.name} depth=${depth} files_analyzed=${layer3Result.aiAnalyzed} findings=${layer3Result.vulnerabilities.length}`);
@@ -38933,9 +39237,34 @@ var require_dist = __commonJS({
38933
39237
  hasBlockingIssues,
38934
39238
  scanDuration: Date.now() - startTime,
38935
39239
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
38936
- validationStats: capturedValidationStats
39240
+ validationStats: capturedValidationStats2
38937
39241
  };
38938
39242
  } catch (error) {
39243
+ if (cancellationToken?.cancelled) {
39244
+ reportProgress("failed", "Scan cancelled");
39245
+ const uniqueVulnerabilities = deduplicateVulnerabilities(allVulnerabilities);
39246
+ const resolvedVulnerabilities = resolveContradictions(uniqueVulnerabilities, middlewareConfig);
39247
+ const sortedVulnerabilities = sortBySeverity(resolvedVulnerabilities);
39248
+ const severityCounts = computeSeverityCounts(sortedVulnerabilities);
39249
+ const categoryCounts = computeCategoryCounts(sortedVulnerabilities);
39250
+ return {
39251
+ repoName: repoInfo.name,
39252
+ repoUrl: repoInfo.url,
39253
+ branch: repoInfo.branch,
39254
+ filesScanned: files.length,
39255
+ filesSkipped: 0,
39256
+ vulnerabilities: sortedVulnerabilities,
39257
+ severityCounts,
39258
+ categoryCounts,
39259
+ hasBlockingIssues: false,
39260
+ // Don't block on partial results
39261
+ scanDuration: Date.now() - startTime,
39262
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
39263
+ validationStats: capturedValidationStats,
39264
+ cancelled: true,
39265
+ cancelReason: cancellationToken.reason
39266
+ };
39267
+ }
38939
39268
  reportProgress("failed", `Scan failed: ${error}`);
38940
39269
  throw error;
38941
39270
  }
@@ -39173,6 +39502,10 @@ Found ${group.length} occurrences at lines: ${lineDisplay}`,
39173
39502
  Object.defineProperty(exports2, "validateFindingsWithAI", { enumerable: true, get: function() {
39174
39503
  return anthropic_2.validateFindingsWithAI;
39175
39504
  } });
39505
+ var types_2 = require_types();
39506
+ Object.defineProperty(exports2, "createCancellationToken", { enumerable: true, get: function() {
39507
+ return types_2.createCancellationToken;
39508
+ } });
39176
39509
  }
39177
39510
  });
39178
39511
 
@@ -42798,6 +43131,103 @@ async function callBackendAPI(files, depth, apiKey, repoInfo) {
42798
43131
  }
42799
43132
  return data.result;
42800
43133
  }
43134
+ async function callBackendAPIStream(files, depth, apiKey, repoInfo, onProgress) {
43135
+ const baseUrl = getApiBaseUrl();
43136
+ const streamUrl = `${baseUrl}/v1/scan/stream`;
43137
+ try {
43138
+ const response = await fetch(streamUrl, {
43139
+ method: "POST",
43140
+ headers: {
43141
+ "Content-Type": "application/json",
43142
+ "Authorization": `Bearer ${apiKey}`,
43143
+ "X-Oculum-Client": "cli",
43144
+ "X-Oculum-Version": "1.0.0"
43145
+ },
43146
+ body: JSON.stringify({
43147
+ files,
43148
+ depth,
43149
+ repoName: repoInfo.name,
43150
+ repoUrl: repoInfo.url,
43151
+ branch: repoInfo.branch
43152
+ })
43153
+ });
43154
+ if (!response.ok) {
43155
+ console.warn(`[API] SSE endpoint failed with ${response.status}, falling back to regular POST`);
43156
+ return await callBackendAPI(files, depth, apiKey, repoInfo);
43157
+ }
43158
+ if (!response.body) {
43159
+ throw new APIError("Response body is empty", 500);
43160
+ }
43161
+ const reader = response.body.getReader();
43162
+ const decoder = new TextDecoder();
43163
+ let buffer = "";
43164
+ let result = null;
43165
+ while (true) {
43166
+ const { done, value } = await reader.read();
43167
+ if (done) break;
43168
+ buffer += decoder.decode(value, { stream: true });
43169
+ const lines = buffer.split("\n");
43170
+ buffer = lines.pop() || "";
43171
+ let currentEvent = null;
43172
+ let currentData = null;
43173
+ for (const line of lines) {
43174
+ if (line.startsWith("event: ")) {
43175
+ currentEvent = line.slice(7).trim();
43176
+ } else if (line.startsWith("data: ")) {
43177
+ currentData = line.slice(6).trim();
43178
+ } else if (line === "" && currentEvent && currentData) {
43179
+ try {
43180
+ const data = JSON.parse(currentData);
43181
+ switch (currentEvent) {
43182
+ case "progress":
43183
+ if (onProgress) {
43184
+ onProgress({
43185
+ status: data.status,
43186
+ message: data.message || "",
43187
+ filesProcessed: data.filesProcessed || 0,
43188
+ totalFiles: data.totalFiles || 0,
43189
+ vulnerabilitiesFound: data.vulnerabilitiesFound || 0
43190
+ });
43191
+ }
43192
+ break;
43193
+ case "complete":
43194
+ if (!data.result) {
43195
+ console.error("[API] Complete event received but no result in data:", data);
43196
+ throw new APIError("Backend scan completed but returned no result data", 500);
43197
+ }
43198
+ result = data.result;
43199
+ break;
43200
+ case "error":
43201
+ console.error("[API] Backend sent error event:", data);
43202
+ throw new APIError(
43203
+ data.message || data.error || "Scan failed",
43204
+ data.status || 500,
43205
+ data.error
43206
+ );
43207
+ }
43208
+ } catch (parseError) {
43209
+ if (parseError instanceof APIError) throw parseError;
43210
+ console.error("[API] Failed to parse SSE event:", parseError);
43211
+ }
43212
+ currentEvent = null;
43213
+ currentData = null;
43214
+ }
43215
+ }
43216
+ }
43217
+ if (!result) {
43218
+ console.error("[API] Stream ended without receiving complete event");
43219
+ throw new APIError("No result received from stream", 500);
43220
+ }
43221
+ console.log("[API] SSE stream completed successfully");
43222
+ return result;
43223
+ } catch (error) {
43224
+ if (error instanceof APIError) {
43225
+ throw error;
43226
+ }
43227
+ console.warn("[API] SSE streaming failed, falling back to regular POST:", error);
43228
+ return await callBackendAPI(files, depth, apiKey, repoInfo);
43229
+ }
43230
+ }
42801
43231
  async function verifyApiKey(apiKey) {
42802
43232
  const baseUrl = getApiBaseUrl();
42803
43233
  const url = `${baseUrl}/v1/verify-key`;
@@ -43404,6 +43834,23 @@ async function collectFilesForScan(targetPath, options) {
43404
43834
  }
43405
43835
  return collectFiles(targetPath);
43406
43836
  }
43837
+ function createEmptyResult(targetPath) {
43838
+ return {
43839
+ repoName: (0, import_path3.basename)((0, import_path3.resolve)(targetPath)),
43840
+ repoUrl: "",
43841
+ branch: "local",
43842
+ filesScanned: 0,
43843
+ filesSkipped: 0,
43844
+ vulnerabilities: [],
43845
+ severityCounts: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
43846
+ categoryCounts: {},
43847
+ hasBlockingIssues: false,
43848
+ scanDuration: 0,
43849
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
43850
+ cancelled: true,
43851
+ cancelReason: "User cancelled scan"
43852
+ };
43853
+ }
43407
43854
  function formatOutput(result, format, noColor) {
43408
43855
  switch (format) {
43409
43856
  case "json":
@@ -43501,6 +43948,10 @@ var quietSpinner = {
43501
43948
  if (msg) console.error(msg);
43502
43949
  return quietSpinner;
43503
43950
  },
43951
+ warn: (msg) => {
43952
+ if (msg) console.warn(msg);
43953
+ return quietSpinner;
43954
+ },
43504
43955
  stop: () => quietSpinner,
43505
43956
  text: ""
43506
43957
  };
@@ -43508,6 +43959,7 @@ async function runScanOnce(targetPath, options) {
43508
43959
  const spinner = options.quiet ? quietSpinner : ora();
43509
43960
  const config = getConfig();
43510
43961
  const noColor = options.color === false;
43962
+ const cancellationToken = (0, import_scanner.createCancellationToken)();
43511
43963
  if ((options.depth === "validated" || options.depth === "deep") && !isAuthenticated()) {
43512
43964
  if (!options.quiet) {
43513
43965
  console.log("");
@@ -43540,19 +43992,28 @@ async function runScanOnce(targetPath, options) {
43540
43992
  }
43541
43993
  spinner.succeed(`Found ${files.length} files to scan`);
43542
43994
  const onProgress = (progress) => {
43543
- const fileInfo = source_default.dim(`(${progress.totalFiles} files)`);
43995
+ if (!options.quiet && !options.verbose) {
43996
+ if (progress.filesProcessed && progress.filesProcessed < progress.totalFiles) {
43997
+ console.log(`[Progress] ${progress.status}: ${progress.filesProcessed}/${progress.totalFiles} files`);
43998
+ }
43999
+ } else if (options.verbose) {
44000
+ if (progress.filesProcessed && progress.filesProcessed < progress.totalFiles) {
44001
+ console.log(`[Progress] ${progress.status}: ${progress.filesProcessed}/${progress.totalFiles} files`);
44002
+ }
44003
+ }
44004
+ const fileProgress = progress.filesProcessed && progress.filesProcessed < progress.totalFiles ? ` \u2022 ${progress.filesProcessed}/${progress.totalFiles} files` : ` (${progress.totalFiles} files)`;
43544
44005
  switch (progress.status) {
43545
44006
  case "layer1":
43546
- spinner.text = `Layer 1: Pattern matching ${fileInfo} ${source_default.dim(`\u2192 ${progress.vulnerabilitiesFound} candidates`)}`;
44007
+ spinner.text = `Layer 1: Pattern matching${fileProgress}`;
43547
44008
  break;
43548
44009
  case "layer2":
43549
- spinner.text = `Layer 2: Code structure analysis ${fileInfo} ${source_default.dim(`\u2192 ${progress.vulnerabilitiesFound} findings`)}`;
44010
+ spinner.text = `Layer 2: Code structure analysis${fileProgress}`;
43550
44011
  break;
43551
44012
  case "validating":
43552
- spinner.text = `AI validation ${source_default.dim(`\u2192 reviewing ${progress.vulnerabilitiesFound} candidates`)}`;
44013
+ spinner.text = `AI validation${fileProgress}`;
43553
44014
  break;
43554
44015
  case "layer3":
43555
- spinner.text = `Layer 3: Deep AI analysis ${source_default.dim(`\u2192 semantic security check`)}`;
44016
+ spinner.text = `Layer 3: Deep AI analysis${fileProgress}`;
43556
44017
  break;
43557
44018
  case "complete":
43558
44019
  const issueText = progress.vulnerabilitiesFound === 1 ? "issue" : "issues";
@@ -43568,12 +44029,24 @@ async function runScanOnce(targetPath, options) {
43568
44029
  }
43569
44030
  };
43570
44031
  let result;
44032
+ let sigintHandler = null;
44033
+ if (!options.quiet) {
44034
+ sigintHandler = () => {
44035
+ spinner.warn(source_default.yellow("\n\u26A0\uFE0F Cancelling scan... (press Ctrl+C again to force quit)"));
44036
+ cancellationToken.cancel("User pressed Ctrl+C");
44037
+ process.once("SIGINT", () => {
44038
+ spinner.fail(source_default.red("Scan aborted forcefully"));
44039
+ process.exit(130);
44040
+ });
44041
+ };
44042
+ process.once("SIGINT", sigintHandler);
44043
+ }
43571
44044
  try {
43572
44045
  spinner.start("Starting scan...");
43573
44046
  const hasLocalAI = !!process.env.ANTHROPIC_API_KEY;
43574
44047
  if (options.depth !== "cheap" && isAuthenticated() && !hasLocalAI) {
43575
- spinner.text = `Sending ${files.length} files to backend for ${options.depth} scan...`;
43576
- result = await callBackendAPI(
44048
+ spinner.text = `Backend ${options.depth} scan starting...`;
44049
+ result = await callBackendAPIStream(
43577
44050
  files,
43578
44051
  options.depth,
43579
44052
  config.apiKey,
@@ -43581,9 +44054,11 @@ async function runScanOnce(targetPath, options) {
43581
44054
  name: (0, import_path3.basename)((0, import_path3.resolve)(targetPath)),
43582
44055
  url: "",
43583
44056
  branch: "local"
43584
- }
44057
+ },
44058
+ onProgress
44059
+ // Stream real-time progress from backend
43585
44060
  );
43586
- spinner.succeed(`Backend scan complete`);
44061
+ spinner.succeed(`Backend ${options.depth} scan complete`);
43587
44062
  } else {
43588
44063
  const enableAI = options.depth !== "cheap" && hasLocalAI;
43589
44064
  const shouldBeQuiet = options.quiet ?? !options.verbose;
@@ -43597,15 +44072,38 @@ async function runScanOnce(targetPath, options) {
43597
44072
  {
43598
44073
  enableAI,
43599
44074
  scanDepth: options.depth,
43600
- quiet: shouldBeQuiet
44075
+ quiet: shouldBeQuiet,
44076
+ cancellationToken
43601
44077
  },
43602
44078
  onProgress
43603
44079
  );
43604
44080
  }
43605
44081
  } catch (err) {
44082
+ if (cancellationToken.cancelled) {
44083
+ spinner.warn(source_default.yellow(`
44084
+ \u26A0\uFE0F Scan cancelled after processing some files`));
44085
+ if (result && result.vulnerabilities) {
44086
+ console.log(source_default.dim(`
44087
+ \u{1F4CA} Partial results: ${result.vulnerabilities.length} issues found before cancellation
44088
+ `));
44089
+ }
44090
+ return {
44091
+ result: result || createEmptyResult(targetPath),
44092
+ output: "Scan cancelled",
44093
+ exitCode: 130
44094
+ // Standard SIGINT exit code
44095
+ };
44096
+ }
43606
44097
  const enhanced = enhanceError(err);
43607
44098
  spinner.fail(enhanced.message);
43608
44099
  throw err;
44100
+ } finally {
44101
+ if (sigintHandler) {
44102
+ process.off("SIGINT", sigintHandler);
44103
+ }
44104
+ }
44105
+ if (!result) {
44106
+ throw new Error("Scan failed: no result returned");
43609
44107
  }
43610
44108
  const output = formatOutput(result, options.format, noColor);
43611
44109
  if (options.output) {
@@ -47869,7 +48367,7 @@ var usageCommand = new Command("usage").description("Show current usage and quot
47869
48367
 
47870
48368
  // src/index.ts
47871
48369
  var program2 = new Command();
47872
- program2.name("oculum").description("AI-native security scanner for detecting vulnerabilities in LLM-generated code").version("1.0.0").addHelpText("after", `
48370
+ program2.name("oculum").description("AI-native security scanner for detecting vulnerabilities in LLM-generated code").version("1.0.5").addHelpText("after", `
47873
48371
  Quick Start:
47874
48372
  $ oculum scan . Scan current directory (free)
47875
48373
  $ oculum ui Interactive mode with guided setup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oculum/cli",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "AI-native security scanner CLI for detecting vulnerabilities in AI-generated code, BYOK patterns, and modern web applications",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -18,13 +18,13 @@
18
18
  "url": "https://github.com/flexipie/oculum/issues"
19
19
  },
20
20
  "scripts": {
21
- "build": "esbuild src/index.ts --bundle --platform=node --target=node18 --outfile=dist/index.js --banner:js=\"#!/usr/bin/env node\" --define:process.env.OCULUM_API_URL='undefined'",
21
+ "build": "esbuild src/index.ts --bundle --platform=node --target=node18 --outfile=dist/index.js --banner:js=\"#!/usr/bin/env node\" --define:process.env.OCULUM_API_URL='undefined' --define:VERSION='\"1.0.5\"'",
22
22
  "dev": "npm run build -- --watch",
23
23
  "test": "echo \"No tests configured yet\"",
24
24
  "lint": "eslint src/"
25
25
  },
26
26
  "dependencies": {
27
- "@oculum/scanner": "^1.0.0",
27
+ "@oculum/scanner": "^1.0.2",
28
28
  "@oculum/shared": "^1.0.0",
29
29
  "commander": "^12.1.0",
30
30
  "chalk": "^5.3.0",