Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -103,12 +106,19 @@ analyzer scan new -o <OBJECT_ID> -f firmware.bin -t linux -a info cve software-b
# Create a scan and wait for completion
analyzer scan new -o <OBJECT_ID> -f image.tar -t docker -a info cve malware --wait

# List all scans
analyzer scan list

# Check scan status
analyzer scan status --scan <SCAN_ID>

# View the security score
analyzer scan score --scan <SCAN_ID>

# Show analysis results (e.g. cve, malware, hardening)
analyzer scan show --scan <SCAN_ID> --analysis cve
analyzer scan show --scan <SCAN_ID> --analysis hardening --page 2 --per-page 50

# Download PDF report (waits for completion)
analyzer scan report --scan <SCAN_ID> --output report.pdf --wait

Expand Down
27 changes: 26 additions & 1 deletion src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@ impl AnalyzerClient {

// -- Scans ----------------------------------------------------------------

#[allow(dead_code)]
pub async fn list_scans(&self) -> Result<Vec<Scan>> {
let url = self.base_url.join("scans/")?;
let resp = self.client.get(url).send().await?;
Expand Down Expand Up @@ -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<serde_json::Value> {
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<bytes::Bytes> {
let url = self.base_url.join(&format!("scans/{scan_id}/report"))?;
let resp = self.client.get(url).send().await?;
Expand Down
160 changes: 159 additions & 1 deletion src/commands/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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::<Vec<_>>()
.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<comfy_table::Cell> = 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<String, serde_json::Value>) -> Vec<String> {
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
// ---------------------------------------------------------------------------
Expand Down
44 changes: 44 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ enum ObjectCommand {

#[derive(Subcommand)]
enum ScanCommand {
/// List all scans.
List,

/// Create a new scan.
New {
/// Object ID to scan against.
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
}
Expand Down
12 changes: 12 additions & 0 deletions src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}