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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "analyzer-cli"
version = "0.2.2"
version = "0.2.3"
edition = "2024"
description = "CLI for Exein Analyzer - firmware and container security scanning"
license = "Apache-2.0"
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ analyzer scan new -o <OBJECT_ID> -f image.tar -t docker -a info cve malware --wa
# Check scan status
analyzer scan status --scan <SCAN_ID>

# View the security score
# View the severity (Exein Rating: 0 = best, 100 = worst)
analyzer scan score --scan <SCAN_ID>

# List available scan types and analyses
Expand Down Expand Up @@ -263,7 +263,7 @@ Use `text` if the AI assistant struggles with large result sets (e.g. hundreds o

**Prerequisites:** You must have a valid configuration before starting the MCP server. Run `analyzer login` at least once, or ensure a profile is configured in `~/.config/analyzer/config.toml`.

The MCP server exposes all CLI operations as structured tools: managing objects, creating scans, retrieving scores, browsing analysis results, checking compliance, downloading SBOMs and reports. It communicates over stdio using JSON-RPC, so stdout is reserved for the protocol — all logs go to stderr.
The MCP server exposes all CLI operations as structured tools: managing objects, creating scans, retrieving severity ratings, browsing analysis results, checking compliance, downloading SBOMs and reports. It communicates over stdio using JSON-RPC, so stdout is reserved for the protocol — all logs go to stderr.

Available tools include:

Expand Down
6 changes: 3 additions & 3 deletions src/commands/object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ pub async fn run_list(client: &AnalyzerClient, format: Format) -> Result<()> {
" {:<36} {:<30} {:<5} {}",
style("ID").underlined(),
style("Name").underlined(),
style("Score").underlined(),
style("Severity").underlined(),
style("Description").underlined(),
);
for obj in &objects {
Expand Down Expand Up @@ -65,8 +65,8 @@ pub async fn run_list(client: &AnalyzerClient, format: Format) -> Result<()> {
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(s) if s <= 30 => style(score_str).green(),
Some(s) if s <= 59 => style(score_str).yellow(),
Some(_) => style(score_str).red(),
None => style(score_str).dim(),
},
Expand Down
2 changes: 1 addition & 1 deletion src/commands/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ pub async fn run_score(client: &AnalyzerClient, scan_id: Uuid, format: Format) -
eprintln!(
" {:<20} {}",
style("Analysis").underlined(),
style("Score").underlined(),
style("Severity").underlined(),
);
for s in &score.scores {
let score_str = format!("{:<5}", s.score);
Expand Down
73 changes: 62 additions & 11 deletions src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,11 @@ impl AnalyzerMcp {
// -- Object tools ---------------------------------------------------------

#[tool(
description = "[Read] List all objects (devices/products) in your Analyzer account. Returns JSON array with id, name, description, tags, score (current and previous Exein Rating), and last scan info."
description = "[Read] List all objects (devices/products) in your Analyzer account. Returns JSON array with id, name, description, tags, severity (current and previous Exein Rating), and last scan info."
)]
async fn list_objects(&self) -> Result<CallToolResult, McpError> {
match self.client.list_objects().await {
Ok(page) => ok_json(&page.data),
Ok(page) => ok_json_objects(&page.data),
Err(e) => ok_err(e),
}
}
Expand Down Expand Up @@ -253,15 +253,15 @@ impl AnalyzerMcp {
}

#[tool(
description = "[Read] Get the Exein Rating (security score) for a completed scan. Score is 0-100 where LOWER IS BETTER: 0 = no issues (best), 100 = worst. Returns overall score plus per-analysis breakdown (cve, hardening, kernel, malware, password-hash, capabilities). A score of 0 means clean/no issues found. Accepts scan_id or object_id."
description = "[Read] Get the Exein Rating (severity) for a completed scan. Severity is 0-100 where LOWER IS BETTER: 0 = no issues (best), 100 = worst. Returns overall severity plus per-analysis breakdown (cve, hardening, kernel, malware, password-hash, capabilities). A severity of 0 means clean/no issues found. Accepts scan_id or object_id."
)]
async fn get_scan_score(
&self,
Parameters(p): Parameters<ScanOrObjectParam>,
) -> Result<CallToolResult, McpError> {
let id = resolve_scan(&self.client, p.scan_id.as_deref(), p.object_id.as_deref()).await?;
match self.client.get_scan_score(id).await {
Ok(score) => ok_json(&score),
Ok(score) => ok_json_score(&score),
Err(e) => ok_err(e),
}
}
Expand Down Expand Up @@ -506,6 +506,11 @@ impl ServerHandler for AnalyzerMcp {
"Exein Analyzer MCP server — scan firmware and container images for \
vulnerabilities, generate SBOMs, and check compliance.\n\
\n\
## Terminology\n\
IMPORTANT: The Exein Rating value is called **Severity**, NOT 'Score'. \
Always use 'Severity' as the label when displaying this value in tables or text. \
Never use the word 'Score' to refer to it.\n\
\n\
## Tool Access Classification\n\
Each tool is tagged [Read], [Write], or [Critical]:\n\
- **[Read]**: Safe, no side effects — call freely.\n\
Expand All @@ -525,7 +530,7 @@ impl ServerHandler for AnalyzerMcp {
5. `get_scan_status` — poll until all analyses reach 'success'.\n\
6. `get_scan_overview` — quick summary of all findings.\n\
7. `get_analysis_results` — drill into specific analysis types.\n\
8. `get_scan_score`, `download_sbom`, `download_report` — scores and artifacts.\n\
8. `get_scan_score`, `download_sbom`, `download_report` — severity rating and artifacts.\n\
9. `get_compliance` — check regulatory compliance (e.g. CRA).\n\
\n\
## Image Types\n\
Expand All @@ -537,17 +542,17 @@ impl ServerHandler for AnalyzerMcp {
cve, software-bom, malware, crypto, hardening, password-hash, kernel, \
capabilities, info, symbols (IDF), tasks (IDF), stack-overflow (IDF).\n\
\n\
## Exein Rating (Security Score)\n\
- Score is 0-100, where **lower is better** (0 = best, 100 = worst).\n\
## Exein Rating (Severity)\n\
- Severity is 0-100, where **lower is better** (0 = best, 100 = worst).\n\
- 0: Perfect — no issues found in this category.\n\
- 1-30: Good security posture.\n\
- 31-59: Mediocre — address higher-risk vulnerabilities.\n\
- 60-100: Poor — critical security issues require immediate attention.\n\
- The overall score is a weighted aggregate of individual analysis scores.\n\
- Per-analysis scores: malware=0 means clean (no malware), cve=100 means \
- The overall severity is a weighted aggregate of individual analysis severities.\n\
- Per-analysis severity: malware=0 means clean (no malware), cve=100 means \
severe vulnerability exposure, hardening=50 means partial compiler protections, etc.\n\
- IMPORTANT: Do NOT interpret score 0 as 'bad'. Score 0 means the best possible result \
(no issues detected). Score 100 is the worst.\n\
- IMPORTANT: Do NOT interpret severity 0 as 'bad'. Severity 0 means the best possible result \
(no issues detected). Severity 100 is the worst.\n\
\n\
## CVE Details\n\
- Use `get_cve_detail` with a CVE ID (e.g. \"CVE-2022-48174\") to get full vulnerability \
Expand Down Expand Up @@ -837,6 +842,52 @@ fn ok_json<T: serde::Serialize>(value: &T) -> Result<CallToolResult, McpError> {
Ok(CallToolResult::success(vec![Content::text(json)]))
}

/// Rename the `score` key to `severity` in a JSON object (top-level only).
fn rename_score_key(mut v: serde_json::Value) -> serde_json::Value {
if let Some(map) = v.as_object_mut() {
if let Some(val) = map.remove("score") {
map.insert("severity".to_string(), val);
}
}
v
}

fn ok_json_score<T: serde::Serialize>(value: &T) -> Result<CallToolResult, McpError> {
let v = serde_json::to_value(value)
.map_err(|e| McpError::internal_error(format!("JSON serialization error: {e}"), None))?;

// ScanScore: rename top-level "score" → "severity" and same inside each "scores" element.
let v = rename_score_key(v);
let v = if let serde_json::Value::Object(mut map) = v {
if let Some(serde_json::Value::Array(arr)) = map.get_mut("scores") {
*arr = arr.drain(..).map(rename_score_key).collect();
}
serde_json::Value::Object(map)
} else {
v
};

let json = serde_json::to_string_pretty(&v)
.map_err(|e| McpError::internal_error(format!("JSON serialization error: {e}"), None))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}

/// Rename "score" → "severity" in each element of a JSON array.
fn ok_json_objects<T: serde::Serialize>(value: &T) -> Result<CallToolResult, McpError> {
let v = serde_json::to_value(value)
.map_err(|e| McpError::internal_error(format!("JSON serialization error: {e}"), None))?;

let v = if let serde_json::Value::Array(arr) = v {
serde_json::Value::Array(arr.into_iter().map(rename_score_key).collect())
} else {
v
};

let json = serde_json::to_string_pretty(&v)
.map_err(|e| McpError::internal_error(format!("JSON serialization error: {e}"), None))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}

fn ok_text(msg: String) -> Result<CallToolResult, McpError> {
Ok(CallToolResult::success(vec![Content::text(msg)]))
}
Expand Down
Loading