diff --git a/Cargo.toml b/Cargo.toml index ebd8b0d..bee6d43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "analyzer-cli" -version = "0.1.0" +version = "0.2.0" edition = "2024" description = "CLI for Exein Analyzer - firmware and container security scanning" license = "Apache-2.0" @@ -25,7 +25,6 @@ tokio-stream = "0.1" futures = "0.3" console = "0.15" indicatif = "0.17" -comfy-table = "7" owo-colors = "4" toml = "0.8" dirs = "6" diff --git a/README.md b/README.md index 0c0b8c0..963e8ed 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A command-line interface for [Exein Analyzer](https://analyzer.exein.io) -- firmware & container security scanning. -Scan firmware images for CVEs, generate SBOMs, check CRA compliance, and more. All from your terminal. +Scan firmware images for CVEs, generate SBOMs, check compliance, browse analysis results, and more. All from your terminal. ## Install @@ -54,12 +54,24 @@ analyzer scan new \ # Uploading firmware.bin [=====================>] 100% (42 MB) # OK Scan completed successfully! -# 4. Download the report -analyzer scan report --scan e5f6g7h8-... --output report.pdf +# 4. View the scan overview +analyzer scan overview --object a1b2c3d4-... +# Shows a summary of all analyses with finding counts by severity + +# 5. Browse CVE results +analyzer scan results --object a1b2c3d4-... --analysis cve +# Paginated table of CVEs with severity, score, and affected package + +# 6. Check CRA compliance +analyzer scan compliance --type cra --object a1b2c3d4-... +# Shows pass/fail status for each CRA requirement + +# 7. Download the report +analyzer scan report --object a1b2c3d4-... -O report.pdf # OK Report saved to report.pdf -# 5. Download the SBOM -analyzer scan sbom --scan e5f6g7h8-... --output sbom.json +# 8. Download the SBOM +analyzer scan sbom --object a1b2c3d4-... -O sbom.json # OK SBOM saved to sbom.json ``` @@ -96,6 +108,8 @@ analyzer object delete ### Scans +#### Creating and managing scans + ```bash # Create a scan (returns immediately) analyzer scan new -o -f firmware.bin -t linux -a info cve software-bom @@ -109,15 +123,6 @@ analyzer scan status --scan # View the security score analyzer scan score --scan -# Download PDF report (waits for completion) -analyzer scan report --scan --output report.pdf --wait - -# Download SBOM -analyzer scan sbom --scan --output sbom.json - -# Download CRA compliance report -analyzer scan cra-report --scan --output cra.pdf --wait - # List available scan types and analyses analyzer scan types @@ -128,6 +133,68 @@ analyzer scan cancel analyzer scan delete ``` +#### Browsing analysis results + +```bash +# View scan overview (summary of all analyses with finding counts) +analyzer scan overview --scan + +# Browse results for a specific analysis type +analyzer scan results --scan --analysis cve +analyzer scan results --scan --analysis malware +analyzer scan results --scan --analysis hardening + +# Paginate through results +analyzer scan results --scan --analysis cve --page 2 --per-page 50 + +# Search / filter results +analyzer scan results --scan --analysis cve --search "openssl" + +# View compliance check results +analyzer scan compliance --type cra --scan +``` + +Supported `--analysis` types: `cve`, `password-hash`, `malware`, `hardening`, `capabilities`, `crypto`, `software-bom`, `kernel`, `info`, `symbols`, `tasks`, `stack-overflow`. + +Supported `--type` compliance standards: `cra` (Cyber Resilience Act). + +#### Downloading reports and artifacts + +```bash +# Download PDF report (waits for completion) +analyzer scan report --scan -O report.pdf --wait + +# Download SBOM +analyzer scan sbom --scan -O sbom.json + +# Download compliance report +analyzer scan compliance-report --type cra --scan -O cra.pdf --wait +``` + +### Using `--object` instead of `--scan` + +All scan commands that accept `--scan ` also accept `--object `. When `--object` is used, the CLI automatically resolves the object's most recent scan and uses that. + +This simplifies the common workflow: instead of finding a scan ID from the object, you can go straight from object to results. + +```bash +# These are equivalent (assuming the object's last scan is e5f6g7h8-...) +analyzer scan overview --scan e5f6g7h8-... +analyzer scan overview --object a1b2c3d4-... + +# Works with all scan commands +analyzer scan status --object +analyzer scan score --object +analyzer scan overview --object +analyzer scan results --object --analysis cve +analyzer scan compliance --type cra --object +analyzer scan report --object -O report.pdf +analyzer scan sbom --object -O sbom.json +analyzer scan compliance-report --type cra --object -O cra.pdf +``` + +Short flags are also available: `-s` for `--scan`, `-o` for `--object`. + ### Configuration ```bash @@ -156,6 +223,8 @@ analyzer object list --format json # Pipe into jq analyzer scan status --scan --format json | jq '.status' +analyzer scan overview --object --format json | jq '.analyses' +analyzer scan results --object --analysis cve --format json | jq '.findings' ``` ### Shell completions diff --git a/src/client/mod.rs b/src/client/mod.rs index 793b366..9423ed4 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -60,7 +60,6 @@ impl AnalyzerClient { Self::json(resp).await } - #[allow(dead_code)] pub async fn get_object(&self, id: Uuid) -> Result { let url = self.base_url.join(&format!("objects/{id}"))?; let resp = self.client.get(url).send().await?; @@ -88,7 +87,6 @@ impl AnalyzerClient { Self::json(resp).await } - #[allow(dead_code)] pub async fn get_scan(&self, id: Uuid) -> Result { let url = self.base_url.join(&format!("scans/{id}"))?; let resp = self.client.get(url).send().await?; @@ -191,9 +189,56 @@ impl AnalyzerClient { Self::bytes(resp).await } - pub async fn download_cra_report(&self, scan_id: Uuid) -> Result { + // -- Analysis Results & Compliance ---------------------------------------- + + pub async fn get_scan_overview(&self, scan_id: Uuid) -> Result { + let url = self.base_url.join(&format!("scans/{scan_id}/overview"))?; + let resp = self.client.get(url).send().await?; + Self::json(resp).await + } + + pub async fn get_analysis_results( + &self, + scan_id: Uuid, + analysis_id: Uuid, + query: &ResultsQuery, + ) -> Result { + let mut url = self + .base_url + .join(&format!("scans/{scan_id}/results/{analysis_id}"))?; + url.query_pairs_mut() + .append_pair("page", &query.page.to_string()) + .append_pair("per-page", &query.per_page.to_string()) + .append_pair("sort-by", &query.sort_by) + .append_pair("sort-ord", &query.sort_ord); + if let Some(search) = &query.search { + url.query_pairs_mut().append_pair("search", search); + } + let resp = self.client.get(url).send().await?; + Self::json(resp).await + } + + pub async fn get_compliance( + &self, + scan_id: Uuid, + ct: ComplianceType, + ) -> Result { + let url = self.base_url.join(&format!( + "scans/{scan_id}/compliance-check/{}", + ct.api_slug() + ))?; + let resp = self.client.get(url).send().await?; + Self::json(resp).await + } + + pub async fn download_compliance_report( + &self, + scan_id: Uuid, + ct: ComplianceType, + ) -> Result { let url = self.base_url.join(&format!( - "scans/{scan_id}/compliance-check/cyber-resilience-act/report" + "scans/{scan_id}/compliance-check/{}/report", + ct.api_slug() ))?; let resp = self.client.get(url).send().await?; Self::bytes(resp).await diff --git a/src/client/models.rs b/src/client/models.rs index ce95daa..6ab9531 100644 --- a/src/client/models.rs +++ b/src/client/models.rs @@ -23,7 +23,13 @@ pub struct Page { #[derive(Debug, Default, Deserialize)] pub struct PageLinks { #[allow(dead_code)] - pub next: Option, + pub next: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PageLink { + #[allow(dead_code)] + pub href: String, } // === Objects === @@ -198,3 +204,461 @@ pub struct AnalysisStatusEntry { pub id: Uuid, pub status: AnalysisStatus, } + +// === Scan Overview === + +#[derive(Debug, Serialize, Deserialize)] +pub struct ScanOverview { + #[serde(default)] + pub info: Option, + #[serde(default, rename = "password-hash")] + pub password_hash: Option, + #[serde(default)] + pub malware: Option, + #[serde(default)] + pub hardening: Option, + #[serde(default)] + pub cve: Option, + #[serde(default)] + pub kernel: Option, + #[serde(default)] + pub tasks: Option, + #[serde(default)] + pub symbols: Option, + #[serde(default, rename = "software-bom")] + pub software_bom: Option, + #[serde(default)] + pub capabilities: Option, + #[serde(default)] + pub crypto: Option, + #[serde(default, rename = "stack-overflow")] + pub stack_overflow: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CountOverview { + pub count: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CveOverview { + pub counts: CveSeverityCount, + #[serde(default)] + pub products: HashMap, + pub total: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CveSeverityCount { + #[serde(default)] + pub critical: u64, + #[serde(default)] + pub high: u64, + #[serde(default)] + pub medium: u64, + #[serde(default)] + pub low: u64, + #[serde(default)] + pub unknown: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct HardeningOverview { + pub counts: HardeningSeverityCount, + pub total: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct HardeningSeverityCount { + #[serde(default)] + pub high: u64, + #[serde(default)] + pub medium: u64, + #[serde(default)] + pub low: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CapabilitiesOverview { + pub executable_count: u64, + pub counts: RiskLevelCount, + #[serde(default)] + pub capabilities: HashMap, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RiskLevelCount { + #[serde(default)] + pub critical: u64, + #[serde(default)] + pub high: u64, + #[serde(default)] + pub medium: u64, + #[serde(default)] + pub low: u64, + #[serde(default)] + pub none: u64, + #[serde(default)] + pub unknown: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CryptoOverview { + #[serde(default)] + pub certificates: u64, + #[serde(default)] + pub public_keys: u64, + #[serde(default)] + pub private_keys: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SoftwareBomOverview { + pub count: u64, + #[serde(default)] + pub licenses: HashMap, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct StackOverflowOverview { + pub method: Option, +} + +// === Analysis Results (paginated) === + +#[derive(Debug, Serialize, Deserialize)] +pub struct AnalysisResults { + pub findings: Vec, + #[serde(rename = "total-findings")] + pub total_findings: u64, + #[serde(default)] + pub filters: serde_json::Value, +} + +/// Query parameters for the results endpoint. +pub struct ResultsQuery { + pub page: u32, + pub per_page: u32, + pub sort_by: String, + pub sort_ord: String, + pub search: Option, +} + +// === Finding types === + +#[derive(Debug, Serialize, Deserialize)] +pub struct CveFinding { + #[serde(default)] + pub cveid: Option, + #[serde(default)] + pub severity: Option, + #[serde(default)] + pub vendor: Option, + #[serde(default)] + pub summary: Option, + #[serde(default)] + pub source: Option, + #[serde(default)] + pub vector: Option, + #[serde(default)] + pub cvss: Option, + #[serde(default)] + pub products: Vec, + #[serde(default)] + pub patch: Vec, + #[serde(default)] + pub references: Vec, + #[serde(default)] + pub problems: Vec, + #[serde(default)] + pub published_date: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CvssScores { + pub v3: Option, + pub v2: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CvssDetail { + #[serde(default, alias = "baseScore")] + pub base_score: Option, + #[serde(default)] + pub severity: Option, + #[serde(default, alias = "vectorString")] + pub vector_string: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CveProduct { + #[serde(default)] + pub product: Option, + #[serde(default)] + pub version: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PasswordFinding { + #[serde(default)] + pub username: Option, + #[serde(default)] + pub password: Option, + #[serde(default)] + pub severity: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MalwareFinding { + #[serde(default)] + pub filename: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub detection_engine: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct HardeningFinding { + #[serde(default)] + pub filename: Option, + #[serde(default)] + pub severity: Option, + #[serde(default)] + pub canary: Option, + #[serde(default)] + pub nx: Option, + #[serde(default)] + pub pie: Option, + #[serde(default)] + pub relro: Option, + #[serde(default)] + pub fortify: Option, + #[serde(default)] + pub stripped: Option, + #[serde(default)] + pub suid: Option, + #[serde(default)] + pub execstack: Option, + #[serde(default, rename = "type")] + pub elf_type: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CapabilityFinding { + #[serde(default)] + pub filename: Option, + #[serde(default)] + pub level: Option, + #[serde(default)] + pub behaviors: Vec, + #[serde(default)] + pub syscalls: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CapabilityBehavior { + #[serde(default, alias = "Description")] + pub description: Option, + #[serde(default, alias = "ID")] + pub id: Option, + #[serde(default, alias = "RiskLevel")] + pub risk_level: Option, + #[serde(default, alias = "RiskScore")] + pub risk_score: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CryptoFinding { + #[serde(default)] + pub filename: Option, + #[serde(default)] + pub parent: Option, + #[serde(default, rename = "type")] + pub crypto_type: Option, + #[serde(default)] + pub subtype: Option, + #[serde(default)] + pub pubsz: Option, + #[serde(default)] + pub aux: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SbomComponent { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub version: Option, + #[serde(default, rename = "type")] + pub component_type: Option, + #[serde(default, rename = "bom-ref")] + pub bom_ref: Option, + #[serde(default)] + pub licenses: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct KernelFinding { + #[serde(default)] + pub file: Option, + #[serde(default)] + pub score: Option, + #[serde(default)] + pub features: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct KernelFeature { + pub name: String, + pub enabled: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct IdfSymbolFinding { + #[serde(default, rename = "symbol-name")] + pub symbol_name: Option, + #[serde(default, rename = "symbol-type")] + pub symbol_type: Option, + #[serde(default, rename = "symbol-bind")] + pub symbol_bind: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct IdfTaskFinding { + #[serde(default, rename = "task-name")] + pub task_name: Option, + #[serde(default)] + pub task_fn: Option, +} + +// === Compliance === + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct ComplianceReport { + pub name: String, + pub created_at: String, + #[serde(default)] + pub updated_at: Option, + pub sections: Vec, + pub checks: ComplianceChecks, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct ComplianceSection { + pub label: String, + pub policy_ref: String, + pub sub_sections: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ComplianceSubSection { + pub label: String, + pub requirements: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct ComplianceRequirement { + pub id: String, + pub description: String, + pub policy_ref: String, + #[serde(default)] + pub explanation: Option, + #[serde(default)] + pub advice: Option, + pub analyzer_status: String, + #[serde(default)] + pub overwritten_status: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct ComplianceChecks { + pub total: u32, + pub passed: u32, + pub unknown: u32, + pub failed: u32, + pub not_applicable: u32, +} + +// === Compliance Type enum (for CLI) === + +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +pub enum ComplianceType { + Cra, +} + +impl ComplianceType { + /// The API slug used in the compliance-check endpoint path. + pub fn api_slug(&self) -> &'static str { + match self { + Self::Cra => "cyber-resilience-act", + } + } + + /// Human-readable display name. + pub fn display_name(&self) -> &'static str { + match self { + Self::Cra => "Cyber Resilience Act", + } + } +} + +// === Analysis Type enum (for CLI) === + +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +pub enum AnalysisType { + Cve, + PasswordHash, + Malware, + Hardening, + Capabilities, + Crypto, + SoftwareBom, + Kernel, + Info, + Symbols, + Tasks, + StackOverflow, +} + +impl AnalysisType { + /// The API name used in the scan's analysis entries. + pub fn api_name(&self) -> &'static str { + match self { + Self::Cve => "cve", + Self::PasswordHash => "password-hash", + Self::Malware => "malware", + Self::Hardening => "hardening", + Self::Capabilities => "capabilities", + Self::Crypto => "crypto", + Self::SoftwareBom => "software-bom", + Self::Kernel => "kernel", + Self::Info => "info", + Self::Symbols => "symbols", + Self::Tasks => "tasks", + Self::StackOverflow => "stack-overflow", + } + } + + /// Default sort-by field for this analysis type. + pub fn default_sort_by(&self) -> &'static str { + match self { + Self::Cve => "severity", + Self::PasswordHash => "severity", + Self::Malware => "filename", + Self::Hardening => "severity", + Self::Capabilities => "severity", + Self::Crypto => "type", + Self::SoftwareBom => "name", + Self::Kernel => "features", + Self::Info => "name", + Self::Symbols => "name", + Self::Tasks => "function", + Self::StackOverflow => "name", + } + } +} diff --git a/src/commands/object.rs b/src/commands/object.rs index cf9ba69..d9d4a64 100644 --- a/src/commands/object.rs +++ b/src/commands/object.rs @@ -1,11 +1,12 @@ //! Object management commands. use anyhow::Result; +use console::style; use uuid::Uuid; use crate::client::AnalyzerClient; use crate::client::models::CreateObject; -use crate::output::{self, Format, score_cell, styled_table}; +use crate::output::{self, Format}; /// List all objects. pub async fn run_list(client: &AnalyzerClient, format: Format) -> Result<()> { @@ -28,14 +29,14 @@ pub async fn run_list(client: &AnalyzerClient, format: Format) -> Result<()> { return Ok(()); } - let mut table = styled_table(); - table.set_header(vec!["ID", "Name", "Description", "Score", "Tags"]); - // Prevent the ID column from wrapping so UUIDs stay on one line - // and remain easy to copy/paste. - if let Some(col) = table.column_mut(0) { - col.set_constraint(comfy_table::ColumnConstraint::ContentWidth); - } - + eprintln!(); + eprintln!( + " {:<36} {:<30} {:<5} {}", + style("ID").underlined(), + style("Name").underlined(), + style("Score").underlined(), + style("Description").underlined(), + ); for obj in &objects { let score = obj .score @@ -44,27 +45,53 @@ pub async fn run_list(client: &AnalyzerClient, format: Format) -> Result<()> { .map(|s| s.value); let tags = if obj.tags.is_empty() { - "-".to_string() + String::new() } else { - obj.tags.join(", ") + obj.tags + .iter() + .map(|t| format!("[{}]", t)) + .collect::>() + .join(" ") }; - table.add_row(vec![ - comfy_table::Cell::new(obj.id), - comfy_table::Cell::new(&obj.name), - comfy_table::Cell::new(obj.description.as_deref().unwrap_or("-")), - score_cell(score), - comfy_table::Cell::new(tags), - ]); + let desc = truncate(obj.description.as_deref().unwrap_or(""), 50); + + let score_str = match score { + Some(s) => format!("{:<5}", s), + None => format!("{:<5}", "--"), + }; + eprintln!( + " {} {:<30} {} {}", + style(obj.id).cyan(), + truncate(&obj.name, 30), + match score { + Some(s) if s >= 80 => style(score_str).green(), + Some(s) if s >= 50 => style(score_str).yellow(), + Some(_) => style(score_str).red(), + None => style(score_str).dim(), + }, + desc, + ); + if !tags.is_empty() { + eprintln!(" {:<36} {}", "", style(&tags).cyan()); + } } - println!("{table}"); + eprintln!(); output::status("Total", &format!("{} object(s)", objects.len())); } } Ok(()) } +fn truncate(s: &str, max: usize) -> String { + if s.len() > max { + format!("{}...", &s[..max - 3]) + } else { + s.to_string() + } +} + /// Create a new object. pub async fn run_new( client: &AnalyzerClient, diff --git a/src/commands/scan.rs b/src/commands/scan.rs index 6ef55c8..d5a71a9 100644 --- a/src/commands/scan.rs +++ b/src/commands/scan.rs @@ -9,10 +9,32 @@ use indicatif::ProgressBar; use uuid::Uuid; use crate::client::AnalyzerClient; -use crate::client::models::{AnalysisStatus, AnalysisStatusEntry, ScanTypeRequest}; -use crate::output::{ - self, Format, format_score, format_status, score_cell, status_cell, styled_table, +use crate::client::models::{ + AnalysisStatus, AnalysisStatusEntry, AnalysisType, CapabilityFinding, ComplianceReport, + ComplianceType, CryptoFinding, CveFinding, HardeningFinding, IdfSymbolFinding, IdfTaskFinding, + KernelFinding, MalwareFinding, PasswordFinding, ResultsQuery, SbomComponent, ScanTypeRequest, }; +use crate::output::{self, Format, format_score, format_status}; + +/// Resolve a scan ID from either an explicit --scan or an --object flag. +/// When --object is used, fetches the object and returns its last scan ID. +pub async fn resolve_scan_id( + client: &AnalyzerClient, + scan_id: Option, + object_id: Option, +) -> Result { + if let Some(sid) = scan_id { + return Ok(sid); + } + if let Some(oid) = object_id { + let object = client.get_object(oid).await?; + let scan = object + .last_scan + .ok_or_else(|| anyhow::anyhow!("object {oid} has no scans yet"))?; + return Ok(scan.status.id); + } + bail!("either --scan or --object must be provided") +} /// Create a new scan. #[allow(clippy::too_many_arguments)] @@ -55,10 +77,10 @@ pub async fn run_new( _ if !wait => { output::success(&format!("Scan {} created", style(resp.id).bold())); eprintln!( - "\n Check status:\n {} {} --scan {}", + "\n Check status:\n {} {} --object {}", style("analyzer").bold(), style("scan status").cyan(), - resp.id, + object_id, ); } _ => {} @@ -120,10 +142,11 @@ pub async fn run_sbom(client: &AnalyzerClient, scan_id: Uuid, output_path: PathB Ok(()) } -/// Download the CRA compliance report. -pub async fn run_cra_report( +/// Download a compliance report. +pub async fn run_compliance_report( client: &AnalyzerClient, scan_id: Uuid, + ct: ComplianceType, output_path: PathBuf, wait: bool, interval: Duration, @@ -132,10 +155,17 @@ pub async fn run_cra_report( if wait { wait_for_completion(client, scan_id, interval, timeout).await?; } - output::status("Downloading", "CRA compliance report..."); - let bytes = client.download_cra_report(scan_id).await?; + output::status( + "Downloading", + &format!("{} compliance report...", ct.display_name()), + ); + let bytes = client.download_compliance_report(scan_id, ct).await?; tokio::fs::write(&output_path, &bytes).await?; - output::success(&format!("CRA report saved to {}", output_path.display())); + output::success(&format!( + "{} report saved to {}", + ct.display_name(), + output_path.display() + )); Ok(()) } @@ -157,16 +187,25 @@ pub async fn run_score(client: &AnalyzerClient, scan_id: Uuid, format: Format) - format_score(score.score) ); if !score.scores.is_empty() { - let mut table = styled_table(); - table.set_header(vec!["Analysis", "Score"]); + eprintln!(); + eprintln!( + " {:<20} {}", + style("Analysis").underlined(), + style("Score").underlined(), + ); for s in &score.scores { - table.add_row(vec![ - comfy_table::Cell::new(&s.analysis_type), - score_cell(Some(s.score)), - ]); + let score_str = format!("{:<5}", s.score); + let score_styled = if s.score >= 80 { + style(score_str).green().to_string() + } else if s.score >= 50 { + style(score_str).yellow().to_string() + } else { + style(score_str).red().to_string() + }; + eprintln!(" {:<20} {}", s.analysis_type, score_styled); } - eprintln!("{table}"); } + eprintln!(); } } Ok(()) @@ -241,19 +280,32 @@ fn print_status( format_status(&status.status.to_string()), ); - let mut table = styled_table(); - table.set_header(vec!["Analysis", "Status"]); - for (key, val) in &status.analyses { - if let Ok(entry) = serde_json::from_value::(val.clone()) { - table.add_row(vec![ - comfy_table::Cell::new(key), - status_cell(&entry.status.to_string()), - ]); + let entries: Vec<_> = status + .analyses + .iter() + .filter_map(|(key, val)| { + serde_json::from_value::(val.clone()) + .ok() + .map(|e| (key.clone(), e)) + }) + .collect(); + + if !entries.is_empty() { + eprintln!(); + eprintln!( + " {:<20} {}", + style("Analysis").underlined(), + style("Status").underlined(), + ); + for (key, entry) in &entries { + eprintln!( + " {:<20} {}", + key, + format_status(&entry.status.to_string()), + ); } } - if table.row_count() > 0 { - eprintln!("{table}"); - } + eprintln!(); } } Ok(()) @@ -322,3 +374,600 @@ async fn wait_for_completion( tokio::time::sleep(interval).await; } } + +// =========================================================================== +// Overview +// =========================================================================== + +/// Show scan overview. +pub async fn run_overview(client: &AnalyzerClient, scan_id: Uuid, format: Format) -> Result<()> { + let overview = client.get_scan_overview(scan_id).await?; + + match format { + Format::Json => { + println!("{}", serde_json::to_string_pretty(&overview)?); + } + Format::Human | Format::Table => { + eprintln!("\n {} {}\n", style("Scan Overview").bold(), scan_id); + + if let Some(cve) = &overview.cve { + let c = &cve.counts; + eprintln!(" {} ({})", style("CVE Vulnerabilities").bold(), cve.total); + eprintln!( + " Critical: {} High: {} Medium: {} Low: {} Unknown: {}", + style(c.critical).red(), + style(c.high).red(), + style(c.medium).yellow(), + style(c.low).green(), + style(c.unknown).dim(), + ); + } + if let Some(m) = &overview.malware { + eprintln!(" {}: {}", style("Malware Detections").bold(), m.count); + } + if let Some(p) = &overview.password_hash { + eprintln!(" {}: {}", style("Password Issues").bold(), p.count); + } + if let Some(h) = &overview.hardening { + let c = &h.counts; + eprintln!(" {} ({})", style("Hardening Issues").bold(), h.total); + eprintln!( + " High: {} Medium: {} Low: {}", + style(c.high).red(), + style(c.medium).yellow(), + style(c.low).green(), + ); + } + if let Some(cap) = &overview.capabilities { + eprintln!( + " {} ({} executables)", + style("Capabilities").bold(), + cap.executable_count + ); + let c = &cap.counts; + eprintln!( + " Critical: {} High: {} Medium: {} Low: {}", + style(c.critical).red(), + style(c.high).red(), + style(c.medium).yellow(), + style(c.low).green(), + ); + } + if let Some(cr) = &overview.crypto { + eprintln!( + " {}: {} certs, {} public keys, {} private keys", + style("Crypto").bold(), + cr.certificates, + cr.public_keys, + cr.private_keys, + ); + } + if let Some(sbom) = &overview.software_bom { + eprintln!( + " {}: {} components", + style("Software BOM").bold(), + sbom.count + ); + } + if let Some(k) = &overview.kernel { + eprintln!(" {}: {} configs", style("Kernel").bold(), k.count); + } + if let Some(s) = &overview.symbols { + eprintln!(" {}: {}", style("Symbols").bold(), s.count); + } + if let Some(t) = &overview.tasks { + eprintln!(" {}: {}", style("Tasks").bold(), t.count); + } + if let Some(so) = &overview.stack_overflow { + if let Some(method) = &so.method { + eprintln!(" {}: {}", style("Stack Overflow").bold(), method); + } + } + eprintln!(); + } + } + Ok(()) +} + +// =========================================================================== +// Results +// =========================================================================== + +/// Resolve an analysis type name to its UUID by fetching the scan metadata. +async fn resolve_analysis_id( + client: &AnalyzerClient, + scan_id: Uuid, + analysis_type: &AnalysisType, +) -> Result { + let scan = client.get_scan(scan_id).await?; + let api_name = analysis_type.api_name(); + + for entry in &scan.analysis { + if entry.entry_type.analyses.iter().any(|a| a == api_name) { + return Ok(entry.id); + } + } + + let available: Vec<_> = scan + .analysis + .iter() + .flat_map(|e| e.entry_type.analyses.iter()) + .collect(); + bail!( + "analysis type '{}' not found in scan. Available: {}", + api_name, + available + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + ); +} + +/// Browse analysis results. +pub async fn run_results( + client: &AnalyzerClient, + scan_id: Uuid, + analysis_type: AnalysisType, + page: Option, + per_page: Option, + search: Option, + format: Format, +) -> Result<()> { + let analysis_id = resolve_analysis_id(client, scan_id, &analysis_type).await?; + + let page = page.unwrap_or(1); + let per_page = per_page.unwrap_or(25); + let query = ResultsQuery { + page, + per_page, + sort_by: analysis_type.default_sort_by().to_string(), + sort_ord: "asc".to_string(), + search, + }; + + let results = client + .get_analysis_results(scan_id, analysis_id, &query) + .await?; + + match format { + Format::Json => { + println!("{}", serde_json::to_string_pretty(&results)?); + } + Format::Human | Format::Table => { + let all_values: Vec<&serde_json::Value> = results.findings.iter().collect(); + + if all_values.is_empty() { + eprintln!("\n No findings.\n"); + return Ok(()); + } + + match analysis_type { + AnalysisType::Cve => render_cve_table(&all_values)?, + AnalysisType::PasswordHash => render_password_table(&all_values)?, + AnalysisType::Malware => render_malware_table(&all_values)?, + AnalysisType::Hardening => render_hardening_table(&all_values)?, + AnalysisType::Capabilities => render_capabilities_table(&all_values)?, + AnalysisType::Crypto => render_crypto_table(&all_values)?, + AnalysisType::SoftwareBom => render_sbom_table(&all_values)?, + AnalysisType::Kernel => render_kernel_table(&all_values)?, + AnalysisType::Symbols => render_symbols_table(&all_values)?, + AnalysisType::Tasks => render_tasks_table(&all_values)?, + AnalysisType::Info => render_info(&all_values)?, + AnalysisType::StackOverflow => render_info(&all_values)?, + } + + let total_pages = results.total_findings.div_ceil(per_page as u64); + eprintln!( + "\n Page {}/{} ({} total) — use --page N to navigate\n", + page, total_pages, results.total_findings, + ); + } + } + Ok(()) +} + +fn render_cve_table(values: &[&serde_json::Value]) -> Result<()> { + eprintln!(); + eprintln!( + " {:<8} {:<15} {:<5} {:<14} {:<20} {}", + style("Severity").underlined(), + style("CVE ID").underlined(), + style("Score").underlined(), + style("Vendor").underlined(), + style("Product").underlined(), + style("Summary").underlined(), + ); + for val in values { + if let Ok(f) = serde_json::from_value::((*val).clone()) { + let score_str = f + .cvss + .as_ref() + .and_then(|c| c.v3.as_ref().or(c.v2.as_ref())) + .and_then(|d| d.base_score) + .map(|s| format!("{s:.1}")) + .unwrap_or_default(); + let sev = format_severity(f.severity.as_deref().unwrap_or("unknown"), 8); + let product = f + .products + .first() + .and_then(|p| p.product.as_deref()) + .unwrap_or("-"); + let summary = f.summary.as_deref().unwrap_or(""); + let summary_trunc = if summary.len() > 40 { + format!("{}...", &summary[..37]) + } else { + summary.to_string() + }; + eprintln!( + " {} {:<15} {:<5} {:<14} {:<20} {}", + sev, + f.cveid.as_deref().unwrap_or("-"), + score_str, + truncate_str(f.vendor.as_deref().unwrap_or("-"), 14), + truncate_str(product, 20), + summary_trunc, + ); + } + } + Ok(()) +} + +fn render_password_table(values: &[&serde_json::Value]) -> Result<()> { + eprintln!(); + eprintln!( + " {:<8} {:<20} {}", + style("Severity").underlined(), + style("Username").underlined(), + style("Password").underlined(), + ); + for val in values { + if let Ok(f) = serde_json::from_value::((*val).clone()) { + let sev = format_severity(f.severity.as_deref().unwrap_or("unknown"), 8); + eprintln!( + " {} {:<20} {}", + sev, + f.username.as_deref().unwrap_or("-"), + f.password.as_deref().unwrap_or("-"), + ); + } + } + Ok(()) +} + +fn render_malware_table(values: &[&serde_json::Value]) -> Result<()> { + eprintln!(); + eprintln!( + " {:<30} {:<40} {}", + style("Filename").underlined(), + style("Description").underlined(), + style("Engine").underlined(), + ); + for val in values { + if let Ok(f) = serde_json::from_value::((*val).clone()) { + eprintln!( + " {:<30} {:<40} {}", + truncate_str(f.filename.as_deref().unwrap_or("-"), 30), + truncate_str(f.description.as_deref().unwrap_or("-"), 40), + f.detection_engine.as_deref().unwrap_or("-"), + ); + } + } + Ok(()) +} + +fn render_hardening_table(values: &[&serde_json::Value]) -> Result<()> { + eprintln!(); + eprintln!( + " {:<8} {:<30} {:<6} {:<3} {:<7} {:<7} {}", + style("Severity").underlined(), + style("Filename").underlined(), + style("Canary").underlined(), + style("NX").underlined(), + style("PIE").underlined(), + style("RELRO").underlined(), + style("Fortify").underlined(), + ); + for val in values { + if let Ok(f) = serde_json::from_value::((*val).clone()) { + let sev = format_severity(f.severity.as_deref().unwrap_or("unknown"), 8); + eprintln!( + " {} {:<30} {} {} {:<7} {:<7} {}", + sev, + truncate_str(f.filename.as_deref().unwrap_or("-"), 30), + format_bool(f.canary.unwrap_or(false), 6), + format_bool(f.nx.unwrap_or(false), 3), + f.pie.as_deref().unwrap_or("-"), + f.relro.as_deref().unwrap_or("-"), + format_bool(f.fortify.unwrap_or(false), 7), + ); + } + } + Ok(()) +} + +fn render_capabilities_table(values: &[&serde_json::Value]) -> Result<()> { + eprintln!(); + eprintln!( + " {:<30} {:<8} {:<9} {}", + style("Filename").underlined(), + style("Severity").underlined(), + style("Behaviors").underlined(), + style("Syscalls").underlined(), + ); + for val in values { + if let Ok(f) = serde_json::from_value::((*val).clone()) { + let sev = format_severity(f.level.as_deref().unwrap_or("unknown"), 8); + eprintln!( + " {:<30} {} {:<9} {}", + truncate_str(f.filename.as_deref().unwrap_or("-"), 30), + sev, + f.behaviors.len(), + f.syscalls.len(), + ); + } + } + Ok(()) +} + +/// Format a severity string with color and fixed-width padding. +fn format_severity(severity: &str, width: usize) -> String { + let padded = format!("{: style(padded).red().bold().to_string(), + "high" => style(padded).red().to_string(), + "medium" => style(padded).yellow().to_string(), + "low" => style(padded).green().to_string(), + _ => style(padded).dim().to_string(), + } +} + +/// Truncate a string to max chars, adding "..." if needed. +fn truncate_str(s: &str, max: usize) -> String { + if s.len() > max { + format!("{}...", &s[..max.saturating_sub(3)]) + } else { + format!("{: String { + if val { + style(format!("{: Result<()> { + eprintln!(); + eprintln!( + " {:<14} {:<20} {:<20} {:<8} {}", + style("Type").underlined(), + style("Filename").underlined(), + style("Path").underlined(), + style("Key Size").underlined(), + style("Aux").underlined(), + ); + for val in values { + if let Ok(f) = serde_json::from_value::((*val).clone()) { + let aux = if f.aux.is_empty() { + "-".to_string() + } else { + f.aux.join(", ") + }; + eprintln!( + " {:<14} {:<20} {:<20} {:<8} {}", + truncate_str(f.crypto_type.as_deref().unwrap_or("-"), 14), + truncate_str(f.filename.as_deref().unwrap_or("-"), 20), + truncate_str(f.parent.as_deref().unwrap_or("-"), 20), + f.pubsz.map(|s| s.to_string()).as_deref().unwrap_or("-"), + truncate_str(&aux, 30), + ); + } + } + Ok(()) +} + +fn render_sbom_table(values: &[&serde_json::Value]) -> Result<()> { + eprintln!(); + eprintln!( + " {:<30} {:<14} {:<12} {}", + style("Name").underlined(), + style("Version").underlined(), + style("Type").underlined(), + style("Licenses").underlined(), + ); + for val in values { + if let Ok(f) = serde_json::from_value::((*val).clone()) { + let licenses = f + .licenses + .iter() + .filter_map(|l| { + l.get("license") + .and_then(|lic| lic.get("id").or_else(|| lic.get("name"))) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .collect::>() + .join(", "); + eprintln!( + " {:<30} {:<14} {:<12} {}", + truncate_str(f.name.as_deref().unwrap_or("-"), 30), + truncate_str(f.version.as_deref().unwrap_or("-"), 14), + f.component_type.as_deref().unwrap_or("-"), + if licenses.is_empty() { "-" } else { &licenses }, + ); + } + } + Ok(()) +} + +fn render_kernel_table(values: &[&serde_json::Value]) -> Result<()> { + for val in values { + if let Ok(f) = serde_json::from_value::((*val).clone()) { + if let Some(file) = &f.file { + eprintln!("\n {} {}", style("Kernel Config:").bold(), file); + } + if let Some(score) = f.score { + eprintln!(" Score: {}", score); + } + eprintln!(); + eprintln!( + " {:<40} {}", + style("Feature").underlined(), + style("Status").underlined(), + ); + for feat in &f.features { + eprintln!(" {:<40} {}", feat.name, format_bool(feat.enabled, 8),); + } + } + } + Ok(()) +} + +fn render_symbols_table(values: &[&serde_json::Value]) -> Result<()> { + eprintln!(); + eprintln!( + " {:<40} {:<12} {}", + style("Name").underlined(), + style("Type").underlined(), + style("Bind").underlined(), + ); + for val in values { + if let Ok(f) = serde_json::from_value::((*val).clone()) { + eprintln!( + " {:<40} {:<12} {}", + truncate_str(f.symbol_name.as_deref().unwrap_or("-"), 40), + f.symbol_type.as_deref().unwrap_or("-"), + f.symbol_bind.as_deref().unwrap_or("-"), + ); + } + } + Ok(()) +} + +fn render_tasks_table(values: &[&serde_json::Value]) -> Result<()> { + eprintln!(); + eprintln!( + " {:<30} {}", + style("Name").underlined(), + style("Function").underlined(), + ); + for val in values { + if let Ok(f) = serde_json::from_value::((*val).clone()) { + eprintln!( + " {:<30} {}", + truncate_str(f.task_name.as_deref().unwrap_or("-"), 30), + f.task_fn.as_deref().unwrap_or("-"), + ); + } + } + Ok(()) +} + +fn render_info(values: &[&serde_json::Value]) -> Result<()> { + for val in values { + eprintln!("\n{}", serde_json::to_string_pretty(val)?); + } + Ok(()) +} + +// =========================================================================== +// Compliance +// =========================================================================== + +/// Show compliance check results. +pub async fn run_compliance( + client: &AnalyzerClient, + scan_id: Uuid, + ct: ComplianceType, + format: Format, +) -> Result<()> { + let report = client.get_compliance(scan_id, ct).await?; + + match format { + Format::Json => { + println!("{}", serde_json::to_string_pretty(&report)?); + } + Format::Human | Format::Table => { + render_compliance_human(&report, ct); + } + } + Ok(()) +} + +fn render_compliance_human(report: &ComplianceReport, ct: ComplianceType) { + let c = &report.checks; + eprintln!( + "\n {} — {}\n", + style(format!("{} Compliance Report", ct.display_name())).bold(), + &report.name, + ); + eprintln!( + " {} passed {} failed {} unknown {} N/A ({} total)\n", + style(c.passed).green(), + style(c.failed).red(), + style(c.unknown).yellow(), + style(c.not_applicable).dim(), + c.total, + ); + + for section in &report.sections { + eprintln!( + " {} ({})", + style(§ion.label).bold(), + section.policy_ref + ); + + for sub in §ion.sub_sections { + eprintln!(" {}", style(&sub.label).underlined()); + eprintln!(); + eprintln!( + " {:<8} {:<16} {}", + style("ID").underlined(), + style("Status").underlined(), + style("Description").underlined(), + ); + for req in &sub.requirements { + let effective_status = req + .overwritten_status + .as_deref() + .unwrap_or(&req.analyzer_status); + let desc = if req.description.len() > 60 { + format!("{}...", &req.description[..57]) + } else { + req.description.clone() + }; + eprintln!( + " {:<8} {} {}", + req.id, + format_compliance_status(effective_status, 16), + desc, + ); + } + eprintln!(); + } + } +} + +/// Format a compliance status string with color and fixed-width padding. +fn format_compliance_status(status: &str, width: usize) -> String { + let normalized = status + .to_lowercase() + .replace("analyzer-", "") + .replace("analyzer_", ""); + let padded = format!("{: style(padded).green().to_string(), + "failed" => style(padded).red().to_string(), + "unknown" => style(padded).yellow().to_string(), + "not_applicable" | "not-applicable" | "notapplicable" => style(padded).dim().to_string(), + _ => padded, + } +} diff --git a/src/main.rs b/src/main.rs index 5ffe5ca..5f770d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ use clap::{Parser, Subcommand}; use uuid::Uuid; use crate::client::AnalyzerClient; +use crate::client::models::{AnalysisType, ComplianceType}; use crate::output::Format; /// Exein Analyzer CLI — firmware & container security scanning. @@ -186,25 +187,34 @@ enum ScanCommand { /// Show scan status. Status { /// Scan UUID. - #[arg(short, long = "scan")] - scan_id: Uuid, + #[arg(short, long = "scan", required_unless_present = "object_id")] + scan_id: Option, + /// Object UUID (uses the object's last scan). + #[arg(short, long = "object", required_unless_present = "scan_id")] + object_id: Option, }, /// Show the security score. Score { /// Scan UUID. - #[arg(short, long = "scan")] - scan_id: Uuid, + #[arg(short, long = "scan", required_unless_present = "object_id")] + scan_id: Option, + /// Object UUID (uses the object's last scan). + #[arg(short, long = "object", required_unless_present = "scan_id")] + object_id: Option, }, /// Download the PDF report. Report { /// Scan UUID. - #[arg(short, long = "scan")] - scan_id: Uuid, + #[arg(short, long = "scan", required_unless_present = "object_id")] + scan_id: Option, + /// Object UUID (uses the object's last scan). + #[arg(short, long = "object", required_unless_present = "scan_id")] + object_id: Option, /// Output file path. - #[arg(short, long)] + #[arg(short = 'O', long)] output: PathBuf, /// Wait for scan completion first. @@ -223,22 +233,32 @@ enum ScanCommand { /// Download the SBOM (CycloneDX JSON). Sbom { /// Scan UUID. - #[arg(short, long = "scan")] - scan_id: Uuid, + #[arg(short, long = "scan", required_unless_present = "object_id")] + scan_id: Option, + /// Object UUID (uses the object's last scan). + #[arg(short, long = "object", required_unless_present = "scan_id")] + object_id: Option, /// Output file path. - #[arg(short, long)] + #[arg(short = 'O', long)] output: PathBuf, }, - /// Download the CRA compliance report (PDF). - CraReport { + /// Download a compliance report (PDF). + ComplianceReport { /// Scan UUID. - #[arg(short, long = "scan")] - scan_id: Uuid, + #[arg(short, long = "scan", required_unless_present = "object_id")] + scan_id: Option, + /// Object UUID (uses the object's last scan). + #[arg(short, long = "object", required_unless_present = "scan_id")] + object_id: Option, + + /// Compliance standard. + #[arg(short = 't', long = "type")] + compliance_type: ComplianceType, /// Output file path. - #[arg(short, long)] + #[arg(short = 'O', long)] output: PathBuf, /// Wait for scan completion first. @@ -256,6 +276,56 @@ enum ScanCommand { /// List available scan types and analysis options. Types, + + /// Show scan overview (summary of all analyses). + Overview { + /// Scan UUID. + #[arg(short, long = "scan", required_unless_present = "object_id")] + scan_id: Option, + /// Object UUID (uses the object's last scan). + #[arg(short, long = "object", required_unless_present = "scan_id")] + object_id: Option, + }, + + /// Browse analysis results (CVEs, malware, hardening, etc.). + Results { + /// Scan UUID. + #[arg(short, long = "scan", required_unless_present = "object_id")] + scan_id: Option, + /// Object UUID (uses the object's last scan). + #[arg(short, long = "object", required_unless_present = "scan_id")] + object_id: Option, + + /// Analysis type to view. + #[arg(short, long = "analysis")] + analysis: AnalysisType, + + /// Page number (default: 1). + #[arg(long)] + page: Option, + + /// Results per page (default: 25). + #[arg(long)] + per_page: Option, + + /// Search / filter string. + #[arg(long)] + search: Option, + }, + + /// Show compliance check results. + Compliance { + /// Scan UUID. + #[arg(short, long = "scan", required_unless_present = "object_id")] + scan_id: Option, + /// Object UUID (uses the object's last scan). + #[arg(short, long = "object", required_unless_present = "scan_id")] + object_id: Option, + + /// Compliance standard. + #[arg(short = 't', long = "type")] + compliance_type: ComplianceType, + }, } // ============================================================================= @@ -342,38 +412,81 @@ async fn run(cli: Cli) -> Result<()> { } ScanCommand::Delete { id } => commands::scan::run_delete(&client, id).await, ScanCommand::Cancel { id } => commands::scan::run_cancel(&client, id).await, - ScanCommand::Status { scan_id } => { - commands::scan::run_status(&client, scan_id, format).await + ScanCommand::Status { scan_id, object_id } => { + let sid = commands::scan::resolve_scan_id(&client, scan_id, object_id).await?; + commands::scan::run_status(&client, sid, format).await } - ScanCommand::Score { scan_id } => { - commands::scan::run_score(&client, scan_id, format).await + ScanCommand::Score { scan_id, object_id } => { + let sid = commands::scan::resolve_scan_id(&client, scan_id, object_id).await?; + commands::scan::run_score(&client, sid, format).await } ScanCommand::Report { scan_id, + object_id, output, wait, interval, timeout, } => { - commands::scan::run_report(&client, scan_id, output, wait, interval, timeout) - .await + let sid = commands::scan::resolve_scan_id(&client, scan_id, object_id).await?; + commands::scan::run_report(&client, sid, output, wait, interval, timeout).await } - ScanCommand::Sbom { scan_id, output } => { - commands::scan::run_sbom(&client, scan_id, output).await + ScanCommand::Sbom { + scan_id, + object_id, + output, + } => { + let sid = commands::scan::resolve_scan_id(&client, scan_id, object_id).await?; + commands::scan::run_sbom(&client, sid, output).await } - ScanCommand::CraReport { + ScanCommand::ComplianceReport { scan_id, + object_id, + compliance_type, output, wait, interval, timeout, } => { - commands::scan::run_cra_report( - &client, scan_id, output, wait, interval, timeout, + let sid = commands::scan::resolve_scan_id(&client, scan_id, object_id).await?; + commands::scan::run_compliance_report( + &client, + sid, + compliance_type, + output, + wait, + interval, + timeout, ) .await } ScanCommand::Types => commands::scan::run_types(&client, format).await, + ScanCommand::Overview { scan_id, object_id } => { + let sid = commands::scan::resolve_scan_id(&client, scan_id, object_id).await?; + commands::scan::run_overview(&client, sid, format).await + } + ScanCommand::Results { + scan_id, + object_id, + analysis, + page, + per_page, + search, + } => { + let sid = commands::scan::resolve_scan_id(&client, scan_id, object_id).await?; + commands::scan::run_results( + &client, sid, analysis, page, per_page, search, format, + ) + .await + } + ScanCommand::Compliance { + scan_id, + object_id, + compliance_type, + } => { + let sid = commands::scan::resolve_scan_id(&client, scan_id, object_id).await?; + commands::scan::run_compliance(&client, sid, compliance_type, format).await + } } } } diff --git a/src/output.rs b/src/output.rs index 013c581..83316bc 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,6 +1,5 @@ //! Output formatting: human (colored), JSON, and table modes. -use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL_CONDENSED}; use console::style; use owo_colors::OwoColorize; @@ -36,15 +35,6 @@ pub fn status(label: &str, msg: &str) { eprintln!("{} {msg}", style(format!("{label:>12}")).cyan().bold()); } -/// Build a styled table. -pub fn styled_table() -> Table { - let mut table = Table::new(); - table - .load_preset(UTF8_FULL_CONDENSED) - .set_content_arrangement(ContentArrangement::Dynamic); - table -} - /// Format a score with colour coding. pub fn format_score(score: Option) -> String { match score { @@ -55,16 +45,6 @@ pub fn format_score(score: Option) -> String { } } -/// Return a comfy_table Cell for a score (correct width for table layout). -pub fn score_cell(score: Option) -> Cell { - match score { - Some(s) if s >= 80 => Cell::new(s).fg(Color::Green), - Some(s) if s >= 50 => Cell::new(s).fg(Color::Yellow), - Some(s) => Cell::new(s).fg(Color::Red), - None => Cell::new("--").fg(Color::DarkGrey), - } -} - /// Format an analysis status string with colour. pub fn format_status(status: &str) -> String { match status { @@ -76,15 +56,3 @@ pub fn format_status(status: &str) -> String { other => other.to_string(), } } - -/// Return a comfy_table Cell for a status (correct width for table layout). -pub fn status_cell(status: &str) -> Cell { - match status { - "success" => Cell::new(status).fg(Color::Green), - "pending" => Cell::new(status).fg(Color::DarkGrey), - "in-progress" => Cell::new(status).fg(Color::Cyan), - "canceled" => Cell::new(status).fg(Color::Yellow), - "error" => Cell::new(status).fg(Color::Red), - other => Cell::new(other), - } -}