diff --git a/Cargo.lock b/Cargo.lock index df84f79..eccf2a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -177,6 +177,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.18.1" @@ -450,6 +461,17 @@ dependencies = [ "syn", ] +[[package]] +name = "dbus" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b" +dependencies = [ + "libc", + "libdbus-sys", + "winapi 0.3.9", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -1168,6 +1190,16 @@ version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +[[package]] +name = "libdbus-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06085512b750d640299b79be4bad3d2fa90a9c00b1fd9e1b46364f66f0485c72" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "libloading" version = "0.8.8" @@ -1354,6 +1386,15 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "normpath" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8911957c4b1549ac0dc74e30db9c8b0e66ddcd6d7acc33098f4c63a64a6d7ed" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "num-modular" version = "0.6.1" @@ -1409,6 +1450,18 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "opener" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0812e5e4df08da354c851a3376fead46db31c2214f849d3de356d774d057681" +dependencies = [ + "bstr", + "dbus", + "normpath", + "windows-sys 0.59.0", +] + [[package]] name = "openssl" version = "0.10.73" @@ -2040,6 +2093,7 @@ dependencies = [ "handlebars", "libloading", "log", + "opener", "pretty_assertions", "regex", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 9064489..933720b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,8 @@ reqwest = { version = "0.12", features = ["json"] } toml = "0.8" # Directory utilities dirs = "6.0" +# Cross-platform browser opening +opener = "0.7" [dev-dependencies] tempfile = "3.8" diff --git a/README.md b/README.md index 568555c..d7ae3ac 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A comprehensive security analysis tool for Solana smart contracts that helps dev - **Multiple Report Formats**: JSON, HTML, Markdown, and CSV outputs - **Plugin System**: Extensible architecture for custom security rules - **CI/CD Integration**: GitHub Actions support with automated security checks -- **Professional Reports**: Beautiful HTML reports with severity rankings and actionable recommendations +- **Professional Reports**: HTML reports with severity rankings and actionable recommendations - **Smart Error Handling**: Clear, colored error messages with proper path validation - **Comprehensive Examples**: 8 educational examples demonstrating vulnerabilities and secure patterns @@ -52,6 +52,9 @@ solsec scan ./my-program --html-only --output results.html # Generate multiple formats at once solsec scan ./my-program --format json,html,markdown,csv +# Don't open browser automatically +solsec scan ./my-program --no-open + # Run fuzz testing solsec fuzz ./my-solana-program --timeout 300 ``` @@ -60,7 +63,9 @@ solsec fuzz ./my-solana-program --timeout 300 ### `solsec scan` -Run static analysis on your Solana smart contracts. Generates both JSON and HTML If no path is provided, it recursively scans the current directory for all `.rs` files, automatically ignoring `target/` and `.git/` folders. +Run static analysis on your Solana smart contracts. Generates both JSON and HTML by default. If no path is provided, it recursively scans the current directory for all `.rs` files, automatically ignoring `target/` and `.git/` folders. + +HTML reports automatically open in the default browser when running interactively, but remain closed in CI/automation environments. ```bash solsec scan [PATH] [OPTIONS] @@ -69,27 +74,31 @@ OPTIONS: -c, --config Configuration file path -o, --output Output directory [default: ./solsec-results] -f, --format Output formats (comma-separated) [default: json,html] [possible values: json, html, markdown, csv] - --json-only Only generate JSON (perfect for CI/CD) - --html-only Only generate HTML (perfect for humans) + --json-only Only generate JSON + --html-only Only generate HTML + --no-open Don't automatically open HTML report in browser --fail-on-critical Exit with non-zero code on critical issues [default: true] EXAMPLES: - # Scan the entire project (generates both JSON and HTML!) + # Scan the entire project (generates both JSON and HTML) solsec scan # Scan a specific directory with default formats solsec scan ./programs/my-program - # Generate only JSON for CI/CD integration + # Generate only JSON for CI/CD integration solsec scan ./programs --json-only --output results.json # Generate only HTML for manual review solsec scan ./programs --html-only --output results.html + # Generate HTML but don't open browser + solsec scan ./programs --html-only --no-open --output results.html + # Generate all available formats solsec scan ./programs --format json,html,markdown,csv - # Legacy: Scan with configuration file + # Scan with configuration file solsec scan ./programs --config solsec.toml --output ./security-results ``` @@ -299,17 +308,32 @@ rm -rf ./tmp-security-results echo "✅ Security scan passed!" ``` +## Browser Opening Behavior + +HTML reports automatically open in the default browser under the following conditions: + +**Opens automatically when:** +- Running in an interactive terminal (not redirected) +- Generating HTML reports (`--html-only` or default formats) +- Not in CI/automation environments + +**Remains closed when:** +- Running in CI environments (GitHub Actions, GitLab CI, etc.) +- Output is redirected or piped +- Using `--no-open` flag +- Only generating non-visual formats (JSON, CSV) + ## 📊 Report Examples ### HTML Report -Beautiful, interactive HTML reports with: +Interactive HTML reports with: - Executive summary with issue counts by severity - Detailed findings with code snippets - Actionable recommendations - Responsive design for all devices ### JSON Report -Machine-readable format perfect for: +Machine-readable format for: - CI/CD pipeline integration - Custom tooling and analysis - Data processing and metrics @@ -368,7 +392,7 @@ solsec scan examples/unchecked_account/vulnerable.rs # 6 issues found solsec scan examples/reentrancy/vulnerable.rs # 2 issues found # Test secure examples (should find 0 issues) -solsec scan examples/*/secure.rs # All pass! +solsec scan examples/*/secure.rs # No issues found # Comprehensive analysis solsec scan examples/ # 26 total issues across all vulnerable examples diff --git a/src/cli.rs b/src/cli.rs index f8bd7c8..7e12243 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -8,6 +8,18 @@ use crate::fuzz::FuzzEngine; use crate::plugin::{PluginAction, PluginManager}; use crate::report::{ReportFormat, ReportGenerator}; +#[derive(Debug)] +pub struct ScanConfig { + pub path: PathBuf, + pub config: Option, + pub output: PathBuf, + pub formats: Vec, + pub json_only: bool, + pub html_only: bool, + pub no_open: bool, + pub fail_on_critical: bool, +} + #[derive(Parser)] #[command(name = "solsec")] #[command(about = "Solana Smart Contract Security Toolkit")] @@ -46,6 +58,10 @@ pub enum Commands { #[arg(long, conflicts_with = "format")] html_only: bool, + /// Don't automatically open HTML report in browser (opens by default in interactive mode) + #[arg(long)] + no_open: bool, + /// Fail with non-zero exit code on critical issues #[arg(long, default_value = "true")] fail_on_critical: bool, @@ -81,29 +97,28 @@ pub enum Commands { }, } -pub async fn handle_scan_command( - path: PathBuf, - config: Option, - output: PathBuf, - formats: Vec, - json_only: bool, - html_only: bool, - fail_on_critical: bool, -) -> Result<()> { - info!("Starting static analysis scan on: {}", path.display()); +pub async fn handle_scan_command(config: ScanConfig) -> Result<()> { + info!( + "Starting static analysis scan on: {}", + config.path.display() + ); - let mut analyzer = StaticAnalyzer::new(config)?; - let results = analyzer.analyze_path(&path).await?; + let mut analyzer = StaticAnalyzer::new(config.config)?; + let results = analyzer.analyze_path(&config.path).await?; // Determine which formats to generate - let formats_to_generate = if json_only { + let formats_to_generate = if config.json_only { vec![ReportFormat::Json] - } else if html_only { + } else if config.html_only { vec![ReportFormat::Html] } else { - formats + config.formats }; + // Check if we should open HTML before generating reports + let should_open = should_open_html(&formats_to_generate, config.no_open); + let mut html_file_path: Option = None; + // Generate reports in all requested formats let report_gen = ReportGenerator::new(); for format in formats_to_generate { @@ -114,14 +129,19 @@ pub async fn handle_scan_command( ReportFormat::Csv => "csv", }; - let output_file = if output.extension().is_some() { + let output_file = if config.output.extension().is_some() { // If user provided a specific filename, respect it for the first format - output.clone() + config.output.clone() } else { // Generate appropriate filename based on format - output.join(format!("security-report.{}", extension)) + config.output.join(format!("security-report.{}", extension)) }; + // Track HTML file path for opening later + if matches!(format, ReportFormat::Html) { + html_file_path = Some(output_file.clone()); + } + report_gen .generate_report(&results, &output_file, format.clone()) .await?; @@ -135,11 +155,18 @@ pub async fn handle_scan_command( critical_count, high_count ); - if fail_on_critical && critical_count > 0 { + if config.fail_on_critical && critical_count > 0 { error!("Critical issues found. Failing as requested."); std::process::exit(1); } + // Open HTML report in browser if appropriate + if should_open { + if let Some(html_path) = html_file_path { + open_html_file(&html_path)?; + } + } + Ok(()) } @@ -199,3 +226,65 @@ pub async fn handle_plugin_command(action: PluginAction, path: Option) Ok(()) } + +/// Detects if we're running in a CI environment +fn is_ci_environment() -> bool { + // Check common CI environment variables + std::env::var("CI").is_ok() + || std::env::var("GITHUB_ACTIONS").is_ok() + || std::env::var("GITLAB_CI").is_ok() + || std::env::var("JENKINS_URL").is_ok() + || std::env::var("TRAVIS").is_ok() + || std::env::var("CIRCLECI").is_ok() + || std::env::var("BUILDKITE").is_ok() + || std::env::var("TF_BUILD").is_ok() // Azure DevOps +} + +/// Detects if we're in an interactive terminal session +fn is_interactive() -> bool { + // Check if stdout is a terminal and not redirected + use std::io::IsTerminal; + std::io::stdout().is_terminal() +} + +/// Determines if we should automatically open the HTML report +fn should_open_html(formats: &[ReportFormat], no_open: bool) -> bool { + // Don't open if user explicitly disabled it + if no_open { + return false; + } + + // Don't open in CI environments + if is_ci_environment() { + return false; + } + + // Don't open if not in interactive terminal + if !is_interactive() { + return false; + } + + // Only open if HTML is being generated + formats.contains(&ReportFormat::Html) +} + +/// Opens the HTML file in the default browser +fn open_html_file(file_path: &PathBuf) -> Result<()> { + match opener::open(file_path) { + Ok(()) => { + info!( + "📖 Opening security report in browser: {}", + file_path.display() + ); + Ok(()) + } + Err(e) => { + warn!( + "Could not open HTML report in browser: {}. You can manually open: {}", + e, + file_path.display() + ); + Ok(()) // Don't fail the entire command if browser opening fails + } + } +} diff --git a/src/main.rs b/src/main.rs index 02a61b5..b13e14e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,18 +33,20 @@ async fn main() -> Result<()> { format, json_only, html_only, + no_open, fail_on_critical, } => { - cli::handle_scan_command( + let scan_config = cli::ScanConfig { path, config, output, - format, + formats: format, json_only, html_only, + no_open, fail_on_critical, - ) - .await + }; + cli::handle_scan_command(scan_config).await } Commands::Fuzz { path, diff --git a/src/report.rs b/src/report.rs index 4a04f1d..ecfa73a 100644 --- a/src/report.rs +++ b/src/report.rs @@ -11,7 +11,7 @@ use std::path::Path; use crate::analyzer::AnalysisResult; use crate::fuzz::FuzzResult; -#[derive(Debug, Clone, ValueEnum, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, ValueEnum, Serialize, Deserialize)] pub enum ReportFormat { Json, Html,