[pitboss] sweep after phase 25: 2 deferred items resolved

This commit is contained in:
pitboss 2026-05-15 16:31:45 -05:00
parent 76d0037073
commit 4228be2db6
3 changed files with 95 additions and 18 deletions

View file

@ -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:");

View file

@ -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!(

View file

@ -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:"),