@oculum/scanner 1.0.5 → 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.
@@ -24,6 +24,49 @@ import { buildProjectContext, getFileValidationContext, type ProjectContext } fr
24
24
  // Import tier system for tier-aware auto-dismiss
25
25
  import { getTierForCategory, type DetectorTier } from '../tiers'
26
26
 
27
+ // ============================================================================
28
+ // Path Normalization Helpers (for AI response path matching)
29
+ // ============================================================================
30
+
31
+ /**
32
+ * Normalize a file path for comparison purposes.
33
+ * Handles common variations: ./src/file.ts, src/file.ts, /src/file.ts
34
+ */
35
+ function normalizePathForComparison(path: string): string {
36
+ return path
37
+ .replace(/^\.\//, '') // Remove leading ./
38
+ .replace(/^\//, '') // Remove leading /
39
+ .replace(/\\/g, '/') // Normalize Windows backslashes
40
+ }
41
+
42
+ /**
43
+ * Find a matching file path from expected paths, handling path format variations.
44
+ * AI responses may use different path formats than what we sent.
45
+ */
46
+ function findMatchingFilePath(responsePath: string, expectedPaths: string[]): string | null {
47
+ // Exact match first
48
+ if (expectedPaths.includes(responsePath)) return responsePath
49
+
50
+ // Normalized match
51
+ const normalized = normalizePathForComparison(responsePath)
52
+ for (const expected of expectedPaths) {
53
+ if (normalizePathForComparison(expected) === normalized) {
54
+ console.log(`[AI Validation] Path fuzzy matched: "${responsePath}" -> "${expected}"`)
55
+ return expected
56
+ }
57
+ }
58
+
59
+ // Basename match (only if unique) - handles cases like "file.ts" matching "src/api/file.ts"
60
+ const basename = responsePath.split('/').pop() || responsePath
61
+ const matches = expectedPaths.filter(p => (p.split('/').pop() || p) === basename)
62
+ if (matches.length === 1) {
63
+ console.log(`[AI Validation] Path basename matched: "${responsePath}" -> "${matches[0]}"`)
64
+ return matches[0]
65
+ }
66
+
67
+ return null
68
+ }
69
+
27
70
  // ============================================================================
28
71
  // Cost Monitoring Types
29
72
  // ============================================================================
@@ -949,15 +992,14 @@ For each candidate finding, return:
949
992
  {
950
993
  "index": <number>,
951
994
  "keep": true | false,
952
- "adjustedSeverity": "critical" | "high" | "medium" | "low" | "info" | null, // Only if keep=true
953
- "notes": "<concise context for developer>" // Only if keep=true, 1-2 sentences max
995
+ "notes": "<concise context>" | null,
996
+ "adjustedSeverity": "critical" | "high" | "medium" | "low" | "info" | null
954
997
  }
955
998
  \`\`\`
956
999
 
957
1000
  **CRITICAL**: To minimize costs:
958
- - For \`keep: false\` (rejected): ONLY include \`index\` and \`keep\` fields. NO explanation needed.
959
- - For \`keep: true\` (accepted): Include \`notes\` field with brief context (10-30 words). Be concise.
960
- - Omit \`adjustedSeverity\` if keeping original severity (null is wasteful).
1001
+ - For \`keep: false\` (rejected): Set \`notes: null\` and \`adjustedSeverity: null\`. NO explanation needed.
1002
+ - For \`keep: true\` (accepted): Include \`notes\` field with brief context (10-30 words). Set \`adjustedSeverity: null\` if keeping original severity.
961
1003
 
962
1004
  ## Severity Guidelines
963
1005
  - **critical/high**: Realistically exploitable, should block deploys - ONLY for clear vulnerabilities
@@ -1235,9 +1277,18 @@ async function validateWithOpenAI(
1235
1277
  type: 'object',
1236
1278
  properties: {
1237
1279
  index: { type: 'number' },
1238
- keep: { type: 'boolean' }
1280
+ keep: { type: 'boolean' },
1281
+ notes: {
1282
+ type: ['string', 'null'],
1283
+ default: null
1284
+ },
1285
+ adjustedSeverity: {
1286
+ type: ['string', 'null'],
1287
+ enum: ['critical', 'high', 'medium', 'low', 'info', null],
1288
+ default: null
1289
+ }
1239
1290
  },
1240
- required: ['index', 'keep'],
1291
+ required: ['index', 'keep', 'notes', 'adjustedSeverity'],
1241
1292
  additionalProperties: false
1242
1293
  }
1243
1294
  }
@@ -1298,9 +1349,15 @@ async function validateWithOpenAI(
1298
1349
  let parsedContent: any
1299
1350
  try {
1300
1351
  parsedContent = JSON.parse(content)
1352
+ console.log(`[OpenAI Debug] Raw parsed content keys:`, Object.keys(parsedContent))
1301
1353
  // Unwrap the validations array if present (from structured output)
1302
1354
  if (parsedContent.validations && Array.isArray(parsedContent.validations)) {
1355
+ console.log(`[OpenAI Debug] Unwrapping 'validations' array with ${parsedContent.validations.length} items`)
1303
1356
  parsedContent = parsedContent.validations
1357
+ } else if (Array.isArray(parsedContent)) {
1358
+ console.log(`[OpenAI Debug] Content is already an array with ${parsedContent.length} items`)
1359
+ } else {
1360
+ console.log(`[OpenAI Debug] Content structure:`, typeof parsedContent, Array.isArray(parsedContent))
1304
1361
  }
1305
1362
  } catch (e) {
1306
1363
  console.warn('[OpenAI] Failed to parse JSON response:', e)
@@ -1314,39 +1371,53 @@ async function validateWithOpenAI(
1314
1371
  expectedFiles
1315
1372
  )
1316
1373
 
1374
+ console.log(`[OpenAI] Batch ${batchNum} parsed ${validationResultsMap.size} file results from ${fileDataList.length} files`)
1375
+ if (validationResultsMap.size === 0) {
1376
+ console.warn(`[OpenAI] WARNING: No file results parsed! Content type: ${typeof parsedContent}, isArray: ${Array.isArray(parsedContent)}`)
1377
+ if (Array.isArray(parsedContent) && parsedContent.length > 0) {
1378
+ console.log(`[OpenAI] First item structure:`, Object.keys(parsedContent[0]))
1379
+ }
1380
+ }
1381
+
1382
+ // Log any missing files from the response (these will be REJECTED)
1383
+ if (validationResultsMap.size !== fileDataList.length) {
1384
+ const missing = fileDataList
1385
+ .filter(({ filePath }) => !validationResultsMap.has(filePath))
1386
+ .map(({ filePath }) => filePath)
1387
+ if (missing.length > 0) {
1388
+ console.warn(`[OpenAI] Missing ${missing.length} files from response (will be REJECTED): ${missing.join(', ')}`)
1389
+ }
1390
+ }
1391
+
1317
1392
  // Apply results per file
1318
1393
  for (const { filePath, findings: fileFindings } of fileDataList) {
1319
1394
  const fileResults = validationResultsMap.get(filePath)
1395
+ console.log(`[OpenAI] File ${filePath}: ${fileResults?.length || 0} validation results for ${fileFindings.length} findings`)
1320
1396
 
1321
1397
  if (!fileResults || fileResults.length === 0) {
1322
1398
  const singleFileResults = parseValidationResponse(content)
1323
1399
  if (singleFileResults.length > 0 && fileDataList.length === 1) {
1324
- const processedFindings = applyValidationResults(fileFindings, singleFileResults)
1400
+ const { processed: processedFindings, dismissedCount } = applyValidationResults(fileFindings, singleFileResults)
1401
+ statsLock.validatedFindings += processedFindings.length + dismissedCount
1402
+ statsLock.dismissedFindings += dismissedCount
1325
1403
  for (const processed of processedFindings) {
1326
- statsLock.validatedFindings++
1327
1404
  if (processed.validationStatus === 'confirmed') statsLock.confirmedFindings++
1328
- else if (processed.validationStatus === 'dismissed') statsLock.dismissedFindings++
1329
1405
  else if (processed.validationStatus === 'downgraded') statsLock.downgradedFindings++
1330
1406
  batchFindings.push(processed)
1331
1407
  }
1332
1408
  } else {
1333
- for (const f of fileFindings) {
1334
- statsLock.validatedFindings++
1335
- statsLock.confirmedFindings++
1336
- batchFindings.push({
1337
- ...f,
1338
- validatedByAI: true,
1339
- validationStatus: 'confirmed' as ValidationStatus,
1340
- validationNotes: 'Kept by default - no explicit validation result',
1341
- })
1342
- }
1409
+ // No validation results - REJECT all findings for this file (conservative approach)
1410
+ console.warn(`[OpenAI] No validation results for ${filePath} - REJECTING ${fileFindings.length} findings`)
1411
+ statsLock.validatedFindings += fileFindings.length
1412
+ statsLock.dismissedFindings += fileFindings.length
1413
+ // Don't add to batchFindings - findings are rejected
1343
1414
  }
1344
1415
  } else {
1345
- const processedFindings = applyValidationResults(fileFindings, fileResults)
1416
+ const { processed: processedFindings, dismissedCount } = applyValidationResults(fileFindings, fileResults)
1417
+ statsLock.validatedFindings += processedFindings.length + dismissedCount
1418
+ statsLock.dismissedFindings += dismissedCount
1346
1419
  for (const processed of processedFindings) {
1347
- statsLock.validatedFindings++
1348
1420
  if (processed.validationStatus === 'confirmed') statsLock.confirmedFindings++
1349
- else if (processed.validationStatus === 'dismissed') statsLock.dismissedFindings++
1350
1421
  else if (processed.validationStatus === 'downgraded') statsLock.downgradedFindings++
1351
1422
  batchFindings.push(processed)
1352
1423
  }
@@ -1647,42 +1718,32 @@ export async function validateFindingsWithAI(
1647
1718
 
1648
1719
  if (singleFileResults.length > 0 && fileDataList.length === 1) {
1649
1720
  // Single file in batch, use single-file parsing
1650
- const processedFindings = applyValidationResults(findings, singleFileResults)
1721
+ const { processed: processedFindings, dismissedCount } = applyValidationResults(findings, singleFileResults)
1722
+ stats.validatedFindings += processedFindings.length + dismissedCount
1723
+ stats.dismissedFindings += dismissedCount
1651
1724
  for (const processed of processedFindings) {
1652
- stats.validatedFindings++
1653
1725
  if (processed.validationStatus === 'confirmed') {
1654
1726
  stats.confirmedFindings++
1655
- } else if (processed.validationStatus === 'dismissed') {
1656
- stats.dismissedFindings++
1657
1727
  } else if (processed.validationStatus === 'downgraded') {
1658
1728
  stats.downgradedFindings++
1659
1729
  }
1660
1730
  validatedFindings.push(processed)
1661
1731
  }
1662
1732
  } else {
1663
- // Keep findings but mark as validation failed for this file
1664
- console.warn(`[AI Validation] No results for ${filePath}, keeping findings unvalidated`)
1665
- for (const f of findings) {
1666
- stats.validatedFindings++
1667
- stats.confirmedFindings++ // Keep by default
1668
- validatedFindings.push({
1669
- ...f,
1670
- validatedByAI: true,
1671
- validationStatus: 'confirmed' as ValidationStatus,
1672
- validationNotes: 'Kept by default - no explicit validation result',
1673
- })
1674
- }
1733
+ // No validation results - REJECT all findings for this file (conservative approach)
1734
+ console.warn(`[AI Validation] No results for ${filePath} - REJECTING ${findings.length} findings`)
1735
+ stats.validatedFindings += findings.length
1736
+ stats.dismissedFindings += findings.length
1737
+ // Don't add to validatedFindings - findings are rejected
1675
1738
  }
1676
1739
  } else {
1677
1740
  // Apply validation results for this file
1678
- const processedFindings = applyValidationResults(findings, fileResults)
1679
-
1741
+ const { processed: processedFindings, dismissedCount } = applyValidationResults(findings, fileResults)
1742
+ stats.validatedFindings += processedFindings.length + dismissedCount
1743
+ stats.dismissedFindings += dismissedCount
1680
1744
  for (const processed of processedFindings) {
1681
- stats.validatedFindings++
1682
1745
  if (processed.validationStatus === 'confirmed') {
1683
1746
  stats.confirmedFindings++
1684
- } else if (processed.validationStatus === 'dismissed') {
1685
- stats.dismissedFindings++
1686
1747
  } else if (processed.validationStatus === 'downgraded') {
1687
1748
  stats.downgradedFindings++
1688
1749
  }
@@ -1985,7 +2046,15 @@ function parseMultiFileValidationResponse(
1985
2046
  continue
1986
2047
  }
1987
2048
 
1988
- const filePath = fileResult.file
2049
+ // Use path normalization to match AI response paths to expected paths
2050
+ const responsePath = fileResult.file
2051
+ const matchedPath = findMatchingFilePath(responsePath, expectedFiles)
2052
+
2053
+ if (!matchedPath) {
2054
+ console.warn(`[AI Validation] Multi-file: Could not match path "${responsePath}" to any expected file`)
2055
+ continue
2056
+ }
2057
+
1989
2058
  const validations: ValidationResult[] = fileResult.validations
1990
2059
  .filter((item: any) =>
1991
2060
  typeof item.index === 'number' &&
@@ -1994,7 +2063,7 @@ function parseMultiFileValidationResponse(
1994
2063
  .map((item: any) => {
1995
2064
  // Normalize notes field: prefer new 'notes', fallback to legacy 'reason' or 'validationNotes'
1996
2065
  const notes = item.notes || item.validationNotes || item.reason || undefined
1997
-
2066
+
1998
2067
  return {
1999
2068
  index: item.index,
2000
2069
  keep: item.keep,
@@ -2006,14 +2075,13 @@ function parseMultiFileValidationResponse(
2006
2075
  }
2007
2076
  })
2008
2077
 
2009
- resultMap.set(filePath, validations)
2078
+ resultMap.set(matchedPath, validations)
2010
2079
  }
2011
2080
 
2012
- // Log any files that weren't in the response
2013
- for (const expectedFile of expectedFiles) {
2014
- if (!resultMap.has(expectedFile)) {
2015
- console.warn(`[AI Validation] Multi-file: No results for ${expectedFile}`)
2016
- }
2081
+ // Log any files that weren't in the response (these will be REJECTED by default)
2082
+ const missingFiles = expectedFiles.filter(f => !resultMap.has(f))
2083
+ if (missingFiles.length > 0) {
2084
+ console.warn(`[AI Validation] Multi-file: Missing ${missingFiles.length} files from response: ${missingFiles.join(', ')}`)
2017
2085
  }
2018
2086
 
2019
2087
  } catch (error) {
@@ -2029,22 +2097,20 @@ function parseMultiFileValidationResponse(
2029
2097
  function applyValidationResults(
2030
2098
  findings: Vulnerability[],
2031
2099
  validationResults: ValidationResult[]
2032
- ): Vulnerability[] {
2100
+ ): { processed: Vulnerability[]; dismissedCount: number } {
2033
2101
  const processed: Vulnerability[] = []
2102
+ let dismissedCount = 0
2034
2103
 
2035
2104
  for (let i = 0; i < findings.length; i++) {
2036
2105
  const finding = findings[i]
2037
2106
  const validation = validationResults.find(v => v.index === i)
2038
2107
 
2039
2108
  if (!validation) {
2040
- // No validation result - keep with warning
2041
- processed.push({
2042
- ...finding,
2043
- validatedByAI: true,
2044
- validationStatus: 'confirmed' as ValidationStatus,
2045
- validationNotes: 'No explicit validation result - kept by default',
2046
- })
2047
- continue
2109
+ // No validation result - REJECT by default (conservative approach)
2110
+ // If AI doesn't explicitly validate a finding, assume it's a false positive
2111
+ console.warn(`[AI Validation] No result for finding ${i}: ${finding.title} - REJECTING`)
2112
+ dismissedCount++
2113
+ continue // Don't add to processed - finding is removed
2048
2114
  }
2049
2115
 
2050
2116
  if (validation.keep) {
@@ -2074,11 +2140,12 @@ function applyValidationResults(
2074
2140
  } else {
2075
2141
  // Finding was dismissed - no need to log verbose reason (cost optimization)
2076
2142
  console.log(`[AI Validation] Rejected: ${finding.title} at ${finding.filePath}:${finding.lineNumber}`)
2143
+ dismissedCount++
2077
2144
  // Don't add to processed - finding is removed
2078
2145
  }
2079
2146
  }
2080
2147
 
2081
- return processed
2148
+ return { processed, dismissedCount }
2082
2149
  }
2083
2150
 
2084
2151
  /**