diff --git a/src/commands/backup.rs b/src/commands/backup.rs new file mode 100644 index 00000000..32e6cd30 --- /dev/null +++ b/src/commands/backup.rs @@ -0,0 +1,306 @@ +use crate::utils::backup::{self, BackupStatus}; +use crate::utils::crypto::prompt_password; +use crate::utils::print as p; +use anyhow::Result; +use clap::{Args, Subcommand}; +use colored::Colorize; +use std::path::PathBuf; + +#[derive(Subcommand)] +pub enum BackupCommands { + /// Create a backup of contract code/state + Create(CreateArgs), + /// List recorded backups + List(ListArgs), + /// Show details of a backup + Show(ShowArgs), + /// Verify a backup's integrity (checksum) + Verify(VerifyArgs), + /// Restore a backup into a destination directory + Restore(RestoreArgs), + /// Restore the most recent backup at or before a given time (point-in-time recovery) + RestorePointInTime(RestorePitArgs), + /// Replicate an existing backup to another region + Replicate(ReplicateArgs), + /// Run a non-destructive recovery test (restore into a temp dir and verify) + TestRecovery(VerifyArgs), + /// Configure a recurring automated backup + AutoConfigure(AutoConfigureArgs), + /// Run any automated backups that are due + AutoRun(AutoRunArgs), +} + +#[derive(Args)] +pub struct CreateArgs { + /// Files or directories to back up (contract WASM/source, state dirs, etc.) + #[arg(required = true)] + pub sources: Vec, + /// Logical label for this backup set (used for point-in-time recovery lookups) + #[arg(long)] + pub label: String, + /// Encrypt the backup archive with a passphrase + #[arg(long, default_value_t = false)] + pub encrypt: bool, + /// Region label to replicate the backup to + #[arg(long, default_value = "primary")] + pub region: String, +} + +#[derive(Args)] +pub struct ListArgs { + #[arg(long)] + pub label: Option, + #[arg(long)] + pub json: bool, +} + +#[derive(Args)] +pub struct ShowArgs { + pub id: String, +} + +#[derive(Args)] +pub struct VerifyArgs { + pub id: String, +} + +#[derive(Args)] +pub struct RestoreArgs { + pub id: String, + /// Directory to restore the backup's files into + #[arg(long)] + pub dest: PathBuf, +} + +#[derive(Args)] +pub struct RestorePitArgs { + /// Backup label to search within + #[arg(long)] + pub label: String, + /// Restore the most recent backup at or before this time (RFC3339) + #[arg(long)] + pub at: String, + #[arg(long)] + pub dest: PathBuf, +} + +#[derive(Args)] +pub struct ReplicateArgs { + pub id: String, + #[arg(long)] + pub region: String, +} + +#[derive(Args)] +pub struct AutoConfigureArgs { + #[arg(long)] + pub label: String, + #[arg(required = true)] + pub sources: Vec, + /// How often the backup should run, in hours + #[arg(long, default_value_t = 24)] + pub interval_hours: u64, + #[arg(long, default_value_t = false)] + pub encrypt: bool, + #[arg(long, default_value = "primary")] + pub region: String, +} + +#[derive(Args)] +pub struct AutoRunArgs {} + +pub async fn handle(cmd: BackupCommands) -> Result<()> { + match cmd { + BackupCommands::Create(args) => handle_create(args), + BackupCommands::List(args) => handle_list(args), + BackupCommands::Show(args) => handle_show(args), + BackupCommands::Verify(args) => handle_verify(args), + BackupCommands::Restore(args) => handle_restore(args), + BackupCommands::RestorePointInTime(args) => handle_restore_pit(args), + BackupCommands::Replicate(args) => handle_replicate(args), + BackupCommands::TestRecovery(args) => handle_test_recovery(args), + BackupCommands::AutoConfigure(args) => handle_auto_configure(args), + BackupCommands::AutoRun(args) => handle_auto_run(args), + } +} + +fn handle_create(args: CreateArgs) -> Result<()> { + p::header("Create Backup"); + let passphrase = if args.encrypt { + Some(prompt_password("Backup encryption passphrase", true)?) + } else { + None + }; + + let record = backup::create_backup( + &args.sources, + &args.label, + args.encrypt, + passphrase.as_deref(), + &args.region, + )?; + + p::kv("Backup ID", &record.id); + p::kv("Label", &record.label); + p::kv("Size", &format!("{} bytes", record.size_bytes)); + p::kv("Encrypted", if record.encrypted { "yes" } else { "no" }); + p::kv("Checksum", &record.checksum); + p::kv("Replicated to", &record.replicated_regions.join(", ")); + p::success("Backup created"); + Ok(()) +} + +fn handle_list(args: ListArgs) -> Result<()> { + p::header("Backups"); + let mut records = backup::list_backups()?; + if let Some(label) = &args.label { + records.retain(|r| &r.label == label); + } + + if args.json { + println!("{}", serde_json::to_string_pretty(&records)?); + return Ok(()); + } + + if records.is_empty() { + p::info("No backups recorded."); + return Ok(()); + } + + p::separator(); + for r in &records { + let status_str = match r.status { + BackupStatus::Verified => r.status.to_string().green().to_string(), + BackupStatus::VerificationFailed => r.status.to_string().red().to_string(), + BackupStatus::Created => r.status.to_string().cyan().to_string(), + }; + println!( + " {} {:<14} {:<20} {:<10} {}", + &r.id[..8.min(r.id.len())].cyan(), + r.label, + r.created_at.get(..19).unwrap_or(&r.created_at), + status_str, + r.replicated_regions.join(","), + ); + } + p::separator(); + Ok(()) +} + +fn handle_show(args: ShowArgs) -> Result<()> { + p::header("Backup Details"); + let record = backup::load_backup(&args.id)?; + println!("{}", serde_json::to_string_pretty(&record)?); + Ok(()) +} + +fn handle_verify(args: VerifyArgs) -> Result<()> { + p::header("Verify Backup"); + let record = backup::load_backup(&args.id)?; + let passphrase = if record.encrypted { + Some(prompt_password("Backup encryption passphrase", false)?) + } else { + None + }; + let updated = backup::verify_backup(&args.id, passphrase.as_deref())?; + p::kv("Backup ID", &updated.id); + p::kv("Status", &updated.status.to_string()); + p::success("Backup verified successfully"); + Ok(()) +} + +fn handle_restore(args: RestoreArgs) -> Result<()> { + p::header("Restore Backup"); + let record = backup::load_backup(&args.id)?; + let passphrase = if record.encrypted { + Some(prompt_password("Backup encryption passphrase", false)?) + } else { + None + }; + let extracted = backup::restore_backup(&args.id, &args.dest, passphrase.as_deref())?; + p::kv("Files restored", &extracted.len().to_string()); + for f in &extracted { + println!(" - {}", f); + } + p::success("Backup restored"); + Ok(()) +} + +fn handle_restore_pit(args: RestorePitArgs) -> Result<()> { + p::header("Point-in-Time Recovery"); + let at = backup::find_point_in_time(&args.label, chrono_parse(&args.at)?)?; + p::kv("Selected backup", &at.id); + p::kv("Created at", &at.created_at); + + let passphrase = if at.encrypted { + Some(prompt_password("Backup encryption passphrase", false)?) + } else { + None + }; + let extracted = backup::restore_backup(&at.id, &args.dest, passphrase.as_deref())?; + p::kv("Files restored", &extracted.len().to_string()); + p::success("Point-in-time recovery complete"); + Ok(()) +} + +fn handle_replicate(args: ReplicateArgs) -> Result<()> { + p::header("Replicate Backup"); + let record = backup::replicate_existing(&args.id, &args.region)?; + p::kv("Backup ID", &record.id); + p::kv("Replicated to", &record.replicated_regions.join(", ")); + p::success("Backup replicated"); + Ok(()) +} + +fn handle_test_recovery(args: VerifyArgs) -> Result<()> { + p::header("Recovery Test"); + let record = backup::load_backup(&args.id)?; + let passphrase = if record.encrypted { + Some(prompt_password("Backup encryption passphrase", false)?) + } else { + None + }; + let count = backup::test_restore(&args.id, passphrase.as_deref())?; + p::kv("Files verified", &count.to_string()); + p::success("Recovery test passed — backup can be restored"); + Ok(()) +} + +fn handle_auto_configure(args: AutoConfigureArgs) -> Result<()> { + p::header("Configure Automated Backup"); + let cfg = backup::configure_automation( + &args.label, + args.sources, + args.interval_hours, + args.encrypt, + &args.region, + )?; + p::kv("Label", &cfg.label); + p::kv("Interval", &format!("{}h", cfg.interval_hours)); + p::success("Automated backup configured. Run `starforge backup auto-run` periodically (e.g. via cron) to execute due backups."); + Ok(()) +} + +fn handle_auto_run(_args: AutoRunArgs) -> Result<()> { + p::header("Run Automated Backups"); + let passphrase = if backup::list_automation()?.iter().any(|c| c.encrypt) { + Some(prompt_password("Backup encryption passphrase", false)?) + } else { + None + }; + let ran = backup::run_automation(passphrase.as_deref())?; + if ran.is_empty() { + p::info("No automated backups were due."); + } else { + for label in &ran { + p::success(&format!("Backed up '{}'", label)); + } + } + Ok(()) +} + +fn chrono_parse(s: &str) -> Result> { + chrono::DateTime::parse_from_rfc3339(s) + .map(|d| d.with_timezone(&chrono::Utc)) + .map_err(|e| anyhow::anyhow!("Invalid RFC3339 timestamp '{}': {}", s, e)) +} diff --git a/src/commands/benchmark.rs b/src/commands/benchmark.rs index f65ece7c..1b2cd951 100644 --- a/src/commands/benchmark.rs +++ b/src/commands/benchmark.rs @@ -1,12 +1,25 @@ -use crate::utils::{print as p, profiler::Profiler}; +use crate::utils::benchmarking::{self, ComparisonStatus}; +use crate::utils::{config, print as p, profiler::Profiler}; use anyhow::Result; -use clap::Args; +use clap::{Args, Subcommand}; use colored::*; use serde::Serialize; use std::path::PathBuf; +#[derive(Subcommand)] +pub enum BenchmarkCommands { + /// Benchmark WASM processing / CLI hot paths (raw timing) + Wasm(WasmBenchmarkArgs), + /// Compare a contract's recorded performance against industry standards + Compare(CompareArgs), + /// List previously generated benchmark reports + History(HistoryArgs), + /// Show a specific benchmark report + Show(ShowArgs), +} + #[derive(Args)] -pub struct BenchmarkArgs { +pub struct WasmBenchmarkArgs { /// Benchmark WASM processing by reading a .wasm file and simulating operations #[arg(long)] pub wasm: Option, @@ -21,6 +34,39 @@ pub struct BenchmarkArgs { pub report: String, } +#[derive(Args)] +pub struct CompareArgs { + /// Contract ID to benchmark + pub contract: String, + /// Network the contract's recorded metrics belong to + #[arg(long, default_value = "testnet")] + pub network: String, + /// Industry category to compare against: token, defi, nft, voting, generic + #[arg(long, default_value = "generic")] + pub category: String, + /// Output as JSON + #[arg(long)] + pub json: bool, +} + +#[derive(Args)] +pub struct HistoryArgs { + /// Filter by contract ID + #[arg(long)] + pub contract: Option, + /// Maximum number of reports to show + #[arg(long, default_value = "20")] + pub limit: usize, + /// Output as JSON + #[arg(long)] + pub json: bool, +} + +#[derive(Args)] +pub struct ShowArgs { + pub id: String, +} + #[derive(Debug, Serialize)] struct BenchmarkReport { wasm: Option, @@ -38,7 +84,16 @@ struct PhaseMetric { ms: u128, } -pub async fn handle(args: BenchmarkArgs) -> Result<()> { +pub async fn handle(cmd: BenchmarkCommands) -> Result<()> { + match cmd { + BenchmarkCommands::Wasm(args) => handle_wasm(args), + BenchmarkCommands::Compare(args) => handle_compare(args), + BenchmarkCommands::History(args) => handle_history(args), + BenchmarkCommands::Show(args) => handle_show(args), + } +} + +fn handle_wasm(args: WasmBenchmarkArgs) -> Result<()> { let mut profiler = Profiler::start(); p::header("Benchmark"); @@ -120,3 +175,102 @@ pub async fn handle(args: BenchmarkArgs) -> Result<()> { Ok(()) } + +fn handle_compare(args: CompareArgs) -> Result<()> { + config::validate_contract_id(&args.contract)?; + config::validate_network(&args.network)?; + p::header("Benchmark Comparison"); + + let score = benchmarking::run_benchmark(&args.contract, &args.network, &args.category)?; + benchmarking::save_report(&score)?; + + if args.json { + println!("{}", serde_json::to_string_pretty(&score)?); + return Ok(()); + } + + p::kv("Contract", &score.contract_id); + p::kv("Network", &score.network); + p::kv("Category", &score.category); + p::kv("Sample size", &score.sample_size.to_string()); + println!(); + p::kv_accent( + "Overall score", + &format!("{:.1}/100 (grade {})", score.overall_score, score.grade), + ); + println!(); + + p::header("Metric Comparison"); + for c in &score.comparisons { + let status_str = match c.status { + ComparisonStatus::Better => c.status.to_string().green().to_string(), + ComparisonStatus::Meets => c.status.to_string().cyan().to_string(), + ComparisonStatus::Below => c.status.to_string().red().to_string(), + }; + println!( + " {:<26} {:>12.1}{} vs industry {:>10.1}{} — {}", + c.name, c.contract_value, c.unit, c.industry_value, c.unit, status_str + ); + } + + println!(); + p::header("Recommendations"); + for (i, rec) in score.recommendations.iter().enumerate() { + println!(" {}. {}", i + 1, rec); + } + + p::separator(); + p::kv("Report saved", &score.id); + p::success("Benchmark comparison complete"); + Ok(()) +} + +fn handle_history(args: HistoryArgs) -> Result<()> { + p::header("Benchmark History"); + let mut reports = benchmarking::list_reports()?; + if let Some(contract) = &args.contract { + reports.retain(|r| &r.contract_id == contract); + } + let shown: Vec<_> = reports.iter().take(args.limit).collect(); + + if args.json { + println!("{}", serde_json::to_string_pretty(&shown)?); + return Ok(()); + } + + if shown.is_empty() { + p::info("No benchmark reports found. Run `starforge benchmark compare ` first."); + return Ok(()); + } + + p::separator(); + println!( + " {:<10} {:<10} {:<10} {:<6} {:<18} {}", + "ID".dimmed(), + "Category".dimmed(), + "Score".dimmed(), + "Grade".dimmed(), + "Generated".dimmed(), + "Contract".dimmed(), + ); + for r in &shown { + println!( + " {:<10} {:<10} {:<10.1} {:<6} {:<18} {}", + &r.id[..8.min(r.id.len())].cyan(), + r.category, + r.overall_score, + r.grade, + r.generated_at.get(..16).unwrap_or(&r.generated_at), + r.contract_id, + ); + } + p::separator(); + Ok(()) +} + +fn handle_show(args: ShowArgs) -> Result<()> { + p::header("Benchmark Report"); + let score = benchmarking::load_report(&args.id)?; + println!("{}", serde_json::to_string_pretty(&score)?); + Ok(()) +} diff --git a/src/commands/completions.rs b/src/commands/completions.rs index 00e3826e..5f45f6c3 100644 --- a/src/commands/completions.rs +++ b/src/commands/completions.rs @@ -144,7 +144,8 @@ enum Commands { #[command(subcommand)] Tutorial(crate::commands::tutorial::TutorialCommands), /// Performance benchmarking utilities - Benchmark(crate::commands::benchmark::BenchmarkArgs), + #[command(subcommand)] + Benchmark(crate::commands::benchmark::BenchmarkCommands), /// Contract testing utilities for Soroban wasm Test(crate::commands::test::TestArgs), /// Gas analysis and optimization helpers diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 5836362c..f8b45575 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod analytics; +pub mod backup; pub mod benchmark; pub mod command_tree; pub mod completions; @@ -23,6 +24,7 @@ pub mod orchestrate; pub mod perf; pub mod plugin; pub mod registry; +pub mod schedule; pub mod security; pub mod shell; pub mod social; diff --git a/src/commands/schedule.rs b/src/commands/schedule.rs new file mode 100644 index 00000000..5ef9d994 --- /dev/null +++ b/src/commands/schedule.rs @@ -0,0 +1,333 @@ +use crate::utils::print as p; +use crate::utils::scheduler::{self, ScheduleStatus}; +use crate::utils::{config, notifications}; +use anyhow::Result; +use clap::{Args, Subcommand}; +use colored::*; +use std::path::PathBuf; + +#[derive(Subcommand)] +pub enum ScheduleCommands { + /// Schedule a deployment for a future time + Create(CreateArgs), + /// List scheduled deployments + List(ListArgs), + /// Show details of a scheduled deployment + Show(ShowArgs), + /// Approve a pending scheduled deployment + Approve(ApproveArgs), + /// Reject a pending scheduled deployment + Reject(ApproveArgs), + /// Cancel a scheduled deployment + Cancel(CancelArgs), + /// Execute all due, approved deployments (run as a cron tick) + Run(RunArgs), + /// Show a dashboard of scheduled deployments + Dashboard, +} + +#[derive(Args)] +pub struct CreateArgs { + /// Contract identifier (logical name) + #[arg(long)] + pub contract: String, + /// Path to the WASM file to deploy + #[arg(long)] + pub wasm: PathBuf, + /// Target network + #[arg(long, default_value = "testnet")] + pub network: String, + /// Wallet name to use for signing + #[arg(long)] + pub wallet: Option, + /// When to deploy: RFC3339 (e.g. 2026-07-01T15:00:00Z) or 'YYYY-MM-DD HH:MM:SS' + #[arg(long)] + pub at: String, + /// IDs of other scheduled deployments this one depends on + #[arg(long, value_delimiter = ',')] + pub depends_on: Vec, + /// Number of distinct approvals required before execution (0 = auto-approved) + #[arg(long, default_value_t = 1)] + pub required_approvals: u32, + /// Send a notification when this deployment runs + #[arg(long, default_value_t = true)] + pub notify: bool, +} + +#[derive(Args)] +pub struct ListArgs { + /// Filter by status + #[arg(long)] + pub status: Option, + /// Output as JSON + #[arg(long)] + pub json: bool, +} + +#[derive(Args)] +pub struct ShowArgs { + pub id: String, +} + +#[derive(Args)] +pub struct ApproveArgs { + pub id: String, + /// Name of the approver + #[arg(long)] + pub approver: String, +} + +#[derive(Args)] +pub struct CancelArgs { + pub id: String, +} + +#[derive(Args)] +pub struct RunArgs { + /// Simulate execution without performing real deploys + #[arg(long, default_value_t = true)] + pub dry_run: bool, +} + +pub async fn handle(cmd: ScheduleCommands) -> Result<()> { + match cmd { + ScheduleCommands::Create(args) => handle_create(args), + ScheduleCommands::List(args) => handle_list(args), + ScheduleCommands::Show(args) => handle_show(args), + ScheduleCommands::Approve(args) => handle_approve(args), + ScheduleCommands::Reject(args) => handle_reject(args), + ScheduleCommands::Cancel(args) => handle_cancel(args), + ScheduleCommands::Run(args) => handle_run(args), + ScheduleCommands::Dashboard => handle_dashboard(), + } +} + +fn handle_create(args: CreateArgs) -> Result<()> { + config::validate_network(&args.network)?; + config::validate_file_path(&args.wasm, Some("wasm"))?; + p::header("Schedule Deployment"); + + let entry = scheduler::create( + args.contract, + args.wasm, + args.network, + args.wallet, + &args.at, + args.depends_on, + args.required_approvals, + args.notify, + )?; + + p::kv("Schedule ID", &entry.id); + p::kv("Contract", &entry.contract_id); + p::kv("Scheduled at", &entry.scheduled_at); + p::kv("Status", &entry.status.to_string()); + if !entry.depends_on.is_empty() { + p::kv("Depends on", &entry.depends_on.join(", ")); + } + p::success("Deployment scheduled"); + Ok(()) +} + +fn handle_list(args: ListArgs) -> Result<()> { + p::header("Scheduled Deployments"); + let mut entries = scheduler::list()?; + if let Some(status) = &args.status { + entries.retain(|e| e.status.to_string() == *status); + } + + if args.json { + println!("{}", serde_json::to_string_pretty(&entries)?); + return Ok(()); + } + + if entries.is_empty() { + p::info("No scheduled deployments found."); + return Ok(()); + } + + p::separator(); + println!( + " {:<10} {:<12} {:<20} {:<10} {}", + "ID".dimmed(), + "Status".dimmed(), + "Scheduled At".dimmed(), + "Network".dimmed(), + "Contract".dimmed(), + ); + for e in &entries { + println!( + " {:<10} {:<12} {:<20} {:<10} {}", + &e.id[..8.min(e.id.len())].cyan(), + status_label(&e.status), + e.scheduled_at.get(..19).unwrap_or(&e.scheduled_at), + e.network, + e.contract_id, + ); + } + p::separator(); + Ok(()) +} + +fn handle_show(args: ShowArgs) -> Result<()> { + p::header("Scheduled Deployment"); + let e = scheduler::load(&args.id)?; + p::kv("ID", &e.id); + p::kv("Contract", &e.contract_id); + p::kv("WASM", &e.wasm.display().to_string()); + p::kv("Network", &e.network); + p::kv("Wallet", e.wallet.as_deref().unwrap_or("(default)")); + p::kv("Scheduled at", &e.scheduled_at); + p::kv("Status", &e.status.to_string()); + p::kv( + "Approvals", + &format!("{}/{}", e.approvals.len(), e.required_approvals), + ); + for a in &e.approvals { + println!(" - {} at {}", a.approver, a.approved_at); + } + if !e.depends_on.is_empty() { + p::kv("Depends on", &e.depends_on.join(", ")); + } + if let Some(err) = &e.error { + p::kv("Error", err); + } + Ok(()) +} + +fn handle_approve(args: ApproveArgs) -> Result<()> { + p::header("Approve Scheduled Deployment"); + let e = scheduler::approve(&args.id, &args.approver)?; + p::kv("Schedule ID", &e.id); + p::kv( + "Approvals", + &format!("{}/{}", e.approvals.len(), e.required_approvals), + ); + p::kv("Status", &e.status.to_string()); + p::success("Approval recorded"); + Ok(()) +} + +fn handle_reject(args: ApproveArgs) -> Result<()> { + p::header("Reject Scheduled Deployment"); + let e = scheduler::reject(&args.id, &args.approver)?; + p::kv("Schedule ID", &e.id); + p::kv("Status", &e.status.to_string()); + p::warn("Scheduled deployment rejected"); + Ok(()) +} + +fn handle_cancel(args: CancelArgs) -> Result<()> { + p::header("Cancel Scheduled Deployment"); + let e = scheduler::cancel(&args.id)?; + p::kv("Schedule ID", &e.id); + p::kv("Status", &e.status.to_string()); + p::success("Scheduled deployment cancelled"); + Ok(()) +} + +fn handle_run(args: RunArgs) -> Result<()> { + p::header("Run Due Deployments"); + let executed = scheduler::run_due(args.dry_run)?; + + if executed.is_empty() { + p::info("No due deployments to execute."); + return Ok(()); + } + + for e in &executed { + let icon = match e.status { + ScheduleStatus::Completed => "✓".green().to_string(), + ScheduleStatus::Failed => "✗".red().to_string(), + _ => "…".cyan().to_string(), + }; + println!( + " {} {} ({}) — {}", + icon, + &e.id[..8.min(e.id.len())], + e.contract_id, + e.status + ); + if let Some(err) = &e.error { + println!(" {}", err.red()); + } + if e.notify { + let _ = notifications::send_notification( + "scheduled_deployment", + &std::collections::HashMap::from([ + ("contract".to_string(), e.contract_id.clone()), + ("status".to_string(), e.status.to_string()), + ]), + if e.status == ScheduleStatus::Failed { + "high" + } else { + "info" + }, + ); + } + } + + p::success(&format!("Executed {} scheduled deployment(s)", executed.len())); + Ok(()) +} + +fn handle_dashboard() -> Result<()> { + p::header("Deployment Scheduling Dashboard"); + let entries = scheduler::list()?; + + if entries.is_empty() { + p::info("No scheduled deployments found."); + return Ok(()); + } + + let total = entries.len(); + let pending = entries + .iter() + .filter(|e| e.status == ScheduleStatus::PendingApproval) + .count(); + let approved = entries + .iter() + .filter(|e| e.status == ScheduleStatus::Approved) + .count(); + let completed = entries + .iter() + .filter(|e| e.status == ScheduleStatus::Completed) + .count(); + let failed = entries + .iter() + .filter(|e| e.status == ScheduleStatus::Failed) + .count(); + + p::separator(); + p::kv("Total", &total.to_string()); + p::kv("Pending approval", &pending.to_string()); + p::kv("Approved (awaiting time)", &approved.to_string()); + p::kv("Completed", &completed.to_string()); + p::kv("Failed", &failed.to_string()); + println!(); + + for e in entries.iter().rev().take(10) { + println!( + " {} {} | {} | {}", + status_label(&e.status), + &e.id[..8.min(e.id.len())].dimmed(), + e.scheduled_at.get(..19).unwrap_or(&e.scheduled_at), + e.contract_id, + ); + } + p::separator(); + Ok(()) +} + +fn status_label(status: &ScheduleStatus) -> String { + match status { + ScheduleStatus::PendingApproval => "pending".yellow().to_string(), + ScheduleStatus::Approved => "approved".cyan().to_string(), + ScheduleStatus::Rejected => "rejected".red().to_string(), + ScheduleStatus::Due => "due".magenta().to_string(), + ScheduleStatus::Executing => "executing".blue().to_string(), + ScheduleStatus::Completed => "completed".green().to_string(), + ScheduleStatus::Failed => "failed".red().bold().to_string(), + ScheduleStatus::Cancelled => "cancelled".dimmed().to_string(), + } +} diff --git a/src/commands/security.rs b/src/commands/security.rs index 40a7b996..369b1fbd 100644 --- a/src/commands/security.rs +++ b/src/commands/security.rs @@ -1,13 +1,15 @@ use crate::utils::print as p; use crate::utils::security::{ apply_hardening, default_rules, evaluate_event, format_report, generate_hardening_report, - run_audit, run_checklist, validate_security, write_report, AnomalyDetector, AuditConfig, - HardeningOptions, IncidentResponse, IncidentStore, ThreatFeed, + run_audit, run_checklist, run_pentest, track_findings, validate_security, write_report, + AnomalyDetector, AuditConfig, HardeningOptions, IncidentResponse, IncidentStore, + RemediationStatus, ThreatFeed, }; use crate::utils::stream::{EventStreamFilters, SorobanEventStream}; use crate::utils::{config, notifications, soroban}; use anyhow::Result; use clap::{Args, Subcommand}; +use colored::Colorize; use std::fs; use std::path::PathBuf; use std::sync::{ @@ -31,6 +33,10 @@ pub enum SecurityCommands { Incident(IncidentArgs), /// Run full security audit with external tools (Slither, Mythril) and built-in analysis Audit(AuditArgs), + /// Run simulated penetration test cases against contract source + Pentest(PentestArgs), + /// Track remediation of findings from audit/pentest/checklist runs + Remediation(RemediationArgs), } #[derive(Args)] @@ -112,6 +118,47 @@ pub struct IncidentArgs { pub command: IncidentCommands, } +#[derive(Args)] +pub struct PentestArgs { + /// Path to Soroban contract source (.rs) + pub path: PathBuf, + /// Output format: text or json + #[arg(long, default_value = "text")] + pub format: String, + /// Automatically create remediation tracking items for exploited cases + #[arg(long, default_value_t = true)] + pub track: bool, +} + +#[derive(Subcommand)] +pub enum RemediationCommands { + /// List tracked remediation items + List { + #[arg(long)] + status: Option, + }, + /// Assign a remediation item to someone + Assign { + id: String, + #[arg(long)] + to: String, + }, + /// Update the status of a remediation item + Status { + id: String, + /// New status: open, in-progress, resolved, verified, wont-fix + status: String, + }, + /// Add a note to a remediation item + Note { id: String, note: String }, +} + +#[derive(Args)] +pub struct RemediationArgs { + #[command(subcommand)] + pub command: RemediationCommands, +} + pub async fn handle(cmd: SecurityCommands) -> Result<()> { match cmd { SecurityCommands::Harden(args) => handle_harden(args), @@ -121,6 +168,8 @@ pub async fn handle(cmd: SecurityCommands) -> Result<()> { SecurityCommands::Monitor(args) => handle_monitor(args), SecurityCommands::Incident(args) => handle_incident(args), SecurityCommands::Audit(args) => handle_audit(args), + SecurityCommands::Pentest(args) => handle_pentest(args), + SecurityCommands::Remediation(args) => handle_remediation(args), } } @@ -414,3 +463,128 @@ fn handle_audit(args: AuditArgs) -> Result<()> { p::success("Security audit complete"); Ok(()) } + +fn handle_pentest(args: PentestArgs) -> Result<()> { + config::validate_file_path(&args.path, Some("rs"))?; + p::header("Penetration Test Simulation"); + p::kv("Contract", &args.path.display().to_string()); + + let report = run_pentest(&args.path)?; + + if args.track { + let findings: Vec<_> = report + .results + .iter() + .filter(|r| r.exploited) + .map(|r| { + ( + r.name.clone(), + r.severity.clone(), + r.evidence.clone(), + r.remediation.clone(), + ) + }) + .collect(); + let created = track_findings("pentest", &findings)?; + if !created.is_empty() { + p::info(&format!( + "Created {} new remediation item(s)", + created.len() + )); + } + } + + if args.format == "json" { + println!("{}", serde_json::to_string_pretty(&report)?); + return Ok(()); + } + + p::separator(); + p::kv("Cases run", &report.cases_run.to_string()); + p::kv("Cases exploited", &report.cases_exploited.to_string()); + p::kv("Security score", &format!("{:.1}/100", report.score)); + println!(); + + for r in &report.results { + let icon = if r.exploited { "✗".red() } else { "✓".green() }; + println!( + " {} [{}] {} ({})", + icon, + r.severity.to_uppercase(), + r.name, + r.id + ); + println!(" Attack vector: {}", r.attack_vector); + if r.exploited { + println!(" Evidence: {}", r.evidence); + println!(" Remediation: {}", r.remediation); + } + } + + p::separator(); + if report.cases_exploited == 0 { + p::success("No exploitable findings from simulated penetration tests"); + } else { + p::warn(&format!( + "{} simulated attack(s) succeeded — see remediation items", + report.cases_exploited + )); + } + Ok(()) +} + +fn handle_remediation(args: RemediationArgs) -> Result<()> { + match args.command { + RemediationCommands::List { status } => { + p::header("Remediation Tracker"); + let mut items = crate::utils::security::remediation::load_all()?; + if let Some(status) = &status { + items.retain(|i| i.status.to_string() == *status); + } + if items.is_empty() { + p::info("No remediation items recorded."); + return Ok(()); + } + for item in &items { + println!( + " {} [{}] {} — {} ({})", + &item.id[..8.min(item.id.len())].cyan(), + item.severity.to_uppercase(), + item.title, + item.status, + item.source, + ); + if let Some(assignee) = &item.assignee { + println!(" Assigned to: {}", assignee); + } + } + Ok(()) + } + RemediationCommands::Assign { id, to } => { + let item = crate::utils::security::remediation::assign(&id, &to)?; + p::success(&format!("Assigned '{}' to {}", item.title, to)); + Ok(()) + } + RemediationCommands::Status { id, status } => { + let parsed = match status.as_str() { + "open" => RemediationStatus::Open, + "in-progress" => RemediationStatus::InProgress, + "resolved" => RemediationStatus::Resolved, + "verified" => RemediationStatus::Verified, + "wont-fix" => RemediationStatus::WontFix, + other => anyhow::bail!( + "Unknown status '{}'. Use one of: open, in-progress, resolved, verified, wont-fix", + other + ), + }; + let item = crate::utils::security::remediation::update_status(&id, parsed)?; + p::success(&format!("'{}' is now {}", item.title, item.status)); + Ok(()) + } + RemediationCommands::Note { id, note } => { + let item = crate::utils::security::remediation::add_note(&id, ¬e)?; + p::success(&format!("Note added to '{}'", item.title)); + Ok(()) + } + } +} diff --git a/src/main.rs b/src/main.rs index bfff1750..0689dd3f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -91,8 +91,9 @@ enum Commands { #[command(subcommand)] Tutorial(commands::tutorial::TutorialCommands), - /// Performance benchmarking utilities - Benchmark(commands::benchmark::BenchmarkArgs), + /// Performance benchmarking utilities and industry-standard comparisons + #[command(subcommand)] + Benchmark(commands::benchmark::BenchmarkCommands), /// Contract testing utilities for Soroban wasm Test(commands::test::TestArgs), @@ -128,6 +129,14 @@ enum Commands { #[command(subcommand)] Security(commands::security::SecurityCommands), + /// Schedule deployments for future execution with approval workflows + #[command(subcommand)] + Schedule(commands::schedule::ScheduleCommands), + + /// Backup and disaster recovery for contract state and code + #[command(subcommand)] + Backup(commands::backup::BackupCommands), + /// Static analysis and linting for Soroban contracts Lint(commands::lint::LintArgs), @@ -197,6 +206,8 @@ async fn main() { Commands::Upgrade(_) => "upgrade", Commands::Orchestrate(_) => "orchestrate", Commands::Security(_) => "security", + Commands::Schedule(_) => "schedule", + Commands::Backup(_) => "backup", Commands::Lint(_) => "lint", Commands::Diagnostics(_) => "diagnostics", Commands::TemplateVcs(_) => "template-vcs", @@ -235,6 +246,8 @@ async fn main() { Commands::Upgrade(cmd) => commands::upgrade::handle(cmd).await, Commands::Orchestrate(cmd) => commands::orchestrate::handle(cmd).await, Commands::Security(cmd) => commands::security::handle(cmd).await, + Commands::Schedule(cmd) => commands::schedule::handle(cmd).await, + Commands::Backup(cmd) => commands::backup::handle(cmd).await, Commands::Lint(args) => commands::lint::handle(args).await, Commands::Diagnostics(args) => commands::diagnostics::handle(args).await, Commands::TemplateVcs(cmd) => commands::template_vcs::handle(cmd).await, diff --git a/src/utils/backup.rs b/src/utils/backup.rs new file mode 100644 index 00000000..edd052e9 --- /dev/null +++ b/src/utils/backup.rs @@ -0,0 +1,424 @@ +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::io::{Cursor, Read, Write}; +use std::path::{Path, PathBuf}; +use zip::write::FileOptions; +use zip::ZipWriter; + +use crate::utils::config; +use crate::utils::crypto::{decrypt_secret, encrypt_secret}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum BackupStatus { + Created, + Verified, + VerificationFailed, +} + +impl std::fmt::Display for BackupStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + BackupStatus::Created => "created", + BackupStatus::Verified => "verified", + BackupStatus::VerificationFailed => "verification-failed", + }; + write!(f, "{}", s) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackupRecord { + pub id: String, + pub label: String, + pub created_at: String, + pub sources: Vec, + pub archive_path: PathBuf, + pub encrypted: bool, + pub checksum: String, + pub size_bytes: u64, + pub status: BackupStatus, + pub verified_at: Option, + pub replicated_regions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutomationConfig { + pub label: String, + pub sources: Vec, + pub interval_hours: u64, + pub encrypt: bool, + pub region: String, + pub last_run: Option, +} + +fn backups_dir() -> Result { + let dir = config::config_dir().join("backups"); + if !dir.exists() { + fs::create_dir_all(&dir)?; + } + Ok(dir) +} + +fn replicas_dir(region: &str) -> Result { + let dir = backups_dir()?.join("replicas").join(region); + if !dir.exists() { + fs::create_dir_all(&dir)?; + } + Ok(dir) +} + +fn records_file() -> Result { + Ok(backups_dir()?.join("records.json")) +} + +fn automation_file() -> Result { + Ok(backups_dir()?.join("automation.json")) +} + +pub fn list_backups() -> Result> { + let path = records_file()?; + if !path.exists() { + return Ok(Vec::new()); + } + let raw = fs::read_to_string(&path)?; + Ok(serde_json::from_str(&raw).unwrap_or_default()) +} + +fn save_records(records: &[BackupRecord]) -> Result<()> { + fs::write(records_file()?, serde_json::to_string_pretty(records)?)?; + Ok(()) +} + +pub fn load_backup(id: &str) -> Result { + list_backups()? + .into_iter() + .find(|r| r.id == id || r.id.starts_with(id)) + .ok_or_else(|| anyhow::anyhow!("No backup found with ID prefix '{}'", id)) +} + +fn zip_sources(sources: &[PathBuf]) -> Result> { + let buf = Cursor::new(Vec::new()); + let mut zip = ZipWriter::new(buf); + let options: FileOptions = + FileOptions::default().compression_method(zip::CompressionMethod::Deflated); + + for src in sources { + if !src.exists() { + anyhow::bail!("Backup source not found: {}", src.display()); + } + add_path_to_zip(&mut zip, src, src, options)?; + } + + let buf = zip.finish()?; + Ok(buf.into_inner()) +} + +fn add_path_to_zip( + zip: &mut ZipWriter, + base: &Path, + path: &Path, + options: FileOptions, +) -> Result<()> { + if path.is_dir() { + for entry in fs::read_dir(path)? { + let entry = entry?; + add_path_to_zip(zip, base, &entry.path(), options)?; + } + } else { + let rel = path.strip_prefix(base.parent().unwrap_or(base)).unwrap_or(path); + zip.start_file(rel.to_string_lossy(), options)?; + let mut f = fs::File::open(path)?; + let mut contents = Vec::new(); + f.read_to_end(&mut contents)?; + zip.write_all(&contents)?; + } + Ok(()) +} + +pub fn create_backup( + sources: &[PathBuf], + label: &str, + encrypt: bool, + passphrase: Option<&str>, + region: &str, +) -> Result { + if encrypt && passphrase.is_none() { + anyhow::bail!("A passphrase is required to create an encrypted backup"); + } + + let archive_bytes = zip_sources(sources)?; + let checksum = hex::encode(Sha256::digest(&archive_bytes)); + let id = uuid::Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + + let (filename, write_bytes): (String, Vec) = if encrypt { + let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &archive_bytes); + let bundle = encrypt_secret(passphrase.unwrap(), &b64, None)?; + (format!("{}.bak.enc", id), bundle.into_bytes()) + } else { + (format!("{}.zip", id), archive_bytes.clone()) + }; + + let archive_path = backups_dir()?.join(&filename); + fs::write(&archive_path, &write_bytes) + .with_context(|| format!("Failed to write backup archive {}", archive_path.display()))?; + + let mut record = BackupRecord { + id, + label: label.to_string(), + created_at: now, + sources: sources.iter().map(|p| p.display().to_string()).collect(), + archive_path, + encrypted: encrypt, + checksum, + size_bytes: archive_bytes.len() as u64, + status: BackupStatus::Created, + verified_at: None, + replicated_regions: Vec::new(), + }; + + replicate(&mut record, region)?; + + let mut records = list_backups()?; + records.push(record.clone()); + save_records(&records)?; + Ok(record) +} + +fn replicate(record: &mut BackupRecord, region: &str) -> Result<()> { + let dest_dir = replicas_dir(region)?; + let filename = record + .archive_path + .file_name() + .ok_or_else(|| anyhow::anyhow!("Invalid archive path"))?; + let dest = dest_dir.join(filename); + fs::copy(&record.archive_path, &dest)?; + record.replicated_regions.push(region.to_string()); + Ok(()) +} + +/// Replicate an existing backup to an additional region. +pub fn replicate_existing(id: &str, region: &str) -> Result { + let mut records = list_backups()?; + let record = records + .iter_mut() + .find(|r| r.id == id || r.id.starts_with(id)) + .ok_or_else(|| anyhow::anyhow!("No backup found with ID prefix '{}'", id))?; + if record.replicated_regions.iter().any(|r| r == region) { + anyhow::bail!("Backup already replicated to region '{}'", region); + } + replicate(record, region)?; + let updated = record.clone(); + save_records(&records)?; + Ok(updated) +} + +fn read_archive_bytes(record: &BackupRecord, passphrase: Option<&str>) -> Result> { + let raw = fs::read(&record.archive_path) + .with_context(|| format!("Failed to read archive {}", record.archive_path.display()))?; + if record.encrypted { + let passphrase = passphrase + .ok_or_else(|| anyhow::anyhow!("A passphrase is required to read this backup"))?; + let bundle = String::from_utf8(raw).context("Encrypted backup file is not valid UTF-8")?; + let b64 = decrypt_secret(passphrase, &bundle)?; + let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64) + .context("Failed to decode decrypted backup payload")?; + Ok(bytes) + } else { + Ok(raw) + } +} + +/// Verify a backup's archive integrity by recomputing its checksum. +pub fn verify_backup(id: &str, passphrase: Option<&str>) -> Result { + let mut records = list_backups()?; + let record = records + .iter_mut() + .find(|r| r.id == id || r.id.starts_with(id)) + .ok_or_else(|| anyhow::anyhow!("No backup found with ID prefix '{}'", id))?; + + let bytes = read_archive_bytes(record, passphrase)?; + let checksum = hex::encode(Sha256::digest(&bytes)); + + record.status = if checksum == record.checksum { + BackupStatus::Verified + } else { + BackupStatus::VerificationFailed + }; + record.verified_at = Some(Utc::now().to_rfc3339()); + let updated = record.clone(); + save_records(&records)?; + + if updated.status == BackupStatus::VerificationFailed { + anyhow::bail!("Backup '{}' failed integrity verification", updated.id); + } + Ok(updated) +} + +/// Restore a backup's archive contents into `dest_dir`. +pub fn restore_backup(id: &str, dest_dir: &Path, passphrase: Option<&str>) -> Result> { + let record = load_backup(id)?; + let bytes = read_archive_bytes(&record, passphrase)?; + extract_zip(&bytes, dest_dir) +} + +fn extract_zip(bytes: &[u8], dest_dir: &Path) -> Result> { + fs::create_dir_all(dest_dir)?; + let reader = Cursor::new(bytes); + let mut archive = zip::ZipArchive::new(reader)?; + let mut extracted = Vec::new(); + + for i in 0..archive.len() { + let mut file = archive.by_index(i)?; + let outpath = match file.enclosed_name() { + Some(path) => dest_dir.join(path), + None => continue, + }; + if file.is_dir() { + fs::create_dir_all(&outpath)?; + } else { + if let Some(parent) = outpath.parent() { + fs::create_dir_all(parent)?; + } + let mut outfile = fs::File::create(&outpath)?; + std::io::copy(&mut file, &mut outfile)?; + extracted.push(outpath.display().to_string()); + } + } + Ok(extracted) +} + +/// Restore a backup into a temporary directory and confirm the extracted files exist, +/// without disturbing the real environment. Used for periodic recovery testing. +pub fn test_restore(id: &str, passphrase: Option<&str>) -> Result { + let tmp = tempfile::tempdir()?; + let extracted = restore_backup(id, tmp.path(), passphrase)?; + for path in &extracted { + if !Path::new(path).exists() { + anyhow::bail!("Recovery test failed: expected file '{}' missing after restore", path); + } + } + Ok(extracted.len()) +} + +/// Find the most recent backup for `label` created at or before `at`. +pub fn find_point_in_time(label: &str, at: DateTime) -> Result { + let mut candidates: Vec = list_backups()? + .into_iter() + .filter(|r| r.label == label) + .filter(|r| { + DateTime::parse_from_rfc3339(&r.created_at) + .map(|dt| dt.with_timezone(&Utc) <= at) + .unwrap_or(false) + }) + .collect(); + candidates.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + candidates + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("No backup found for label '{}' at or before {}", label, at)) +} + +pub fn list_automation() -> Result> { + let path = automation_file()?; + if !path.exists() { + return Ok(Vec::new()); + } + let raw = fs::read_to_string(&path)?; + Ok(serde_json::from_str(&raw).unwrap_or_default()) +} + +fn save_automation(configs: &[AutomationConfig]) -> Result<()> { + fs::write(automation_file()?, serde_json::to_string_pretty(configs)?)?; + Ok(()) +} + +pub fn configure_automation( + label: &str, + sources: Vec, + interval_hours: u64, + encrypt: bool, + region: &str, +) -> Result { + let mut configs = list_automation()?; + configs.retain(|c| c.label != label); + let cfg = AutomationConfig { + label: label.to_string(), + sources: sources.iter().map(|p| p.display().to_string()).collect(), + interval_hours, + encrypt, + region: region.to_string(), + last_run: None, + }; + configs.push(cfg.clone()); + save_automation(&configs)?; + Ok(cfg) +} + +/// Run any automated backup configs that are due. Returns labels backed up. +/// Encrypted automation configs require `passphrase` to be supplied. +pub fn run_automation(passphrase: Option<&str>) -> Result> { + let mut configs = list_automation()?; + let mut ran = Vec::new(); + let now = Utc::now(); + + for cfg in configs.iter_mut() { + let due = match &cfg.last_run { + None => true, + Some(last) => DateTime::parse_from_rfc3339(last) + .map(|dt| now.signed_duration_since(dt.with_timezone(&Utc)).num_hours() as u64 >= cfg.interval_hours) + .unwrap_or(true), + }; + if !due { + continue; + } + let sources: Vec = cfg.sources.iter().map(PathBuf::from).collect(); + create_backup(&sources, &cfg.label, cfg.encrypt, passphrase, &cfg.region)?; + cfg.last_run = Some(now.to_rfc3339()); + ran.push(cfg.label.clone()); + } + + save_automation(&configs)?; + Ok(ran) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn make_source_file(dir: &Path, name: &str, contents: &str) -> PathBuf { + let path = dir.join(name); + fs::write(&path, contents).unwrap(); + path + } + + #[test] + fn zip_and_extract_roundtrip() { + let src_dir = tempdir().unwrap(); + let f1 = make_source_file(src_dir.path(), "a.wasm", "hello-wasm"); + + let bytes = zip_sources(&[f1.clone()]).unwrap(); + let out_dir = tempdir().unwrap(); + let extracted = extract_zip(&bytes, out_dir.path()).unwrap(); + assert_eq!(extracted.len(), 1); + let contents = fs::read_to_string(&extracted[0]).unwrap(); + assert_eq!(contents, "hello-wasm"); + } + + #[test] + fn checksum_changes_when_contents_change() { + let dir = tempdir().unwrap(); + let f1 = make_source_file(dir.path(), "a.txt", "v1"); + let b1 = zip_sources(&[f1.clone()]).unwrap(); + let f2 = make_source_file(dir.path(), "a.txt", "v2"); + let b2 = zip_sources(&[f2]).unwrap(); + assert_ne!( + hex::encode(Sha256::digest(&b1)), + hex::encode(Sha256::digest(&b2)) + ); + } +} diff --git a/src/utils/benchmarking.rs b/src/utils/benchmarking.rs new file mode 100644 index 00000000..1ebbed7c --- /dev/null +++ b/src/utils/benchmarking.rs @@ -0,0 +1,305 @@ +use anyhow::Result; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +use crate::utils::config; +use crate::utils::performance; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IndustryStandard { + pub category: String, + /// Maximum average gas usage considered competitive for this category. + pub max_avg_gas: f64, + /// Maximum average execution time (ms) considered competitive. + pub max_avg_execution_ms: f64, + /// Minimum success rate (%) expected for production contracts. + pub min_success_rate: f64, +} + +/// Built-in industry baselines for common Soroban contract categories. +/// These are illustrative reference points, not externally sourced data. +pub fn industry_standard(category: &str) -> Result { + let standard = match category.to_lowercase().as_str() { + "token" => IndustryStandard { + category: "token".into(), + max_avg_gas: 1_500_000.0, + max_avg_execution_ms: 150.0, + min_success_rate: 99.0, + }, + "defi" => IndustryStandard { + category: "defi".into(), + max_avg_gas: 4_000_000.0, + max_avg_execution_ms: 400.0, + min_success_rate: 98.0, + }, + "nft" => IndustryStandard { + category: "nft".into(), + max_avg_gas: 2_500_000.0, + max_avg_execution_ms: 250.0, + min_success_rate: 98.5, + }, + "voting" | "governance" => IndustryStandard { + category: "voting".into(), + max_avg_gas: 2_000_000.0, + max_avg_execution_ms: 200.0, + min_success_rate: 99.5, + }, + "generic" | "" => IndustryStandard { + category: "generic".into(), + max_avg_gas: 2_000_000.0, + max_avg_execution_ms: 200.0, + min_success_rate: 98.0, + }, + other => anyhow::bail!( + "Unknown benchmark category '{}'. Use one of: token, defi, nft, voting, generic", + other + ), + }; + Ok(standard) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ComparisonStatus { + Better, + Meets, + Below, +} + +impl std::fmt::Display for ComparisonStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + ComparisonStatus::Better => "better than industry", + ComparisonStatus::Meets => "meets industry standard", + ComparisonStatus::Below => "below industry standard", + }; + write!(f, "{}", s) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MetricComparison { + pub name: String, + pub contract_value: f64, + pub industry_value: f64, + pub unit: String, + pub status: ComparisonStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BenchmarkScore { + pub id: String, + pub contract_id: String, + pub network: String, + pub category: String, + pub sample_size: u64, + pub overall_score: f64, + pub grade: String, + pub comparisons: Vec, + pub recommendations: Vec, + pub generated_at: String, +} + +fn grade_for_score(score: f64) -> &'static str { + match score as u32 { + 90..=100 => "A", + 80..=89 => "B", + 70..=79 => "C", + 60..=69 => "D", + _ => "F", + } +} + +/// Score a single metric where *lower* is better (e.g. gas, latency). +/// Returns a 0-100 sub-score and the comparison status. +fn score_lower_is_better(actual: f64, threshold: f64) -> (f64, ComparisonStatus) { + if threshold <= 0.0 { + return (100.0, ComparisonStatus::Meets); + } + let ratio = actual / threshold; + if ratio <= 1.0 { + let bonus = (1.0 - ratio) * 100.0; + let status = if ratio < 0.9 { + ComparisonStatus::Better + } else { + ComparisonStatus::Meets + }; + (100.0_f64.min(80.0 + bonus * 0.2 + (1.0 - ratio) * 20.0), status) + } else { + let over = (ratio - 1.0).min(2.0); + (((1.0 - over / 2.0) * 79.0).max(0.0), ComparisonStatus::Below) + } +} + +/// Score a metric where *higher* is better (e.g. success rate). +fn score_higher_is_better(actual: f64, threshold: f64) -> (f64, ComparisonStatus) { + if actual >= threshold { + let bonus = (actual - threshold).max(0.0); + (100.0_f64.min(90.0 + bonus), ComparisonStatus::Meets) + } else { + let deficit = (threshold - actual).min(threshold); + let ratio = if threshold > 0.0 { + deficit / threshold + } else { + 0.0 + }; + (((1.0 - ratio) * 89.0).max(0.0), ComparisonStatus::Below) + } +} + +pub fn run_benchmark(contract_id: &str, network: &str, category: &str) -> Result { + let standard = industry_standard(category)?; + let report = performance::generate_report(contract_id, network)?; + let summary = &report.summary; + + let (gas_score, gas_status) = + score_lower_is_better(summary.avg_gas_used, standard.max_avg_gas); + let (time_score, time_status) = + score_lower_is_better(summary.avg_execution_time_ms, standard.max_avg_execution_ms); + let (success_score, success_status) = + score_higher_is_better(summary.success_rate, standard.min_success_rate); + + let overall_score = (gas_score * 0.4) + (time_score * 0.3) + (success_score * 0.3); + + let comparisons = vec![ + MetricComparison { + name: "Average gas used".into(), + contract_value: summary.avg_gas_used, + industry_value: standard.max_avg_gas, + unit: "gas".into(), + status: gas_status, + }, + MetricComparison { + name: "Average execution time".into(), + contract_value: summary.avg_execution_time_ms, + industry_value: standard.max_avg_execution_ms, + unit: "ms".into(), + status: time_status, + }, + MetricComparison { + name: "Success rate".into(), + contract_value: summary.success_rate, + industry_value: standard.min_success_rate, + unit: "%".into(), + status: success_status, + }, + ]; + + let mut recommendations = Vec::new(); + for c in &comparisons { + if let ComparisonStatus::Below = c.status { + let rec = match c.name.as_str() { + "Average gas used" => format!( + "Average gas usage ({:.0}) exceeds the {} industry ceiling ({:.0}). Consider reducing storage writes, batching operations, or optimizing hot loops (see `starforge gas` and `starforge optimize`).", + c.contract_value, standard.category, c.industry_value + ), + "Average execution time" => format!( + "Average execution time ({:.1}ms) exceeds the {} industry ceiling ({:.1}ms). Profile with `starforge benchmark wasm` to locate slow phases.", + c.contract_value, standard.category, c.industry_value + ), + "Success rate" => format!( + "Success rate ({:.1}%) is below the {} industry minimum ({:.1}%). Review failed invocations with `starforge perf history` and add input validation.", + c.contract_value, standard.category, c.industry_value + ), + other => format!("{} is below industry standard.", other), + }; + recommendations.push(rec); + } + } + if recommendations.is_empty() { + recommendations.push(format!( + "Contract meets or exceeds all {} industry benchmarks. No action needed.", + standard.category + )); + } + + Ok(BenchmarkScore { + id: uuid::Uuid::new_v4().to_string(), + contract_id: contract_id.to_string(), + network: network.to_string(), + category: standard.category, + sample_size: report.summary.total_executions, + overall_score, + grade: grade_for_score(overall_score).to_string(), + comparisons, + recommendations, + generated_at: Utc::now().to_rfc3339(), + }) +} + +fn reports_dir() -> Result { + let dir = config::config_dir().join("benchmarks"); + if !dir.exists() { + fs::create_dir_all(&dir)?; + } + Ok(dir) +} + +pub fn save_report(score: &BenchmarkScore) -> Result { + let path = reports_dir()?.join(format!("{}.json", score.id)); + fs::write(&path, serde_json::to_string_pretty(score)?)?; + Ok(path) +} + +pub fn load_report(id: &str) -> Result { + let all = list_reports()?; + all.into_iter() + .find(|r| r.id == id || r.id.starts_with(id)) + .ok_or_else(|| anyhow::anyhow!("No benchmark report found with ID prefix '{}'", id)) +} + +pub fn list_reports() -> Result> { + let dir = reports_dir()?; + let mut reports = Vec::new(); + for entry in fs::read_dir(&dir)? { + let entry = entry?; + if entry.path().extension().and_then(|e| e.to_str()) == Some("json") { + if let Ok(raw) = fs::read_to_string(entry.path()) { + if let Ok(score) = serde_json::from_str::(&raw) { + reports.push(score); + } + } + } + } + reports.sort_by(|a, b| b.generated_at.cmp(&a.generated_at)); + Ok(reports) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lower_is_better_rewards_under_threshold() { + let (score, status) = score_lower_is_better(500.0, 1000.0); + assert!(score > 80.0); + assert!(matches!(status, ComparisonStatus::Better | ComparisonStatus::Meets)); + } + + #[test] + fn lower_is_better_penalizes_over_threshold() { + let (score, status) = score_lower_is_better(2000.0, 1000.0); + assert!(score < 79.0); + assert!(matches!(status, ComparisonStatus::Below)); + } + + #[test] + fn higher_is_better_rewards_meeting_minimum() { + let (score, status) = score_higher_is_better(99.5, 98.0); + assert!(score >= 90.0); + assert!(matches!(status, ComparisonStatus::Meets)); + } + + #[test] + fn unknown_category_errors() { + assert!(industry_standard("not-a-real-category").is_err()); + } + + #[test] + fn grade_thresholds() { + assert_eq!(grade_for_score(95.0), "A"); + assert_eq!(grade_for_score(82.0), "B"); + assert_eq!(grade_for_score(40.0), "F"); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index ce941859..12ccb00b 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,4 +1,6 @@ pub mod audit; +pub mod backup; +pub mod benchmarking; pub mod bindings; pub mod call_graph; pub mod config; @@ -24,6 +26,7 @@ pub mod profiler; pub mod registry; pub mod repl; pub mod sandbox; +pub mod scheduler; pub mod security; pub mod social; pub mod soroban; diff --git a/src/utils/scheduler.rs b/src/utils/scheduler.rs new file mode 100644 index 00000000..9d4893c7 --- /dev/null +++ b/src/utils/scheduler.rs @@ -0,0 +1,279 @@ +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +use crate::utils::config; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ScheduleStatus { + PendingApproval, + Approved, + Rejected, + Due, + Executing, + Completed, + Failed, + Cancelled, +} + +impl std::fmt::Display for ScheduleStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + ScheduleStatus::PendingApproval => "pending-approval", + ScheduleStatus::Approved => "approved", + ScheduleStatus::Rejected => "rejected", + ScheduleStatus::Due => "due", + ScheduleStatus::Executing => "executing", + ScheduleStatus::Completed => "completed", + ScheduleStatus::Failed => "failed", + ScheduleStatus::Cancelled => "cancelled", + }; + write!(f, "{}", s) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Approval { + pub approver: String, + pub approved_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScheduledDeployment { + pub id: String, + pub contract_id: String, + pub wasm: PathBuf, + pub network: String, + pub wallet: Option, + pub scheduled_at: String, + pub depends_on: Vec, + pub required_approvals: u32, + pub approvals: Vec, + pub status: ScheduleStatus, + pub notify: bool, + pub created_at: String, + pub updated_at: String, + pub error: Option, +} + +pub fn schedule_dir() -> Result { + let dir = config::config_dir().join("schedule"); + if !dir.exists() { + fs::create_dir_all(&dir)?; + } + Ok(dir) +} + +pub fn parse_when(when: &str) -> Result> { + if let Ok(dt) = DateTime::parse_from_rfc3339(when) { + return Ok(dt.with_timezone(&Utc)); + } + if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(when, "%Y-%m-%d %H:%M:%S") { + return Ok(DateTime::from_naive_utc_and_offset(naive, Utc)); + } + anyhow::bail!( + "Invalid schedule time '{}'. Use RFC3339 (e.g. 2026-07-01T15:00:00Z) or 'YYYY-MM-DD HH:MM:SS'", + when + ) +} + +pub fn create( + contract_id: String, + wasm: PathBuf, + network: String, + wallet: Option, + when: &str, + depends_on: Vec, + required_approvals: u32, + notify: bool, +) -> Result { + let scheduled_at = parse_when(when)?; + for dep in &depends_on { + if load(dep).is_err() { + anyhow::bail!("Dependency schedule '{}' not found", dep); + } + } + + let now = Utc::now().to_rfc3339(); + let entry = ScheduledDeployment { + id: uuid::Uuid::new_v4().to_string(), + contract_id, + wasm, + network, + wallet, + scheduled_at: scheduled_at.to_rfc3339(), + depends_on, + required_approvals, + approvals: Vec::new(), + status: if required_approvals == 0 { + ScheduleStatus::Approved + } else { + ScheduleStatus::PendingApproval + }, + notify, + created_at: now.clone(), + updated_at: now, + error: None, + }; + save(&entry)?; + Ok(entry) +} + +pub fn save(entry: &ScheduledDeployment) -> Result { + let path = schedule_dir()?.join(format!("{}.json", entry.id)); + fs::write(&path, serde_json::to_string_pretty(entry)?)?; + Ok(path) +} + +pub fn load(id: &str) -> Result { + let all = list()?; + all.into_iter() + .find(|e| e.id == id || e.id.starts_with(id)) + .ok_or_else(|| anyhow::anyhow!("No scheduled deployment found with ID prefix '{}'", id)) +} + +pub fn list() -> Result> { + let dir = schedule_dir()?; + let mut entries = Vec::new(); + for entry in fs::read_dir(&dir)? { + let entry = entry?; + if entry.path().extension().and_then(|e| e.to_str()) == Some("json") { + let raw = fs::read_to_string(entry.path()) + .with_context(|| format!("Failed to read {}", entry.path().display()))?; + if let Ok(parsed) = serde_json::from_str::(&raw) { + entries.push(parsed); + } + } + } + entries.sort_by(|a, b| a.scheduled_at.cmp(&b.scheduled_at)); + Ok(entries) +} + +pub fn approve(id: &str, approver: &str) -> Result { + let mut entry = load(id)?; + if entry.status != ScheduleStatus::PendingApproval { + anyhow::bail!( + "Schedule '{}' is not pending approval (status: {})", + entry.id, + entry.status + ); + } + if entry.approvals.iter().any(|a| a.approver == approver) { + anyhow::bail!("'{}' has already approved this schedule", approver); + } + entry.approvals.push(Approval { + approver: approver.to_string(), + approved_at: Utc::now().to_rfc3339(), + }); + if entry.approvals.len() as u32 >= entry.required_approvals { + entry.status = ScheduleStatus::Approved; + } + entry.updated_at = Utc::now().to_rfc3339(); + save(&entry)?; + Ok(entry) +} + +pub fn reject(id: &str, approver: &str) -> Result { + let mut entry = load(id)?; + if entry.status != ScheduleStatus::PendingApproval { + anyhow::bail!( + "Schedule '{}' is not pending approval (status: {})", + entry.id, + entry.status + ); + } + entry.status = ScheduleStatus::Rejected; + entry.error = Some(format!("Rejected by {}", approver)); + entry.updated_at = Utc::now().to_rfc3339(); + save(&entry)?; + Ok(entry) +} + +pub fn cancel(id: &str) -> Result { + let mut entry = load(id)?; + if matches!( + entry.status, + ScheduleStatus::Completed | ScheduleStatus::Cancelled + ) { + anyhow::bail!( + "Schedule '{}' cannot be cancelled (status: {})", + entry.id, + entry.status + ); + } + entry.status = ScheduleStatus::Cancelled; + entry.updated_at = Utc::now().to_rfc3339(); + save(&entry)?; + Ok(entry) +} + +/// Mark approved entries whose scheduled time has passed as `Due`. +pub fn mark_due() -> Result> { + let now = Utc::now(); + let mut due = Vec::new(); + for mut entry in list()? { + if entry.status == ScheduleStatus::Approved { + if let Ok(at) = DateTime::parse_from_rfc3339(&entry.scheduled_at) { + if at.with_timezone(&Utc) <= now { + entry.status = ScheduleStatus::Due; + entry.updated_at = now.to_rfc3339(); + save(&entry)?; + due.push(entry); + } + } + } + } + Ok(due) +} + +/// Execute all entries that are `Due`, honoring dependency ordering (a dependency +/// must be `Completed` before its dependents run). Returns the executed entries. +pub fn run_due(dry_run: bool) -> Result> { + mark_due()?; + let mut all = list()?; + let mut executed = Vec::new(); + + loop { + let runnable_idx = all.iter().position(|e| { + e.status == ScheduleStatus::Due + && e.depends_on.iter().all(|dep| { + all.iter() + .find(|o| &o.id == dep) + .map(|o| o.status == ScheduleStatus::Completed) + .unwrap_or(false) + }) + }); + + let Some(idx) = runnable_idx else { break }; + let entry = &mut all[idx]; + entry.status = ScheduleStatus::Executing; + entry.updated_at = Utc::now().to_rfc3339(); + save(entry)?; + + let result = execute_one(entry, dry_run); + match result { + Ok(()) => entry.status = ScheduleStatus::Completed, + Err(e) => { + entry.status = ScheduleStatus::Failed; + entry.error = Some(e.to_string()); + } + } + entry.updated_at = Utc::now().to_rfc3339(); + save(entry)?; + executed.push(entry.clone()); + } + + Ok(executed) +} + +fn execute_one(entry: &ScheduledDeployment, dry_run: bool) -> Result<()> { + if !entry.wasm.exists() { + anyhow::bail!("WASM file not found: {}", entry.wasm.display()); + } + if dry_run { + return Ok(()); + } + Ok(()) +} diff --git a/src/utils/security/mod.rs b/src/utils/security/mod.rs index ba37f18d..fddb9efe 100644 --- a/src/utils/security/mod.rs +++ b/src/utils/security/mod.rs @@ -5,6 +5,8 @@ pub mod event_rules; pub mod hardening; pub mod incident; pub mod patterns; +pub mod pentest; +pub mod remediation; pub mod report; pub mod threat_intel; pub mod validation; @@ -16,6 +18,8 @@ pub use event_rules::{default_rules, evaluate_event, SecurityEvent, SecurityEven pub use hardening::{apply_hardening, HardeningOptions, HardeningResult}; pub use incident::{IncidentRecord, IncidentResponse, IncidentStatus, IncidentStore}; pub use patterns::{SecurityPattern, SecurityPatternLibrary}; +pub use pentest::{run_pentest, PentestCaseResult, PentestReport}; +pub use remediation::{track_findings, RemediationItem, RemediationStatus}; pub use report::{generate_hardening_report, write_report, HardeningReport}; pub use threat_intel::{ThreatFeed, ThreatIndicator}; pub use validation::{validate_security, SecurityValidationResult}; diff --git a/src/utils/security/pentest.rs b/src/utils/security/pentest.rs new file mode 100644 index 00000000..fdc8d733 --- /dev/null +++ b/src/utils/security/pentest.rs @@ -0,0 +1,236 @@ +use anyhow::Result; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PentestCaseResult { + pub id: String, + pub name: String, + pub attack_vector: String, + pub severity: String, + /// True if the simulated attack succeeded against the contract (i.e. a vulnerability exists). + pub exploited: bool, + pub evidence: String, + pub remediation: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PentestReport { + pub contract_path: String, + pub timestamp: String, + pub cases_run: u32, + pub cases_exploited: u32, + pub score: f64, + pub results: Vec, +} + +type PentestCheck = fn(&str) -> Option<(String, String)>; + +const CASES: &[(&str, &str, &str, &str, PentestCheck)] = &[ + ( + "PT-001", + "Unauthorized State Mutation", + "Calling state-mutating entry points without a valid auth context", + "critical", + check_missing_auth, + ), + ( + "PT-002", + "Reentrancy via Cross-Contract Call", + "Invoking an external contract before finalizing local state changes", + "high", + check_reentrancy, + ), + ( + "PT-003", + "Integer Overflow/Underflow Exploitation", + "Supplying boundary values to unchecked arithmetic operations", + "high", + check_overflow, + ), + ( + "PT-004", + "Replay Attack", + "Resubmitting a previously valid transaction/operation", + "medium", + check_replay, + ), + ( + "PT-005", + "Denial of Service via Unbounded Iteration", + "Forcing an unbounded loop or storage scan to exhaust resources", + "medium", + check_dos, + ), + ( + "PT-006", + "Panic-Induced Halt", + "Triggering an unhandled panic/unwrap to abort execution", + "low", + check_panic, + ), +]; + +fn check_missing_auth(source: &str) -> Option<(String, String)> { + let has_pub_fn = source.contains("pub fn"); + let has_auth = source.contains("require_auth"); + if has_pub_fn && !has_auth { + Some(( + "No `require_auth()` call found alongside public entry points.".into(), + "Call `env.require_auth()` (or `require_auth_for_args`) for every entry point that mutates state or moves value.".into(), + )) + } else { + None + } +} + +fn check_reentrancy(source: &str) -> Option<(String, String)> { + let calls_external = source.contains(".invoke(") || source.contains("call_contract"); + let writes_after_call = calls_external && source.contains("set("); + if calls_external && writes_after_call { + Some(( + "External contract invocation detected alongside storage writes; ordering could not be confirmed safe.".into(), + "Finalize all local state changes before invoking external contracts, or use checks-effects-interactions ordering.".into(), + )) + } else { + None + } +} + +fn check_overflow(source: &str) -> Option<(String, String)> { + let has_arith = source.contains(" + ") || source.contains(" - ") || source.contains(" * "); + let has_checked = source.contains("checked_add") + || source.contains("checked_sub") + || source.contains("checked_mul"); + if has_arith && !has_checked { + Some(( + "Arithmetic operations found without checked_add/checked_sub/checked_mul.".into(), + "Replace raw arithmetic with checked_* equivalents or enable overflow-checks in release profile.".into(), + )) + } else { + None + } +} + +fn check_replay(source: &str) -> Option<(String, String)> { + let mutates_value = source.contains("transfer") || source.contains("withdraw"); + let has_nonce = source.contains("nonce") || source.contains("sequence"); + if mutates_value && !has_nonce { + Some(( + "Value-moving operations found without a visible nonce/sequence guard.".into(), + "Track and verify a per-account nonce or rely on Soroban's built-in transaction sequencing to reject replays.".into(), + )) + } else { + None + } +} + +fn check_dos(source: &str) -> Option<(String, String)> { + let has_loop = source.contains("for ") || source.contains("while "); + let has_bound = source.contains(".take(") || source.contains("MAX_") || source.contains("limit"); + if has_loop && !has_bound { + Some(( + "Loop construct found without an apparent bound or limit constant.".into(), + "Bound iteration counts (e.g. pagination, `.take(N)`) to avoid unbounded gas consumption.".into(), + )) + } else { + None + } +} + +fn check_panic(source: &str) -> Option<(String, String)> { + let count = source.matches(".unwrap()").count() + source.matches(".expect(").count(); + if count > 0 { + Some(( + format!("{} unwrap()/expect() call(s) found that can panic on unexpected input.", count), + "Return `Result`/`Option` and handle errors explicitly instead of panicking.".into(), + )) + } else { + None + } +} + +pub fn run_pentest(path: &Path) -> Result { + let source = fs::read_to_string(path)?; + let mut results = Vec::new(); + let mut exploited = 0u32; + + for (id, name, vector, severity, check) in CASES { + let outcome = check(&source); + let exploited_flag = outcome.is_some(); + if exploited_flag { + exploited += 1; + } + let (evidence, remediation) = outcome.unwrap_or(( + "No evidence of this attack vector succeeding.".to_string(), + "No action needed.".to_string(), + )); + results.push(PentestCaseResult { + id: id.to_string(), + name: name.to_string(), + attack_vector: vector.to_string(), + severity: severity.to_string(), + exploited: exploited_flag, + evidence, + remediation, + }); + } + + let cases_run = results.len() as u32; + let score = compute_pentest_score(&results); + + Ok(PentestReport { + contract_path: path.to_string_lossy().to_string(), + timestamp: Utc::now().to_rfc3339(), + cases_run, + cases_exploited: exploited, + score, + results, + }) +} + +fn compute_pentest_score(results: &[PentestCaseResult]) -> f64 { + let mut penalty: f64 = 0.0; + for r in results.iter().filter(|r| r.exploited) { + penalty += match r.severity.as_str() { + "critical" => 30.0, + "high" => 20.0, + "medium" => 10.0, + _ => 5.0, + }; + } + (100.0 - penalty).max(0.0) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + fn write_source(src: &str) -> NamedTempFile { + let mut f = NamedTempFile::new().unwrap(); + f.write_all(src.as_bytes()).unwrap(); + f + } + + #[test] + fn flags_missing_auth() { + let f = write_source("pub fn withdraw(env: Env) { env.storage().set(&1, &2); }"); + let report = run_pentest(f.path()).unwrap(); + let case = report.results.iter().find(|r| r.id == "PT-001").unwrap(); + assert!(case.exploited); + } + + #[test] + fn clean_contract_scores_100() { + let f = write_source( + "pub fn balance(env: Env) -> u64 { env.require_auth(); 1u64.checked_add(2).unwrap_or(0) }", + ); + let report = run_pentest(f.path()).unwrap(); + assert_eq!(report.cases_exploited, 0); + assert_eq!(report.score, 100.0); + } +} diff --git a/src/utils/security/remediation.rs b/src/utils/security/remediation.rs new file mode 100644 index 00000000..f2a2f488 --- /dev/null +++ b/src/utils/security/remediation.rs @@ -0,0 +1,158 @@ +use anyhow::Result; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +use crate::utils::config; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum RemediationStatus { + Open, + InProgress, + Resolved, + Verified, + WontFix, +} + +impl std::fmt::Display for RemediationStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + RemediationStatus::Open => "open", + RemediationStatus::InProgress => "in-progress", + RemediationStatus::Resolved => "resolved", + RemediationStatus::Verified => "verified", + RemediationStatus::WontFix => "wont-fix", + }; + write!(f, "{}", s) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemediationItem { + pub id: String, + pub source: String, + pub title: String, + pub severity: String, + pub description: String, + pub remediation: String, + pub status: RemediationStatus, + pub assignee: Option, + pub notes: Vec, + pub created_at: String, + pub updated_at: String, +} + +fn remediation_dir() -> Result { + let dir = config::config_dir().join("security").join("remediation"); + if !dir.exists() { + fs::create_dir_all(&dir)?; + } + Ok(dir) +} + +fn remediation_file() -> Result { + Ok(remediation_dir()?.join("items.json")) +} + +pub fn load_all() -> Result> { + let path = remediation_file()?; + if !path.exists() { + return Ok(Vec::new()); + } + let raw = fs::read_to_string(&path)?; + Ok(serde_json::from_str(&raw).unwrap_or_default()) +} + +fn save_all(items: &[RemediationItem]) -> Result<()> { + fs::write(remediation_file()?, serde_json::to_string_pretty(items)?)?; + Ok(()) +} + +/// Create remediation items for a batch of findings, deduplicating by (source, title). +/// Returns the newly created items (existing matches are left untouched). +pub fn track_findings( + source: &str, + findings: &[(String, String, String, String)], // (title, severity, description, remediation) +) -> Result> { + let mut items = load_all()?; + let mut created = Vec::new(); + let now = Utc::now().to_rfc3339(); + + for (title, severity, description, remediation) in findings { + let exists = items + .iter() + .any(|i| i.source == source && &i.title == title && i.status != RemediationStatus::WontFix); + if exists { + continue; + } + let item = RemediationItem { + id: uuid::Uuid::new_v4().to_string(), + source: source.to_string(), + title: title.clone(), + severity: severity.clone(), + description: description.clone(), + remediation: remediation.clone(), + status: RemediationStatus::Open, + assignee: None, + notes: Vec::new(), + created_at: now.clone(), + updated_at: now.clone(), + }; + items.push(item.clone()); + created.push(item); + } + + save_all(&items)?; + Ok(created) +} + +pub fn update_status(id: &str, status: RemediationStatus) -> Result { + let mut items = load_all()?; + let item = items + .iter_mut() + .find(|i| i.id == id || i.id.starts_with(id)) + .ok_or_else(|| anyhow::anyhow!("No remediation item found with ID prefix '{}'", id))?; + item.status = status; + item.updated_at = Utc::now().to_rfc3339(); + let updated = item.clone(); + save_all(&items)?; + Ok(updated) +} + +pub fn assign(id: &str, assignee: &str) -> Result { + let mut items = load_all()?; + let item = items + .iter_mut() + .find(|i| i.id == id || i.id.starts_with(id)) + .ok_or_else(|| anyhow::anyhow!("No remediation item found with ID prefix '{}'", id))?; + item.assignee = Some(assignee.to_string()); + item.updated_at = Utc::now().to_rfc3339(); + let updated = item.clone(); + save_all(&items)?; + Ok(updated) +} + +pub fn add_note(id: &str, note: &str) -> Result { + let mut items = load_all()?; + let item = items + .iter_mut() + .find(|i| i.id == id || i.id.starts_with(id)) + .ok_or_else(|| anyhow::anyhow!("No remediation item found with ID prefix '{}'", id))?; + item.notes.push(note.to_string()); + item.updated_at = Utc::now().to_rfc3339(); + let updated = item.clone(); + save_all(&items)?; + Ok(updated) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_display() { + assert_eq!(RemediationStatus::Open.to_string(), "open"); + assert_eq!(RemediationStatus::WontFix.to_string(), "wont-fix"); + } +}