diff --git a/Cargo.lock b/Cargo.lock index d4becca..4cb18f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,6 +85,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + [[package]] name = "arraydeque" version = "0.5.1" @@ -1117,6 +1123,16 @@ dependencies = [ "url", ] +[[package]] +name = "markdown-table" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f418a49329ec3474d51712a08850681aff718aef4c807e8e2dc043ce6b83066f" +dependencies = [ + "anyhow", + "pad", +] + [[package]] name = "marked-yaml" version = "0.8.0" @@ -1308,6 +1324,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "pad" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ad9b889f1b12e0b9ee24db044b5129150d5eada288edc800f789928dc8c0e3" +dependencies = [ + "unicode-width", +] + [[package]] name = "parking_lot" version = "0.12.4" @@ -2010,7 +2035,7 @@ dependencies = [ [[package]] name = "sysdig-lsp" -version = "0.6.0" +version = "0.7.0" dependencies = [ "async-trait", "bollard", @@ -2020,6 +2045,7 @@ dependencies = [ "dirs", "futures", "itertools", + "markdown-table", "marked-yaml", "mockall", "rand", @@ -2441,6 +2467,12 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 6a8698f..93b24fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sysdig-lsp" -version = "0.6.0" +version = "0.7.0" edition = "2024" authors = [ "Sysdig Inc." ] readme = "README.md" @@ -18,6 +18,7 @@ clap = { version = "4.5.34", features = ["derive"] } dirs = "6.0.0" futures = "0.3.31" itertools = "0.14.0" +markdown-table = "0.2.0" marked-yaml = { version = "0.8.0", features = ["serde"] } rand = "0.9.0" regex = "1.11.1" diff --git a/README.md b/README.md index f982314..ca399a3 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,9 @@ helping you detect vulnerabilities and misconfigurations earlier in the developm | Build and Scan Dockerfile | Supported | [Supported](./docs/features/build_and_scan.md) (0.4.0+) | | Layered image analysis | Supported | [Supported](./docs/features/layered_analysis.md) (0.5.0+) | | Docker-compose image analysis | Supported | [Supported](./docs/features/docker_compose_image_analysis.md) (0.6.0+) | +| Vulnerability explanation | Supported | [Supported](./docs/features/vulnerability_explanation.md) (0.7.0+) | | K8s Manifest image analysis | Supported | In roadmap | | Infrastructure-as-code analysis | Supported | In roadmap | -| Vulnerability explanation | Supported | In roadmap | ## Build diff --git a/docs/features/README.md b/docs/features/README.md index 0d10d56..957c09a 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -21,4 +21,8 @@ Sysdig LSP provides tools to integrate container security checks into your devel ## [Docker-compose Image Analysis](./docker_compose_image_analysis.md) - Scans the images defined in your `docker-compose.yml` files for vulnerabilities. +## [Vulnerability Explanation](./vulnerability_explanation.md) +- Displays a detailed summary of scan results when hovering over a scanned image name. +- Provides immediate feedback on vulnerabilities, severities, and available fixes. + See the linked documents for more details. diff --git a/docs/features/vulnerability_explanation.gif b/docs/features/vulnerability_explanation.gif new file mode 100644 index 0000000..bf9538d Binary files /dev/null and b/docs/features/vulnerability_explanation.gif differ diff --git a/docs/features/vulnerability_explanation.md b/docs/features/vulnerability_explanation.md new file mode 100644 index 0000000..91906c4 --- /dev/null +++ b/docs/features/vulnerability_explanation.md @@ -0,0 +1,15 @@ +# Vulnerability Explanation + +Sysdig LSP provides on-demand vulnerability explanations directly in your editor. After running a scan on an image (e.g., base image, Docker Compose service), you can hover over the image name to see a detailed summary of the scan results. + +This feature allows you to quickly assess the security posture of an image without leaving your code, displaying information such as total vulnerabilities, severity breakdown, and fixable packages in a convenient tooltip. + +![Sysdig LSP showing a vulnerability summary on hover](./vulnerability_explanation.gif) + +## How It Works + +1. **Run a Scan**: Use a code action or code lens to scan an image in your `Dockerfile` or `docker-compose.yml`. +2. **Hover to View**: Move your cursor over the image name you just scanned. +3. **Get Instant Feedback**: A tooltip will appear with a formatted Markdown summary of the vulnerabilities found. + +This provides immediate context, helping you decide whether to update a base image or investigate a specific package. diff --git a/src/app/document_database.rs b/src/app/document_database.rs index 06758f4..674a4bd 100644 --- a/src/app/document_database.rs +++ b/src/app/document_database.rs @@ -1,7 +1,7 @@ use std::{collections::HashMap, sync::Arc}; use tokio::sync::RwLock; -use tower_lsp::lsp_types::Diagnostic; +use tower_lsp::lsp_types::{Diagnostic, Position, Range}; #[derive(Default, Debug, Clone)] pub struct InMemoryDocumentDatabase { @@ -12,6 +12,13 @@ pub struct InMemoryDocumentDatabase { struct Document { pub text: String, pub diagnostics: Vec, + pub documentations: Vec, +} + +#[derive(Default, Debug, Clone)] +struct Documentation { + pub range: Range, + pub content: String, } impl InMemoryDocumentDatabase { @@ -71,6 +78,46 @@ impl InMemoryDocumentDatabase { .into_iter() .map(|(uri, doc)| (uri, doc.diagnostics)) } + + pub async fn append_documentation(&self, uri: &str, range: Range, documentation: String) { + self.documents + .write() + .await + .entry(uri.into()) + .and_modify(|d| { + d.documentations.push(Documentation { + range, + content: documentation.clone(), + }) + }) + .or_insert_with(|| Document { + documentations: vec![Documentation { + range, + content: documentation, + }], + ..Default::default() + }); + } + + pub async fn read_documentation_at(&self, uri: &str, position: Position) -> Option { + let documents = self.documents.read().await; + let document_asked_for = documents.get(uri); + let mut documentations_for_document = document_asked_for + .iter() + .flat_map(|d| d.documentations.iter()); + let first_documentation_in_range = documentations_for_document.find(|documentation| { + position > documentation.range.start && position < documentation.range.end + }); + + first_documentation_in_range.map(|d| d.content.clone()) + } + + pub async fn remove_documentations(&self, uri: &str) { + let mut documents = self.documents.write().await; + if let Some(document_asked_for) = documents.get_mut(uri) { + document_asked_for.documentations.clear(); + }; + } } #[cfg(test)] diff --git a/src/app/lsp_interactor.rs b/src/app/lsp_interactor.rs index 2123612..a90c8e6 100644 --- a/src/app/lsp_interactor.rs +++ b/src/app/lsp_interactor.rs @@ -1,6 +1,6 @@ use tower_lsp::{ jsonrpc::Result, - lsp_types::{Diagnostic, MessageType}, + lsp_types::{Diagnostic, MessageType, Position, Range}, }; use super::{InMemoryDocumentDatabase, LSPClient}; @@ -26,6 +26,7 @@ where pub async fn update_document_with_text(&self, uri: &str, text: &str) { self.document_database.write_document_text(uri, text).await; self.document_database.remove_diagnostics(uri).await; + self.document_database.remove_documentations(uri).await; let _ = self.publish_all_diagnostics().await; } @@ -56,4 +57,15 @@ where .append_document_diagnostics(uri, diagnostics) .await } + + pub async fn append_documentation(&self, uri: &str, range: Range, documentation: String) { + self.document_database + .append_documentation(uri, range, documentation) + .await + } + pub async fn read_documentation_at(&self, uri: &str, position: Position) -> Option { + self.document_database + .read_documentation_at(uri, position) + .await + } } diff --git a/src/app/lsp_server/commands/build_and_scan.rs b/src/app/lsp_server/commands/build_and_scan.rs index fbc7eb1..450ac18 100644 --- a/src/app/lsp_server/commands/build_and_scan.rs +++ b/src/app/lsp_server/commands/build_and_scan.rs @@ -6,6 +6,7 @@ use tower_lsp::lsp_types::{ Diagnostic, DiagnosticSeverity, Location, MessageType, Position, Range, }; +use crate::app::markdown::{MarkdownData, MarkdownLayerData}; use crate::{ app::{ImageBuilder, ImageScanner, LSPClient, LspInteractor, lsp_server::WithContext}, domain::scanresult::{layer::Layer, scan_result::ScanResult, severity::Severity}, @@ -108,7 +109,8 @@ where .await; let diagnostic = diagnostic_for_image(line, &document_text, &scan_result); - let diagnostics_per_layer = diagnostics_for_layers(&document_text, &scan_result)?; + let (diagnostics_per_layer, docs_per_layer) = + diagnostics_for_layers(&document_text, &scan_result)?; self.interactor.remove_diagnostics(uri).await; self.interactor @@ -117,14 +119,26 @@ where self.interactor .append_document_diagnostics(uri, &diagnostics_per_layer) .await; + self.interactor + .append_documentation( + uri, + self.location.range, + MarkdownData::from(scan_result).to_string(), + ) + .await; + for (range, docs) in docs_per_layer { + self.interactor.append_documentation(uri, range, docs).await; + } self.interactor.publish_all_diagnostics().await } } +pub type LayerScanResult = (Vec, Vec<(Range, String)>); + pub fn diagnostics_for_layers( document_text: &str, scan_result: &ScanResult, -) -> Result> { +) -> Result { let instructions = parse_dockerfile(document_text); let layers = &scan_result.layers(); @@ -132,6 +146,7 @@ pub fn diagnostics_for_layers( let mut layer_idx = layers.len().checked_sub(1); let mut diagnostics = Vec::new(); + let mut docs = Vec::new(); while let (Some(i), Some(l)) = (instr_idx, layer_idx) { let instr = &instructions[i]; @@ -162,12 +177,16 @@ pub fn diagnostics_for_layers( }; diagnostics.push(diagnostic); + docs.push(( + instr.range, + MarkdownLayerData::from(layer.clone()).to_string(), + )); fill_vulnerability_hints_for_layer(layer, instr.range, &mut diagnostics) } } - Ok(diagnostics) + Ok((diagnostics, docs)) } fn fill_vulnerability_hints_for_layer( diff --git a/src/app/lsp_server/commands/scan_base_image.rs b/src/app/lsp_server/commands/scan_base_image.rs index 436e5ca..497f91f 100644 --- a/src/app/lsp_server/commands/scan_base_image.rs +++ b/src/app/lsp_server/commands/scan_base_image.rs @@ -2,7 +2,9 @@ use itertools::Itertools; use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Location, MessageType}; use crate::{ - app::{ImageScanner, LSPClient, LspInteractor, lsp_server::WithContext}, + app::{ + ImageScanner, LSPClient, LspInteractor, lsp_server::WithContext, markdown::MarkdownData, + }, domain::scanresult::severity::Severity, }; @@ -103,6 +105,14 @@ where self.interactor .append_document_diagnostics(uri, &[diagnostic]) .await; - self.interactor.publish_all_diagnostics().await + self.interactor.publish_all_diagnostics().await?; + self.interactor + .append_documentation( + self.location.uri.as_str(), + self.location.range, + MarkdownData::from(scan_result).to_string(), + ) + .await; + Ok(()) } } diff --git a/src/app/lsp_server/lsp_server_inner.rs b/src/app/lsp_server/lsp_server_inner.rs index 773e25a..d7e745e 100644 --- a/src/app/lsp_server/lsp_server_inner.rs +++ b/src/app/lsp_server/lsp_server_inner.rs @@ -1,11 +1,14 @@ use serde_json::Value; use tower_lsp::jsonrpc::{Error, ErrorCode, Result}; +use tower_lsp::lsp_types::HoverContents::Markup; +use tower_lsp::lsp_types::MarkupKind::Markdown; use tower_lsp::lsp_types::{ CodeActionOrCommand, CodeActionParams, CodeActionProviderCapability, CodeActionResponse, CodeLens, CodeLensOptions, CodeLensParams, DidChangeConfigurationParams, DidChangeTextDocumentParams, DidOpenTextDocumentParams, ExecuteCommandOptions, - ExecuteCommandParams, InitializeParams, InitializeResult, InitializedParams, MessageType, - ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, + ExecuteCommandParams, Hover, HoverParams, HoverProviderCapability, InitializeParams, + InitializeResult, InitializedParams, MarkupContent, MessageType, ServerCapabilities, + TextDocumentSyncCapability, TextDocumentSyncKind, }; use tracing::{debug, info}; @@ -105,6 +108,7 @@ where commands: SupportedCommands::all_supported_commands_as_string(), ..Default::default() }), + hover_provider: Some(HoverProviderCapability::Simple(true)), ..Default::default() }, ..Default::default() @@ -228,6 +232,32 @@ where } } + pub async fn hover(&self, params: HoverParams) -> Result> { + let documentation_found = self + .interactor + .read_documentation_at( + params + .text_document_position_params + .text_document + .uri + .as_str(), + params.text_document_position_params.position, + ) + .await; + + if documentation_found.is_none() { + return Ok(None); + } + + Ok(Some(Hover { + contents: Markup(MarkupContent { + kind: Markdown, + value: documentation_found.unwrap(), + }), + range: None, + })) + } + pub async fn shutdown(&self) -> Result<()> { Ok(()) } diff --git a/src/app/lsp_server/mod.rs b/src/app/lsp_server/mod.rs index 0d5a883..f68a970 100644 --- a/src/app/lsp_server/mod.rs +++ b/src/app/lsp_server/mod.rs @@ -5,8 +5,8 @@ use tower_lsp::LanguageServer; use tower_lsp::jsonrpc::{Error, Result}; use tower_lsp::lsp_types::{ CodeActionParams, CodeActionResponse, CodeLens, CodeLensParams, DidChangeConfigurationParams, - DidChangeTextDocumentParams, DidOpenTextDocumentParams, ExecuteCommandParams, InitializeParams, - InitializeResult, InitializedParams, Range, + DidChangeTextDocumentParams, DidOpenTextDocumentParams, ExecuteCommandParams, Hover, + HoverParams, InitializeParams, InitializeResult, InitializedParams, Range, }; use super::{InMemoryDocumentDatabase, LSPClient}; @@ -90,6 +90,10 @@ where self.inner.read().await.execute_command(params).await } + async fn hover(&self, params: HoverParams) -> Result> { + self.inner.read().await.hover(params).await + } + async fn shutdown(&self) -> Result<()> { self.inner.read().await.shutdown().await } diff --git a/src/app/markdown/markdown_data.rs b/src/app/markdown/markdown_data.rs new file mode 100644 index 0000000..f592409 --- /dev/null +++ b/src/app/markdown/markdown_data.rs @@ -0,0 +1,351 @@ +use std::fmt::{Display, Formatter}; + +use crate::domain::scanresult::scan_result::ScanResult; + +use super::{ + markdown_fixable_package_table::FixablePackageTable, + markdown_policy_evaluated_table::PolicyEvaluatedTable, markdown_summary::MarkdownSummary, + markdown_vulnerability_evaluated_table::VulnerabilityEvaluatedTable, +}; + +#[derive(Clone, Debug, Default)] +pub struct MarkdownData { + pub summary: MarkdownSummary, + pub fixable_packages: FixablePackageTable, + pub policies: PolicyEvaluatedTable, + pub vulnerabilities: VulnerabilityEvaluatedTable, +} + +impl From for MarkdownData { + fn from(value: ScanResult) -> Self { + Self { + summary: MarkdownSummary::from(&value), + fixable_packages: FixablePackageTable::from(&value), + policies: PolicyEvaluatedTable::from(&value), + vulnerabilities: VulnerabilityEvaluatedTable::from(&value), + } + } +} + +impl Display for MarkdownData { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let summary_section = self.summary.to_string(); + let fixable_packages_section = self.fixable_packages.to_string(); + let policy_evaluation_section = self.policies.to_string(); + let vulnerability_detail_section = self.vulnerabilities.to_string(); + + write!( + f, + "## Sysdig Scan Result\n{}\n{}\n{}\n{}", + summary_section, + fixable_packages_section, + policy_evaluation_section, + vulnerability_detail_section + ) + } +} + +#[cfg(test)] +mod test { + use super::super::markdown_fixable_package_table::{ + FixablePackage, FixablePackageTable, FixablePackageVulnerabilities, + }; + use super::super::markdown_policy_evaluated_table::{PolicyEvaluated, PolicyEvaluatedTable}; + use super::super::markdown_summary::MarkdownSummary; + use super::super::markdown_summary_table::MarkdownSummaryTable; + use super::super::markdown_vulnerability_evaluated_table::{ + VulnerabilityEvaluated, VulnerabilityEvaluatedTable, + }; + + use super::*; + + #[test] + fn converts_markdown_data_to_markdown_text() { + let markdown_data = MarkdownData { + summary: MarkdownSummary { + pull_string: "ubuntu:23.04".to_string(), + image_id: "sha256:f4cdeba72b994748f5eb1f525a70a9cc553b66037ec37e23645fbf3f0f5c160d" + .to_string(), + digest: "sha256:5a828e28de105c3d7821c4442f0f5d1c52dc16acf4999d5f31a3bc0f03f06edd" + .to_string(), + base_os: "ubuntu 23.04".to_string(), + + total_vulns_found: MarkdownSummaryTable { + total_found: 11, + critical: 0, + critical_fixable: 0, + high: 0, + high_fixable: 0, + medium: 9, + medium_fixable: 9, + low: 2, + low_fixable: 2, + negligible: 0, + negligible_fixable: 0, + }, + }, + fixable_packages: FixablePackageTable(vec![ + FixablePackage { + name: "libgnutls30".to_string(), + package_type: "os".to_string(), + version: "3.7.8-5ubuntu1.1".to_string(), + suggested_fix: Some("3.7.8-5ubuntu1.2".to_string()), + vulnerabilities: FixablePackageVulnerabilities { + critical: 0, + high: 0, + medium: 2, + low: 0, + negligible: 0, + }, + exploits: 0, + }, + FixablePackage { + name: "libc-bin".to_string(), + package_type: "os".to_string(), + version: "2.37-0ubuntu2.1".to_string(), + suggested_fix: Some("2.37-0ubuntu2.2".to_string()), + vulnerabilities: FixablePackageVulnerabilities { + critical: 0, + high: 0, + medium: 1, + low: 1, + negligible: 0, + }, + exploits: 0, + }, + FixablePackage { + name: "libc6".to_string(), + package_type: "os".to_string(), + version: "2.37-0ubuntu2.1".to_string(), + suggested_fix: Some("2.37-0ubuntu2.2".to_string()), + vulnerabilities: FixablePackageVulnerabilities { + critical: 0, + high: 0, + medium: 1, + low: 1, + negligible: 0, + }, + exploits: 0, + }, + FixablePackage { + name: "libpam-modules".to_string(), + package_type: "os".to_string(), + version: "1.5.2-5ubuntu1".to_string(), + suggested_fix: Some("1.5.2-5ubuntu1.1".to_string()), + vulnerabilities: FixablePackageVulnerabilities { + critical: 0, + high: 0, + medium: 1, + low: 0, + negligible: 0, + }, + exploits: 0, + }, + FixablePackage { + name: "libpam-modules-bin".to_string(), + package_type: "os".to_string(), + version: "1.5.2-5ubuntu1".to_string(), + suggested_fix: Some("1.5.2-5ubuntu1.1".to_string()), + vulnerabilities: FixablePackageVulnerabilities { + critical: 0, + high: 0, + medium: 1, + low: 0, + negligible: 0, + }, + exploits: 0, + }, + FixablePackage { + name: "libpam-runtime".to_string(), + package_type: "os".to_string(), + version: "1.5.2-5ubuntu1".to_string(), + suggested_fix: Some("1.5.2-5ubuntu1.1".to_string()), + vulnerabilities: FixablePackageVulnerabilities { + critical: 0, + high: 0, + medium: 1, + low: 0, + negligible: 0, + }, + exploits: 0, + }, + FixablePackage { + name: "libpam0g".to_string(), + package_type: "os".to_string(), + version: "1.5.2-5ubuntu1".to_string(), + suggested_fix: Some("1.5.2-5ubuntu1.1".to_string()), + vulnerabilities: FixablePackageVulnerabilities { + critical: 0, + high: 0, + medium: 1, + low: 0, + negligible: 0, + }, + exploits: 0, + }, + FixablePackage { + name: "tar".to_string(), + package_type: "os".to_string(), + version: "1.34+dfsg-1.2ubuntu0.1".to_string(), + suggested_fix: Some("1.34+dfsg-1.2ubuntu0.2".to_string()), + vulnerabilities: FixablePackageVulnerabilities { + critical: 0, + high: 0, + medium: 1, + low: 0, + negligible: 0, + }, + exploits: 0, + }, + ]), + policies: PolicyEvaluatedTable(vec![ + PolicyEvaluated { + name: "carholder policy - pk".to_string(), + passed: false, + failures: 1, + risks_accepted: 0, + }, + PolicyEvaluated { + name: "Critical Vulnerability Found".to_string(), + passed: true, + failures: 0, + risks_accepted: 0, + }, + PolicyEvaluated { + name: "Forbid Secrets in Images".to_string(), + passed: true, + failures: 0, + risks_accepted: 0, + }, + PolicyEvaluated { + name: "NIST SP 800-Star".to_string(), + passed: false, + failures: 14, + risks_accepted: 0, + }, + PolicyEvaluated { + name: "PolicyCardHolder".to_string(), + passed: false, + failures: 1, + risks_accepted: 0, + }, + PolicyEvaluated { + name: "Sensitive Information or Secret Found".to_string(), + passed: true, + failures: 0, + risks_accepted: 0, + }, + PolicyEvaluated { + name: "Sysdig Best Practices".to_string(), + passed: true, + failures: 0, + risks_accepted: 0, + }, + ]), + + vulnerabilities: VulnerabilityEvaluatedTable(vec![ + VulnerabilityEvaluated { + cve: "CVE-2023-39804".to_string(), + severity: "Medium".to_string(), + packages_found: 1, + fixable: true, + exploitable: false, + accepted_risk: false, + }, + VulnerabilityEvaluated { + cve: "CVE-2023-4806".to_string(), + severity: "Low".to_string(), + packages_found: 2, + fixable: true, + exploitable: false, + accepted_risk: false, + }, + VulnerabilityEvaluated { + cve: "CVE-2023-5156".to_string(), + severity: "Medium".to_string(), + packages_found: 2, + fixable: true, + exploitable: false, + accepted_risk: false, + }, + VulnerabilityEvaluated { + cve: "CVE-2024-0553".to_string(), + severity: "Medium".to_string(), + packages_found: 1, + fixable: true, + exploitable: false, + accepted_risk: false, + }, + VulnerabilityEvaluated { + cve: "CVE-2024-0567".to_string(), + severity: "Medium".to_string(), + packages_found: 1, + fixable: true, + exploitable: false, + accepted_risk: false, + }, + VulnerabilityEvaluated { + cve: "CVE-2024-22365".to_string(), + severity: "Medium".to_string(), + packages_found: 4, + fixable: true, + exploitable: false, + accepted_risk: false, + }, + ]), + }; + let expected_markdown_output = r#"## Sysdig Scan Result +### Summary +* **PullString**: ubuntu:23.04 +* **ImageID**: `sha256:f4cdeba72b994748f5eb1f525a70a9cc553b66037ec37e23645fbf3f0f5c160d` +* **Digest**: `sha256:5a828e28de105c3d7821c4442f0f5d1c52dc16acf4999d5f31a3bc0f03f06edd` +* **BaseOS**: ubuntu 23.04 + +| TOTAL VULNS FOUND | CRITICAL | HIGH | MEDIUM | LOW | NEGLIGIBLE | +| :-------------: | :----: | :-: | :---------: | :---------: | :------: | +| 11 | 0 | 0 | 9 (9 Fixable) | 2 (2 Fixable) | 0 | + + +### Fixable Packages +| PACKAGE | TYPE | VERSION | SUGGESTED FIX | CRITICAL | HIGH | MEDIUM | LOW | NEGLIGIBLE | EXPLOIT | +| :--------------- | :-: | :------------------- | :------------------- | :----: | :-: | :--: | :-: | :------: | :---: | +| libgnutls30 | os | 3.7.8-5ubuntu1.1 | 3.7.8-5ubuntu1.2 | - | - | 2 | - | - | - | +| libc-bin | os | 2.37-0ubuntu2.1 | 2.37-0ubuntu2.2 | - | - | 1 | 1 | - | - | +| libc6 | os | 2.37-0ubuntu2.1 | 2.37-0ubuntu2.2 | - | - | 1 | 1 | - | - | +| libpam-modules | os | 1.5.2-5ubuntu1 | 1.5.2-5ubuntu1.1 | - | - | 1 | - | - | - | +| libpam-modules-bin | os | 1.5.2-5ubuntu1 | 1.5.2-5ubuntu1.1 | - | - | 1 | - | - | - | +| libpam-runtime | os | 1.5.2-5ubuntu1 | 1.5.2-5ubuntu1.1 | - | - | 1 | - | - | - | +| libpam0g | os | 1.5.2-5ubuntu1 | 1.5.2-5ubuntu1.1 | - | - | 1 | - | - | - | +| tar | os | 1.34+dfsg-1.2ubuntu0.1 | 1.34+dfsg-1.2ubuntu0.2 | - | - | 1 | - | - | - | + + +### Policy Evaluation + +| POLICY | STATUS | FAILURES | RISKS ACCEPTED | +| :---------------------------------- | :--: | :----: | :----------: | +| carholder policy - pk | ❌ | 1 | 0 | +| Critical Vulnerability Found | ✅ | 0 | 0 | +| Forbid Secrets in Images | ✅ | 0 | 0 | +| NIST SP 800-Star | ❌ | 14 | 0 | +| PolicyCardHolder | ❌ | 1 | 0 | +| Sensitive Information or Secret Found | ✅ | 0 | 0 | +| Sysdig Best Practices | ✅ | 0 | 0 | + + +### Vulnerability Detail + +| VULN CVE | SEVERITY | PACKAGES | FIXABLE | EXPLOITABLE | ACCEPTED RISK | +| :----------- | :----- | :----- | :---- | :-------- | :---------- | +| CVE-2023-39804 | Medium | 1 | ✅ | ❌ | ❌ | +| CVE-2023-4806 | Low | 2 | ✅ | ❌ | ❌ | +| CVE-2023-5156 | Medium | 2 | ✅ | ❌ | ❌ | +| CVE-2024-0553 | Medium | 1 | ✅ | ❌ | ❌ | +| CVE-2024-0567 | Medium | 1 | ✅ | ❌ | ❌ | +| CVE-2024-22365 | Medium | 4 | ✅ | ❌ | ❌ |"#; + + assert_eq!( + markdown_data.to_string().trim(), + expected_markdown_output.trim() + ); + } +} diff --git a/src/app/markdown/markdown_fixable_package_table.rs b/src/app/markdown/markdown_fixable_package_table.rs new file mode 100644 index 0000000..eddb69e --- /dev/null +++ b/src/app/markdown/markdown_fixable_package_table.rs @@ -0,0 +1,186 @@ +use std::{ + fmt::{Display, Formatter}, + sync::Arc, +}; + +use markdown_table::{Heading, HeadingAlignment, MarkdownTable}; + +use crate::domain::scanresult::{layer::Layer, scan_result::ScanResult, severity::Severity}; + +#[derive(Clone, Debug, Default)] +pub struct FixablePackage { + pub name: String, + pub package_type: String, + pub version: String, + pub suggested_fix: Option, + pub vulnerabilities: FixablePackageVulnerabilities, + pub exploits: u32, +} + +#[derive(Clone, Debug, Default)] +pub struct FixablePackageVulnerabilities { + pub critical: u32, + pub high: u32, + pub medium: u32, + pub low: u32, + pub negligible: u32, +} + +#[derive(Clone, Debug, Default)] +pub struct FixablePackageTable(pub Vec); + +impl From<&ScanResult> for FixablePackageTable { + fn from(value: &ScanResult) -> Self { + FixablePackageTable( + value + .packages() + .into_iter() + .filter(|p| p.vulnerabilities().iter().any(|v| v.fixable())) + .map(|p| { + let mut vulns = FixablePackageVulnerabilities::default(); + let mut exploits = 0; + for v in p.vulnerabilities() { + if v.exploitable() { + exploits += 1; + } + match v.severity() { + Severity::Critical => vulns.critical += 1, + Severity::High => vulns.high += 1, + Severity::Medium => vulns.medium += 1, + Severity::Low => vulns.low += 1, + Severity::Negligible => vulns.negligible += 1, + Severity::Unknown => {} + } + } + + FixablePackage { + name: p.name().to_string(), + package_type: p.package_type().to_string(), + version: p.version().to_string(), + suggested_fix: p + .vulnerabilities() + .iter() + .find_map(|v| v.fix_version().map(|s| s.to_string())), + vulnerabilities: vulns, + exploits, + } + }) + .collect(), + ) + } +} + +impl From<&Arc> for FixablePackageTable { + fn from(value: &Arc) -> Self { + FixablePackageTable( + value + .packages() + .into_iter() + .filter(|p| p.vulnerabilities().iter().any(|v| v.fixable())) + .map(|p| { + let mut vulns = FixablePackageVulnerabilities::default(); + let mut exploits = 0; + for v in p.vulnerabilities() { + if v.exploitable() { + exploits += 1; + } + match v.severity() { + Severity::Critical => vulns.critical += 1, + Severity::High => vulns.high += 1, + Severity::Medium => vulns.medium += 1, + Severity::Low => vulns.low += 1, + Severity::Negligible => vulns.negligible += 1, + Severity::Unknown => {} + } + } + + FixablePackage { + name: p.name().to_string(), + package_type: p.package_type().to_string(), + version: p.version().to_string(), + suggested_fix: p + .vulnerabilities() + .iter() + .find_map(|v| v.fix_version().map(|s| s.to_string())), + vulnerabilities: vulns, + exploits, + } + }) + .collect(), + ) + } +} + +impl Display for FixablePackageTable { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if self.0.is_empty() { + return f.write_str(""); + } + + let headers = vec![ + Heading::new("PACKAGE".to_string(), Some(HeadingAlignment::Left)), + Heading::new("TYPE".to_string(), Some(HeadingAlignment::Center)), + Heading::new("VERSION".to_string(), Some(HeadingAlignment::Left)), + Heading::new("SUGGESTED FIX".to_string(), Some(HeadingAlignment::Left)), + Heading::new("CRITICAL".to_string(), Some(HeadingAlignment::Center)), + Heading::new("HIGH".to_string(), Some(HeadingAlignment::Center)), + Heading::new("MEDIUM".to_string(), Some(HeadingAlignment::Center)), + Heading::new("LOW".to_string(), Some(HeadingAlignment::Center)), + Heading::new("NEGLIGIBLE".to_string(), Some(HeadingAlignment::Center)), + Heading::new("EXPLOIT".to_string(), Some(HeadingAlignment::Center)), + ]; + + let data = self + .0 + .iter() + .map(|p| { + vec![ + p.name.clone(), + p.package_type.clone(), + p.version.clone(), + p.suggested_fix.clone().unwrap_or_default(), + if p.vulnerabilities.critical > 0 { + p.vulnerabilities.critical.to_string() + } else { + "-".to_string() + }, + if p.vulnerabilities.high > 0 { + p.vulnerabilities.high.to_string() + } else { + "-".to_string() + }, + if p.vulnerabilities.medium > 0 { + p.vulnerabilities.medium.to_string() + } else { + "-".to_string() + }, + if p.vulnerabilities.low > 0 { + p.vulnerabilities.low.to_string() + } else { + "-".to_string() + }, + if p.vulnerabilities.negligible > 0 { + p.vulnerabilities.negligible.to_string() + } else { + "-".to_string() + }, + if p.exploits > 0 { + p.exploits.to_string() + } else { + "-".to_string() + }, + ] + }) + .collect(); + + let mut table = MarkdownTable::new(data); + table.with_headings(headers); + + let format = format!( + "\n### Fixable Packages\n{}", + table.as_markdown().unwrap_or_default() + ); + + f.write_str(&format) + } +} diff --git a/src/app/markdown/markdown_layer_data.rs b/src/app/markdown/markdown_layer_data.rs new file mode 100644 index 0000000..bb58cce --- /dev/null +++ b/src/app/markdown/markdown_layer_data.rs @@ -0,0 +1,38 @@ +use std::{ + fmt::{Display, Formatter}, + sync::Arc, +}; + +use crate::domain::scanresult::layer::Layer; + +use super::{ + markdown_fixable_package_table::FixablePackageTable, + markdown_vulnerability_evaluated_table::VulnerabilityEvaluatedTable, +}; + +pub struct MarkdownLayerData { + pub fixable_packages: FixablePackageTable, + pub vulnerabilities: VulnerabilityEvaluatedTable, +} + +impl From> for MarkdownLayerData { + fn from(value: Arc) -> Self { + Self { + fixable_packages: FixablePackageTable::from(&value), + vulnerabilities: VulnerabilityEvaluatedTable::from(&value), + } + } +} + +impl Display for MarkdownLayerData { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let fixable_packages_section = self.fixable_packages.to_string(); + let vulnerability_detail_section = self.vulnerabilities.to_string(); + + write!( + f, + "## Sysdig Scan Result for Layer\n{}\n{}", + fixable_packages_section, vulnerability_detail_section + ) + } +} diff --git a/src/app/markdown/markdown_policy_evaluated_table.rs b/src/app/markdown/markdown_policy_evaluated_table.rs new file mode 100644 index 0000000..46fac3a --- /dev/null +++ b/src/app/markdown/markdown_policy_evaluated_table.rs @@ -0,0 +1,74 @@ +use std::fmt::{Display, Formatter}; + +use itertools::Itertools; +use markdown_table::{Heading, HeadingAlignment, MarkdownTable}; + +use crate::domain::scanresult::scan_result::ScanResult; + +#[derive(Clone, Debug, Default)] +pub struct PolicyEvaluated { + pub name: String, + pub passed: bool, + pub failures: u32, + pub risks_accepted: u32, +} + +#[derive(Clone, Debug, Default)] +pub struct PolicyEvaluatedTable(pub Vec); + +impl Display for PolicyEvaluatedTable { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if self.0.is_empty() { + return f.write_str(""); + } + + let headers = vec![ + Heading::new("POLICY".to_string(), Some(HeadingAlignment::Left)), + Heading::new("STATUS".to_string(), Some(HeadingAlignment::Center)), + Heading::new("FAILURES".to_string(), Some(HeadingAlignment::Center)), + Heading::new("RISKS ACCEPTED".to_string(), Some(HeadingAlignment::Center)), + ]; + + let data = self + .0 + .iter() + .map(|p| { + vec![ + p.name.clone(), + if p.passed { "✅" } else { "❌" }.to_string(), + p.failures.to_string(), + p.risks_accepted.to_string(), + ] + }) + .collect(); + + let mut table = MarkdownTable::new(data); + table.with_headings(headers); + + let format = format!( + "\n### Policy Evaluation\n\n{}", + table.as_markdown().unwrap_or_default() + ); + + f.write_str(&format) + } +} + +impl From<&ScanResult> for PolicyEvaluatedTable { + fn from(value: &ScanResult) -> Self { + PolicyEvaluatedTable( + value + .policies() + .iter() + .map(|p| PolicyEvaluated { + name: p.name().to_string(), + passed: p.evaluation_result().is_passed(), + failures: p.bundles().iter().map(|b| b.rules().len()).sum::() as u32, + risks_accepted: 0, // FIXME(fede): Cannot determine this from the current data model + }) + .sorted_by(|a, b| b.failures.cmp(&a.failures)) + .sorted_by_key(|p| p.passed) + .collect(), + ) + } +} diff --git a/src/app/markdown/markdown_summary.rs b/src/app/markdown/markdown_summary.rs new file mode 100644 index 0000000..c7b76fc --- /dev/null +++ b/src/app/markdown/markdown_summary.rs @@ -0,0 +1,37 @@ +use std::fmt::{Display, Formatter}; + +use crate::domain::scanresult::scan_result::ScanResult; + +use super::markdown_summary_table::MarkdownSummaryTable; + +#[derive(Clone, Debug, Default)] +pub struct MarkdownSummary { + pub pull_string: String, + pub image_id: String, + pub digest: String, + pub base_os: String, + pub total_vulns_found: MarkdownSummaryTable, +} + +impl From<&ScanResult> for MarkdownSummary { + fn from(value: &ScanResult) -> Self { + MarkdownSummary { + pull_string: value.metadata().pull_string().to_string(), + image_id: value.metadata().image_id().to_string(), + digest: value.metadata().digest().unwrap_or("").to_string(), + base_os: value.metadata().base_os().name().to_string(), + total_vulns_found: MarkdownSummaryTable::from(value), + } + } +} + +impl Display for MarkdownSummary { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let format = format!( + "### Summary\n* **PullString**: {}\n* **ImageID**: `{}`\n* **Digest**: `{}`\n* **BaseOS**: {}\n\n{}", + &self.pull_string, &self.image_id, &self.digest, &self.base_os, &self.total_vulns_found + ); + + f.write_str(&format) + } +} diff --git a/src/app/markdown/markdown_summary_table.rs b/src/app/markdown/markdown_summary_table.rs new file mode 100644 index 0000000..243306c --- /dev/null +++ b/src/app/markdown/markdown_summary_table.rs @@ -0,0 +1,104 @@ +use std::fmt::{Display, Formatter}; + +use markdown_table::{Heading, HeadingAlignment, MarkdownTable}; + +use crate::domain::scanresult::{scan_result::ScanResult, severity::Severity}; + +#[derive(Clone, Debug, Default)] +pub struct MarkdownSummaryTable { + pub total_found: u32, + pub critical: u32, + pub critical_fixable: u32, + pub high: u32, + pub high_fixable: u32, + pub medium: u32, + pub medium_fixable: u32, + pub low: u32, + pub low_fixable: u32, + pub negligible: u32, + pub negligible_fixable: u32, +} + +impl From<&ScanResult> for MarkdownSummaryTable { + fn from(value: &ScanResult) -> Self { + let mut summary = MarkdownSummaryTable::default(); + + for vuln in value.vulnerabilities() { + summary.total_found += 1; + let fixable = vuln.fixable(); + match vuln.severity() { + Severity::Critical => { + summary.critical += 1; + if fixable { + summary.critical_fixable += 1; + } + } + Severity::High => { + summary.high += 1; + if fixable { + summary.high_fixable += 1; + } + } + Severity::Medium => { + summary.medium += 1; + if fixable { + summary.medium_fixable += 1; + } + } + Severity::Low => { + summary.low += 1; + if fixable { + summary.low_fixable += 1; + } + } + Severity::Negligible => { + summary.negligible += 1; + if fixable { + summary.negligible_fixable += 1; + } + } + Severity::Unknown => {} + } + } + + summary + } +} + +impl Display for MarkdownSummaryTable { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let headers = vec![ + Heading::new( + "TOTAL VULNS FOUND".to_string(), + Some(HeadingAlignment::Center), + ), + Heading::new("CRITICAL".to_string(), Some(HeadingAlignment::Center)), + Heading::new("HIGH".to_string(), Some(HeadingAlignment::Center)), + Heading::new("MEDIUM".to_string(), Some(HeadingAlignment::Center)), + Heading::new("LOW".to_string(), Some(HeadingAlignment::Center)), + Heading::new("NEGLIGIBLE".to_string(), Some(HeadingAlignment::Center)), + ]; + + let summary_vulns_line = |total_vulns: u32, fixable_vulns: u32| { + if fixable_vulns > 0 { + format!("{} ({} Fixable)", total_vulns, fixable_vulns) + } else { + total_vulns.to_string() + } + }; + + let data = vec![vec![ + self.total_found.to_string(), + summary_vulns_line(self.critical, self.critical_fixable), + summary_vulns_line(self.high, self.high_fixable), + summary_vulns_line(self.medium, self.medium_fixable), + summary_vulns_line(self.low, self.low_fixable), + summary_vulns_line(self.negligible, self.negligible_fixable), + ]]; + + let mut table = MarkdownTable::new(data); + table.with_headings(headers); + + f.write_str(&table.as_markdown().unwrap_or_default()) + } +} diff --git a/src/app/markdown/markdown_vulnerability_evaluated_table.rs b/src/app/markdown/markdown_vulnerability_evaluated_table.rs new file mode 100644 index 0000000..6b534e3 --- /dev/null +++ b/src/app/markdown/markdown_vulnerability_evaluated_table.rs @@ -0,0 +1,119 @@ +use std::{ + fmt::{Display, Formatter}, + sync::Arc, +}; + +use itertools::Itertools; +use markdown_table::{Heading, HeadingAlignment, MarkdownTable}; + +use crate::domain::scanresult::{layer::Layer, scan_result::ScanResult}; + +#[derive(Clone, Debug, Default)] +pub struct VulnerabilityEvaluated { + pub cve: String, + pub severity: String, + pub packages_found: u32, + pub fixable: bool, + pub exploitable: bool, + pub accepted_risk: bool, +} + +#[derive(Clone, Debug, Default)] +pub struct VulnerabilityEvaluatedTable(pub Vec); + +impl From<&ScanResult> for VulnerabilityEvaluatedTable { + fn from(value: &ScanResult) -> Self { + VulnerabilityEvaluatedTable( + value + .vulnerabilities() + .iter() + .sorted_by_key(|v| v.cve()) + .sorted_by(|a, b| { + b.found_in_packages() + .len() + .cmp(&a.found_in_packages().len()) + }) + .sorted_by(|a, b| b.fixable().cmp(&a.fixable())) + .sorted_by(|a, b| b.exploitable().cmp(&a.exploitable())) + .sorted_by_key(|v| v.severity()) + .map(|v| VulnerabilityEvaluated { + cve: v.cve().to_string(), + severity: v.severity().to_string(), + packages_found: v.found_in_packages().len() as u32, + fixable: v.fixable(), + exploitable: v.exploitable(), + accepted_risk: !v.accepted_risks().is_empty(), + }) + .collect(), + ) + } +} +impl From<&Arc> for VulnerabilityEvaluatedTable { + fn from(value: &Arc) -> Self { + VulnerabilityEvaluatedTable( + value + .vulnerabilities() + .iter() + .sorted_by_key(|v| v.cve()) + .sorted_by(|a, b| { + b.found_in_packages() + .len() + .cmp(&a.found_in_packages().len()) + }) + .sorted_by(|a, b| b.fixable().cmp(&a.fixable())) + .sorted_by(|a, b| b.exploitable().cmp(&a.exploitable())) + .sorted_by_key(|v| v.severity()) + .map(|v| VulnerabilityEvaluated { + cve: v.cve().to_string(), + severity: v.severity().to_string(), + packages_found: v.found_in_packages().len() as u32, + fixable: v.fixable(), + exploitable: v.exploitable(), + accepted_risk: !v.accepted_risks().is_empty(), + }) + .collect(), + ) + } +} + +impl Display for VulnerabilityEvaluatedTable { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if self.0.is_empty() { + return f.write_str(""); + } + + let headers = vec![ + Heading::new("VULN CVE".to_string(), Some(HeadingAlignment::Left)), + Heading::new("SEVERITY".to_string(), Some(HeadingAlignment::Left)), + Heading::new("PACKAGES".to_string(), Some(HeadingAlignment::Left)), + Heading::new("FIXABLE".to_string(), Some(HeadingAlignment::Left)), + Heading::new("EXPLOITABLE".to_string(), Some(HeadingAlignment::Left)), + Heading::new("ACCEPTED RISK".to_string(), Some(HeadingAlignment::Left)), + ]; + + let data = self + .0 + .iter() + .map(|v| { + vec![ + v.cve.clone(), + v.severity.clone(), + v.packages_found.to_string(), + if v.fixable { "✅" } else { "❌" }.to_string(), + if v.exploitable { "✅" } else { "❌" }.to_string(), + if v.accepted_risk { "✅" } else { "❌" }.to_string(), + ] + }) + .collect(); + + let mut table = MarkdownTable::new(data); + table.with_headings(headers); + + let format = format!( + "\n### Vulnerability Detail\n\n{}", + table.as_markdown().unwrap_or_default() + ); + + f.write_str(&format) + } +} diff --git a/src/app/markdown/mod.rs b/src/app/markdown/mod.rs new file mode 100644 index 0000000..b0738db --- /dev/null +++ b/src/app/markdown/mod.rs @@ -0,0 +1,10 @@ +mod markdown_data; +mod markdown_fixable_package_table; +mod markdown_layer_data; +mod markdown_policy_evaluated_table; +mod markdown_summary; +mod markdown_summary_table; +mod markdown_vulnerability_evaluated_table; + +pub use markdown_data::MarkdownData; +pub use markdown_layer_data::MarkdownLayerData; diff --git a/src/app/mod.rs b/src/app/mod.rs index bd5a917..6a1c776 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -5,6 +5,7 @@ mod image_scanner; mod lsp_client; mod lsp_interactor; mod lsp_server; +mod markdown; mod queries; pub use document_database::*; diff --git a/src/domain/scanresult/package_type.rs b/src/domain/scanresult/package_type.rs index dcb2fe2..13cceec 100644 --- a/src/domain/scanresult/package_type.rs +++ b/src/domain/scanresult/package_type.rs @@ -1,3 +1,5 @@ +use std::fmt::{Display, Formatter}; + #[derive(PartialEq, Eq, Hash, Clone, Copy, Debug)] pub enum PackageType { Unknown, @@ -11,3 +13,24 @@ pub enum PackageType { Php, CSharp, } + +impl Display for PackageType { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + PackageType::Unknown => "unknown", + PackageType::Os => "os", + PackageType::Python => "python", + PackageType::Java => "java", + PackageType::Javascript => "javascript", + PackageType::Golang => "golang", + PackageType::Rust => "rust", + PackageType::Ruby => "ruby", + PackageType::Php => "php", + PackageType::CSharp => "csharp", + } + ) + } +} diff --git a/src/domain/scanresult/severity.rs b/src/domain/scanresult/severity.rs index d8709a2..c9488eb 100644 --- a/src/domain/scanresult/severity.rs +++ b/src/domain/scanresult/severity.rs @@ -1,3 +1,5 @@ +use std::fmt::{Display, Formatter}; + #[derive(PartialEq, Eq, Hash, Clone, Copy, PartialOrd, Ord, Debug)] pub enum Severity { Critical, @@ -7,3 +9,20 @@ pub enum Severity { Negligible, Unknown, } + +impl Display for Severity { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Severity::Critical => "Critical", + Severity::High => "High", + Severity::Medium => "Medium", + Severity::Low => "Low", + Severity::Negligible => "Negligible", + Severity::Unknown => "Unknown", + } + ) + } +} diff --git a/tests/fixtures/compose.yaml b/tests/fixtures/compose.yaml index a1a3e2b..c0c166b 100644 --- a/tests/fixtures/compose.yaml +++ b/tests/fixtures/compose.yaml @@ -11,4 +11,4 @@ services: # Another service for good measure api: - image: my-api:1.0 + image: quay.io/sysdig/agent-slim:latest diff --git a/tests/general.rs b/tests/general.rs index 68c6251..10a2bf8 100644 --- a/tests/general.rs +++ b/tests/general.rs @@ -11,9 +11,10 @@ use sysdig_lsp::domain::scanresult::scan_type::ScanType; use tower_lsp::LanguageServer; use tower_lsp::lsp_types::{ CodeActionContext, CodeActionParams, DiagnosticSeverity, DidChangeConfigurationParams, - DidChangeTextDocumentParams, DidOpenTextDocumentParams, ExecuteCommandParams, InitializeParams, - PartialResultParams, Position, Range, TextDocumentIdentifier, TextDocumentItem, Url, - VersionedTextDocumentIdentifier, WorkDoneProgressParams, + DidChangeTextDocumentParams, DidOpenTextDocumentParams, ExecuteCommandParams, HoverParams, + InitializeParams, PartialResultParams, Position, Range, TextDocumentIdentifier, + TextDocumentItem, TextDocumentPositionParams, Url, VersionedTextDocumentIdentifier, + WorkDoneProgressParams, }; #[fixture] @@ -343,6 +344,84 @@ async fn test_execute_command( ); } +#[rstest] +#[awt] +#[tokio::test] +async fn test_hover( + #[future] server_with_open_file: TestSetup, + open_file_url: Url, + scan_result: ScanResult, +) { + // Given + server_with_open_file + .component_factory + .image_scanner + .lock() + .await + .expect_scan_image() + .with(mockall::predicate::eq("alpine")) + .times(1) + .returning(move |_| Ok(scan_result.clone())); + + let params = ExecuteCommandParams { + command: "sysdig-lsp.execute-scan".to_string(), + arguments: vec![ + json!({"range":{"end":{"character":11,"line":0},"start":{"character": 0,"line":0}},"uri":open_file_url.clone()}), + json!("alpine"), + ], + work_done_progress_params: WorkDoneProgressParams::default(), + }; + let result = server_with_open_file.server.execute_command(params).await; + assert!(result.is_ok()); + + // When + let params = HoverParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier::new(open_file_url), + position: Position::new(0, 5), // Position inside "alpine" + }, + work_done_progress_params: WorkDoneProgressParams::default(), + }; + let result = server_with_open_file.server.hover(params).await; + assert!(result.is_ok()); + let hover = result.unwrap().unwrap(); + + // Then + let expected_markdown = r#"## Sysdig Scan Result +### Summary +* **PullString**: alpine:latest +* **ImageID**: `sha256:12345` +* **Digest**: `sha256:67890` +* **BaseOS**: alpine:3.18 + +| TOTAL VULNS FOUND | CRITICAL | HIGH | MEDIUM | LOW | NEGLIGIBLE | +| :-------------: | :----: | :---------: | :--: | :-: | :------: | +| 1 | 0 | 1 (1 Fixable) | 0 | 0 | 0 | + + +### Fixable Packages +| PACKAGE | TYPE | VERSION | SUGGESTED FIX | CRITICAL | HIGH | MEDIUM | LOW | NEGLIGIBLE | EXPLOIT | +| :----- | :-: | :---- | :---------- | :----: | :-: | :--: | :-: | :------: | :---: | +| package1 | os | 1.0.0 | 1.0.1 | - | 1 | - | - | - | - | + + + +### Vulnerability Detail + +| VULN CVE | SEVERITY | PACKAGES | FIXABLE | EXPLOITABLE | ACCEPTED RISK | +| :---------- | :----- | :----- | :---- | :-------- | :---------- | +| CVE-2021-1234 | High | 1 | ✅ | ❌ | ❌ | +"#; + + let expected_json = serde_json::json!({ + "contents": { + "kind": "markdown", + "value": expected_markdown.to_string() + } + }); + assert_eq!(serde_json::to_value(hover).unwrap(), expected_json); +} + #[rstest] #[awt] #[tokio::test]