diff --git a/Cargo.lock b/Cargo.lock index 353e6b6..61e6747 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,6 +40,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "amm-pool" version = "0.1.0" @@ -413,12 +419,27 @@ dependencies = [ "syn 2.0.39", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cast" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.56" @@ -565,6 +586,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "console_error_panic_hook" version = "0.1.7" @@ -668,6 +703,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -980,6 +1040,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1147,6 +1213,11 @@ name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "heck" @@ -1452,6 +1523,25 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b23a0c8dfe501baac4adf6ebbfa6eddf8f0c07f56b058cc1288017e32397846c" +dependencies = [ + "quote", + "syn 2.0.39", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1512,6 +1602,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -1622,6 +1721,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1649,6 +1754,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1686,6 +1800,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -2248,6 +2363,27 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "rayon" version = "1.11.0" @@ -2412,6 +2548,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -2421,7 +2570,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -2500,9 +2649,11 @@ dependencies = [ "assert_cmd", "clap", "colored 2.2.0", + "crossterm", "jsonschema", "mockito", "predicates", + "ratatui", "rayon", "regex", "reqwest", @@ -2699,6 +2850,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -3177,6 +3349,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.39", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3234,7 +3428,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -3592,6 +3786,35 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "untrusted" version = "0.9.0" @@ -3818,6 +4041,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -3827,6 +4066,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/tooling/sanctifier-cli/Cargo.toml b/tooling/sanctifier-cli/Cargo.toml index 7cc5b16..66f1a6d 100644 --- a/tooling/sanctifier-cli/Cargo.toml +++ b/tooling/sanctifier-cli/Cargo.toml @@ -28,6 +28,8 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking", rayon = "1.10" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } +crossterm = "0.28" +ratatui = "0.29" [dev-dependencies] assert_cmd = "2.0" diff --git a/tooling/sanctifier-cli/src/commands/mod.rs b/tooling/sanctifier-cli/src/commands/mod.rs index fc348ab..d7009cc 100644 --- a/tooling/sanctifier-cli/src/commands/mod.rs +++ b/tooling/sanctifier-cli/src/commands/mod.rs @@ -6,5 +6,6 @@ pub mod init; pub mod reentrancy; pub mod report; pub mod storage; +pub mod tui; pub mod update; pub mod webhook; diff --git a/tooling/sanctifier-cli/src/commands/tui.rs b/tooling/sanctifier-cli/src/commands/tui.rs new file mode 100644 index 0000000..0a9b61b --- /dev/null +++ b/tooling/sanctifier-cli/src/commands/tui.rs @@ -0,0 +1,820 @@ +use crate::commands::analyze::{ + analyze_single_file, collect_rs_files, is_soroban_project, load_config, run_with_timeout, + FileAnalysisResult, +}; +use crate::vulndb::{VulnDatabase, VulnMatch}; +use anyhow::Context; +use clap::Args; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs, Wrap}; +use ratatui::{Frame, Terminal}; +use rayon::prelude::*; +use sanctifier_core::finding_codes; +use sanctifier_core::{Analyzer, SizeWarningLevel}; +use std::fs; +use std::io::{self, IsTerminal}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tracing::warn; + +#[derive(Args, Debug)] +pub struct TuiArgs { + /// Path to the contract directory or a single `.rs` file + #[arg(default_value = ".")] + pub path: PathBuf, + + /// Ledger entry size limit in bytes + #[arg(short, long, default_value = "64000")] + pub limit: usize, + + /// Path to a custom vulnerability database JSON file + #[arg(long)] + pub vuln_db: Option, + + /// Per-file analysis timeout in seconds (0 = disabled) + #[arg(short, long, default_value = "30")] + pub timeout: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum DashboardSeverity { + Critical, + High, + Medium, + Low, + Info, +} + +impl DashboardSeverity { + fn label(self) -> &'static str { + match self { + DashboardSeverity::Critical => "Critical", + DashboardSeverity::High => "High", + DashboardSeverity::Medium => "Medium", + DashboardSeverity::Low => "Low", + DashboardSeverity::Info => "Info", + } + } + + fn color(self) -> Color { + match self { + DashboardSeverity::Critical => Color::Red, + DashboardSeverity::High => Color::LightRed, + DashboardSeverity::Medium => Color::Yellow, + DashboardSeverity::Low => Color::Cyan, + DashboardSeverity::Info => Color::Blue, + } + } +} + +struct DashboardSection { + title: String, + code: String, + severity: DashboardSeverity, + items: Vec, +} + +impl DashboardSection { + fn count(&self) -> usize { + self.items.len() + } +} + +struct DashboardData { + path: String, + total_files: usize, + total_findings: usize, + highest_severity: DashboardSeverity, + duration_ms: u64, + vuln_db_version: String, + sections: Vec, +} + +struct DashboardApp { + data: DashboardData, + active_tab: usize, + selected_item: usize, +} + +impl DashboardApp { + fn new(data: DashboardData) -> Self { + Self { + data, + active_tab: 0, + selected_item: 0, + } + } + + fn current_section(&self) -> &DashboardSection { + &self.data.sections[self.active_tab] + } + + fn next_tab(&mut self) { + self.active_tab = (self.active_tab + 1) % self.data.sections.len(); + self.selected_item = 0; + } + + fn previous_tab(&mut self) { + self.active_tab = if self.active_tab == 0 { + self.data.sections.len() - 1 + } else { + self.active_tab - 1 + }; + self.selected_item = 0; + } + + fn next_item(&mut self) { + let len = self.current_section().items.len(); + if len > 0 { + self.selected_item = (self.selected_item + 1) % len; + } + } + + fn previous_item(&mut self) { + let len = self.current_section().items.len(); + if len > 0 { + self.selected_item = if self.selected_item == 0 { + len - 1 + } else { + self.selected_item - 1 + }; + } + } +} + +pub fn exec(args: TuiArgs) -> anyhow::Result<()> { + let data = build_dashboard_data(&args)?; + + if !io::stdout().is_terminal() { + println!("{}", render_snapshot(&data)); + return Ok(()); + } + + run_terminal_dashboard(data) +} + +fn build_dashboard_data(args: &TuiArgs) -> anyhow::Result { + let mut path = args.path.clone(); + + #[cfg(not(windows))] + { + let as_text = path.to_string_lossy(); + if as_text.contains('\\') { + path = PathBuf::from(as_text.replace('\\', "/")); + } + } + + if !is_soroban_project(&path) { + anyhow::bail!( + "{:?} is not a valid Soroban project (no Cargo.toml with soroban-sdk found)", + path + ); + } + + let start = Instant::now(); + let mut config = load_config(&path); + config.ledger_limit = args.limit; + let analyzer = Arc::new(Analyzer::new(config)); + let vuln_db = Arc::new(match &args.vuln_db { + Some(db_path) => VulnDatabase::load(db_path)?, + None => VulnDatabase::load_default(), + }); + + let rs_files = if path.is_dir() { + collect_rs_files(&path, &analyzer.config.ignore_paths) + } else if path.extension().and_then(|s| s.to_str()) == Some("rs") { + vec![path.clone()] + } else { + Vec::new() + }; + + let total_files = rs_files.len(); + let counter = Arc::new(AtomicUsize::new(0)); + let timeout_dur = if args.timeout == 0 { + None + } else { + Some(Duration::from_secs(args.timeout)) + }; + + let mut results: Vec = rs_files + .par_iter() + .map(|file_path| { + let idx = counter.fetch_add(1, Ordering::Relaxed) + 1; + eprintln!( + "[{}/{}] Preparing dashboard for {}", + idx, + total_files, + file_path.display() + ); + + let content = match fs::read_to_string(file_path) { + Ok(content) => content, + Err(_) => return FileAnalysisResult::default(), + }; + + let analyzer = Arc::clone(&analyzer); + let vuln_db = Arc::clone(&vuln_db); + let file_name = file_path.display().to_string(); + let file_name_clone = file_name.clone(); + + match run_with_timeout(timeout_dur, move || { + analyze_single_file(&analyzer, &vuln_db, &content, &file_name_clone) + }) { + Some(result) => result, + None => { + warn!( + target: "sanctifier", + file = %file_name, + timeout_secs = args.timeout, + "Dashboard analysis timed out" + ); + FileAnalysisResult { + file_path: file_name, + timed_out: true, + ..Default::default() + } + } + } + }) + .collect(); + + results.sort_by(|left, right| left.file_path.cmp(&right.file_path)); + + let mut sections = vec![ + DashboardSection { + title: "Authentication Gaps".into(), + code: finding_codes::AUTH_GAP.into(), + severity: DashboardSeverity::Critical, + items: Vec::new(), + }, + DashboardSection { + title: "Panics".into(), + code: finding_codes::PANIC_USAGE.into(), + severity: DashboardSeverity::High, + items: Vec::new(), + }, + DashboardSection { + title: "Arithmetic".into(), + code: finding_codes::ARITHMETIC_OVERFLOW.into(), + severity: DashboardSeverity::High, + items: Vec::new(), + }, + DashboardSection { + title: "Ledger Size".into(), + code: finding_codes::LEDGER_SIZE_RISK.into(), + severity: DashboardSeverity::Medium, + items: Vec::new(), + }, + DashboardSection { + title: "Storage".into(), + code: finding_codes::STORAGE_COLLISION.into(), + severity: DashboardSeverity::High, + items: Vec::new(), + }, + DashboardSection { + title: "Unsafe Patterns".into(), + code: finding_codes::UNSAFE_PATTERN.into(), + severity: DashboardSeverity::Medium, + items: Vec::new(), + }, + DashboardSection { + title: "Events".into(), + code: finding_codes::EVENT_INCONSISTENCY.into(), + severity: DashboardSeverity::Low, + items: Vec::new(), + }, + DashboardSection { + title: "Unhandled Results".into(), + code: finding_codes::UNHANDLED_RESULT.into(), + severity: DashboardSeverity::High, + items: Vec::new(), + }, + DashboardSection { + title: "Upgrade Risks".into(), + code: finding_codes::UPGRADE_RISK.into(), + severity: DashboardSeverity::High, + items: Vec::new(), + }, + DashboardSection { + title: "SMT".into(), + code: finding_codes::SMT_INVARIANT_VIOLATION.into(), + severity: DashboardSeverity::Critical, + items: Vec::new(), + }, + DashboardSection { + title: "SEP-41".into(), + code: finding_codes::SEP41_INTERFACE_DEVIATION.into(), + severity: DashboardSeverity::Medium, + items: Vec::new(), + }, + DashboardSection { + title: "Known Vulns".into(), + code: "VDB".into(), + severity: DashboardSeverity::High, + items: Vec::new(), + }, + DashboardSection { + title: "Timeouts".into(), + code: finding_codes::ANALYSIS_TIMEOUT.into(), + severity: DashboardSeverity::Info, + items: Vec::new(), + }, + ]; + + for result in results { + sections[0].items.extend( + result + .auth_gaps + .into_iter() + .map(|gap| format!("Function {}", gap.function_name)), + ); + sections[1].items.extend( + result + .panic_issues + .into_iter() + .map(|issue| format!("{} at {}", issue.issue_type, issue.location)), + ); + sections[2].items.extend( + result + .arithmetic_issues + .into_iter() + .map(|issue| format!("{} at {}", issue.operation, issue.location)), + ); + sections[3] + .items + .extend( + result + .size_warnings + .into_iter() + .map(|warning| match warning.level { + SizeWarningLevel::ExceedsLimit => { + format!( + "{} estimated at {} bytes", + warning.struct_name, warning.estimated_size + ) + } + SizeWarningLevel::NearLimit => { + format!( + "{} near limit at {} bytes", + warning.struct_name, warning.estimated_size + ) + } + }), + ); + sections[4] + .items + .extend(result.collisions.into_iter().map(|collision| { + format!( + "{} [{}] at {}", + collision.key_value, collision.key_type, collision.location + ) + })); + sections[5].items.extend( + result + .unsafe_patterns + .into_iter() + .map(|pattern| pattern.snippet), + ); + sections[6] + .items + .extend(result.event_issues.into_iter().map(|issue| { + format!( + "{} {:?} at {}", + issue.event_name, issue.issue_type, issue.location + ) + })); + sections[7] + .items + .extend(result.unhandled_results.into_iter().map(|issue| { + format!( + "{} ignored {} at {}", + issue.function_name, issue.call_expression, issue.location + ) + })); + sections[8] + .items + .extend(result.upgrade_reports.into_iter().flat_map(|report| { + report.findings.into_iter().map(|finding| { + format!( + "{:?} at {}: {}", + finding.category, finding.location, finding.message + ) + }) + })); + sections[9] + .items + .extend(result.smt_issues.into_iter().map(|issue| { + format!( + "{} at {}: {}", + issue.function_name, issue.location, issue.description + ) + })); + sections[10] + .items + .extend(result.sep41_issues.into_iter().map(|issue| { + format!( + "{} {:?} at {}", + issue.function_name, issue.kind, issue.location + ) + })); + sections[11] + .items + .extend(result.vuln_matches.into_iter().map(format_vuln_match)); + if result.timed_out { + sections[12] + .items + .push(format!("{} exceeded {}s", result.file_path, args.timeout)); + } + } + + if !sections[11].items.is_empty() { + sections[11].severity = highest_vulnerability_severity(§ions[11].items); + } + + let total_findings = sections.iter().map(DashboardSection::count).sum(); + let highest_severity = sections + .iter() + .filter(|section| !section.items.is_empty()) + .map(|section| section.severity) + .min() + .unwrap_or(DashboardSeverity::Info); + + Ok(DashboardData { + path: path.display().to_string(), + total_files, + total_findings, + highest_severity, + duration_ms: start.elapsed().as_millis() as u64, + vuln_db_version: vuln_db.version.clone(), + sections, + }) +} + +fn highest_vulnerability_severity(items: &[String]) -> DashboardSeverity { + items + .iter() + .filter_map(|item| { + if item.contains("(CRITICAL)") { + Some(DashboardSeverity::Critical) + } else if item.contains("(HIGH)") { + Some(DashboardSeverity::High) + } else if item.contains("(MEDIUM)") { + Some(DashboardSeverity::Medium) + } else if item.contains("(LOW)") { + Some(DashboardSeverity::Low) + } else { + None + } + }) + .min() + .unwrap_or(DashboardSeverity::High) +} + +fn format_vuln_match(matched: VulnMatch) -> String { + format!( + "{} ({}) at {}:{}", + matched.name, + matched.severity.to_uppercase(), + matched.file, + matched.line + ) +} + +fn render_snapshot(data: &DashboardData) -> String { + let mut lines = vec![ + "Sanctifier TUI Snapshot".to_string(), + format!("Project: {}", data.path), + format!("Files scanned: {}", data.total_files), + format!("Total findings: {}", data.total_findings), + format!("Highest severity: {}", data.highest_severity.label()), + format!("Duration: {} ms", data.duration_ms), + format!("Vuln DB: {}", data.vuln_db_version), + String::new(), + ]; + + for section in &data.sections { + lines.push(format!( + "{} [{}] {} finding(s)", + section.title, + section.code, + section.count() + )); + if section.items.is_empty() { + lines.push(" - No findings detected.".to_string()); + } else { + for item in §ion.items { + lines.push(format!(" - {}", item)); + } + } + lines.push(String::new()); + } + + lines.join("\n") +} + +fn run_terminal_dashboard(data: DashboardData) -> anyhow::Result<()> { + enable_raw_mode().context("failed to enable raw mode")?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen).context("failed to enter alternate screen")?; + + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend).context("failed to create terminal backend")?; + let mut app = DashboardApp::new(data); + + let result = run_event_loop(&mut terminal, &mut app); + + disable_raw_mode().ok(); + execute!(terminal.backend_mut(), LeaveAlternateScreen).ok(); + terminal.show_cursor().ok(); + + result +} + +fn run_event_loop( + terminal: &mut Terminal>, + app: &mut DashboardApp, +) -> anyhow::Result<()> { + loop { + terminal.draw(|frame| render_dashboard(frame, app))?; + + if let Event::Key(key) = event::read()? { + if key.kind != KeyEventKind::Press { + continue; + } + match key.code { + KeyCode::Char('q') | KeyCode::Esc => return Ok(()), + KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => app.next_tab(), + KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => app.previous_tab(), + KeyCode::Down | KeyCode::Char('j') => app.next_item(), + KeyCode::Up | KeyCode::Char('k') => app.previous_item(), + _ => {} + } + } + } +} + +fn render_dashboard(frame: &mut Frame<'_>, app: &DashboardApp) { + let root = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(4), + Constraint::Length(7), + Constraint::Length(3), + Constraint::Min(10), + Constraint::Length(3), + ]) + .split(frame.area()); + + render_header(frame, root[0], app); + render_summary(frame, root[1], app); + render_tabs(frame, root[2], app); + render_body(frame, root[3], app); + render_footer(frame, root[4]); +} + +fn render_header(frame: &mut Frame<'_>, area: Rect, app: &DashboardApp) { + let status = format!( + "{} severity | {} finding(s) | {} file(s)", + app.data.highest_severity.label(), + app.data.total_findings, + app.data.total_files + ); + let text = Text::from(vec![ + Line::from(vec![ + Span::styled( + "Sanctifier TUI", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + status, + Style::default().fg(app.data.highest_severity.color()), + ), + ]), + Line::from(app.data.path.clone()), + ]); + let widget = + Paragraph::new(text).block(Block::default().borders(Borders::ALL).title("Overview")); + frame.render_widget(widget, area); +} + +fn render_summary(frame: &mut Frame<'_>, area: Rect, app: &DashboardApp) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + ]) + .split(area); + + let cards = [ + ( + "Critical", + count_by_severity(&app.data, DashboardSeverity::Critical), + ), + ( + "High", + count_by_severity(&app.data, DashboardSeverity::High), + ), + ( + "Medium", + count_by_severity(&app.data, DashboardSeverity::Medium), + ), + ("Low/Info", count_by_low_info(&app.data)), + ]; + + for (idx, (label, count)) in cards.iter().enumerate() { + let severity = match *label { + "Critical" => DashboardSeverity::Critical, + "High" => DashboardSeverity::High, + "Medium" => DashboardSeverity::Medium, + _ => DashboardSeverity::Low, + }; + let card = Paragraph::new(format!("{}\n{}", count, label)) + .style( + Style::default() + .fg(severity.color()) + .add_modifier(Modifier::BOLD), + ) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(card, chunks[idx]); + } +} + +fn render_tabs(frame: &mut Frame<'_>, area: Rect, app: &DashboardApp) { + let titles: Vec> = app + .data + .sections + .iter() + .map(|section| Line::from(format!("{} ({})", section.title, section.count()))) + .collect(); + + let tabs = Tabs::new(titles) + .select(app.active_tab) + .block(Block::default().borders(Borders::ALL).title("Categories")) + .highlight_style( + Style::default() + .fg(Color::Black) + .bg(app.current_section().severity.color()) + .add_modifier(Modifier::BOLD), + ); + + frame.render_widget(tabs, area); +} + +fn render_body(frame: &mut Frame<'_>, area: Rect, app: &DashboardApp) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(42), Constraint::Percentage(58)]) + .split(area); + + let section = app.current_section(); + let items: Vec> = if section.items.is_empty() { + vec![ListItem::new("No findings in this category.")] + } else { + section + .items + .iter() + .map(|item| ListItem::new(item.as_str())) + .collect() + }; + + let mut state = ListState::default(); + if !section.items.is_empty() { + state.select(Some(app.selected_item)); + } + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("{} [{}]", section.title, section.code)), + ) + .highlight_style( + Style::default() + .bg(section.severity.color()) + .fg(Color::Black) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">> "); + + frame.render_stateful_widget(list, chunks[0], &mut state); + + let detail_text = selected_detail(section, app.selected_item, &app.data); + let detail = Paragraph::new(detail_text) + .block(Block::default().borders(Borders::ALL).title("Details")) + .wrap(Wrap { trim: false }); + frame.render_widget(Clear, chunks[1]); + frame.render_widget(detail, chunks[1]); +} + +fn render_footer(frame: &mut Frame<'_>, area: Rect) { + let footer = Paragraph::new("Tab/Left/Right switch categories Up/Down move q exits") + .block(Block::default().borders(Borders::ALL).title("Keys")); + frame.render_widget(footer, area); +} + +fn selected_detail( + section: &DashboardSection, + selected_item: usize, + data: &DashboardData, +) -> Text<'static> { + if section.items.is_empty() { + return Text::from(vec![ + Line::from("No findings detected in this category."), + Line::from(format!("Database version: {}", data.vuln_db_version)), + Line::from(format!("Render time: {} ms", data.duration_ms)), + ]); + } + + let detail = §ion.items[selected_item.min(section.items.len() - 1)]; + Text::from(vec![ + Line::from(vec![ + Span::styled( + section.severity.label(), + Style::default() + .fg(section.severity.color()) + .add_modifier(Modifier::BOLD), + ), + Span::raw(format!(" {} [{}]", section.title, section.code)), + ]), + Line::from(""), + Line::from(detail.clone()), + Line::from(""), + Line::from(format!("Project: {}", data.path)), + Line::from(format!("Files scanned: {}", data.total_files)), + ]) +} + +fn count_by_severity(data: &DashboardData, severity: DashboardSeverity) -> usize { + data.sections + .iter() + .filter(|section| section.severity == severity) + .map(DashboardSection::count) + .sum() +} + +fn count_by_low_info(data: &DashboardData) -> usize { + data.sections + .iter() + .filter(|section| { + section.severity == DashboardSeverity::Low + || section.severity == DashboardSeverity::Info + }) + .map(DashboardSection::count) + .sum() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snapshot_includes_empty_sections() { + let data = DashboardData { + path: "demo.rs".into(), + total_files: 1, + total_findings: 0, + highest_severity: DashboardSeverity::Info, + duration_ms: 14, + vuln_db_version: "test".into(), + sections: vec![DashboardSection { + title: "Authentication Gaps".into(), + code: "S001".into(), + severity: DashboardSeverity::Critical, + items: Vec::new(), + }], + }; + + let rendered = render_snapshot(&data); + assert!(rendered.contains("Sanctifier TUI Snapshot")); + assert!(rendered.contains("No findings detected.")); + } + + #[test] + fn vuln_match_severity_prefers_critical() { + let items = vec![ + "Issue One (MEDIUM) at file.rs:10".to_string(), + "Issue Two (CRITICAL) at file.rs:20".to_string(), + ]; + + assert_eq!( + highest_vulnerability_severity(&items), + DashboardSeverity::Critical + ); + } +} diff --git a/tooling/sanctifier-cli/src/main.rs b/tooling/sanctifier-cli/src/main.rs index db309e2..d36f1a4 100644 --- a/tooling/sanctifier-cli/src/main.rs +++ b/tooling/sanctifier-cli/src/main.rs @@ -29,6 +29,8 @@ pub enum Commands { Badge(commands::badge::BadgeArgs), /// Generate a Markdown or HTML security report Report(commands::report::ReportArgs), + /// Launch the interactive terminal dashboard + Tui(commands::tui::TuiArgs), /// Detect potential storage key collisions in Soroban contracts Storage(commands::storage::StorageArgs), /// Initialize Sanctifier in a new project @@ -82,6 +84,9 @@ fn run() -> anyhow::Result<()> { Commands::Report(args) => { commands::report::exec(args)?; } + Commands::Tui(args) => { + commands::tui::exec(args)?; + } Commands::Storage(args) => { commands::storage::exec(args)?; } diff --git a/tooling/sanctifier-cli/tests/cli_tests.rs b/tooling/sanctifier-cli/tests/cli_tests.rs index 9680d99..9a7b9cd 100644 --- a/tooling/sanctifier-cli/tests/cli_tests.rs +++ b/tooling/sanctifier-cli/tests/cli_tests.rs @@ -296,6 +296,34 @@ fn test_update_help() { .stdout(predicates::str::contains("latest Sanctifier binary")); } +#[test] +fn test_tui_help() { + let mut cmd = Command::cargo_bin("sanctifier").unwrap(); + cmd.arg("tui") + .arg("--help") + .assert() + .success() + .stdout(predicates::str::contains("interactive terminal dashboard")); +} + +#[test] +fn test_tui_snapshot_for_vulnerable_contract() { + let fixture_path = env::current_dir() + .unwrap() + .join("tests/fixtures/vulnerable_contract.rs"); + + Command::cargo_bin("sanctifier") + .unwrap() + .arg("tui") + .arg(fixture_path) + .env_remove("RUST_LOG") + .assert() + .success() + .stdout(predicates::str::contains("Sanctifier TUI Snapshot")) + .stdout(predicates::str::contains("Authentication Gaps [S001]")) + .stdout(predicates::str::contains("Total findings:")); +} + #[test] fn test_init_creates_sanctify_toml_in_current_directory() { let temp_dir = tempdir().unwrap();