From 4228be2db6506e86a3764625571321b03d5e8ac5 Mon Sep 17 00:00:00 2001 From: pitboss Date: Fri, 15 May 2026 16:31:45 -0500 Subject: [PATCH] [pitboss] sweep after phase 25: 2 deferred items resolved --- src/commands/scan.rs | 27 +++++++++----- src/fmt.rs | 74 +++++++++++++++++++++++++++++++++++++-- tests/console_snapshot.rs | 12 +++---- 3 files changed, 95 insertions(+), 18 deletions(-) diff --git a/src/commands/scan.rs b/src/commands/scan.rs index 4d549e7a..371f8f9f 100644 --- a/src/commands/scan.rs +++ b/src/commands/scan.rs @@ -467,15 +467,15 @@ pub fn handle( let idx = Indexer::from_pool(&project_name, &pool)?; idx.vacuum()?; } - // Indexed scan path: Phase 25 chain composer needs a - // SurfaceMap. The indexed pipeline does not yet thread one - // out — Phase 23's CLI loads it from SQLite when needed. For - // now return an empty map so chain emission produces no - // chains; this matches pre-Phase-25 behaviour for indexed - // scans. + // Indexed scan path: persist + return the SurfaceMap so the + // Phase 25 chain composer can walk it. `scan_with_index_parallel_observer` + // already builds and persists the map into the `surface_map` + // SQLite table; reload it through the same pool so the indexed + // chain emission matches the non-indexed branch. + let scan_pool = Arc::clone(&pool); let diags = scan_with_index_parallel_observer( &project_name, - pool, + scan_pool, config, show_progress, &scan_path, @@ -484,7 +484,11 @@ pub fn handle( None, Some(&preview_tier_seen), )?; - (diags, crate::surface::SurfaceMap::new()) + let surface_map = { + let idx = Indexer::from_pool(&project_name, &pool)?; + idx.load_surface_map()?.unwrap_or_default() + }; + (diags, surface_map) }; // Print the Preview-tier banner to stderr once, after file enumeration @@ -646,7 +650,12 @@ pub fn handle( tracing::debug!("Printing to console"); print!( "{}", - crate::fmt::render_console(&diags, &project_name, Some(&stats)) + crate::fmt::render_console( + &diags_for_output, + &project_name, + Some(&stats), + &chains, + ) ); if let Some(ref diff) = verdict_diff { println!("\nBaseline comparison:"); diff --git a/src/fmt.rs b/src/fmt.rs index 9a601e4f..f064f3d7 100644 --- a/src/fmt.rs +++ b/src/fmt.rs @@ -4,6 +4,7 @@ //! severity hierarchy, normalised taint flow rendering, and stable wrapping. #![allow(clippy::collapsible_if)] +use crate::chain::finding::ChainFinding; use crate::commands::scan::{Diag, SuppressionStats}; use crate::patterns::Severity; use console::style; @@ -17,14 +18,26 @@ const DEFAULT_WIDTH: usize = 100; // ───────────────────────────────────────────────────────────────────────────── /// Render all diagnostics as grouped, formatted console output with a summary. +/// +/// `chains` is the list of composed exploit chains emitted alongside +/// `diags`. When non-empty, a `Chains` section is printed ahead of the +/// per-file findings. Callers that have already gated constituent +/// findings on `[output] show_chain_constituents` should pass the +/// filtered `diags` slice so the constituent listing matches the JSON / +/// SARIF emitters. pub fn render_console( diags: &[Diag], project_name: &str, suppression_stats: Option<&SuppressionStats>, + chains: &[ChainFinding], ) -> String { let width = terminal_width(); let mut out = String::new(); + if !chains.is_empty() { + out.push_str(&render_chains(chains, width)); + } + let mut grouped: BTreeMap<&str, Vec<&Diag>> = BTreeMap::new(); for d in diags { grouped.entry(&d.path).or_default().push(d); @@ -240,6 +253,61 @@ const LOGO: &[&str] = &[ /// Indentation for body/evidence lines (spaces). const BODY_INDENT: usize = 6; +/// Render the `Chains` header section. Each chain is summarised on +/// two lines: severity + impact + score header, then sink location + +/// constituent count. +fn render_chains(chains: &[ChainFinding], _width: usize) -> String { + let mut out = String::new(); + out.push_str(&format!( + "{}\n", + style(format!("Chains ({})", chains.len())).bold().underlined() + )); + for c in chains { + let sev = chain_severity_tag(c.severity); + let impact = format!("{:?}", c.implied_impact); + let header = format!( + " {} [{}] {} (score: {:.1}, {} members)", + sev, + impact, + style(&c.sink.function_name).bold(), + c.score, + c.members.len() + ); + out.push_str(&format!("{header}\n")); + out.push_str(&format!( + " {} {}:{}:{}\n", + style("sink:").dim(), + c.sink.file, + c.sink.line, + c.sink.col + )); + for m in &c.members { + out.push_str(&format!( + " {} {} {}:{}:{}\n", + style("via:").dim(), + style(&m.rule_id).dim(), + m.location.file, + m.location.line, + m.location.col + )); + } + out.push('\n'); + } + out +} + +/// Render a chain severity tag with the same shape as the per-diag +/// severity tag so chain output reads consistently next to findings. +fn chain_severity_tag(s: crate::chain::finding::ChainSeverity) -> String { + use crate::chain::finding::ChainSeverity; + match s { + ChainSeverity::Critical => format!("{} {}", style("✖").red().bold(), style("[CRITICAL]").red().bold()), + ChainSeverity::High => format!("{} {}", style("✖").red(), style("[HIGH]").red()), + ChainSeverity::Medium => format!("{} {}", style("⚠").yellow(), style("[MEDIUM]").yellow()), + ChainSeverity::Low => format!("{} {}", style("●").dim(), style("[LOW]").dim()), + } +} + /// Render a single diagnostic block. fn render_diag(d: &Diag, width: usize) -> String { let mut out = String::new(); @@ -882,7 +950,7 @@ mod tests { stable_hash: 0, }, ]; - let output = render_console(&diags, "test-project", None); + let output = render_console(&diags, "test-project", None, &[]); let stripped = strip_ansi(&output); assert!(stripped.contains("src/a.rs")); assert!(stripped.contains("src/b.rs")); @@ -917,7 +985,7 @@ mod tests { alternative_finding_ids: Vec::new(), stable_hash: 0, }]; - let output = render_console(&diags, "proj", None); + let output = render_console(&diags, "proj", None, &[]); let stripped = strip_ansi(&output); assert!(stripped.contains("Source:"), "should contain Source label"); assert!(stripped.contains("Sink:"), "should contain Sink label"); @@ -976,7 +1044,7 @@ mod tests { stable_hash: 0, }, ]; - let output = render_console(&diags, "proj", None); + let output = render_console(&diags, "proj", None, &[]); let stripped = strip_ansi(&output); // There should be a blank line between the two findings assert!( diff --git a/tests/console_snapshot.rs b/tests/console_snapshot.rs index d9c01723..54a46b11 100644 --- a/tests/console_snapshot.rs +++ b/tests/console_snapshot.rs @@ -127,7 +127,7 @@ fn diag_with_verdict(status: VerifyStatus) -> Diag { #[test] fn console_confirmed_shows_payload_id() { let diag = diag_with_verdict(VerifyStatus::Confirmed); - let output = render_console(&[diag], "proj", None); + let output = render_console(&[diag], "proj", None, &[]); let stripped = strip_ansi(&output); assert!( stripped.contains("[DYN: confirmed via sqli-tautology]"), @@ -138,7 +138,7 @@ fn console_confirmed_shows_payload_id() { #[test] fn console_not_confirmed_shows_annotation() { let diag = diag_with_verdict(VerifyStatus::NotConfirmed); - let output = render_console(&[diag], "proj", None); + let output = render_console(&[diag], "proj", None, &[]); let stripped = strip_ansi(&output); assert!( stripped.contains("[DYN: not confirmed]"), @@ -149,7 +149,7 @@ fn console_not_confirmed_shows_annotation() { #[test] fn console_unsupported_shows_reason() { let diag = diag_with_verdict(VerifyStatus::Unsupported); - let output = render_console(&[diag], "proj", None); + let output = render_console(&[diag], "proj", None, &[]); let stripped = strip_ansi(&output); assert!( stripped.contains("[DYN: unsupported (no payloads for cap)]"), @@ -160,7 +160,7 @@ fn console_unsupported_shows_reason() { #[test] fn console_inconclusive_shows_reason() { let diag = diag_with_verdict(VerifyStatus::Inconclusive); - let output = render_console(&[diag], "proj", None); + let output = render_console(&[diag], "proj", None, &[]); let stripped = strip_ansi(&output); assert!( stripped.contains("[DYN: inconclusive (build failed)]"), @@ -171,7 +171,7 @@ fn console_inconclusive_shows_reason() { #[test] fn console_no_annotation_when_no_dynamic_verdict() { let diag = base_diag(); - let output = render_console(&[diag], "proj", None); + let output = render_console(&[diag], "proj", None, &[]); let stripped = strip_ansi(&output); assert!( !stripped.contains("[DYN:"), @@ -183,7 +183,7 @@ fn console_no_annotation_when_no_dynamic_verdict() { fn console_no_annotation_when_evidence_has_no_verdict() { let mut diag = base_diag(); diag.evidence = Some(Evidence::default()); - let output = render_console(&[diag], "proj", None); + let output = render_console(&[diag], "proj", None, &[]); let stripped = strip_ansi(&output); assert!( !stripped.contains("[DYN:"),