diff --git a/src/index.ts b/src/index.ts index 5b4f17fc..36f48bc2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,7 @@ import { printCompactOutput, printCoverage, printFinalStatus, + printIncompleteDiagnostics, printOverrideHint, printSkippedDependencies, printSuggestedFixCommands, @@ -578,6 +579,27 @@ if (parsedArgs) { // baseline. Re-saving here would silently absorb a new vulnerability into a // fresh baseline, so a ratcheted CI job could never fail on a regression. if (options.ratchet) { + // A baseline records detection results, so detection-impact gaps make it + // untrustworthy: refuse to save or evaluate one from partial data. + // Remediation-only gaps do not change the finding set, so they only warn. + if (!scanState.completeness.complete) { + printIncompleteDiagnostics(scanState.completeness); + } + const hasDetectionGaps = scanState.completeness.diagnostics.some(d => d.impact === "detection"); + if (hasDetectionGaps) { + console.log(chalk.red("Scan data is incomplete. Refusing to create or evaluate a baseline from partial detection data. Resolve the issues above and re-run --ratchet.")); + auditLogHandle.emit({ + ts: new Date().toISOString(), + type: "scan.finished", + schemaVersion: 1, + durationMs: Date.now() - scanStartTime, + findingsCount: scanState.sorted.length, + exitCode: EXIT_ERROR, + }); + auditLogHandle.close(); + process.exit(EXIT_ERROR); + return; + } const outcome = ratchetOutcome(baseline, scanState.sorted); if (outcome.action === "save") { writeBaseline(projectArg ?? ".", scanState.sorted); @@ -666,6 +688,7 @@ if (parsedArgs) { coverage: scanState.coverage, minSeverity: scanState.minSeverity, tableFindings: scanState.tableFindings, + completeness: scanState.completeness, overrideFindings, maintenanceFindings, }, scanInput, projectPath); @@ -702,13 +725,13 @@ if (parsedArgs) { console.log("\n" + renderMaintenanceFindings(maintenanceFindings, { verbose: true })); } printCoverage([...scanInput.notes, ...scanState.coverage]); - printFinalStatus(scanState.sorted, overrideCount); + printFinalStatus(scanState.sorted, overrideCount, scanState.completeness); if (showOverrideHint) printOverrideHint(); } else { const compactPmLabel = scanState.suggestedFixCommands ? `${chalk.cyan(scanState.suggestedFixCommands.packageManager)} ${chalk.gray(`(${scanState.suggestedFixCommands.sourceLabel})`)}` : undefined; - printCompactOutput(scanState.sorted, scanInput, { offline, all: !!options.all, packageManager: compactPmLabel }); + printCompactOutput(scanState.sorted, scanInput, { offline, all: !!options.all, packageManager: compactPmLabel, completeness: scanState.completeness }); if (options.checkOverrides) { console.log("\n" + renderOverrideFindings(overrideFindings, { verbose: false, projectPath })); } @@ -740,6 +763,7 @@ if (parsedArgs) { overrideFindings: options.checkOverrides ? overrideFindings : undefined, // Same undefined-vs-[] convention as overrideFindings above. maintenanceFindings: options.checkMaintenance ? maintenanceFindings : undefined, + completeness: scanState.completeness, }); const { reportPath } = await writeHtmlReport({ outputDir, @@ -826,7 +850,7 @@ async function scanProject(params: { fetchImpl?: typeof fetch; }) { const directDependencyNames = readDirectDependencyNames(params.projectPath, !!params.options.prodOnly); - const findings = await scanPackages(params.scanInput.packages, params.batchSize, params.options, { + const { findings, completeness } = await scanPackages(params.scanInput.packages, params.batchSize, params.options, { directDependencyNames, scanSource: params.scanInput.source, scanFilePath: params.scanInput.filePath, @@ -887,6 +911,7 @@ async function scanProject(params: { tableFindings, suggestedFixCommands, allPackages: params.scanInput.packages, + completeness, }; } diff --git a/src/output/html-reporter.ts b/src/output/html-reporter.ts index 33857e7a..9c7f6c1f 100644 --- a/src/output/html-reporter.ts +++ b/src/output/html-reporter.ts @@ -8,7 +8,7 @@ import { pluralize } from "../utils/string.js"; import { MAL_GIT_SOURCE_PINNED_DISPLAY, MAL_GIT_SOURCE_FLOATING_DISPLAY } from "../constants.js"; import { renderOverrideFindingsHtml } from "./override-findings-html.js"; import { renderMaintenanceFindingsHtml } from "./maintenance-findings-html.js"; -import type { Finding } from "../types.js"; +import type { Finding, ScanCompleteness } from "../types.js"; import type { OverrideFinding } from "../overrides/types.js"; import type { MaintenanceFinding } from "../maintenance/types.js"; import type { SuggestedFixCommandPlan } from "../remediation/fix-commands.js"; @@ -40,6 +40,7 @@ export type ReportData = { skippedDependencies: string[]; overrideFindings?: ReadonlyArray; maintenanceFindings?: ReadonlyArray; + completeness?: ScanCompleteness; }; export function buildReportData(params: { @@ -55,6 +56,7 @@ export function buildReportData(params: { skippedDependencies: string[]; overrideFindings?: ReadonlyArray; maintenanceFindings?: ReadonlyArray; + completeness?: ScanCompleteness; }): ReportData { return { projectPath: params.projectPath, @@ -72,6 +74,7 @@ export function buildReportData(params: { skippedDependencies: params.skippedDependencies, overrideFindings: params.overrideFindings, maintenanceFindings: params.maintenanceFindings, + completeness: params.completeness, }; } @@ -208,6 +211,12 @@ button.header-link:hover{color:var(--link);border-color:var(--link)} .fix-target-note{color:var(--text-3);font-style:italic;font-size:10px} .fix-target-coverage{flex-basis:100%;color:var(--text-3);font-size:10px;line-height:1.45} .chain-proof{font-size:0.78rem;color:var(--text-2);margin-left:6px;font-family:monospace} +.completeness-banner{margin:16px 32px 0;background:#e3b34111;border:1px solid #e3b34144;border-radius:8px;padding:14px 18px;display:flex;gap:14px;align-items:flex-start} +.completeness-banner-icon{font-size:22px;flex-shrink:0;line-height:1} +.completeness-banner-body strong{font-size:13px;color:var(--text);display:block;margin-bottom:4px} +.completeness-banner-body p{font-size:12px;color:var(--text-2);margin:0} +.completeness-banner-body ul{margin:8px 0 0;padding:0 0 0 18px;display:flex;flex-direction:column;gap:3px} +.completeness-banner-body li{font-size:11px;color:var(--text-2);line-height:1.5} .break-badge{font-size:10px;font-weight:700;background:#fb850022;color:#fb8500;border:1px solid #fb850066;border-radius:4px;padding:1px 5px} .tier-ok{display:inline-flex;align-items:center;gap:4px;font-size:10px;padding:2px 8px;border-radius:4px;background:#3fb95022;color:#3fb950;border:1px solid #3fb95033;margin-bottom:6px} .tier-warn{display:inline-flex;align-items:center;gap:4px;font-size:10px;padding:2px 8px;border-radius:4px;background:#e3b34122;color:#e3b341;border:1px solid #e3b34133;margin-bottom:6px} @@ -245,6 +254,7 @@ export function renderHtmlReport(data: ReportData): string { const totalCVEs = new Set(data.findings.flatMap(f => f.vulnerabilities.map(v => v.id))).size; const noticesHtml = renderNotices(data.notes, data.warnings); + const completenessBannerHtml = renderCompletenessBanner(data.completeness); const fixPlanHtml = renderFixPlan(data.suggestedFixCommands); const skippedKeys = new Set( (data.suggestedFixCommands?.skipped ?? []).map(s => `${s.package}@${s.version}`) @@ -319,6 +329,7 @@ ${REPORT_STYLES}
${totalCVEs}CVEs
+${completenessBannerHtml} ${fixPlanHtml}
@@ -621,6 +632,35 @@ function renderTransitiveContextCol(finding: SerializedFinding): string {
`; } +export function renderCompletenessBanner(completeness: ScanCompleteness | null | undefined): string { + if (completeness == null || completeness.complete) return ""; + + const hasDetectionFailure = completeness.diagnostics.some(d => d.impact === "detection"); + const hasRemediationFailure = completeness.diagnostics.some(d => d.impact === "remediation"); + + let bannerText: string; + if (hasDetectionFailure) { + bannerText = "Some vulnerability findings may be incomplete because the following issues occurred:"; + } else if (hasRemediationFailure) { + bannerText = "Vulnerability findings are available, but remediation guidance may be incomplete because the following issues occurred:"; + } else { + bannerText = "The scan result may be incomplete because the following issues occurred:"; + } + + const diagRows = completeness.diagnostics.map(d => + `
  • ${escapeHtml(d.message)}
  • ` + ).join("\n"); + + return `
    +
    +
    + Scan data is incomplete +

    ${bannerText}

    +
      ${diagRows}
    +
    +
    `; +} + function renderNotices(notes: string[], warnings: string[]): string { if (notes.length === 0 && warnings.length === 0) return ""; const total = notes.length + warnings.length; diff --git a/src/output/multi-folder-html-reporter.ts b/src/output/multi-folder-html-reporter.ts index c07655cd..e4b45e71 100644 --- a/src/output/multi-folder-html-reporter.ts +++ b/src/output/multi-folder-html-reporter.ts @@ -1,12 +1,14 @@ import fs from "node:fs"; import path from "node:path"; import type { MultiFolderScanResult } from "../scan/multi-folder-scan.js"; +import { aggregateMultiFolderCompleteness } from "../scan/multi-folder-scan.js"; import { REPORT_STYLES, escapeHtml, openInBrowser, renderFindingRow, renderFixPlan, + renderCompletenessBanner, serializeHtmlFinding, } from "./html-reporter.js"; import type { SerializedFinding } from "./html-reporter.js"; @@ -59,6 +61,7 @@ function renderFolderSection( const findingRowsHtml = serialized.map((f, i) => renderFindingRow(f, idxOffset + i)).join("\n"); const fixPlanHtml = renderFixPlan(result.suggestedFixCommands); const noticesHtml = renderFolderNotices(result.coverage); + const completenessBannerHtml = renderCompletenessBanner(result.completeness); const emptyRow = result.sorted.length === 0 ? `No findings` @@ -71,6 +74,7 @@ function renderFolderSection( ${escapeHtml(summaryText)}
    + ${completenessBannerHtml} ${fixPlanHtml} ${noticesHtml}
    @@ -127,6 +131,9 @@ export async function writeMultiFolderHtmlReport(params: { ); const folderCount = params.results.length; + const aggregateCompleteness = aggregateMultiFolderCompleteness(params.results); + const aggregateBannerHtml = renderCompletenessBanner(aggregateCompleteness); + // Pre-serialize findings once per folder for both HTML rendering and JS filter/sort const allSerialized: SerializedFinding[][] = params.results.map(r => r.sorted.map(f => serializeHtmlFinding(f, r.suggestedFixCommands)), @@ -221,6 +228,8 @@ details[open] .folder-summary::before{transform:rotate(90deg)}
    ${totalCVEs}CVEs
    +${aggregateBannerHtml} + ${folderSectionsHtml}