@oculum/cli 1.0.4 → 1.0.7

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 +417 -21
  2. package/package.json +2 -2
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
 
@@ -43404,6 +43737,23 @@ async function collectFilesForScan(targetPath, options) {
43404
43737
  }
43405
43738
  return collectFiles(targetPath);
43406
43739
  }
43740
+ function createEmptyResult(targetPath) {
43741
+ return {
43742
+ repoName: (0, import_path3.basename)((0, import_path3.resolve)(targetPath)),
43743
+ repoUrl: "",
43744
+ branch: "local",
43745
+ filesScanned: 0,
43746
+ filesSkipped: 0,
43747
+ vulnerabilities: [],
43748
+ severityCounts: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
43749
+ categoryCounts: {},
43750
+ hasBlockingIssues: false,
43751
+ scanDuration: 0,
43752
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
43753
+ cancelled: true,
43754
+ cancelReason: "User cancelled scan"
43755
+ };
43756
+ }
43407
43757
  function formatOutput(result, format, noColor) {
43408
43758
  switch (format) {
43409
43759
  case "json":
@@ -43501,6 +43851,10 @@ var quietSpinner = {
43501
43851
  if (msg) console.error(msg);
43502
43852
  return quietSpinner;
43503
43853
  },
43854
+ warn: (msg) => {
43855
+ if (msg) console.warn(msg);
43856
+ return quietSpinner;
43857
+ },
43504
43858
  stop: () => quietSpinner,
43505
43859
  text: ""
43506
43860
  };
@@ -43508,6 +43862,7 @@ async function runScanOnce(targetPath, options) {
43508
43862
  const spinner = options.quiet ? quietSpinner : ora();
43509
43863
  const config = getConfig();
43510
43864
  const noColor = options.color === false;
43865
+ const cancellationToken = (0, import_scanner.createCancellationToken)();
43511
43866
  if ((options.depth === "validated" || options.depth === "deep") && !isAuthenticated()) {
43512
43867
  if (!options.quiet) {
43513
43868
  console.log("");
@@ -43540,22 +43895,28 @@ async function runScanOnce(targetPath, options) {
43540
43895
  }
43541
43896
  spinner.succeed(`Found ${files.length} files to scan`);
43542
43897
  const onProgress = (progress) => {
43543
- if (options.verbose) {
43544
- console.log(`[Progress] ${progress.status}: ${progress.filesProcessed}/${progress.totalFiles} files, ${progress.vulnerabilitiesFound} findings`);
43898
+ if (!options.quiet && !options.verbose) {
43899
+ if (progress.filesProcessed && progress.filesProcessed < progress.totalFiles) {
43900
+ console.log(`[Progress] ${progress.status}: ${progress.filesProcessed}/${progress.totalFiles} files`);
43901
+ }
43902
+ } else if (options.verbose) {
43903
+ if (progress.filesProcessed && progress.filesProcessed < progress.totalFiles) {
43904
+ console.log(`[Progress] ${progress.status}: ${progress.filesProcessed}/${progress.totalFiles} files`);
43905
+ }
43545
43906
  }
43546
43907
  const fileProgress = progress.filesProcessed && progress.filesProcessed < progress.totalFiles ? ` \u2022 ${progress.filesProcessed}/${progress.totalFiles} files` : ` (${progress.totalFiles} files)`;
43547
43908
  switch (progress.status) {
43548
43909
  case "layer1":
43549
- spinner.text = `Layer 1: Pattern matching${fileProgress} ${source_default.dim(`\u2192 ${progress.vulnerabilitiesFound} candidates`)}`;
43910
+ spinner.text = `Layer 1: Pattern matching${fileProgress}`;
43550
43911
  break;
43551
43912
  case "layer2":
43552
- spinner.text = `Layer 2: Code structure analysis${fileProgress} ${source_default.dim(`\u2192 ${progress.vulnerabilitiesFound} findings`)}`;
43913
+ spinner.text = `Layer 2: Code structure analysis${fileProgress}`;
43553
43914
  break;
43554
43915
  case "validating":
43555
- spinner.text = `AI validation${fileProgress} ${source_default.dim(`\u2192 reviewing ${progress.vulnerabilitiesFound} candidates`)}`;
43916
+ spinner.text = `AI validation${fileProgress}`;
43556
43917
  break;
43557
43918
  case "layer3":
43558
- spinner.text = `Layer 3: Deep AI analysis${fileProgress} ${source_default.dim(`\u2192 semantic security check`)}`;
43919
+ spinner.text = `Layer 3: Deep AI analysis${fileProgress}`;
43559
43920
  break;
43560
43921
  case "complete":
43561
43922
  const issueText = progress.vulnerabilitiesFound === 1 ? "issue" : "issues";
@@ -43571,6 +43932,18 @@ async function runScanOnce(targetPath, options) {
43571
43932
  }
43572
43933
  };
43573
43934
  let result;
43935
+ let sigintHandler = null;
43936
+ if (!options.quiet) {
43937
+ sigintHandler = () => {
43938
+ spinner.warn(source_default.yellow("\n\u26A0\uFE0F Cancelling scan... (press Ctrl+C again to force quit)"));
43939
+ cancellationToken.cancel("User pressed Ctrl+C");
43940
+ process.once("SIGINT", () => {
43941
+ spinner.fail(source_default.red("Scan aborted forcefully"));
43942
+ process.exit(130);
43943
+ });
43944
+ };
43945
+ process.once("SIGINT", sigintHandler);
43946
+ }
43574
43947
  try {
43575
43948
  spinner.start("Starting scan...");
43576
43949
  const hasLocalAI = !!process.env.ANTHROPIC_API_KEY;
@@ -43600,15 +43973,38 @@ async function runScanOnce(targetPath, options) {
43600
43973
  {
43601
43974
  enableAI,
43602
43975
  scanDepth: options.depth,
43603
- quiet: shouldBeQuiet
43976
+ quiet: shouldBeQuiet,
43977
+ cancellationToken
43604
43978
  },
43605
43979
  onProgress
43606
43980
  );
43607
43981
  }
43608
43982
  } catch (err) {
43983
+ if (cancellationToken.cancelled) {
43984
+ spinner.warn(source_default.yellow(`
43985
+ \u26A0\uFE0F Scan cancelled after processing some files`));
43986
+ if (result && result.vulnerabilities) {
43987
+ console.log(source_default.dim(`
43988
+ \u{1F4CA} Partial results: ${result.vulnerabilities.length} issues found before cancellation
43989
+ `));
43990
+ }
43991
+ return {
43992
+ result: result || createEmptyResult(targetPath),
43993
+ output: "Scan cancelled",
43994
+ exitCode: 130
43995
+ // Standard SIGINT exit code
43996
+ };
43997
+ }
43609
43998
  const enhanced = enhanceError(err);
43610
43999
  spinner.fail(enhanced.message);
43611
44000
  throw err;
44001
+ } finally {
44002
+ if (sigintHandler) {
44003
+ process.off("SIGINT", sigintHandler);
44004
+ }
44005
+ }
44006
+ if (!result) {
44007
+ throw new Error("Scan failed: no result returned");
43612
44008
  }
43613
44009
  const output = formatOutput(result, options.format, noColor);
43614
44010
  if (options.output) {
@@ -47872,7 +48268,7 @@ var usageCommand = new Command("usage").description("Show current usage and quot
47872
48268
 
47873
48269
  // src/index.ts
47874
48270
  var program2 = new Command();
47875
- program2.name("oculum").description("AI-native security scanner for detecting vulnerabilities in LLM-generated code").version("1.0.4").addHelpText("after", `
48271
+ program2.name("oculum").description("AI-native security scanner for detecting vulnerabilities in LLM-generated code").version("1.0.7").addHelpText("after", `
47876
48272
  Quick Start:
47877
48273
  $ oculum scan . Scan current directory (free)
47878
48274
  $ 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.4",
3
+ "version": "1.0.7",
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,7 +18,7 @@
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' --define:VERSION='\"1.0.4\"'",
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.7\"'",
22
22
  "dev": "npm run build -- --watch",
23
23
  "test": "echo \"No tests configured yet\"",
24
24
  "lint": "eslint src/"