From 7659a273e9e9852c5a2841213d694bb857870e31 Mon Sep 17 00:00:00 2001 From: Gianluigi Spagnuolo Date: Fri, 13 Feb 2026 17:07:05 +0100 Subject: [PATCH] feat: add scan listing and analysis result display commands #3 --- README.md | 14 +++- src/client/mod.rs | 27 +++++++- src/commands/scan.rs | 160 ++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 44 ++++++++++++ src/output.rs | 12 ++++ 5 files changed, 253 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0c0b8c0..e2c80b4 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,14 @@ analyzer scan new \ # Uploading firmware.bin [=====================>] 100% (42 MB) # OK Scan completed successfully! -# 4. Download the report +# 4. View CVE results +analyzer scan show --scan e5f6g7h8-... --analysis cve + +# 5. Download the report analyzer scan report --scan e5f6g7h8-... --output report.pdf # OK Report saved to report.pdf -# 5. Download the SBOM +# 6. Download the SBOM analyzer scan sbom --scan e5f6g7h8-... --output sbom.json # OK SBOM saved to sbom.json ``` @@ -103,12 +106,19 @@ analyzer scan new -o -f firmware.bin -t linux -a info cve software-b # Create a scan and wait for completion analyzer scan new -o -f image.tar -t docker -a info cve malware --wait +# List all scans +analyzer scan list + # Check scan status analyzer scan status --scan # View the security score analyzer scan score --scan +# Show analysis results (e.g. cve, malware, hardening) +analyzer scan show --scan --analysis cve +analyzer scan show --scan --analysis hardening --page 2 --per-page 50 + # Download PDF report (waits for completion) analyzer scan report --scan --output report.pdf --wait diff --git a/src/client/mod.rs b/src/client/mod.rs index 793b366..9f74435 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -81,7 +81,6 @@ impl AnalyzerClient { // -- Scans ---------------------------------------------------------------- - #[allow(dead_code)] pub async fn list_scans(&self) -> Result> { let url = self.base_url.join("scans/")?; let resp = self.client.get(url).send().await?; @@ -179,6 +178,32 @@ impl AnalyzerClient { Self::json(resp).await } + pub async fn get_analysis_results( + &self, + scan_id: Uuid, + analysis_id: Uuid, + page: u32, + per_page: u32, + sort_by: &str, + sort_ord: &str, + ) -> Result { + let url = self + .base_url + .join(&format!("scans/{scan_id}/results/{analysis_id}"))?; + let resp = self + .client + .get(url) + .query(&[ + ("page", page.to_string()), + ("per-page", per_page.to_string()), + ("sort-by", sort_by.to_string()), + ("sort-ord", sort_ord.to_string()), + ]) + .send() + .await?; + Self::json(resp).await + } + pub async fn download_report(&self, scan_id: Uuid) -> Result { let url = self.base_url.join(&format!("scans/{scan_id}/report"))?; let resp = self.client.get(url).send().await?; diff --git a/src/commands/scan.rs b/src/commands/scan.rs index 6ef55c8..b2b6497 100644 --- a/src/commands/scan.rs +++ b/src/commands/scan.rs @@ -11,9 +11,52 @@ 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, + self, Format, format_score, format_status, score_cell, severity_cell, status_cell, styled_table, }; +/// List all scans. +pub async fn run_list(client: &AnalyzerClient, format: Format) -> Result<()> { + let scans = client.list_scans().await?; + + match format { + Format::Json => { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::to_value(&scans)?)? + ); + } + Format::Human | Format::Table => { + if scans.is_empty() { + output::status("Scans", "None found. Create one with: analyzer scan new"); + return Ok(()); + } + + let mut table = styled_table(); + table.set_header(vec!["ID", "File", "Type", "Score", "Created"]); + // Prevent the ID column from wrapping so UUIDs stay on one line. + if let Some(col) = table.column_mut(0) { + col.set_constraint(comfy_table::ColumnConstraint::ContentWidth); + } + + for scan in &scans { + let score = scan.score.as_ref().and_then(|s| s.score); + + table.add_row(vec![ + comfy_table::Cell::new(scan.id), + comfy_table::Cell::new(&scan.image.file_name), + comfy_table::Cell::new(scan.image_type.as_deref().unwrap_or("-")), + score_cell(score), + comfy_table::Cell::new(scan.created.format("%Y-%m-%d %H:%M")), + ]); + } + + println!("{table}"); + output::status("Total", &format!("{} scan(s)", scans.len())); + } + } + Ok(()) +} + /// Create a new scan. #[allow(clippy::too_many_arguments)] pub async fn run_new( @@ -200,6 +243,121 @@ pub async fn run_types(client: &AnalyzerClient, format: Format) -> Result<()> { Ok(()) } +/// Show results for a specific analysis within a scan. +#[allow(clippy::too_many_arguments)] +pub async fn run_show( + client: &AnalyzerClient, + scan_id: Uuid, + analysis: &str, + page: u32, + per_page: u32, + sort_by: &str, + sort_ord: &str, + format: Format, +) -> Result<()> { + // Resolve the analysis name to its UUID via the scan status. + let status = client.get_scan_status(scan_id).await?; + let entry_value = status.analyses.get(analysis).ok_or_else(|| { + let available: Vec<&String> = status.analyses.keys().collect(); + anyhow::anyhow!( + "analysis '{}' not found in scan. Available: {}", + analysis, + available + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + ) + })?; + let entry: crate::client::models::AnalysisStatusEntry = + serde_json::from_value(entry_value.clone())?; + + let results = client + .get_analysis_results(scan_id, entry.id, page, per_page, sort_by, sort_ord) + .await?; + + match format { + Format::Json => { + println!("{}", serde_json::to_string_pretty(&results)?); + } + Format::Human | Format::Table => { + // The API returns { "findings": [...], "filters": {...} }. + // Extract the findings array for table rendering. + let items = results + .get("findings") + .and_then(|v| v.as_array()) + .or_else(|| results.as_array()); + + match items { + Some(arr) if !arr.is_empty() => { + if let Some(first) = arr.first().and_then(|v| v.as_object()) { + let columns = build_columns(first); + + let mut table = styled_table(); + table.set_header(&columns); + + for item in arr { + if let Some(obj) = item.as_object() { + let row: Vec = columns + .iter() + .map(|col| { + let text = match obj.get(col) { + Some(serde_json::Value::String(s)) => s.clone(), + Some(serde_json::Value::Null) => "-".to_string(), + Some(v) => v.to_string(), + None => "-".to_string(), + }; + style_cell(col, text) + }) + .collect(); + table.add_row(row); + } + } + + println!("{table}"); + output::status("Total", &format!("{} result(s)", arr.len())); + } else { + println!("{}", serde_json::to_string_pretty(&results)?); + } + } + Some(_) => { + output::status("Results", "No results found for this analysis."); + } + None => { + println!("{}", serde_json::to_string_pretty(&results)?); + } + } + } + } + Ok(()) +} + +// TODO: each analysis type should have its own column layout (ordering, hidden +// fields, primary field after severity, etc.) instead of using a generic renderer. + +/// Build column list: severity first (if present), then the rest. +fn build_columns(first: &serde_json::Map) -> Vec { + let mut cols = Vec::with_capacity(first.len()); + if first.contains_key("severity") { + cols.push("severity".to_string()); + } + for key in first.keys() { + if key != "severity" { + cols.push(key.clone()); + } + } + cols +} + +/// Style a cell: severity gets colour + bold, everything else plain. +fn style_cell(col: &str, text: String) -> comfy_table::Cell { + if col == "severity" { + severity_cell(&text).add_attribute(comfy_table::Attribute::Bold) + } else { + comfy_table::Cell::new(text) + } +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/src/main.rs b/src/main.rs index 5ffe5ca..2272701 100644 --- a/src/main.rs +++ b/src/main.rs @@ -139,6 +139,9 @@ enum ObjectCommand { #[derive(Subcommand)] enum ScanCommand { + /// List all scans. + List, + /// Create a new scan. New { /// Object ID to scan against. @@ -254,6 +257,33 @@ enum ScanCommand { timeout: Duration, }, + /// Show results for a specific analysis. + Show { + /// Scan UUID. + #[arg(short, long = "scan")] + scan_id: Uuid, + + /// Analysis name (e.g. cve, malware, info). + #[arg(short, long = "analysis")] + analysis: String, + + /// Page number. + #[arg(long, default_value_t = 1)] + page: u32, + + /// Results per page. + #[arg(long, default_value_t = 20)] + per_page: u32, + + /// Field to sort results by. + #[arg(long, default_value = "severity")] + sort_by: String, + + /// Sort order (asc or desc). + #[arg(long, default_value = "desc")] + sort_ord: String, + }, + /// List available scan types and analysis options. Types, } @@ -325,6 +355,7 @@ async fn run(cli: Cli) -> Result<()> { Command::Scan(cmd) => { let client = make_client(api_key.as_deref(), url.as_deref(), profile.as_deref())?; match cmd { + ScanCommand::List => commands::scan::run_list(&client, format).await, ScanCommand::New { object_id, file, @@ -373,6 +404,19 @@ async fn run(cli: Cli) -> Result<()> { ) .await } + ScanCommand::Show { + scan_id, + analysis, + page, + per_page, + sort_by, + sort_ord, + } => { + commands::scan::run_show( + &client, scan_id, &analysis, page, per_page, &sort_by, &sort_ord, format, + ) + .await + } ScanCommand::Types => commands::scan::run_types(&client, format).await, } } diff --git a/src/output.rs b/src/output.rs index 013c581..2fd6d88 100644 --- a/src/output.rs +++ b/src/output.rs @@ -88,3 +88,15 @@ pub fn status_cell(status: &str) -> Cell { other => Cell::new(other), } } + +/// Return a comfy_table Cell for a severity level with colour coding. +pub fn severity_cell(severity: &str) -> Cell { + match severity.to_uppercase().as_str() { + "CRITICAL" => Cell::new(severity).fg(Color::Magenta), + "HIGH" => Cell::new(severity).fg(Color::Red), + "MEDIUM" => Cell::new(severity).fg(Color::Yellow), + "LOW" => Cell::new(severity).fg(Color::Green), + "NONE" | "INFO" => Cell::new(severity).fg(Color::DarkGrey), + _ => Cell::new(severity), + } +}