diff --git a/Cargo.toml b/Cargo.toml index f211c3b..1d4279c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index c154ae9..a1ced58 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ analyzer scan new -o -f image.tar -t docker -a info cve malware --wa # Check scan status analyzer scan status --scan -# View the security score +# View the severity (Exein Rating: 0 = best, 100 = worst) analyzer scan score --scan # List available scan types and analyses @@ -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: diff --git a/src/commands/object.rs b/src/commands/object.rs index d9d4a64..08473f1 100644 --- a/src/commands/object.rs +++ b/src/commands/object.rs @@ -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 { @@ -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(), }, diff --git a/src/commands/scan.rs b/src/commands/scan.rs index 43c56fa..62c2964 100644 --- a/src/commands/scan.rs +++ b/src/commands/scan.rs @@ -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); diff --git a/src/mcp.rs b/src/mcp.rs index 384bf6c..1ac7619 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -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 { match self.client.list_objects().await { - Ok(page) => ok_json(&page.data), + Ok(page) => ok_json_objects(&page.data), Err(e) => ok_err(e), } } @@ -253,7 +253,7 @@ 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, @@ -261,7 +261,7 @@ impl AnalyzerMcp { ) -> Result { 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), } } @@ -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\ @@ -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\ @@ -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 \ @@ -837,6 +842,52 @@ fn ok_json(value: &T) -> Result { 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(value: &T) -> Result { + 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(value: &T) -> Result { + 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 { Ok(CallToolResult::success(vec![Content::text(msg)])) }