diff --git a/AGENTS.md b/AGENTS.md index 92cc9c8..2ff538b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,13 +38,14 @@ The project follows a modular, three-layer, Hexagonal-like architecture that cle * `tests/common.rs` * `tests/fixtures/` (sample Dockerfiles, scan results, etc.) * Documentation for user-facing capabilities is under `docs/features/`. +* Planned features are described in `docs/roadmap.md`; each entry is linked from the README feature table. When a roadmap feature is implemented, move its section to a `docs/features/*.md` document and update the README table with the release version. * Build tooling and shortcuts are defined in `Justfile` and `flake.nix`. ### 2.2 Domain Layer (`src/domain/`) The domain layer contains pure business logic and domain models. -Key module: +Key modules: * `scanresult/`: defines core entities and value objects: * `ScanResult`: core aggregate representing a full scan result. @@ -53,6 +54,11 @@ Key module: * `Layer`: container image layer information. * `Policy`: policy evaluation results. * Value objects such as `Severity`, `Architecture`, `OperatingSystem`. +* `iacscanresult/`: light domain model for IaC scan results: + * `IacScanResult`: aggregate with the list of findings. + * `IacFinding`: rule name, severity, affected resources. + * `IacResource`: source file, location, resource type and name. + * `IacSeverity`: High/Medium/Low/Unknown value object. ### 2.3 Application Layer (`src/app/`) @@ -62,12 +68,13 @@ Key components: * **`LSPServer` (`lsp_server/`)** – main LSP implementation built on `tower-lsp`: * `lsp_server_inner.rs`: core LSP protocol handlers (initialize, text sync, code lenses, commands, diagnostics, hover, etc.). - * `commands/`: concrete LSP command implementations (e.g. `scan_base_image`, `build_and_scan`). + * `commands/`: concrete LSP command implementations (e.g. `scan_base_image`, `build_and_scan`, `iac_scan`). * `command_generator.rs`: generates Code Lens entries and associated commands. * `supported_commands.rs`: registry of available commands exposed to the client. * **`LspInteractor`** – manages communication with the LSP client and document state. * **`ImageScanner`** – trait for scanning container images (implemented by infrastructure components). * **`ImageBuilder`** – trait for building Docker images. +* **`IacScanner`** – trait for scanning IaC files/directories for misconfigurations. * **`DocumentDatabase` (`document_database.rs`)** – in-memory store for: * Document text * Diagnostics (LSP warnings/errors for vulnerabilities) @@ -86,6 +93,11 @@ Key components: * Downloads and manages scanner binary versions. * Parses JSON scan results (e.g. via `sysdig_image_scanner_json_scan_result_v1.rs`). +* **`SysdigIacScanner`** + * Runs the Sysdig CLI scanner in `--iac` mode over a file or directory (recursive). + * Shares the `ScannerBinaryManager` with `SysdigImageScanner` (single shared `Arc>` created in `ConcreteComponentFactory`), so the CLI binary is installed only once. + * Reads the report from a temp `--output-json` file and parses it via `sysdig_iac_scanner_json_result_v1.rs`. + * **`DockerImageBuilder`** * Builds container images using Bollard (Docker API client). @@ -121,7 +133,7 @@ The high-level LSP flow is: 1. **Initialize** – Client sends configuration (e.g. `api_url`, `api_token`) via `initializationOptions`. 2. **`didOpen` / `didChange`** – Document updates trigger parsing and analysis. 3. **`codeLens`** – The server generates “Scan base image” code lenses on relevant lines (e.g. Dockerfile `FROM` instructions). -4. **`executeCommand`** – Clicking a lens triggers commands like `scan_base_image` or `build_and_scan`. +4. **`executeCommand`** – Clicking a lens triggers commands like `scan_base_image`, `build_and_scan` or `iac_scan` (`sysdig-lsp.execute-iac-scan`, which also runs workspace-wide when invoked without arguments). 5. **`publishDiagnostics`** – Vulnerability findings are sent as diagnostics to the editor. 6. **`hover`** – Hovering on diagnostics or vulnerable elements shows detailed vulnerability information. diff --git a/Cargo.lock b/Cargo.lock index 07dbe5d..f2353f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -455,6 +455,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "filetime" version = "0.2.29" @@ -1988,7 +1994,7 @@ dependencies = [ [[package]] name = "sysdig-lsp" -version = "0.8.7" +version = "0.9.0" dependencies = [ "async-trait", "bollard", @@ -2010,6 +2016,7 @@ dependencies = [ "serial_test", "tabled", "tar", + "tempfile", "thiserror", "tokio", "tower-lsp", @@ -2061,6 +2068,19 @@ dependencies = [ "xattr", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "termtree" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 1c78054..0ec4f5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sysdig-lsp" -version = "0.8.7" +version = "0.9.0" edition = "2024" authors = [ "Sysdig Inc." ] readme = "README.md" @@ -34,6 +34,7 @@ tower-lsp = "0.20.0" tracing = "0.1.41" tracing-subscriber = "0.3.19" version-compare = "0.2.0" +tempfile = "3.27.0" [dev-dependencies] rstest = "0.26.0" diff --git a/README.md b/README.md index 1fa6446..377ed62 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,19 @@ helping you detect vulnerabilities and misconfigurations earlier in the developm | 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 | [Supported](./docs/features/k8s_manifest_image_analysis.md) (0.8.0+) | -| Infrastructure-as-code analysis | Supported | In roadmap | +| Infrastructure-as-code analysis | Supported | [Supported](./docs/features/iac_scan.md) (0.9.0+) | +| Structured scan results for clients (tree view data) | Supported | [In roadmap](./docs/roadmap.md#structured-scan-results-for-clients) | +| Policy evaluation results | Supported | [Supported](./docs/features/vulnerability_explanation.md) (0.7.0+) | +| Scan arbitrary image (without document) | Supported | [In roadmap](./docs/roadmap.md#scan-arbitrary-image) | +| Scan result summary notification (status bar data) | Supported | [In roadmap](./docs/roadmap.md#scan-result-summary-notification) | +| Link to scan results in Sysdig Secure | Supported | [In roadmap](./docs/roadmap.md#link-to-scan-results-in-sysdig-secure) | +| Standalone / offline mode | Supported | [In roadmap](./docs/roadmap.md#standalone--offline-mode) | +| Upload scan results to Sysdig Secure | Supported | [In roadmap](./docs/roadmap.md#upload-scan-results-to-sysdig-secure) | +| Custom policies configuration | Supported | [In roadmap](./docs/roadmap.md#custom-policies-configuration) | +| Configurable report detail level | Supported | [In roadmap](./docs/roadmap.md#configurable-report-detail-level) | +| Custom CLI scanner source | Supported | [In roadmap](./docs/roadmap.md#custom-cli-scanner-source) | +| Scan whole manifest at once | Supported | [In roadmap](./docs/roadmap.md#scan-whole-manifest) | +| Build args support in Build and Scan | Supported | [In roadmap](./docs/roadmap.md#build-args-support-in-build-and-scan) | ## Installation @@ -175,7 +187,8 @@ Navigate to **Settings > Configure Kate > LSP Client > User Server Settings** an "highlightingModeRegex": "^(Dockerfile|YAML)$", "initializationOptions": { "sysdig": { - "api_url": "https://secure.sysdig.com" + "api_url": "https://secure.sysdig.com", + "api_token": "your token" } } } @@ -183,6 +196,8 @@ Navigate to **Settings > Configure Kate > LSP Client > User Server Settings** an } ``` +If `sysdig.api_token` is omitted, the token is read from the `SECURE_API_TOKEN` environment variable instead. + ### JetBrains IDEs > [!WARNING] @@ -209,10 +224,13 @@ Navigate to **Settings > Configure Kate > LSP Client > User Server Settings** an ```json { "sysdig": { - "api_url": "https://secure.sysdig.com" + "api_url": "https://secure.sysdig.com", + "api_token": "your token" } } ``` + If `sysdig.api_token` is omitted, the token is read from the `SECURE_API_TOKEN` environment variable instead. + Note that the IDE must be launched from an environment where the variable is set (e.g. from a terminal), otherwise it won't see it. ### Vim with coc.nvim (to be reviewed) @@ -225,13 +243,16 @@ Add the following to your `coc.nvim` configuration: "filetypes": ["dockerfile", "yaml"], "initializationOptions": { "sysdig": { - "api_url": "https://secure.sysdig.com" + "api_url": "https://secure.sysdig.com", + "api_token": "your token" } } } } ``` +If `sysdig.api_token` is omitted, the token is read from the `SECURE_API_TOKEN` environment variable instead. + ### Neovim with nvim-lspconfig Install [nvim-lspconfig](https://github.com/neovim/nvim-lspconfig?tab=readme-ov-file#install): @@ -256,7 +277,7 @@ if not configs.sysdig then init_options = { sysdig = { api_url = "https://us2.app.sysdig.com", - -- api_token = "my_token", -- if not specified, will be retrieved from the SYSDIG_API_TOKEN env var. + -- api_token = "my_token", -- if not specified, will be retrieved from the SECURE_API_TOKEN env var. }, }, }, @@ -278,7 +299,7 @@ vim.lsp.config.sysdig = { init_options = { sysdig = { api_url = "https://us2.app.sysdig.com", - -- api_token = "my_token", -- if not specified, will be retrieved from the SYSDIG_API_TOKEN env var. + -- api_token = "my_token", -- if not specified, will be retrieved from the SECURE_API_TOKEN env var. }, }, } diff --git a/docs/features/README.md b/docs/features/README.md index a4dd40d..4505de5 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -29,4 +29,10 @@ Sysdig LSP provides tools to integrate container security checks into your devel - Displays a detailed summary of scan results when hovering over a scanned image name. - Provides immediate feedback on vulnerabilities, severities, and available fixes. +## [Infrastructure-as-Code Analysis](./iac_scan.md) +- Scans IaC files (Kubernetes manifests, Terraform, etc.) for misconfigurations. +- Scans the whole workspace recursively or a single file via code lens. + See the linked documents for more details. + +For planned features, see the [roadmap](../roadmap.md). diff --git a/docs/features/iac_scan.md b/docs/features/iac_scan.md new file mode 100644 index 0000000..cc4239e --- /dev/null +++ b/docs/features/iac_scan.md @@ -0,0 +1,64 @@ +# Infrastructure-as-Code Analysis + +Sysdig LSP scans your Infrastructure-as-Code files for misconfigurations and reports the findings as diagnostics in your editor's problems panel. + +> [!IMPORTANT] +> Sysdig LSP analyzes IaC files from disk, not from the editor buffer. +> +> Save the file before scanning to analyze unsaved changes. + +![Sysdig LSP executing an IaC scan on a Kubernetes manifest](./iac_scan.png) + +## Usage + +The feature is exposed through a single command, `sysdig-lsp.execute-iac-scan`, which accepts an optional path: + +- **Without arguments**: scans the workspace root recursively. All IaC files the scanner understands (Kubernetes + manifests, Terraform, etc.) are analyzed. Requires the client to have sent a workspace root (via `workspaceFolders` + or `rootUri`) during initialization. +- **With a file URI**: scans just that file. This is what the **"Scan IaC file"** code lens (shown at the top of + Kubernetes manifests and Docker Compose files) invokes. The argument must be a valid `file://` URI; anything else is + rejected. + +## Example + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web-deployment +spec: + replicas: 3 + template: + spec: + containers: + - name: nginx + image: nginx:1.19 +``` + +In this example, Sysdig LSP will offer a **"Scan IaC file"** code lens at the top of the manifest. Running it reports +every misconfiguration found by the scanner (e.g. missing memory limits for the `nginx` container) as a diagnostic on +the file. + +## Diagnostics + +Each finding is reported as a diagnostic on the affected file with `source: "sysdig-iac"`: + +- Message format: `: (: )` +- Severity mapping: `high` → Error, `medium` → Warning, `low`/unknown → Information + +Diagnostics from different scan types coexist on the same document: image scan diagnostics are tagged with +`source: "sysdig-vuln"` and are never touched by IaC scans (and vice versa). Re-scanning refreshes only the IaC +diagnostics in scope: a single-file scan replaces that file's findings, a workspace scan replaces them for every file +under the scanned root. + +## Limitations + +- Findings are anchored at the top of the file (range `0,0`): the CLI scanner reports the location as an opaque string + which is included in the diagnostic message instead. +- No code lens on Terraform files yet: most LSP clients are configured to route only `dockerfile` and `yaml` file types + to Sysdig LSP. Terraform files are still covered by the recursive workspace scan. +- Editing a file does **not** clear its IaC diagnostics (they anchor at the top of the file and stay meaningful); + re-run the scan to refresh them. Vulnerability diagnostics, which anchor to specific lines, are cleared on edits. +- Multi-root workspaces: only the first workspace folder is scanned by the workspace-wide command. Findings for files + outside that folder (e.g. produced by single-file scans) are preserved. diff --git a/docs/features/iac_scan.png b/docs/features/iac_scan.png new file mode 100644 index 0000000..be0632e Binary files /dev/null and b/docs/features/iac_scan.png differ diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..3b3ef35 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,73 @@ +# Roadmap + +This document describes the features planned for Sysdig LSP. The goal is for the LSP to become the core engine of the +[Sysdig VSCode Extension](https://github.com/sysdiglabs/vscode-extension) (and other editor plugins), so most of these +features close the gap with what the extension implements today in TypeScript. + +Once a feature is implemented, its section should be moved to a dedicated document under [`docs/features/`](./features/README.md) +and the [README feature table](../README.md#features) updated with the release version. + +## Structured scan results for clients + +Expose the full scan result (packages, vulnerabilities, severities, exploitable/fix-available flags, source locations, +and the policy evaluation tree: Policy → Rule Bundle → Rule → Failure with remediation hints) through a custom LSP +request or notification (e.g. `sysdig/scanResult`). Today the LSP only surfaces results as diagnostics and Markdown +hovers, which is not machine-consumable; clients need structured JSON to render tree views, filters (exploitable / +fix available), and rich UIs like the extension's "Vulnerabilities" and "Policy Evaluation" panels. + +## Scan arbitrary image + +Allow executing a scan for any image pull string without requiring a document `Location`. The current +`sysdig-lsp.execute-scan` command takes a `Location` argument, so clients cannot trigger a scan from a command palette +prompt (e.g. "Scan Image for Vulnerabilities" in the extension). This needs a command variant that only takes the image +pull string and reports results through the structured scan result channel instead of document diagnostics. + +## Scan result summary notification + +Send a custom notification with the vulnerability counts per severity (critical/high/medium/low/negligible) and the +policy pass/fail summary after each scan, so clients can render lightweight UI such as a status bar item without +parsing diagnostics. + +## Link to scan results in Sysdig Secure + +Expose the scan `resultUrl` returned by the scanner so clients can offer an "Open in Sysdig Secure" action. The URL is +already parsed from the scanner JSON output but is currently dropped when mapping to the domain `ScanResult`. Depends on +[uploading results](#upload-scan-results-to-sysdig-secure), since the URL is only meaningful when the result exists in +the backend. + +## Standalone / offline mode + +Support running the scanner with `--standalone` using a local vulnerability database, with a configurable policy: +always, never, or automatically when the Sysdig backend is unreachable (connectivity check with a short timeout). +Standalone scans skip result upload and policy evaluation. + +## Upload scan results to Sysdig Secure + +Add a configuration option to upload scan results to the Sysdig Secure backend. The scanner is currently always invoked +with `--skipupload`. + +## Custom policies configuration + +Allow configuring additional policies to evaluate during scans (scanner `--policy` flag), e.g. via a +`sysdig.policies` initialization option. + +## Configurable report detail level + +Add a configuration option to toggle detailed CVE tables (CVSS score/vector, exploitability, fix version) in hover +reports, equivalent to the extension's `detailedReports` setting. + +## Custom CLI scanner source + +Allow configuring a custom download URL for the CLI scanner binary (e.g. for air-gapped environments). The download URL +is currently hardcoded to `download.sysdig.com`. + +## Scan whole manifest + +Provide a single command that scans all images found in a Docker Compose file or Kubernetes manifest at once, instead of +requiring one command execution per image. + +## Build args support in Build and Scan + +Accept Dockerfile `ARG` values as arguments of the `sysdig-lsp.execute-build-and-scan` command and forward them as +build args to the Docker build. Prompting the user for the values is client-side UI; the LSP only needs to accept and +apply them. diff --git a/src/app/component_factory.rs b/src/app/component_factory.rs index 4720a72..1a1aaa5 100644 --- a/src/app/component_factory.rs +++ b/src/app/component_factory.rs @@ -4,7 +4,7 @@ use serde::Deserialize; use thiserror::Error; use tower_lsp::jsonrpc::{Error as LspError, ErrorCode}; -use super::{ImageBuilder, ImageScanner}; +use super::{IacScanner, ImageBuilder, ImageScanner}; #[derive(Clone, Debug, Default, Deserialize)] pub struct Config { @@ -22,6 +22,7 @@ pub struct SysdigConfig { pub struct Components { pub scanner: Box, pub builder: Box, + pub iac_scanner: Box, } pub trait ComponentFactory: Send + Sync { diff --git a/src/app/document_database.rs b/src/app/document_database.rs index f155027..7aa3a26 100644 --- a/src/app/document_database.rs +++ b/src/app/document_database.rs @@ -10,7 +10,10 @@ pub struct InMemoryDocumentDatabase { #[derive(Default, Debug, Clone)] struct Document { - pub text: String, + /// `Some` once the client has opened the document (even if its content is + /// empty); `None` for entries that only hold diagnostics for files that were + /// never opened (e.g. discovered by a workspace-wide IaC scan). + pub text: Option, pub diagnostics: Vec, pub documentations: Vec, } @@ -21,6 +24,16 @@ struct Documentation { pub content: String, } +/// Which documents a diagnostics replacement clears before inserting new ones. +#[derive(Debug, Clone, Copy)] +pub enum DiagnosticsScope<'a> { + /// Only the document with this exact URI. + Document(&'a str), + /// Every document whose URI starts with this prefix (e.g. a workspace root + /// with a trailing `/`). An empty prefix matches all documents. + DocumentsWithUriPrefix(&'a str), +} + impl InMemoryDocumentDatabase { pub async fn write_document_text(&self, uri: impl Into, text: impl Into) { let text = text.into(); @@ -29,9 +42,9 @@ impl InMemoryDocumentDatabase { .write() .await .entry(uri.into()) - .and_modify(|e| e.text = text.clone()) + .and_modify(|e| e.text = Some(text.clone())) .or_insert_with(|| Document { - text, + text: Some(text), ..Default::default() }); } @@ -41,31 +54,73 @@ impl InMemoryDocumentDatabase { } pub async fn read_document_text(&self, uri: &str) -> Option { - self.read_document(uri).await.map(|e| e.text) + self.read_document(uri).await.and_then(|e| e.text) } - pub async fn append_document_diagnostics( - &self, - uri: impl Into, - diagnostics: &[Diagnostic], - ) { - self.documents - .write() - .await - .entry(uri.into()) - .and_modify(|d| d.diagnostics.extend_from_slice(diagnostics)) - .or_insert_with(|| Document { - diagnostics: diagnostics.to_vec(), - ..Default::default() + /// Drops the given document entries if they (still) hold no state at all: + /// never opened by the client and no diagnostics/documentation left to publish. + pub async fn prune_documents_if_empty(&self, uris: &[&str]) { + let mut documents = self.documents.write().await; + for uri in uris { + let is_empty = documents.get(*uri).is_some_and(|d| { + d.text.is_none() && d.diagnostics.is_empty() && d.documentations.is_empty() }); + if is_empty { + documents.remove(*uri); + } + } } - pub async fn remove_diagnostics(&self, uri: impl Into) { - self.documents - .write() - .await - .entry(uri.into()) - .and_modify(|d| d.diagnostics.clear()); + /// Atomically replaces every diagnostic tagged with `source` by `new_diagnostics`, + /// under a single write lock so concurrent commands cannot observe or interleave + /// a half-updated state. + /// + /// `scope` limits the removal; diagnostics with a different (or no) source are + /// always preserved. + pub async fn replace_diagnostics_with_source( + &self, + source: &str, + scope: DiagnosticsScope<'_>, + new_diagnostics: HashMap>, + ) { + let mut documents = self.documents.write().await; + + let retain_other_sources = |document: &mut Document| { + document + .diagnostics + .retain(|diag| diag.source.as_deref() != Some(source)) + }; + match scope { + DiagnosticsScope::Document(uri) => { + if let Some(document) = documents.get_mut(uri) { + retain_other_sources(document); + } + } + DiagnosticsScope::DocumentsWithUriPrefix(prefix) => documents + .iter_mut() + .filter(|(uri, _)| uri.starts_with(prefix)) + .for_each(|(_, document)| retain_other_sources(document)), + } + + for (uri, diagnostics) in new_diagnostics { + // Inserting outside the cleared scope would accumulate duplicates on + // every repeated call, so the invariant is enforced here on data + // (diagnostic URIs can derive from external scanner output). + let in_scope = match scope { + DiagnosticsScope::Document(scoped_uri) => uri == scoped_uri, + DiagnosticsScope::DocumentsWithUriPrefix(prefix) => uri.starts_with(prefix), + }; + if !in_scope { + tracing::warn!("dropping diagnostics outside the replacement scope: {uri}"); + continue; + } + + documents + .entry(uri) + .or_default() + .diagnostics + .extend(diagnostics); + } } pub async fn all_diagnostics(&self) -> impl Iterator)> { @@ -146,7 +201,7 @@ mod tests { db.write_document_text("file://main.rs", "contents").await; let document = db.read_document("file://main.rs").await.unwrap(); - assert_eq!(document.text, "contents"); + assert_eq!(document.text.as_deref(), Some("contents")); } #[tokio::test] @@ -157,7 +212,21 @@ mod tests { db.write_document_text("file://main.rs", "updated").await; let document = db.read_document("file://main.rs").await.unwrap(); - assert_eq!(document.text, "updated"); + assert_eq!(document.text.as_deref(), Some("updated")); + } + + /// Seeds diagnostics as-is: replacing a source no diagnostic has just appends. + async fn seed_diagnostics( + db: &InMemoryDocumentDatabase, + uri: &str, + diagnostics: Vec, + ) { + db.replace_diagnostics_with_source( + "__nonexistent__", + DiagnosticsScope::DocumentsWithUriPrefix(""), + HashMap::from([(uri.to_string(), diagnostics)]), + ) + .await; } #[tokio::test] @@ -168,8 +237,7 @@ mod tests { create_diagnostic((0, 0), (0, 2), "Missing doc comment"), ]; - db.append_document_diagnostics("file://test.rs", &diagnostics) - .await; + seed_diagnostics(&db, "file://test.rs", diagnostics.clone()).await; let retrieved_doc = db.read_document("file://test.rs").await.unwrap(); assert_eq!(retrieved_doc.diagnostics.len(), diagnostics.len()); @@ -183,15 +251,17 @@ mod tests { async fn test_all_diagnostics() { let db = InMemoryDocumentDatabase::default(); - db.append_document_diagnostics( + seed_diagnostics( + &db, "file://mod1.rs", - &[create_diagnostic((0, 0), (0, 6), "Incorrect module name")], + vec![create_diagnostic((0, 0), (0, 6), "Incorrect module name")], ) .await; - db.append_document_diagnostics( + seed_diagnostics( + &db, "file://mod2.rs", - &[ + vec![ create_diagnostic((0, 0), (0, 6), "Incorrect module name"), create_diagnostic((0, 7), (0, 8), "Unexpected token"), ], @@ -214,6 +284,177 @@ mod tests { assert_eq!(mod2_diag.message, "Unexpected token"); } + fn create_diagnostic_with_source(message: &str, source: Option<&str>) -> Diagnostic { + Diagnostic { + source: source.map(str::to_string), + ..create_diagnostic((0, 0), (0, 1), message) + } + } + + #[tokio::test] + async fn test_replace_diagnostics_with_source_across_all_documents() { + let db = InMemoryDocumentDatabase::default(); + + let iac = create_diagnostic_with_source("IaC finding", Some("sysdig-iac")); + let other = create_diagnostic_with_source("Other tool finding", Some("other-source")); + let untagged = create_diagnostic_with_source("Image scan finding", None); + + seed_diagnostics( + &db, + "file://doc1.yaml", + vec![iac.clone(), other.clone(), untagged.clone()], + ) + .await; + seed_diagnostics(&db, "file://doc2.yaml", vec![iac.clone()]).await; + + let replacement = create_diagnostic_with_source("New IaC finding", Some("sysdig-iac")); + db.replace_diagnostics_with_source( + "sysdig-iac", + DiagnosticsScope::DocumentsWithUriPrefix(""), + HashMap::from([("file://doc3.yaml".to_string(), vec![replacement])]), + ) + .await; + + let all_diagnostics: Vec<_> = db + .all_diagnostics() + .await + .sorted_by(|(x, _), (y, _)| Ord::cmp(x, y)) + .collect(); + + assert_eq!(all_diagnostics.len(), 3); + // Exact source match: "other-source" and untagged diagnostics are preserved + assert_eq!(all_diagnostics[0].0, "file://doc1.yaml"); + let messages: Vec<_> = all_diagnostics[0] + .1 + .iter() + .map(|d| d.message.as_str()) + .collect(); + assert_eq!(messages, vec!["Other tool finding", "Image scan finding"]); + // doc2 entry persists with empty diagnostics, so publish clears the client + assert_eq!(all_diagnostics[1].0, "file://doc2.yaml"); + assert!(all_diagnostics[1].1.is_empty()); + // The replacement is appended + assert_eq!(all_diagnostics[2].0, "file://doc3.yaml"); + assert_eq!(all_diagnostics[2].1[0].message, "New IaC finding"); + } + + #[tokio::test] + async fn test_replace_drops_insertions_outside_a_document_scope() { + let db = InMemoryDocumentDatabase::default(); + + let in_scope = create_diagnostic_with_source("In scope", Some("sysdig-iac")); + let out_of_scope = create_diagnostic_with_source("Out of scope", Some("sysdig-iac")); + db.replace_diagnostics_with_source( + "sysdig-iac", + DiagnosticsScope::Document("file:///a.yaml"), + HashMap::from([ + ("file:///a.yaml".to_string(), vec![in_scope]), + ("file:///b.yaml".to_string(), vec![out_of_scope]), + ]), + ) + .await; + + let all_diagnostics: Vec<_> = db + .all_diagnostics() + .await + .sorted_by(|(x, _), (y, _)| Ord::cmp(x, y)) + .collect(); + + // Insertions outside the cleared scope are dropped: they would accumulate + // duplicates on every rescan since no replacement would ever clear them. + assert_eq!(all_diagnostics.len(), 1); + assert_eq!(all_diagnostics[0].0, "file:///a.yaml"); + assert_eq!(all_diagnostics[0].1[0].message, "In scope"); + } + + #[tokio::test] + async fn test_replace_diagnostics_with_source_scoped_by_uri_prefix() { + let db = InMemoryDocumentDatabase::default(); + + let iac = create_diagnostic_with_source("IaC finding", Some("sysdig-iac")); + seed_diagnostics(&db, "file:///workspace/a.yaml", vec![iac.clone()]).await; + seed_diagnostics(&db, "file:///outside/b.yaml", vec![iac.clone()]).await; + + db.replace_diagnostics_with_source( + "sysdig-iac", + DiagnosticsScope::DocumentsWithUriPrefix("file:///workspace/"), + HashMap::new(), + ) + .await; + + let all_diagnostics: Vec<_> = db + .all_diagnostics() + .await + .sorted_by(|(x, _), (y, _)| Ord::cmp(x, y)) + .collect(); + + // Only documents under the prefix are cleared + assert_eq!(all_diagnostics[0].0, "file:///outside/b.yaml"); + assert_eq!(all_diagnostics[0].1.len(), 1); + assert_eq!(all_diagnostics[1].0, "file:///workspace/a.yaml"); + assert!(all_diagnostics[1].1.is_empty()); + } + + #[tokio::test] + async fn test_prune_keeps_open_documents_with_empty_text() { + let db = InMemoryDocumentDatabase::default(); + + db.write_document_text("file:///empty.yaml", "").await; + seed_diagnostics(&db, "file:///never-opened.yaml", vec![]).await; + + db.prune_documents_if_empty(&["file:///empty.yaml", "file:///never-opened.yaml"]) + .await; + + // An opened-but-empty document must survive (code lens reads its text); + // a never-opened entry without diagnostics is dropped. + assert!(db.read_document_text("file:///empty.yaml").await.is_some()); + let remaining: Vec<_> = db.all_diagnostics().await.map(|(uri, _)| uri).collect(); + assert_eq!(remaining, vec!["file:///empty.yaml".to_string()]); + } + + #[tokio::test] + async fn test_prune_keeps_entries_that_are_no_longer_empty() { + let db = InMemoryDocumentDatabase::default(); + + let iac = create_diagnostic_with_source("IaC finding", Some("sysdig-iac")); + seed_diagnostics(&db, "file:///refilled.yaml", vec![iac]).await; + + // A concurrent replacement refilled the entry between the publish snapshot + // and the prune: it must survive so the next publish sends its diagnostics. + db.prune_documents_if_empty(&["file:///refilled.yaml"]) + .await; + + let remaining: Vec<_> = db.all_diagnostics().await.map(|(uri, _)| uri).collect(); + assert_eq!(remaining, vec!["file:///refilled.yaml".to_string()]); + } + + #[tokio::test] + async fn test_replace_diagnostics_with_source_scoped_to_a_single_document() { + let db = InMemoryDocumentDatabase::default(); + + let iac = create_diagnostic_with_source("IaC finding", Some("sysdig-iac")); + seed_diagnostics(&db, "file://doc1.yaml", vec![iac.clone()]).await; + seed_diagnostics(&db, "file://doc2.yaml", vec![iac.clone()]).await; + + let replacement = create_diagnostic_with_source("New IaC finding", Some("sysdig-iac")); + db.replace_diagnostics_with_source( + "sysdig-iac", + DiagnosticsScope::Document("file://doc1.yaml"), + HashMap::from([("file://doc1.yaml".to_string(), vec![replacement])]), + ) + .await; + + let all_diagnostics: Vec<_> = db + .all_diagnostics() + .await + .sorted_by(|(x, _), (y, _)| Ord::cmp(x, y)) + .collect(); + + // doc1 replaced, doc2 untouched by the scoped replacement + assert_eq!(all_diagnostics[0].1[0].message, "New IaC finding"); + assert_eq!(all_diagnostics[1].1[0].message, "IaC finding"); + } + #[tokio::test] async fn test_empty_database() { let db = InMemoryDocumentDatabase::default(); diff --git a/src/app/iac_scanner.rs b/src/app/iac_scanner.rs new file mode 100644 index 0000000..cb747ac --- /dev/null +++ b/src/app/iac_scanner.rs @@ -0,0 +1,44 @@ +use std::{ + error::Error, + path::{Path, PathBuf}, +}; + +use thiserror::Error; +use tower_lsp::lsp_types::Url; + +use crate::domain::iacscanresult::iac_scan_result::IacScanResult; + +/// Scope of an IaC scan. Makes invalid states (e.g. a recursive scan of a single file) +/// unrepresentable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IacScanScope { + /// Scan a single IaC file. Keeps the original client URI so diagnostics are + /// published under the exact URI the editor opened the document with + /// (a path→URI round-trip is not guaranteed to be byte-identical). + File { uri: Url, path: PathBuf }, + /// Scan a directory recursively. + Directory(PathBuf), +} + +impl IacScanScope { + pub fn path(&self) -> &Path { + match self { + IacScanScope::File { path, .. } => path, + IacScanScope::Directory(path) => path, + } + } +} + +#[async_trait::async_trait] +pub trait IacScanner { + async fn scan_iac(&self, scope: &IacScanScope) -> Result; +} + +#[derive(Error, Debug)] +pub enum IacScanError { + #[error("invalid configuration for the IaC scanner, check the API URL and token: {0}")] + InvalidConfiguration(String), + + #[error("error in the internal IaC scanner execution: {0}")] + InternalScannerError(Box), +} diff --git a/src/app/lsp_interactor.rs b/src/app/lsp_interactor.rs index 15eb3ed..4cf6c5a 100644 --- a/src/app/lsp_interactor.rs +++ b/src/app/lsp_interactor.rs @@ -1,13 +1,20 @@ +use std::collections::HashMap; + use tower_lsp::{ jsonrpc::Result, lsp_types::{Diagnostic, MessageType, Position, Range}, }; -use super::{InMemoryDocumentDatabase, LSPClient}; +use super::{DiagnosticsScope, InMemoryDocumentDatabase, LSPClient, VULN_DIAGNOSTIC_SOURCE}; +#[derive(Clone)] pub struct LspInteractor { client: C, document_database: InMemoryDocumentDatabase, + /// Serializes snapshot+publish+prune sequences: without it, a concurrent + /// publish could send a stale snapshot after a clearing publish, and the + /// prune would then drop the entry so no future publish self-heals it. + publish_lock: std::sync::Arc>, } impl LspInteractor { @@ -15,6 +22,7 @@ impl LspInteractor { Self { client, document_database, + publish_lock: Default::default(), } } } @@ -25,7 +33,16 @@ 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; + // Vulnerability diagnostics anchor to specific lines, so they go stale as soon + // as the text changes. IaC diagnostics anchor to the top of the file and keep + // being meaningful across edits, so they survive the document lifecycle. + self.document_database + .replace_diagnostics_with_source( + VULN_DIAGNOSTIC_SOURCE, + DiagnosticsScope::Document(uri), + HashMap::new(), + ) + .await; self.document_database.remove_documentations(uri).await; let _ = self.publish_all_diagnostics().await; } @@ -35,12 +52,29 @@ where } pub async fn publish_all_diagnostics(&self) -> Result<()> { - let all_diagnostics = self.document_database.all_diagnostics().await; - for (url, diagnostics) in all_diagnostics { + let _guard = self.publish_lock.lock().await; + + let all_diagnostics: Vec<_> = self.document_database.all_diagnostics().await.collect(); + for (url, diagnostics) in &all_diagnostics { self.client - .publish_diagnostics(&url, diagnostics, None) + .publish_diagnostics(url, diagnostics.clone(), None) .await; } + + // Drop only the entries whose clearing publish we just sent, so the + // database doesn't grow unbounded with never-opened files discovered by + // workspace scans. Pruning is limited to the URIs observed empty in THIS + // snapshot: an entry emptied concurrently after the snapshot was taken + // stays in the database, so the next publish still sends its clearing + // update instead of stranding stale diagnostics on the client forever. + let published_as_empty: Vec<&str> = all_diagnostics + .iter() + .filter(|(_, diagnostics)| diagnostics.is_empty()) + .map(|(url, _)| url.as_str()) + .collect(); + self.document_database + .prune_documents_if_empty(&published_as_empty) + .await; Ok(()) } @@ -48,13 +82,14 @@ where self.document_database.read_document_text(uri).await } - pub async fn remove_diagnostics(&self, uri: &str) { - self.document_database.remove_diagnostics(uri).await - } - - pub async fn append_document_diagnostics(&self, uri: &str, diagnostics: &[Diagnostic]) { + pub async fn replace_diagnostics_with_source( + &self, + source: &str, + scope: DiagnosticsScope<'_>, + new_diagnostics: HashMap>, + ) { self.document_database - .append_document_diagnostics(uri, diagnostics) + .replace_diagnostics_with_source(source, scope, new_diagnostics) .await } diff --git a/src/app/lsp_server/command_generator.rs b/src/app/lsp_server/command_generator.rs index 7d27a5c..61af21d 100644 --- a/src/app/lsp_server/command_generator.rs +++ b/src/app/lsp_server/command_generator.rs @@ -27,6 +27,13 @@ impl From for CommandInfo { arguments: Some(vec![json!(location)]), range: location.range, }, + + SupportedCommands::ExecuteIacScan { uri } => CommandInfo { + title: "Scan IaC file".to_owned(), + command: value.as_string_command(), + arguments: uri.as_ref().map(|u| vec![json!(u)]), + range: Range::default(), + }, } } } @@ -55,7 +62,7 @@ impl From for CodeLens { } } -pub fn generate_commands_for_uri(uri: &Url, content: &str) -> Result, String> { +pub fn generate_commands_for_uri(uri: &Url, content: &str) -> Vec { let file_uri = uri.as_str(); if file_uri.contains("docker-compose.yml") @@ -67,12 +74,14 @@ pub fn generate_commands_for_uri(uri: &Url, content: &str) -> Result Result, String> { - let mut commands = vec![]; +fn generate_compose_commands(url: &Url, content: &str) -> Vec { + // The IaC scan doesn't need parseable image instructions: the CLI scanner + // parses the file itself, so the lens is offered even if image parsing fails. + let mut commands = vec![iac_scan_command_for(url)]; match parse_compose_file(content) { Ok(instructions) => { for instruction in instructions { @@ -85,10 +94,17 @@ fn generate_compose_commands(url: &Url, content: &str) -> Result return Err(format!("{}", err)), + Err(err) => tracing::warn!("unable to generate image scan commands: {err}"), } - Ok(commands) + commands +} + +fn iac_scan_command_for(url: &Url) -> CommandInfo { + SupportedCommands::ExecuteIacScan { + uri: Some(url.clone()), + } + .into() } fn is_k8s_manifest_file(file_uri: &str, content: &str) -> bool { @@ -102,8 +118,9 @@ fn is_k8s_manifest_file(file_uri: &str, content: &str) -> bool { content.contains("apiVersion:") && content.contains("kind:") } -fn generate_k8s_manifest_commands(url: &Url, content: &str) -> Result, String> { - let mut commands = vec![]; +fn generate_k8s_manifest_commands(url: &Url, content: &str) -> Vec { + // See generate_compose_commands: the IaC lens is independent of image parsing. + let mut commands = vec![iac_scan_command_for(url)]; match parse_k8s_manifest(content) { Ok(instructions) => { for instruction in instructions { @@ -116,10 +133,10 @@ fn generate_k8s_manifest_commands(url: &Url, content: &str) -> Result return Err(format!("{}", err)), + Err(err) => tracing::warn!("unable to generate image scan commands: {err}"), } - Ok(commands) + commands } fn generate_dockerfile_commands(uri: &Url, content: &str) -> Vec { diff --git a/src/app/lsp_server/commands/build_and_scan.rs b/src/app/lsp_server/commands/build_and_scan.rs index 19a6928..4405cfb 100644 --- a/src/app/lsp_server/commands/build_and_scan.rs +++ b/src/app/lsp_server/commands/build_and_scan.rs @@ -1,4 +1,4 @@ -use std::{path::PathBuf, str::FromStr, sync::Arc}; +use std::{collections::HashMap, path::PathBuf, str::FromStr, sync::Arc}; use itertools::Itertools; use tower_lsp::jsonrpc::Result; @@ -8,12 +8,15 @@ use tower_lsp::lsp_types::{ use crate::app::markdown::{MarkdownData, MarkdownLayerData}; use crate::{ - app::{ImageBuilder, ImageScanner, LSPClient, LspInteractor, lsp_server::WithContext}, + app::{ + DiagnosticsScope, ImageBuilder, ImageScanner, LSPClient, LspInteractor, + lsp_server::WithContext, + }, domain::scanresult::{layer::Layer, scan_result::ScanResult, severity::Severity}, infra::parse_dockerfile, }; -use super::LspCommand; +use super::{LspCommand, VULN_DIAGNOSTIC_SOURCE}; pub struct BuildAndScanCommand<'a, C, B: ?Sized, S: ?Sized> where @@ -112,13 +115,17 @@ where let (diagnostics_per_layer, docs_per_layer) = diagnostics_for_layers(&document_text, &scan_result)?; - self.interactor.remove_diagnostics(uri).await; + let mut diagnostics = Vec::with_capacity(1 + diagnostics_per_layer.len()); + diagnostics.push(diagnostic); + diagnostics.extend(diagnostics_per_layer); + self.interactor.remove_documentations(uri).await; self.interactor - .append_document_diagnostics(uri, &[diagnostic]) - .await; - self.interactor - .append_document_diagnostics(uri, &diagnostics_per_layer) + .replace_diagnostics_with_source( + VULN_DIAGNOSTIC_SOURCE, + DiagnosticsScope::Document(uri), + HashMap::from([(uri.to_owned(), diagnostics)]), + ) .await; self.interactor .append_documentation( @@ -174,6 +181,7 @@ pub fn diagnostics_for_layers( range: instr.range, severity: Some(DiagnosticSeverity::WARNING), message: msg, + source: Some(VULN_DIAGNOSTIC_SOURCE.to_owned()), ..Default::default() }; @@ -214,6 +222,7 @@ fn fill_vulnerability_hints_for_layer( vuln.severity(), url ), + source: Some(VULN_DIAGNOSTIC_SOURCE.to_owned()), ..Default::default() }); }); @@ -236,6 +245,7 @@ fn diagnostic_for_image(line: u32, document_text: &str, scan_result: &ScanResult range: range_for_selected_line, severity: Some(DiagnosticSeverity::HINT), message: "No vulnerabilities found.".to_owned(), + source: Some(VULN_DIAGNOSTIC_SOURCE.to_owned()), ..Default::default() }; diff --git a/src/app/lsp_server/commands/iac_scan.rs b/src/app/lsp_server/commands/iac_scan.rs new file mode 100644 index 0000000..4488fba --- /dev/null +++ b/src/app/lsp_server/commands/iac_scan.rs @@ -0,0 +1,179 @@ +use std::collections::HashMap; + +use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, MessageType, Range, Url}; + +use crate::{ + app::{ + DiagnosticsScope, IacScanError, IacScanScope, IacScanner, LSPClient, LspInteractor, + lsp_server::WithContext, + }, + domain::iacscanresult::iac_severity::IacSeverity, +}; + +use super::{IAC_DIAGNOSTIC_SOURCE, LspCommand}; + +pub struct IacScanCommand<'a, C, S: ?Sized> +where + S: IacScanner, +{ + iac_scanner: &'a S, + interactor: &'a LspInteractor, + scope: IacScanScope, +} + +impl<'a, C, S: ?Sized> IacScanCommand<'a, C, S> +where + S: IacScanner, +{ + pub fn new(iac_scanner: &'a S, interactor: &'a LspInteractor, scope: IacScanScope) -> Self { + Self { + iac_scanner, + interactor, + scope, + } + } +} + +#[async_trait::async_trait] +impl<'a, C, S: ?Sized> LspCommand for IacScanCommand<'a, C, S> +where + C: LSPClient + Sync, + S: IacScanner + Sync, +{ + async fn execute(&mut self) -> tower_lsp::jsonrpc::Result<()> { + let path_display = self.scope.path().display().to_string(); + self.interactor + .show_message( + MessageType::INFO, + format!("Starting IaC scan of {path_display}...").as_str(), + ) + .await; + + let scan_result = self + .iac_scanner + .scan_iac(&self.scope) + .await + .map_err(|e| match &e { + IacScanError::InvalidConfiguration(_) => { + tower_lsp::jsonrpc::Error::invalid_params(e.to_string()) + } + IacScanError::InternalScannerError(_) => { + tower_lsp::jsonrpc::Error::internal_error().with_message(e.to_string()) + } + })?; + + let findings_count = scan_result.findings.len(); + let mut diagnostics_per_uri: HashMap> = HashMap::new(); + for finding in &scan_result.findings { + for resource in &finding.resources { + let Some(uri) = self.uri_for_resource_source(&resource.source) else { + tracing::warn!( + "unable to build a file URI for IaC finding resource: {}", + resource.source.display() + ); + continue; + }; + + let diagnostic = Diagnostic { + range: Range::default(), + severity: Some(diagnostic_severity_for(finding.severity)), + message: format!( + "{}: {} ({}: {})", + finding.name, resource.location, resource.resource_type, resource.name + ), + source: Some(IAC_DIAGNOSTIC_SOURCE.to_owned()), + ..Default::default() + }; + + diagnostics_per_uri.entry(uri).or_default().push(diagnostic); + } + } + + // A file scan only refreshes the IaC diagnostics of that file; a directory + // scan refreshes them for every file under the scanned root — but not + // beyond it, so results for files outside the root are preserved. + let scope_key = match &self.scope { + IacScanScope::File { uri, .. } => uri.to_string(), + IacScanScope::Directory(root) => Url::from_file_path(root) + .map(|u| { + let uri = String::from(u); + // A root of `/` already yields a trailing slash (`file:///`). + if uri.ends_with('/') { + uri + } else { + format!("{uri}/") + } + }) + // An empty prefix matches every document: falling back to the + // previous whole-database refresh is safe, just broader. + .unwrap_or_default(), + }; + let scope = match &self.scope { + IacScanScope::File { .. } => DiagnosticsScope::Document(&scope_key), + IacScanScope::Directory(_) => DiagnosticsScope::DocumentsWithUriPrefix(&scope_key), + }; + self.interactor + .replace_diagnostics_with_source(IAC_DIAGNOSTIC_SOURCE, scope, diagnostics_per_uri) + .await; + self.interactor.publish_all_diagnostics().await?; + + self.interactor + .show_message( + MessageType::INFO, + format!("Finished IaC scan of {path_display}: {findings_count} findings.").as_str(), + ) + .await; + + Ok(()) + } +} + +impl<'a, C, S: ?Sized> IacScanCommand<'a, C, S> +where + S: IacScanner, +{ + /// Diagnostics for the scanned file are published under the exact URI the + /// client used (a path→URI round-trip is not guaranteed to be byte-identical); + /// URIs are only synthesized for other files discovered by directory scans. + fn uri_for_resource_source(&self, source: &std::path::Path) -> Option { + match &self.scope { + IacScanScope::File { uri, path } if source == path => Some(uri.to_string()), + _ => Url::from_file_path(source).map(String::from).ok(), + } + } +} + +fn diagnostic_severity_for(severity: IacSeverity) -> DiagnosticSeverity { + match severity { + IacSeverity::High => DiagnosticSeverity::ERROR, + IacSeverity::Medium => DiagnosticSeverity::WARNING, + IacSeverity::Low | IacSeverity::Unknown => DiagnosticSeverity::INFORMATION, + } +} + +#[cfg(test)] +mod tests { + use super::diagnostic_severity_for; + use crate::domain::iacscanresult::iac_severity::IacSeverity; + use tower_lsp::lsp_types::DiagnosticSeverity; + + #[test] + fn it_maps_iac_severities_to_diagnostic_severities() { + assert_eq!( + diagnostic_severity_for(IacSeverity::High), + DiagnosticSeverity::ERROR + ); + assert_eq!( + diagnostic_severity_for(IacSeverity::Medium), + DiagnosticSeverity::WARNING + ); + assert_eq!( + diagnostic_severity_for(IacSeverity::Low), + DiagnosticSeverity::INFORMATION + ); + assert_eq!( + diagnostic_severity_for(IacSeverity::Unknown), + DiagnosticSeverity::INFORMATION + ); + } +} diff --git a/src/app/lsp_server/commands/mod.rs b/src/app/lsp_server/commands/mod.rs index 873c28d..053895b 100644 --- a/src/app/lsp_server/commands/mod.rs +++ b/src/app/lsp_server/commands/mod.rs @@ -1,8 +1,11 @@ pub mod build_and_scan; +pub mod iac_scan; pub mod scan_base_image; use tower_lsp::jsonrpc::Result; +pub use crate::app::{IAC_DIAGNOSTIC_SOURCE, VULN_DIAGNOSTIC_SOURCE}; + #[async_trait::async_trait] pub trait LspCommand { async fn execute(&mut self) -> Result<()>; diff --git a/src/app/lsp_server/commands/scan_base_image.rs b/src/app/lsp_server/commands/scan_base_image.rs index 8d2e6e8..1b3ff82 100644 --- a/src/app/lsp_server/commands/scan_base_image.rs +++ b/src/app/lsp_server/commands/scan_base_image.rs @@ -1,14 +1,17 @@ +use std::collections::HashMap; + use itertools::Itertools; use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Location, MessageType}; use crate::{ app::{ - ImageScanner, LSPClient, LspInteractor, lsp_server::WithContext, markdown::MarkdownData, + DiagnosticsScope, ImageScanner, LSPClient, LspInteractor, lsp_server::WithContext, + markdown::MarkdownData, }, domain::scanresult::severity::Severity, }; -use super::LspCommand; +use super::{LspCommand, VULN_DIAGNOSTIC_SOURCE}; pub struct ScanBaseImageCommand<'a, C, S: ?Sized> where @@ -72,6 +75,7 @@ where range: self.location.range, severity: Some(DiagnosticSeverity::HINT), message: "No vulnerabilities found.".to_owned(), + source: Some(VULN_DIAGNOSTIC_SOURCE.to_owned()), ..Default::default() }; @@ -110,10 +114,13 @@ where }; let uri = self.location.uri.as_str(); - self.interactor.remove_diagnostics(uri).await; self.interactor.remove_documentations(uri).await; self.interactor - .append_document_diagnostics(uri, &[diagnostic]) + .replace_diagnostics_with_source( + VULN_DIAGNOSTIC_SOURCE, + DiagnosticsScope::Document(uri), + HashMap::from([(uri.to_owned(), vec![diagnostic])]), + ) .await; self.interactor.publish_all_diagnostics().await?; self.interactor diff --git a/src/app/lsp_server/lsp_server_inner.rs b/src/app/lsp_server/lsp_server_inner.rs index 91ef495..406120d 100644 --- a/src/app/lsp_server/lsp_server_inner.rs +++ b/src/app/lsp_server/lsp_server_inner.rs @@ -1,5 +1,8 @@ +use std::path::PathBuf; +use std::sync::Arc; + use serde_json::Value; -use tower_lsp::jsonrpc::{self, Error, ErrorCode, Result}; +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::{ @@ -8,7 +11,7 @@ use tower_lsp::lsp_types::{ DidChangeTextDocumentParams, DidOpenTextDocumentParams, ExecuteCommandOptions, ExecuteCommandParams, Hover, HoverParams, HoverProviderCapability, InitializeParams, InitializeResult, InitializedParams, MarkupContent, MessageType, ServerCapabilities, - TextDocumentSyncCapability, TextDocumentSyncKind, + TextDocumentSyncCapability, TextDocumentSyncKind, Url, }; use tracing::{debug, info}; @@ -16,9 +19,11 @@ use super::super::component_factory::{ComponentFactory, Components, Config}; use super::super::queries::QueryExecutor; use super::command_generator; use super::commands::{ - LspCommand, build_and_scan::BuildAndScanCommand, scan_base_image::ScanBaseImageCommand, + LspCommand, build_and_scan::BuildAndScanCommand, iac_scan::IacScanCommand, + scan_base_image::ScanBaseImageCommand, }; use super::{InMemoryDocumentDatabase, LSPClient, WithContext}; +use crate::app::IacScanScope; use crate::app::LspInteractor; use super::supported_commands::SupportedCommands; @@ -27,7 +32,112 @@ pub struct LSPServerInner { interactor: LspInteractor, query_executor: QueryExecutor, component_factory: F, - components: Option, + components: Option>, + workspace_root: Option, +} + +/// Executes LSP commands with its own clones of the server dependencies, so +/// long-running scans don't hold the server-wide lock. +pub struct CommandExecutor { + components: Option>, + interactor: LspInteractor, + workspace_root: Option, +} + +impl CommandExecutor +where + C: LSPClient + Send + Sync + 'static, +{ + pub async fn execute_command(&self, params: ExecuteCommandParams) -> Result> { + let command: SupportedCommands = params.try_into()?; + let command_name = command.to_string(); + + let result = match command { + SupportedCommands::ExecuteBaseImageScan { location, image } => { + self.execute_base_image_scan(location, image).await + } + SupportedCommands::ExecuteBuildAndScan { location } => { + self.execute_build_and_scan(location).await + } + SupportedCommands::ExecuteIacScan { uri } => self.execute_iac_scan(uri).await, + }; + + match result { + Ok(_) => Ok(None), + Err(e) => Err(self.handle_command_error(&command_name, e).await), + } + } + + /// Resolved here (not when building the executor) so a missing initialization + /// flows through `handle_command_error` and is surfaced to the user. + fn components(&self) -> Result<&Arc> { + self.components + .as_ref() + .ok_or_else(|| Error::internal_error().with_message("LSP not initialized")) + } + + async fn execute_base_image_scan( + &self, + location: tower_lsp::lsp_types::Location, + image: String, + ) -> Result<()> { + ScanBaseImageCommand::new( + self.components()?.scanner.as_ref(), + &self.interactor, + location, + image, + ) + .execute() + .await + } + + async fn execute_build_and_scan(&self, location: tower_lsp::lsp_types::Location) -> Result<()> { + let components = self.components()?; + BuildAndScanCommand::new( + components.builder.as_ref(), + components.scanner.as_ref(), + &self.interactor, + location, + ) + .execute() + .await + } + + async fn execute_iac_scan(&self, uri: Option) -> Result<()> { + let scope = match uri { + Some(uri) => { + let path = uri.to_file_path().map_err(|_| { + Error::invalid_params(format!( + "only file:// URIs are supported, received: {uri}" + )) + })?; + IacScanScope::File { uri, path } + } + None => IacScanScope::Directory(self.workspace_root.clone().ok_or_else(|| { + Error::internal_error() + .with_message("no workspace root available; open a folder or pass a file URI") + })?), + }; + + IacScanCommand::new( + self.components()?.iac_scanner.as_ref(), + &self.interactor, + scope, + ) + .execute() + .await + } + + async fn handle_command_error(&self, command_name: &str, e: Error) -> Error { + self.interactor + .show_message(MessageType::ERROR, e.to_string().as_str()) + .await; + Error { + code: e.code, + message: format!("error calling command: '{command_name}': {}", e.message).into(), + data: e.data, + } + } } impl LSPServerInner { @@ -39,6 +149,7 @@ impl LSPServerInner { query_executor: QueryExecutor::new(document_database.clone()), component_factory, components: None, + workspace_root: None, } } } @@ -56,7 +167,7 @@ where debug!("updating with configuration: {config:?}"); let components = self.component_factory.create_components(config)?; - self.components.replace(components); + self.components.replace(Arc::new(components)); debug!("updated configuration"); Ok(()) @@ -77,14 +188,15 @@ where ))); }; - let commands = command_generator::generate_commands_for_uri(uri, &content); - commands.map_err(|e| jsonrpc::Error::internal_error().with_message(e)) + Ok(command_generator::generate_commands_for_uri(uri, &content)) } pub async fn initialize( &mut self, initialize_params: InitializeParams, ) -> Result { + self.workspace_root = workspace_root_from(&initialize_params); + let Some(config) = initialize_params.initialization_options else { return Err(Error { code: ErrorCode::InvalidParams, @@ -168,67 +280,18 @@ where Ok(Some(code_lenses)) } - fn components(&self) -> Result<&Components> { - self.components - .as_ref() - .ok_or_else(|| Error::internal_error().with_message("LSP not initialized")) - } - - async fn execute_base_image_scan( - &self, - location: tower_lsp::lsp_types::Location, - image: String, - ) -> Result<()> { - let components = self.components()?; - ScanBaseImageCommand::new( - components.scanner.as_ref(), - &self.interactor, - location, - image, - ) - .execute() - .await - } - - async fn execute_build_and_scan(&self, location: tower_lsp::lsp_types::Location) -> Result<()> { - let components = self.components()?; - BuildAndScanCommand::new( - components.builder.as_ref(), - components.scanner.as_ref(), - &self.interactor, - location, - ) - .execute() - .await - } - - async fn handle_command_error(&self, command_name: &str, e: Error) -> Error { - self.interactor - .show_message(MessageType::ERROR, e.to_string().as_str()) - .await; - Error { - code: e.code, - message: format!("error calling command: '{command_name}': {}", e.message).into(), - data: e.data, - } - } - - pub async fn execute_command(&self, params: ExecuteCommandParams) -> Result> { - let command: SupportedCommands = params.try_into()?; - let command_name = command.to_string(); - - let result = match command { - SupportedCommands::ExecuteBaseImageScan { location, image } => { - self.execute_base_image_scan(location, image).await - } - SupportedCommands::ExecuteBuildAndScan { location } => { - self.execute_build_and_scan(location).await - } - }; - - match result { - Ok(_) => Ok(None), - Err(e) => Err(self.handle_command_error(&command_name, e).await), + /// Clones everything a command needs so it can run without holding the + /// server lock: commands spawn long-lived scanner subprocesses, and keeping + /// the read guard for their whole duration would block `did_change_configuration` + /// (write) and, since the lock is FIFO-fair, every request queued after it. + pub fn command_executor(&self) -> CommandExecutor + where + C: Clone, + { + CommandExecutor { + components: self.components.clone(), + interactor: self.interactor.clone(), + workspace_root: self.workspace_root.clone(), } } @@ -262,3 +325,19 @@ where Ok(()) } } + +fn workspace_root_from(initialize_params: &InitializeParams) -> Option { + let from_workspace_folders = initialize_params + .workspace_folders + .as_ref() + .and_then(|folders| folders.first()) + .and_then(|folder| folder.uri.to_file_path().ok()); + + #[allow(deprecated)] + from_workspace_folders.or_else(|| { + initialize_params + .root_uri + .as_ref() + .and_then(|uri| uri.to_file_path().ok()) + }) +} diff --git a/src/app/lsp_server/mod.rs b/src/app/lsp_server/mod.rs index 07eb3ca..20fb387 100644 --- a/src/app/lsp_server/mod.rs +++ b/src/app/lsp_server/mod.rs @@ -44,7 +44,7 @@ impl LSPServer { #[async_trait::async_trait] impl LanguageServer for LSPServer where - C: LSPClient + Send + Sync + 'static, + C: LSPClient + Clone + Send + Sync + 'static, F: ComponentFactory + Send + Sync + 'static, { async fn initialize(&self, params: InitializeParams) -> Result { @@ -80,7 +80,12 @@ where } async fn execute_command(&self, params: ExecuteCommandParams) -> Result> { - self.inner.read().await.execute_command(params).await + // Clone the command dependencies under a short-lived guard and run the + // command without holding the server lock: scans can take minutes, and + // holding the (FIFO-fair) read guard would stall every other request as + // soon as a write (did_change_configuration) queues behind it. + let executor = self.inner.read().await.command_executor(); + executor.execute_command(params).await } async fn hover(&self, params: HoverParams) -> Result> { diff --git a/src/app/lsp_server/supported_commands.rs b/src/app/lsp_server/supported_commands.rs index 807e656..c7f3be1 100644 --- a/src/app/lsp_server/supported_commands.rs +++ b/src/app/lsp_server/supported_commands.rs @@ -2,16 +2,20 @@ use std::fmt::Display; use tower_lsp::{ jsonrpc::{self, Error}, - lsp_types::{ExecuteCommandParams, Location}, + lsp_types::{ExecuteCommandParams, Location, Url}, }; const CMD_EXECUTE_SCAN: &str = "sysdig-lsp.execute-scan"; const CMD_BUILD_AND_SCAN: &str = "sysdig-lsp.execute-build-and-scan"; +const CMD_EXECUTE_IAC_SCAN: &str = "sysdig-lsp.execute-iac-scan"; +// The variants intentionally mirror the `sysdig-lsp.execute-*` command identifiers. +#[allow(clippy::enum_variant_names)] #[derive(Debug, Clone)] pub enum SupportedCommands { ExecuteBaseImageScan { location: Location, image: String }, ExecuteBuildAndScan { location: Location }, + ExecuteIacScan { uri: Option }, } impl SupportedCommands { @@ -19,12 +23,13 @@ impl SupportedCommands { match self { SupportedCommands::ExecuteBaseImageScan { .. } => CMD_EXECUTE_SCAN, SupportedCommands::ExecuteBuildAndScan { .. } => CMD_BUILD_AND_SCAN, + SupportedCommands::ExecuteIacScan { .. } => CMD_EXECUTE_IAC_SCAN, } .to_string() } pub fn all_supported_commands_as_string() -> Vec { - [CMD_EXECUTE_SCAN, CMD_BUILD_AND_SCAN] + [CMD_EXECUTE_SCAN, CMD_BUILD_AND_SCAN, CMD_EXECUTE_IAC_SCAN] .into_iter() .map(|s| s.to_string()) .collect() @@ -48,6 +53,18 @@ impl TryFrom for SupportedCommands { location: serde_json::from_value(location.clone()) .map_err(|_| Error::invalid_params("location must be a Location object"))?, }), + (CMD_EXECUTE_IAC_SCAN, []) => Ok(SupportedCommands::ExecuteIacScan { uri: None }), + (CMD_EXECUTE_IAC_SCAN, [uri]) => { + let uri = uri + .as_str() + .ok_or_else(|| Error::invalid_params("uri must be a string"))?; + let uri = Url::parse(uri) + .map_err(|e| Error::invalid_params(format!("uri must be a valid URI: {e}")))?; + Ok(SupportedCommands::ExecuteIacScan { uri: Some(uri) }) + } + (CMD_EXECUTE_IAC_SCAN, _) => { + Err(Error::invalid_params("expected at most one uri argument")) + } (other, _) => Err(Error::invalid_params(format!( "command not supported: {other}" ))), @@ -67,6 +84,79 @@ impl Display for SupportedCommands { SupportedCommands::ExecuteBuildAndScan { location } => { write!(f, "ExecuteBuildAndScan(location: {location:?})") } + SupportedCommands::ExecuteIacScan { uri } => { + write!(f, "ExecuteIacScan(uri: {uri:?})") + } + } + } +} + +#[cfg(test)] +mod tests { + use super::SupportedCommands; + use serde_json::json; + use tower_lsp::{jsonrpc, lsp_types::ExecuteCommandParams}; + + fn params(command: &str, arguments: Vec) -> ExecuteCommandParams { + ExecuteCommandParams { + command: command.to_string(), + arguments, + ..Default::default() + } + } + + #[test] + fn it_parses_iac_scan_without_arguments() { + let command: SupportedCommands = params("sysdig-lsp.execute-iac-scan", vec![]) + .try_into() + .unwrap_or_else(|e| panic!("failed to parse: {e}")); + + assert!(matches!( + command, + SupportedCommands::ExecuteIacScan { uri: None } + )); + } + + #[test] + fn it_parses_iac_scan_with_a_uri_argument() { + let command: SupportedCommands = + params("sysdig-lsp.execute-iac-scan", vec![json!("file:///a.yaml")]) + .try_into() + .unwrap_or_else(|e| panic!("failed to parse: {e}")); + + match command { + SupportedCommands::ExecuteIacScan { uri: Some(uri) } => { + assert_eq!(uri.as_str(), "file:///a.yaml") + } + other => panic!("unexpected command: {other}"), } } + + #[test] + fn it_rejects_iac_scan_with_a_non_string_argument() { + let result: Result = + params("sysdig-lsp.execute-iac-scan", vec![json!(42)]).try_into(); + + assert!(result.is_err()); + } + + #[test] + fn it_rejects_iac_scan_with_an_invalid_uri() { + let result: Result = + params("sysdig-lsp.execute-iac-scan", vec![json!("not a uri")]).try_into(); + + assert!(result.is_err()); + } + + #[test] + fn it_rejects_iac_scan_with_multiple_arguments() { + let result: Result = params( + "sysdig-lsp.execute-iac-scan", + vec![json!("file:///a.yaml"), json!("file:///b.yaml")], + ) + .try_into(); + + let err = result.expect_err("should reject multiple arguments"); + assert!(err.message.contains("at most one")); + } } diff --git a/src/app/mod.rs b/src/app/mod.rs index c53d7c2..d7eebfb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,5 +1,6 @@ pub mod component_factory; mod document_database; +mod iac_scanner; mod image_builder; mod image_scanner; mod lsp_client; @@ -9,6 +10,13 @@ mod markdown; mod queries; pub use document_database::*; +pub use iac_scanner::{IacScanError, IacScanScope, IacScanner}; + +/// `Diagnostic.source` tags identifying which scan type produced a diagnostic. +/// Each producer replaces only its own diagnostics, so different scan types +/// coexist on the same document with independent lifecycles. +pub const IAC_DIAGNOSTIC_SOURCE: &str = "sysdig-iac"; +pub const VULN_DIAGNOSTIC_SOURCE: &str = "sysdig-vuln"; pub use image_builder::{ImageBuildError, ImageBuildResult, ImageBuilder}; pub use image_scanner::{ImageScanError, ImageScanner}; pub use lsp_client::LSPClient; diff --git a/src/domain/iacscanresult/iac_finding.rs b/src/domain/iacscanresult/iac_finding.rs new file mode 100644 index 0000000..be07f44 --- /dev/null +++ b/src/domain/iacscanresult/iac_finding.rs @@ -0,0 +1,8 @@ +use crate::domain::iacscanresult::{iac_resource::IacResource, iac_severity::IacSeverity}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IacFinding { + pub name: String, + pub severity: IacSeverity, + pub resources: Vec, +} diff --git a/src/domain/iacscanresult/iac_resource.rs b/src/domain/iacscanresult/iac_resource.rs new file mode 100644 index 0000000..ece06d7 --- /dev/null +++ b/src/domain/iacscanresult/iac_resource.rs @@ -0,0 +1,12 @@ +use std::path::PathBuf; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IacResource { + /// Absolute path of the file the finding was reported on. + pub source: PathBuf, + /// Location of the resource inside the file, as reported by the scanner + /// (e.g. `spec.template.spec.containers[0]`). + pub location: String, + pub resource_type: String, + pub name: String, +} diff --git a/src/domain/iacscanresult/iac_scan_result.rs b/src/domain/iacscanresult/iac_scan_result.rs new file mode 100644 index 0000000..780e601 --- /dev/null +++ b/src/domain/iacscanresult/iac_scan_result.rs @@ -0,0 +1,6 @@ +use crate::domain::iacscanresult::iac_finding::IacFinding; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct IacScanResult { + pub findings: Vec, +} diff --git a/src/domain/iacscanresult/iac_severity.rs b/src/domain/iacscanresult/iac_severity.rs new file mode 100644 index 0000000..8b76fb5 --- /dev/null +++ b/src/domain/iacscanresult/iac_severity.rs @@ -0,0 +1,7 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IacSeverity { + High, + Medium, + Low, + Unknown, +} diff --git a/src/domain/iacscanresult/mod.rs b/src/domain/iacscanresult/mod.rs new file mode 100644 index 0000000..b748d41 --- /dev/null +++ b/src/domain/iacscanresult/mod.rs @@ -0,0 +1,4 @@ +pub mod iac_finding; +pub mod iac_resource; +pub mod iac_scan_result; +pub mod iac_severity; diff --git a/src/domain/mod.rs b/src/domain/mod.rs index dd65c0e..88c984c 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -1,2 +1,3 @@ #![allow(dead_code)] +pub mod iacscanresult; pub mod scanresult; diff --git a/src/infra/component_factory_impl.rs b/src/infra/component_factory_impl.rs index 1479c55..2fd2356 100644 --- a/src/infra/component_factory_impl.rs +++ b/src/infra/component_factory_impl.rs @@ -1,6 +1,13 @@ +use std::sync::Arc; + +use tokio::sync::Mutex; + use crate::{ app::component_factory::{ComponentFactory, ComponentFactoryError, Components, Config}, - infra::{DockerImageBuilder, SysdigAPIToken, SysdigImageScanner, connect_to_docker}, + infra::{ + DockerImageBuilder, SysdigAPIToken, SysdigImageScanner, connect_to_docker, + scanner_binary_manager::ScannerBinaryManager, sysdig_iac_scanner::SysdigIacScanner, + }, }; pub struct ConcreteComponentFactory; @@ -19,19 +26,27 @@ impl ComponentFactory for ConcreteComponentFactory { let docker_connection = connect_to_docker() .map_err(|e| ComponentFactoryError::DockerClientError(e.to_string()))?; + // Both scanners share the same binary manager so the CLI binary is installed only once + let scanner_binary_manager = Arc::new(Mutex::new(ScannerBinaryManager::default())); + // Create scanner WITH the docker_host so CLI subprocess uses the same socket let scanner = SysdigImageScanner::with_docker_host( config.sysdig.api_url.clone(), - token, + token.clone(), docker_connection.socket_path.clone(), + scanner_binary_manager.clone(), ); // Create builder with the Docker client let builder = DockerImageBuilder::new(docker_connection.client); + let iac_scanner = + SysdigIacScanner::new(config.sysdig.api_url.clone(), token, scanner_binary_manager); + Ok(Components { scanner: Box::new(scanner), builder: Box::new(builder), + iac_scanner: Box::new(iac_scanner), }) } } diff --git a/src/infra/mod.rs b/src/infra/mod.rs index 8ae00fa..140d090 100644 --- a/src/infra/mod.rs +++ b/src/infra/mod.rs @@ -5,6 +5,8 @@ mod docker_socket_discovery; mod dockerfile_ast_parser; mod k8s_manifest_ast_parser; mod scanner_binary_manager; +mod sysdig_iac_scanner; +mod sysdig_iac_scanner_json_result_v1; mod sysdig_image_scanner; mod sysdig_image_scanner_json_scan_result_v1; diff --git a/src/infra/scanner_binary_manager.rs b/src/infra/scanner_binary_manager.rs index afe59e6..b0017f9 100644 --- a/src/infra/scanner_binary_manager.rs +++ b/src/infra/scanner_binary_manager.rs @@ -35,6 +35,10 @@ pub(in crate::infra) enum ScannerBinaryManagerError { HTTPError(#[from] reqwest::Error), } +/// Exit codes of the Sysdig CLI scanner, shared by every scan mode. +pub(super) const SCANNER_EXIT_CODE_INVALID_PARAMS: i32 = 2; +pub(super) const SCANNER_EXIT_CODE_INTERNAL_ERROR: i32 = 3; + #[derive(Clone, Default)] pub(super) struct ScannerBinaryManager {} diff --git a/src/infra/sysdig_iac_scanner.rs b/src/infra/sysdig_iac_scanner.rs new file mode 100644 index 0000000..cf5e819 --- /dev/null +++ b/src/infra/sysdig_iac_scanner.rs @@ -0,0 +1,171 @@ +use std::{path::PathBuf, sync::Arc}; + +use thiserror::Error; +use tokio::{process::Command, sync::Mutex}; + +use crate::{ + app::{IacScanError, IacScanScope, IacScanner}, + domain::iacscanresult::iac_scan_result::IacScanResult, +}; + +use super::{ + scanner_binary_manager::{ + SCANNER_EXIT_CODE_INTERNAL_ERROR, SCANNER_EXIT_CODE_INVALID_PARAMS, ScannerBinaryManager, + ScannerBinaryManagerError, + }, + sysdig_iac_scanner_json_result_v1::JsonIacScanResultV1, + sysdig_image_scanner::SysdigAPIToken, +}; + +const MAX_LOGGED_REPORT_BYTES: usize = 2048; + +pub struct SysdigIacScanner { + url: String, + api_token: SysdigAPIToken, + scanner_binary_manager: Arc>, +} + +#[derive(Error, Debug)] +pub(in crate::infra) enum SysdigIacScannerError { + #[error("scanner binary manager error: {0}")] + ScannerBinaryManager(#[from] ScannerBinaryManagerError), + + #[error("error executing the command: {0}")] + CommandExecution(#[from] std::io::Error), + + #[error("error reading the IaC report at {path}: {source}")] + ReportRead { + path: PathBuf, + source: std::io::Error, + }, + + #[error("error deserializing the IaC report: {0}")] + ReportDeserialization(#[from] serde_json::Error), + + #[error("invalid parameters provided to the IaC scanner, check the URL and API Token: {0:?}")] + InvalidParametersProvided(String), + + #[error("internal scanner execution error, this is commonly a bug in the CLI scanner: {0:?}")] + InternalScannerExecutionError(String), +} + +impl From for IacScanError { + fn from(value: SysdigIacScannerError) -> Self { + match value { + SysdigIacScannerError::InvalidParametersProvided(stderr) => { + IacScanError::InvalidConfiguration(stderr) + } + other => IacScanError::InternalScannerError(Box::new(other)), + } + } +} + +impl SysdigIacScanner { + pub(super) fn new( + url: String, + api_token: SysdigAPIToken, + scanner_binary_manager: Arc>, + ) -> Self { + Self { + url, + api_token, + scanner_binary_manager, + } + } + + async fn scan( + &self, + scope: &IacScanScope, + ) -> Result { + let path_to_cli = self + .scanner_binary_manager + .lock() + .await + .install_expected_version_if_not_present() + .await?; + + // Created with O_EXCL by tempfile (no predictable-path attacks) and + // removed on drop, so failed scans don't leak files in the temp dir. + let output_file = tempfile::Builder::new() + .prefix("sysdig-lsp-iac-") + .suffix(".json") + .tempfile()?; + + let mut command = Command::new(path_to_cli); + command.arg("--iac").arg("--apiurl").arg(&self.url); + if matches!(scope, IacScanScope::Directory(_)) { + command.arg("--recursive"); + } + command + .arg("--severity-threshold") + .arg("never") + .arg("--output-json") + .arg(output_file.path()) + .arg(scope.path()) + .env("SECURE_API_TOKEN", self.api_token.0.as_str()) + // Don't leave the scanner running if the LSP request is cancelled. + .kill_on_drop(true); + + let output = command.output().await?; + + match output.status.code() { + Some(SCANNER_EXIT_CODE_INVALID_PARAMS) => { + return Err(SysdigIacScannerError::InvalidParametersProvided( + String::from_utf8_lossy(&output.stderr).to_string(), + )); + } + Some(SCANNER_EXIT_CODE_INTERNAL_ERROR) => { + return Err(SysdigIacScannerError::InternalScannerExecutionError( + String::from_utf8_lossy(&output.stderr).to_string(), + )); + } + None => { + return Err(SysdigIacScannerError::InternalScannerExecutionError( + format!( + "scanner terminated by a signal: {}", + String::from_utf8_lossy(&output.stderr) + ), + )); + } + _ => {} + }; + + let report_bytes = match tokio::fs::read(output_file.path()).await { + Ok(bytes) => bytes, + Err(e) => { + return if output.status.success() { + Err(SysdigIacScannerError::ReportRead { + path: output_file.path().to_path_buf(), + source: e, + }) + } else { + Err(SysdigIacScannerError::InternalScannerExecutionError( + String::from_utf8_lossy(&output.stderr).to_string(), + )) + }; + } + }; + + deserialize_with_debug(&report_bytes) + } +} + +#[async_trait::async_trait] +impl IacScanner for SysdigIacScanner { + async fn scan_iac(&self, scope: &IacScanScope) -> Result { + let scan = self.scan(scope).await?; + Ok(scan.into_scan_result(scope)) + } +} + +fn deserialize_with_debug(json_bytes: &[u8]) -> Result { + serde_json::from_slice(json_bytes).map_err(|e| { + let truncated = + String::from_utf8_lossy(&json_bytes[..json_bytes.len().min(MAX_LOGGED_REPORT_BYTES)]); + tracing::error!( + "Failed to deserialize IaC scanner output. Raw JSON (truncated): {}", + truncated + ); + SysdigIacScannerError::ReportDeserialization(e) + }) +} diff --git a/src/infra/sysdig_iac_scanner_json_result_v1.rs b/src/infra/sysdig_iac_scanner_json_result_v1.rs new file mode 100644 index 0000000..b0aa691 --- /dev/null +++ b/src/infra/sysdig_iac_scanner_json_result_v1.rs @@ -0,0 +1,231 @@ +use serde::Deserialize; + +use crate::app::IacScanScope; +use crate::domain::iacscanresult::{ + iac_finding::IacFinding, iac_resource::IacResource, iac_scan_result::IacScanResult, + iac_severity::IacSeverity, +}; + +#[derive(Deserialize, Debug, Default)] +pub(super) struct JsonIacScanResultV1 { + #[serde(default)] + pub result: JsonIacResult, +} + +#[derive(Deserialize, Debug, Default)] +pub(super) struct JsonIacResult { + #[serde(default)] + pub findings: Vec, +} + +#[derive(Deserialize, Debug, Default)] +pub(super) struct JsonIacFinding { + pub name: Option, + pub severity: Option, + #[serde(default)] + pub resources: Vec, +} + +#[derive(Deserialize, Debug, Default)] +pub(super) struct JsonIacResource { + pub source: Option, + pub location: Option, + #[serde(rename = "type")] + pub resource_type: Option, + pub name: Option, +} + +impl JsonIacScanResultV1 { + /// Converts the raw scanner report into the domain model, resolving each + /// resource `source` to an absolute path. + /// + /// The CLI scanner reports sources relative to the scanned root (with a + /// leading `/`) when scanning a directory, and an unreliable value when + /// scanning a single file; in the latter case every finding belongs to the + /// scanned file itself. This anti-corruption layer hides that contract from + /// the rest of the application. + pub(super) fn into_scan_result(self, scope: &IacScanScope) -> IacScanResult { + let findings = self + .result + .findings + .into_iter() + .map(|finding| IacFinding { + name: finding.name.unwrap_or_default(), + severity: parse_severity(finding.severity.as_deref().unwrap_or_default()), + resources: finding + .resources + .into_iter() + .filter_map(|resource| { + let source = resolve_source(scope, resource.source.as_deref())?; + Some(IacResource { + source, + location: resource.location.unwrap_or_default(), + resource_type: resource.resource_type.unwrap_or_default(), + name: resource.name.unwrap_or_default(), + }) + }) + .collect(), + }) + .collect(); + + IacScanResult { findings } + } +} + +fn resolve_source(scope: &IacScanScope, raw_source: Option<&str>) -> Option { + match scope { + IacScanScope::File { path, .. } => Some(path.clone()), + IacScanScope::Directory(root) => { + let raw_source = raw_source.unwrap_or_default().trim_start_matches('/'); + if raw_source.is_empty() { + // Joining an empty source would attribute the finding to the + // scanned directory itself, which editors cannot render. + tracing::warn!("skipping IaC finding resource without a source file"); + return None; + } + Some(root.join(raw_source)) + } + } +} + +fn parse_severity(severity: &str) -> IacSeverity { + match severity.to_ascii_lowercase().as_str() { + "high" => IacSeverity::High, + "medium" => IacSeverity::Medium, + "low" => IacSeverity::Low, + other => { + // Unknown severities render as the least severe diagnostic; make the + // downgrade observable in case the CLI schema ever adds new values. + tracing::warn!("unknown IaC finding severity reported by the scanner: {other:?}"); + IacSeverity::Unknown + } + } +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use super::{JsonIacScanResultV1, parse_severity}; + use crate::app::IacScanScope; + use crate::domain::iacscanresult::iac_severity::IacSeverity; + + fn parse(json: &str) -> JsonIacScanResultV1 { + serde_json::from_str(json).unwrap_or_else(|e| panic!("failed to parse: {e}")) + } + + #[test] + fn it_parses_a_representative_iac_report_scanned_recursively() { + let json = r#"{ + "result": { + "findings": [ + { + "name": "Container runs without memory limits", + "severity": "High", + "resources": [ + { + "source": "/deployment.yaml", + "location": "spec.template.spec.containers[0]", + "type": "Deployment", + "name": "nginx-deployment" + } + ] + } + ] + } + }"#; + + let scope = IacScanScope::Directory(PathBuf::from("/workspace")); + let result = parse(json).into_scan_result(&scope); + + assert_eq!(result.findings.len(), 1); + let finding = &result.findings[0]; + assert_eq!(finding.name, "Container runs without memory limits"); + assert_eq!(finding.severity, IacSeverity::High); + assert_eq!(finding.resources.len(), 1); + let resource = &finding.resources[0]; + assert_eq!(resource.source, Path::new("/workspace/deployment.yaml")); + assert_eq!(resource.location, "spec.template.spec.containers[0]"); + assert_eq!(resource.resource_type, "Deployment"); + assert_eq!(resource.name, "nginx-deployment"); + } + + #[test] + fn it_resolves_nested_sources_against_the_scanned_root() { + let json = r#"{"result":{"findings":[{"name":"x","severity":"low","resources":[ + {"source":"subdir/deploy.yaml","location":"l","type":"t","name":"n"}]}]}}"#; + + let scope = IacScanScope::Directory(PathBuf::from("/workspace")); + let result = parse(json).into_scan_result(&scope); + + assert_eq!( + result.findings[0].resources[0].source, + Path::new("/workspace/subdir/deploy.yaml") + ); + } + + #[test] + fn it_attributes_all_findings_to_the_scanned_file_in_file_scope() { + let json = r#"{"result":{"findings":[{"name":"x","severity":"low","resources":[ + {"source":"/whatever-the-cli-says","location":"l","type":"t","name":"n"}]}]}}"#; + + let scope = IacScanScope::File { + uri: "file:///deployment.yaml" + .parse() + .unwrap_or_else(|e| panic!("invalid uri: {e}")), + path: PathBuf::from("/deployment.yaml"), + }; + let result = parse(json).into_scan_result(&scope); + + assert_eq!( + result.findings[0].resources[0].source, + Path::new("/deployment.yaml") + ); + } + + #[test] + fn it_skips_resources_without_a_source_in_directory_scope() { + let json = r#"{"result":{"findings":[{"name":"x","severity":"low","resources":[ + {"location":"l","type":"t","name":"n"}, + {"source":"","location":"l","type":"t","name":"n"}]}]}}"#; + + let scope = IacScanScope::Directory(PathBuf::from("/workspace")); + let result = parse(json).into_scan_result(&scope); + + assert!(result.findings[0].resources.is_empty()); + } + + #[test] + fn it_parses_an_empty_report() { + let scope = IacScanScope::Directory(PathBuf::from("/workspace")); + let result = parse("{}").into_scan_result(&scope); + assert!(result.findings.is_empty()); + } + + #[test] + fn it_parses_findings_with_missing_fields() { + let json = + r#"{"result":{"findings":[{"severity":"weird","resources":[{"source":"/x.yaml"}]}]}}"#; + let scope = IacScanScope::Directory(PathBuf::from("/workspace")); + let result = parse(json).into_scan_result(&scope); + + assert_eq!(result.findings.len(), 1); + assert_eq!(result.findings[0].name, ""); + assert_eq!(result.findings[0].severity, IacSeverity::Unknown); + assert_eq!( + result.findings[0].resources[0].source, + Path::new("/workspace/x.yaml") + ); + assert_eq!(result.findings[0].resources[0].location, ""); + } + + #[test] + fn it_parses_severities_case_insensitively() { + assert_eq!(parse_severity("HIGH"), IacSeverity::High); + assert_eq!(parse_severity("High"), IacSeverity::High); + assert_eq!(parse_severity("medium"), IacSeverity::Medium); + assert_eq!(parse_severity("Low"), IacSeverity::Low); + assert_eq!(parse_severity("weird"), IacSeverity::Unknown); + assert_eq!(parse_severity(""), IacSeverity::Unknown); + } +} diff --git a/src/infra/sysdig_image_scanner.rs b/src/infra/sysdig_image_scanner.rs index 1a9f7c3..c678746 100644 --- a/src/infra/sysdig_image_scanner.rs +++ b/src/infra/sysdig_image_scanner.rs @@ -12,7 +12,10 @@ use crate::{ }; use super::{ - scanner_binary_manager::{ScannerBinaryManager, ScannerBinaryManagerError}, + scanner_binary_manager::{ + SCANNER_EXIT_CODE_INTERNAL_ERROR, SCANNER_EXIT_CODE_INVALID_PARAMS, ScannerBinaryManager, + ScannerBinaryManagerError, + }, sysdig_image_scanner_json_scan_result_v1::JsonScanResultV1, }; @@ -75,11 +78,17 @@ impl SysdigImageScanner { /// Creates a new scanner with a specific Docker host. /// The docker_host should be in DOCKER_HOST format (e.g., "unix:///var/run/docker.sock"). - pub fn with_docker_host(url: String, api_token: SysdigAPIToken, docker_host: String) -> Self { + /// The scanner binary manager is shared so every scanner reuses the same CLI binary installation. + pub(super) fn with_docker_host( + url: String, + api_token: SysdigAPIToken, + docker_host: String, + scanner_binary_manager: Arc>, + ) -> Self { Self { url, api_token, - scanner_binary_manager: Default::default(), + scanner_binary_manager, docker_host: Some(docker_host), } } @@ -118,20 +127,30 @@ impl SysdigImageScanner { let output = Command::new(path_to_cli) .args(args) .envs(env_vars) + // Don't leave the scanner running if the LSP request is cancelled. + .kill_on_drop(true) .output() .await?; - match output.status.code().unwrap_or(0) { - 2 => { + match output.status.code() { + Some(SCANNER_EXIT_CODE_INVALID_PARAMS) => { return Err(SysdigImageScannerError::InvalidParametersProvided( String::from_utf8_lossy(&output.stderr).to_string(), )); } - 3 => { + Some(SCANNER_EXIT_CODE_INTERNAL_ERROR) => { return Err(SysdigImageScannerError::InternalScannerExecutionError( String::from_utf8_lossy(&output.stderr).to_string(), )); } + None => { + return Err(SysdigImageScannerError::InternalScannerExecutionError( + format!( + "scanner terminated by a signal: {}", + String::from_utf8_lossy(&output.stderr) + ), + )); + } _ => {} }; diff --git a/tests/common.rs b/tests/common.rs index b743ea0..c324594 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -4,18 +4,21 @@ use tokio::sync::Mutex; use mockall::mock; use sysdig_lsp::{ app::{ - ImageBuildError, ImageBuildResult, ImageBuilder, ImageScanError, ImageScanner, LSPServer, + IacScanError, IacScanScope, IacScanner, ImageBuildError, ImageBuildResult, ImageBuilder, + ImageScanError, ImageScanner, LSPServer, component_factory::{ComponentFactory, ComponentFactoryError, Components, Config}, }, - domain::scanresult::scan_result::ScanResult, + domain::{iacscanresult::iac_scan_result::IacScanResult, scanresult::scan_result::ScanResult}, }; use tower_lsp::lsp_types::{Diagnostic, MessageType}; // --- Contenido de recorder.rs --- +pub type PublishedDiagnostics = Vec<(String, Vec)>; + #[derive(Clone)] pub struct TestClientRecorder { pub messages: Arc>>, - pub diagnostics: Arc>>>, + pub diagnostics: Arc>, } impl TestClientRecorder { @@ -48,11 +51,14 @@ impl sysdig_lsp::app::LSPClient for TestClientRecorder { async fn publish_diagnostics( &self, - _url: &str, + url: &str, diagnostics: Vec, _version: Option, ) { - self.diagnostics.lock().await.push(diagnostics); + self.diagnostics + .lock() + .await + .push((url.to_string(), diagnostics)); } } @@ -73,11 +79,21 @@ mock! { } } +mock! { + pub IacScanner {} + #[async_trait::async_trait] + impl IacScanner for IacScanner { + async fn scan_iac(&self, scope: &IacScanScope) -> Result; + } +} + // --- Implementaciones de traits para Arc> --- #[derive(Clone)] pub struct MockImageBuilderWrapper(pub Arc>); #[derive(Clone)] pub struct MockImageScannerWrapper(pub Arc>); +#[derive(Clone)] +pub struct MockIacScannerWrapper(pub Arc>); #[async_trait::async_trait] impl ImageBuilder for MockImageBuilderWrapper { @@ -96,11 +112,19 @@ impl ImageScanner for MockImageScannerWrapper { } } +#[async_trait::async_trait] +impl IacScanner for MockIacScannerWrapper { + async fn scan_iac(&self, scope: &IacScanScope) -> Result { + self.0.lock().await.scan_iac(scope).await + } +} + // --- Estructuras de Setup --- #[derive(Clone)] pub struct MockComponentFactory { pub image_builder: Arc>, pub image_scanner: Arc>, + pub iac_scanner: Arc>, } impl ComponentFactory for MockComponentFactory { @@ -108,6 +132,7 @@ impl ComponentFactory for MockComponentFactory { Ok(Components { builder: Box::new(MockImageBuilderWrapper(self.image_builder.clone())), scanner: Box::new(MockImageScannerWrapper(self.image_scanner.clone())), + iac_scanner: Box::new(MockIacScannerWrapper(self.iac_scanner.clone())), }) } } @@ -124,6 +149,7 @@ impl TestSetup { let component_factory = MockComponentFactory { image_builder: Arc::new(Mutex::new(MockImageBuilder::new())), image_scanner: Arc::new(Mutex::new(MockImageScanner::new())), + iac_scanner: Arc::new(Mutex::new(MockIacScanner::new())), }; let server = LSPServer::new(client_recorder.clone(), component_factory.clone()); Self { diff --git a/tests/general.rs b/tests/general.rs index f4393d9..5a3c292 100644 --- a/tests/general.rs +++ b/tests/general.rs @@ -35,6 +35,35 @@ async fn initialized_server() -> TestSetup { setup } +#[rstest] +#[tokio::test] +async fn test_initialize_advertises_all_supported_commands() { + let setup = TestSetup::new(); + let params = InitializeParams { + initialization_options: Some(serde_json::json!({ + "sysdig": { "apiUrl": "http://localhost:8080", "api_token": "dummy-token" } + })), + ..Default::default() + }; + let result = setup.server.initialize(params).await.unwrap(); + + let advertised = result + .capabilities + .execute_command_provider + .expect("executeCommand capability must be advertised") + .commands; + for command in [ + "sysdig-lsp.execute-scan", + "sysdig-lsp.execute-build-and-scan", + "sysdig-lsp.execute-iac-scan", + ] { + assert!( + advertised.iter().any(|c| c == command), + "clients gate executeCommand on advertised capabilities; missing: {command}" + ); + } +} + #[rstest] #[awt] #[tokio::test] @@ -334,7 +363,7 @@ async fn test_execute_command( .lock() .await; assert_eq!(diagnostics.len(), 1); - let diagnostic = &diagnostics[0][0]; + let diagnostic = &diagnostics[0].1[0]; assert_eq!( diagnostic.message, "Vulnerabilities found for alpine: 0 Critical, 1 High, 0 Medium, 0 Low, 0 Negligible" @@ -464,6 +493,17 @@ async fn test_k8s_manifest_code_lens(#[future] initialized_server: TestSetup) { let result_json = serde_json::to_value(result).unwrap(); let expected_json = serde_json::json!([ + { + "command": { + "arguments": ["file:///deployment.yaml"], + "command": "sysdig-lsp.execute-iac-scan", + "title": "Scan IaC file" + }, + "range": { + "end": { "character": 0, "line": 0 }, + "start": { "character": 0, "line": 0 } + } + }, { "command": { "arguments": [ @@ -488,3 +528,1072 @@ async fn test_k8s_manifest_code_lens(#[future] initialized_server: TestSetup) { assert_eq!(result_json, expected_json); } + +use std::path::PathBuf; +use sysdig_lsp::app::{IacScanError, IacScanScope}; +use sysdig_lsp::domain::iacscanresult::{ + iac_finding::IacFinding, iac_resource::IacResource, iac_scan_result::IacScanResult, + iac_severity::IacSeverity, +}; +use tower_lsp::lsp_types::MessageType; + +fn file_scope(uri: &str, path: &str) -> IacScanScope { + IacScanScope::File { + uri: uri.parse().unwrap(), + path: PathBuf::from(path), + } +} + +fn iac_finding_for(source: &str, name: &str) -> IacFinding { + IacFinding { + name: name.to_string(), + severity: IacSeverity::High, + resources: vec![IacResource { + source: PathBuf::from(source), + location: "spec.template.spec.containers[0]".to_string(), + resource_type: "Deployment".to_string(), + name: "nginx-deployment".to_string(), + }], + } +} + +fn execute_iac_scan_params(arguments: Vec) -> ExecuteCommandParams { + ExecuteCommandParams { + command: "sysdig-lsp.execute-iac-scan".to_string(), + arguments, + work_done_progress_params: WorkDoneProgressParams::default(), + } +} + +fn last_published_diagnostics_for<'a>( + published: &'a [(String, Vec)], + url: &str, +) -> Option<&'a Vec> { + published + .iter() + .rev() + .find(|(u, _)| u == url) + .map(|(_, d)| d) +} + +#[fixture] +#[awt] +async fn server_with_open_k8s_manifest(#[future] initialized_server: TestSetup) -> TestSetup { + initialized_server + .server + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem::new( + "file:///deployment.yaml".parse().unwrap(), + "yaml".to_string(), + 1, + include_str!("fixtures/k8s-deployment.yaml").to_string(), + ), + }) + .await; + initialized_server +} + +#[rstest] +#[awt] +#[tokio::test] +async fn test_execute_iac_scan_for_single_file(#[future] server_with_open_k8s_manifest: TestSetup) { + let scan_result = IacScanResult { + findings: vec![iac_finding_for( + "/deployment.yaml", + "Container runs without memory limits", + )], + }; + server_with_open_k8s_manifest + .component_factory + .iac_scanner + .lock() + .await + .expect_scan_iac() + .withf(|scope| *scope == file_scope("file:///deployment.yaml", "/deployment.yaml")) + .times(1) + .returning(move |_| Ok(scan_result.clone())); + + server_with_open_k8s_manifest + .client_recorder + .diagnostics + .lock() + .await + .clear(); + + let result = server_with_open_k8s_manifest + .server + .execute_command(execute_iac_scan_params(vec![json!( + "file:///deployment.yaml" + )])) + .await; + assert!(result.is_ok()); + + let diagnostics = server_with_open_k8s_manifest + .client_recorder + .diagnostics + .lock() + .await; + let diags_for_file = last_published_diagnostics_for(&diagnostics, "file:///deployment.yaml") + .expect("no diagnostics published for the scanned file"); + + assert_eq!(diags_for_file.len(), 1); + let diagnostic = &diags_for_file[0]; + assert_eq!( + diagnostic.message, + "Container runs without memory limits: spec.template.spec.containers[0] (Deployment: nginx-deployment)" + ); + assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR)); + assert_eq!( + diagnostic.range, + Range::new(Position::new(0, 0), Position::new(0, 0)) + ); + assert_eq!(diagnostic.source.as_deref(), Some("sysdig-iac")); +} + +#[rstest] +#[tokio::test] +async fn test_execute_iac_scan_for_workspace_publishes_multiple_files() { + let setup = TestSetup::new(); + let params = InitializeParams { + initialization_options: Some(serde_json::json!({ + "sysdig": { + "apiUrl": "http://localhost:8080", + "api_token": "dummy-token" + } + })), + workspace_folders: Some(vec![tower_lsp::lsp_types::WorkspaceFolder { + uri: "file:///workspace".parse().unwrap(), + name: "workspace".to_string(), + }]), + ..Default::default() + }; + assert!(setup.server.initialize(params).await.is_ok()); + + let scan_result = IacScanResult { + findings: vec![ + iac_finding_for("/workspace/a.yaml", "Finding in a"), + iac_finding_for("/workspace/subdir/b.yaml", "Finding in b"), + ], + }; + setup + .component_factory + .iac_scanner + .lock() + .await + .expect_scan_iac() + .withf(|scope| *scope == IacScanScope::Directory(PathBuf::from("/workspace"))) + .times(1) + .returning(move |_| Ok(scan_result.clone())); + + let result = setup + .server + .execute_command(execute_iac_scan_params(vec![])) + .await; + assert!(result.is_ok()); + + let diagnostics = setup.client_recorder.diagnostics.lock().await; + let diags_a = last_published_diagnostics_for(&diagnostics, "file:///workspace/a.yaml") + .expect("no diagnostics for a.yaml"); + let diags_b = last_published_diagnostics_for(&diagnostics, "file:///workspace/subdir/b.yaml") + .expect("no diagnostics for subdir/b.yaml"); + + assert_eq!(diags_a.len(), 1); + assert!(diags_a[0].message.starts_with("Finding in a")); + assert_eq!(diags_b.len(), 1); + assert!(diags_b[0].message.starts_with("Finding in b")); +} + +#[rstest] +#[awt] +#[tokio::test] +async fn test_iac_rescan_clears_stale_diagnostics( + #[future] server_with_open_k8s_manifest: TestSetup, +) { + let scan_result = IacScanResult { + findings: vec![iac_finding_for("/deployment.yaml", "Stale finding")], + }; + { + let mut scanner = server_with_open_k8s_manifest + .component_factory + .iac_scanner + .lock() + .await; + scanner + .expect_scan_iac() + .times(1) + .returning(move |_| Ok(scan_result.clone())); + scanner + .expect_scan_iac() + .times(1) + .returning(|_| Ok(IacScanResult::default())); + } + + let first = server_with_open_k8s_manifest + .server + .execute_command(execute_iac_scan_params(vec![json!( + "file:///deployment.yaml" + )])) + .await; + assert!(first.is_ok()); + let second = server_with_open_k8s_manifest + .server + .execute_command(execute_iac_scan_params(vec![json!( + "file:///deployment.yaml" + )])) + .await; + assert!(second.is_ok()); + + let diagnostics = server_with_open_k8s_manifest + .client_recorder + .diagnostics + .lock() + .await; + let last = last_published_diagnostics_for(&diagnostics, "file:///deployment.yaml") + .expect("no diagnostics published for the scanned file"); + assert!( + last.is_empty(), + "stale IaC diagnostics were not cleared: {last:?}" + ); +} + +#[rstest] +#[awt] +#[tokio::test] +async fn test_iac_scan_preserves_image_scan_diagnostics( + #[future] server_with_open_k8s_manifest: TestSetup, + scan_result: ScanResult, +) { + // First, an image scan on the same document produces a vulnerability diagnostic + server_with_open_k8s_manifest + .component_factory + .image_scanner + .lock() + .await + .expect_scan_image() + .times(1) + .returning(move |_| Ok(scan_result.clone())); + + let image_scan = server_with_open_k8s_manifest + .server + .execute_command(ExecuteCommandParams { + command: "sysdig-lsp.execute-scan".to_string(), + arguments: vec![ + json!({"range":{"end":{"character":25,"line":10},"start":{"character":15,"line":10}},"uri":"file:///deployment.yaml"}), + json!("nginx:1.19"), + ], + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .await; + assert!(image_scan.is_ok()); + + // Then an IaC scan on the same file + let iac_result = IacScanResult { + findings: vec![iac_finding_for("/deployment.yaml", "IaC finding")], + }; + server_with_open_k8s_manifest + .component_factory + .iac_scanner + .lock() + .await + .expect_scan_iac() + .times(1) + .returning(move |_| Ok(iac_result.clone())); + + let iac_scan = server_with_open_k8s_manifest + .server + .execute_command(execute_iac_scan_params(vec![json!( + "file:///deployment.yaml" + )])) + .await; + assert!(iac_scan.is_ok()); + + let diagnostics = server_with_open_k8s_manifest + .client_recorder + .diagnostics + .lock() + .await; + let last = last_published_diagnostics_for(&diagnostics, "file:///deployment.yaml") + .expect("no diagnostics published"); + + let sources: Vec<_> = last.iter().filter_map(|d| d.source.as_deref()).collect(); + assert!( + sources.contains(&"sysdig-vuln") && sources.contains(&"sysdig-iac"), + "both scan types must coexist on the same document, got: {sources:?}" + ); +} + +#[rstest] +#[awt] +#[tokio::test] +async fn test_execute_iac_scan_shows_error_when_scanner_fails( + #[future] server_with_open_k8s_manifest: TestSetup, +) { + server_with_open_k8s_manifest + .component_factory + .iac_scanner + .lock() + .await + .expect_scan_iac() + .times(1) + .returning(|_| Err(IacScanError::InternalScannerError("boom".into()))); + + let result = server_with_open_k8s_manifest + .server + .execute_command(execute_iac_scan_params(vec![json!( + "file:///deployment.yaml" + )])) + .await; + assert!(result.is_err()); + + let messages = server_with_open_k8s_manifest + .client_recorder + .messages + .lock() + .await; + assert!( + messages + .iter() + .any(|(t, m)| *t == MessageType::ERROR && m.contains("boom")), + "expected an ERROR message to be shown to the client, got: {messages:?}" + ); +} + +#[rstest] +#[awt] +#[tokio::test] +async fn test_execute_iac_scan_without_workspace_root_fails( + #[future] initialized_server: TestSetup, +) { + let result = initialized_server + .server + .execute_command(execute_iac_scan_params(vec![])) + .await; + + let err = result.expect_err("should fail without a workspace root"); + assert!(err.message.contains("no workspace root")); +} + +#[rstest] +#[awt] +#[tokio::test] +async fn test_iac_lens_is_offered_even_if_image_parsing_fails( + #[future] initialized_server: TestSetup, +) { + let compose_url: Url = "file:///docker-compose.yml".parse().unwrap(); + initialized_server + .server + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem::new( + compose_url.clone(), + "yaml".to_string(), + 1, + "services: [broken".to_string(), + ), + }) + .await; + + let result = initialized_server + .server + .code_lens(tower_lsp::lsp_types::CodeLensParams { + text_document: TextDocumentIdentifier::new(compose_url), + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .unwrap(); + + // The IaC scan doesn't need parseable image instructions: the CLI scanner + // parses the file itself, so the lens survives image parse failures. + assert_eq!(result.len(), 1); + let lens = serde_json::to_value(&result[0]).unwrap(); + assert_eq!(lens["command"]["command"], "sysdig-lsp.execute-iac-scan"); +} + +#[rstest] +#[awt] +#[tokio::test] +async fn test_iac_scan_aggregates_multiple_findings_on_the_same_file( + #[future] server_with_open_k8s_manifest: TestSetup, +) { + let scan_result = IacScanResult { + findings: vec![ + iac_finding_for("/deployment.yaml", "First finding"), + iac_finding_for("/deployment.yaml", "Second finding"), + ], + }; + server_with_open_k8s_manifest + .component_factory + .iac_scanner + .lock() + .await + .expect_scan_iac() + .times(1) + .returning(move |_| Ok(scan_result.clone())); + + let result = server_with_open_k8s_manifest + .server + .execute_command(execute_iac_scan_params(vec![json!( + "file:///deployment.yaml" + )])) + .await; + assert!(result.is_ok()); + + let diagnostics = server_with_open_k8s_manifest + .client_recorder + .diagnostics + .lock() + .await; + let last = last_published_diagnostics_for(&diagnostics, "file:///deployment.yaml") + .expect("no diagnostics published"); + + assert_eq!(last.len(), 2); + let messages: Vec<_> = last.iter().map(|d| d.message.as_str()).collect(); + assert!(messages[0].starts_with("First finding")); + assert!(messages[1].starts_with("Second finding")); +} + +#[rstest] +#[awt] +#[tokio::test] +async fn test_execute_iac_scan_maps_invalid_configuration_to_invalid_params( + #[future] server_with_open_k8s_manifest: TestSetup, +) { + server_with_open_k8s_manifest + .component_factory + .iac_scanner + .lock() + .await + .expect_scan_iac() + .times(1) + .returning(|_| Err(IacScanError::InvalidConfiguration("bad token".to_string()))); + + let result = server_with_open_k8s_manifest + .server + .execute_command(execute_iac_scan_params(vec![json!( + "file:///deployment.yaml" + )])) + .await; + + let err = result.expect_err("should fail with invalid configuration"); + assert_eq!(err.code, tower_lsp::jsonrpc::ErrorCode::InvalidParams); +} + +#[rstest] +#[awt] +#[tokio::test] +async fn test_execute_iac_scan_rejects_non_file_uris(#[future] initialized_server: TestSetup) { + let result = initialized_server + .server + .execute_command(execute_iac_scan_params(vec![json!( + "https://example.com/deployment.yaml" + )])) + .await; + + let err = result.expect_err("should reject non-file URIs"); + assert!(err.message.contains("only file:// URIs are supported")); +} + +#[rstest] +#[tokio::test] +async fn test_workspace_root_falls_back_to_root_uri() { + let setup = TestSetup::new(); + #[allow(deprecated)] + let params = InitializeParams { + initialization_options: Some(serde_json::json!({ + "sysdig": { "apiUrl": "http://localhost:8080", "api_token": "dummy-token" } + })), + root_uri: Some("file:///workspace".parse().unwrap()), + ..Default::default() + }; + assert!(setup.server.initialize(params).await.is_ok()); + + setup + .component_factory + .iac_scanner + .lock() + .await + .expect_scan_iac() + .withf(|scope| *scope == IacScanScope::Directory(PathBuf::from("/workspace"))) + .times(1) + .returning(|_| Ok(IacScanResult::default())); + + let result = setup + .server + .execute_command(execute_iac_scan_params(vec![])) + .await; + assert!(result.is_ok()); +} + +#[rstest] +#[awt] +#[tokio::test] +async fn test_image_rescan_preserves_iac_diagnostics( + #[future] server_with_open_k8s_manifest: TestSetup, + scan_result: ScanResult, +) { + // IaC scan first + let iac_result = IacScanResult { + findings: vec![iac_finding_for("/deployment.yaml", "IaC finding")], + }; + server_with_open_k8s_manifest + .component_factory + .iac_scanner + .lock() + .await + .expect_scan_iac() + .times(1) + .returning(move |_| Ok(iac_result.clone())); + + let iac_scan = server_with_open_k8s_manifest + .server + .execute_command(execute_iac_scan_params(vec![json!( + "file:///deployment.yaml" + )])) + .await; + assert!(iac_scan.is_ok()); + + // Image scan afterwards must not wipe the IaC findings + server_with_open_k8s_manifest + .component_factory + .image_scanner + .lock() + .await + .expect_scan_image() + .times(1) + .returning(move |_| Ok(scan_result.clone())); + + let image_scan = server_with_open_k8s_manifest + .server + .execute_command(ExecuteCommandParams { + command: "sysdig-lsp.execute-scan".to_string(), + arguments: vec![ + json!({"range":{"end":{"character":25,"line":10},"start":{"character":15,"line":10}},"uri":"file:///deployment.yaml"}), + json!("nginx:1.19"), + ], + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .await; + assert!(image_scan.is_ok()); + + let diagnostics = server_with_open_k8s_manifest + .client_recorder + .diagnostics + .lock() + .await; + let last = last_published_diagnostics_for(&diagnostics, "file:///deployment.yaml") + .expect("no diagnostics published"); + + let sources: Vec<_> = last.iter().filter_map(|d| d.source.as_deref()).collect(); + assert!( + sources.contains(&"sysdig-vuln") && sources.contains(&"sysdig-iac"), + "image rescan must preserve IaC diagnostics, got: {sources:?}" + ); +} + +#[rstest] +#[tokio::test] +async fn test_workspace_rescan_clears_files_dropped_from_the_report() { + let setup = TestSetup::new(); + let params = InitializeParams { + initialization_options: Some(serde_json::json!({ + "sysdig": { "apiUrl": "http://localhost:8080", "api_token": "dummy-token" } + })), + workspace_folders: Some(vec![tower_lsp::lsp_types::WorkspaceFolder { + uri: "file:///workspace".parse().unwrap(), + name: "workspace".to_string(), + }]), + ..Default::default() + }; + assert!(setup.server.initialize(params).await.is_ok()); + + { + let mut scanner = setup.component_factory.iac_scanner.lock().await; + let first_result = IacScanResult { + findings: vec![ + iac_finding_for("/workspace/a.yaml", "Finding in a"), + iac_finding_for("/workspace/b.yaml", "Finding in b"), + ], + }; + scanner + .expect_scan_iac() + .times(1) + .returning(move |_| Ok(first_result.clone())); + let second_result = IacScanResult { + findings: vec![iac_finding_for("/workspace/b.yaml", "Finding in b")], + }; + scanner + .expect_scan_iac() + .times(1) + .returning(move |_| Ok(second_result.clone())); + } + + for _ in 0..2 { + let result = setup + .server + .execute_command(execute_iac_scan_params(vec![])) + .await; + assert!(result.is_ok()); + } + + let diagnostics = setup.client_recorder.diagnostics.lock().await; + let last_a = last_published_diagnostics_for(&diagnostics, "file:///workspace/a.yaml") + .expect("a.yaml should have received a clearing publish"); + assert!( + last_a.is_empty(), + "findings for a file dropped from the report must be cleared: {last_a:?}" + ); + let last_b = last_published_diagnostics_for(&diagnostics, "file:///workspace/b.yaml") + .expect("no diagnostics for b.yaml"); + assert_eq!(last_b.len(), 1); +} + +#[rstest] +#[awt] +#[tokio::test] +async fn test_document_edit_preserves_iac_diagnostics_and_clears_vuln_ones( + #[future] server_with_open_k8s_manifest: TestSetup, + scan_result: ScanResult, +) { + // Produce one IaC and one vulnerability diagnostic on the same document + let iac_result = IacScanResult { + findings: vec![iac_finding_for("/deployment.yaml", "IaC finding")], + }; + server_with_open_k8s_manifest + .component_factory + .iac_scanner + .lock() + .await + .expect_scan_iac() + .times(1) + .returning(move |_| Ok(iac_result.clone())); + server_with_open_k8s_manifest + .component_factory + .image_scanner + .lock() + .await + .expect_scan_image() + .times(1) + .returning(move |_| Ok(scan_result.clone())); + + assert!( + server_with_open_k8s_manifest + .server + .execute_command(execute_iac_scan_params(vec![json!( + "file:///deployment.yaml" + )])) + .await + .is_ok() + ); + assert!( + server_with_open_k8s_manifest + .server + .execute_command(ExecuteCommandParams { + command: "sysdig-lsp.execute-scan".to_string(), + arguments: vec![ + json!({"range":{"end":{"character":25,"line":10},"start":{"character":15,"line":10}},"uri":"file:///deployment.yaml"}), + json!("nginx:1.19"), + ], + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .await + .is_ok() + ); + + // Editing the document goes through the full lifecycle + server_with_open_k8s_manifest + .server + .did_change(DidChangeTextDocumentParams { + text_document: VersionedTextDocumentIdentifier::new( + "file:///deployment.yaml".parse().unwrap(), + 2, + ), + content_changes: vec![tower_lsp::lsp_types::TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: "apiVersion: v1\nkind: Pod\n".to_string(), + }], + }) + .await; + + let diagnostics = server_with_open_k8s_manifest + .client_recorder + .diagnostics + .lock() + .await; + let last = last_published_diagnostics_for(&diagnostics, "file:///deployment.yaml") + .expect("no diagnostics published"); + + let sources: Vec<_> = last.iter().filter_map(|d| d.source.as_deref()).collect(); + assert!( + sources.contains(&"sysdig-iac"), + "IaC diagnostics anchor at the top of the file and must survive edits: {sources:?}" + ); + assert!( + !sources.contains(&"sysdig-vuln"), + "vulnerability diagnostics anchor to lines and must be cleared on edits: {sources:?}" + ); +} + +#[rstest] +#[tokio::test] +async fn test_workspace_rescan_preserves_findings_outside_the_scanned_root() { + let setup = TestSetup::new(); + let params = InitializeParams { + initialization_options: Some(serde_json::json!({ + "sysdig": { "apiUrl": "http://localhost:8080", "api_token": "dummy-token" } + })), + workspace_folders: Some(vec![tower_lsp::lsp_types::WorkspaceFolder { + uri: "file:///workspace".parse().unwrap(), + name: "workspace".to_string(), + }]), + ..Default::default() + }; + assert!(setup.server.initialize(params).await.is_ok()); + + { + let mut scanner = setup.component_factory.iac_scanner.lock().await; + // File-scoped scan of a file outside the workspace root. The path shares + // the root as a string prefix ("/workspace-other" vs "/workspace") to pin + // the trailing-slash boundary of the prefix-scoped clearing. + let outside_result = IacScanResult { + findings: vec![iac_finding_for( + "/workspace-other/x.yaml", + "Outside finding", + )], + }; + scanner + .expect_scan_iac() + .withf(|scope| matches!(scope, IacScanScope::File { .. })) + .times(1) + .returning(move |_| Ok(outside_result.clone())); + // Workspace scan afterwards returns nothing + scanner + .expect_scan_iac() + .withf(|scope| matches!(scope, IacScanScope::Directory(_))) + .times(1) + .returning(|_| Ok(IacScanResult::default())); + } + + assert!( + setup + .server + .execute_command(execute_iac_scan_params(vec![json!( + "file:///workspace-other/x.yaml" + )])) + .await + .is_ok() + ); + assert!( + setup + .server + .execute_command(execute_iac_scan_params(vec![])) + .await + .is_ok() + ); + + let diagnostics = setup.client_recorder.diagnostics.lock().await; + let last = last_published_diagnostics_for(&diagnostics, "file:///workspace-other/x.yaml") + .expect("no diagnostics for the outside file"); + assert_eq!( + last.len(), + 1, + "a workspace scan must not clear findings outside its root: {last:?}" + ); +} + +/// IacScanner double that blocks inside scan_iac until released, to observe the +/// server while a command is in flight. +#[derive(Clone)] +struct BlockingIacScanner { + started: std::sync::Arc, + release: std::sync::Arc, +} + +#[async_trait::async_trait] +impl sysdig_lsp::app::IacScanner for BlockingIacScanner { + async fn scan_iac( + &self, + _scope: &IacScanScope, + ) -> Result { + self.started.notify_one(); + self.release.notified().await; + Ok(IacScanResult::default()) + } +} + +#[derive(Clone)] +struct BlockingComponentFactory { + iac_scanner: BlockingIacScanner, +} + +impl sysdig_lsp::app::component_factory::ComponentFactory for BlockingComponentFactory { + fn create_components( + &self, + _config: sysdig_lsp::app::component_factory::Config, + ) -> Result< + sysdig_lsp::app::component_factory::Components, + sysdig_lsp::app::component_factory::ComponentFactoryError, + > { + Ok(sysdig_lsp::app::component_factory::Components { + scanner: Box::new(common::MockImageScannerWrapper(std::sync::Arc::new( + tokio::sync::Mutex::new(common::MockImageScanner::new()), + ))), + builder: Box::new(common::MockImageBuilderWrapper(std::sync::Arc::new( + tokio::sync::Mutex::new(common::MockImageBuilder::new()), + ))), + iac_scanner: Box::new(self.iac_scanner.clone()), + }) + } +} + +#[rstest] +#[tokio::test] +async fn test_commands_run_without_holding_the_server_lock() { + use std::sync::Arc; + use std::time::Duration; + + let started = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let recorder = common::TestClientRecorder::new(); + let server = Arc::new(sysdig_lsp::app::LSPServer::new( + recorder.clone(), + BlockingComponentFactory { + iac_scanner: BlockingIacScanner { + started: started.clone(), + release: release.clone(), + }, + }, + )); + + let init = InitializeParams { + initialization_options: Some(serde_json::json!({ + "sysdig": { "apiUrl": "http://localhost:8080", "api_token": "dummy-token" } + })), + ..Default::default() + }; + assert!(server.initialize(init).await.is_ok()); + server + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem::new( + "file:///deployment.yaml".parse().unwrap(), + "yaml".to_string(), + 1, + include_str!("fixtures/k8s-deployment.yaml").to_string(), + ), + }) + .await; + + let command_server = server.clone(); + let command = tokio::spawn(async move { + command_server + .execute_command(execute_iac_scan_params(vec![json!( + "file:///deployment.yaml" + )])) + .await + }); + started.notified().await; + + // While the scan is in flight, a write-lock operation and a read operation + // must both complete: the command must not hold the (FIFO-fair) server lock. + tokio::time::timeout( + Duration::from_secs(1), + server.did_change_configuration(DidChangeConfigurationParams { + settings: serde_json::json!({ + "sysdig": { "apiUrl": "http://localhost:8080", "api_token": "dummy-token" } + }), + }), + ) + .await + .expect("did_change_configuration deadlocked behind a running command"); + + tokio::time::timeout( + Duration::from_secs(1), + server.code_lens(tower_lsp::lsp_types::CodeLensParams { + text_document: TextDocumentIdentifier::new("file:///deployment.yaml".parse().unwrap()), + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }), + ) + .await + .expect("code_lens deadlocked behind a running command") + .expect("code_lens failed"); + + release.notify_one(); + let result = command.await.expect("command task panicked"); + assert!(result.is_ok()); +} + +#[rstest] +#[tokio::test] +async fn test_workspace_scan_never_publishes_findings_escaping_the_root() { + let setup = TestSetup::new(); + let params = InitializeParams { + initialization_options: Some(serde_json::json!({ + "sysdig": { "apiUrl": "http://localhost:8080", "api_token": "dummy-token" } + })), + workspace_folders: Some(vec![tower_lsp::lsp_types::WorkspaceFolder { + uri: "file:///workspace".parse().unwrap(), + name: "workspace".to_string(), + }]), + ..Default::default() + }; + assert!(setup.server.initialize(params).await.is_ok()); + + // A finding whose source escapes the scanned root (e.g. via `..`) must be + // dropped: inserting it outside the cleared scope would accumulate duplicates + // on every rescan. + let scan_result = IacScanResult { + findings: vec![ + iac_finding_for("/outside/x.yaml", "Escaping finding"), + iac_finding_for("/workspace/ok.yaml", "In-root finding"), + ], + }; + setup + .component_factory + .iac_scanner + .lock() + .await + .expect_scan_iac() + .times(2) + .returning(move |_| Ok(scan_result.clone())); + + for _ in 0..2 { + let result = setup + .server + .execute_command(execute_iac_scan_params(vec![])) + .await; + assert!(result.is_ok()); + } + + let diagnostics = setup.client_recorder.diagnostics.lock().await; + assert!( + diagnostics + .iter() + .all(|(url, _)| url != "file:///outside/x.yaml"), + "findings escaping the scanned root must never be published" + ); + let in_root = last_published_diagnostics_for(&diagnostics, "file:///workspace/ok.yaml") + .expect("no diagnostics for the in-root file"); + assert_eq!(in_root.len(), 1, "rescans must not accumulate duplicates"); +} + +#[rstest] +#[tokio::test] +async fn test_execute_command_on_uninitialized_server_surfaces_the_error() { + let setup = TestSetup::new(); + + let result = setup + .server + .execute_command(execute_iac_scan_params(vec![json!("file:///a.yaml")])) + .await; + + let err = result.expect_err("commands must fail before initialization"); + assert!(err.message.contains("LSP not initialized")); + + let messages = setup.client_recorder.messages.lock().await; + assert!( + messages + .iter() + .any(|(t, m)| *t == MessageType::ERROR && m.contains("LSP not initialized")), + "the error must be surfaced to the user via showMessage: {messages:?}" + ); +} + +#[rstest] +#[tokio::test] +async fn test_workspace_scan_drops_findings_with_relative_sources() { + let setup = TestSetup::new(); + let params = InitializeParams { + initialization_options: Some(serde_json::json!({ + "sysdig": { "apiUrl": "http://localhost:8080", "api_token": "dummy-token" } + })), + workspace_folders: Some(vec![tower_lsp::lsp_types::WorkspaceFolder { + uri: "file:///workspace".parse().unwrap(), + name: "workspace".to_string(), + }]), + ..Default::default() + }; + assert!(setup.server.initialize(params).await.is_ok()); + + // A relative source cannot be turned into a file URI: the finding is dropped + // (with a warning) instead of being published under a broken URI. + let scan_result = IacScanResult { + findings: vec![iac_finding_for("relative.yaml", "Relative finding")], + }; + setup + .component_factory + .iac_scanner + .lock() + .await + .expect_scan_iac() + .times(1) + .returning(move |_| Ok(scan_result.clone())); + + let result = setup + .server + .execute_command(execute_iac_scan_params(vec![])) + .await; + assert!(result.is_ok()); + + let diagnostics = setup.client_recorder.diagnostics.lock().await; + assert!( + diagnostics + .iter() + .all(|(_, diags)| diags.iter().all(|d| !d.message.contains("Relative"))), + "findings with relative sources must not be published: {diagnostics:?}" + ); +} + +#[rstest] +#[awt] +#[tokio::test] +async fn test_compose_code_lens(#[future] initialized_server: TestSetup) { + let compose_url: Url = "file:///docker-compose.yml".parse().unwrap(); + initialized_server + .server + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem::new( + compose_url.clone(), + "yaml".to_string(), + 1, + include_str!("fixtures/docker-compose.yml").to_string(), + ), + }) + .await; + + let result = initialized_server + .server + .code_lens(tower_lsp::lsp_types::CodeLensParams { + text_document: TextDocumentIdentifier::new(compose_url), + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .unwrap(); + + let lenses = serde_json::to_value(result).unwrap(); + let lenses = lenses.as_array().unwrap(); + + // First lens: whole-file IaC scan + assert_eq!( + lenses[0]["command"]["command"], + "sysdig-lsp.execute-iac-scan" + ); + assert_eq!(lenses[0]["command"]["title"], "Scan IaC file"); + assert_eq!( + lenses[0]["command"]["arguments"], + json!(["file:///docker-compose.yml"]) + ); + + // Then one image scan lens per compose image + let images: Vec<_> = lenses[1..] + .iter() + .map(|l| { + assert_eq!(l["command"]["command"], "sysdig-lsp.execute-scan"); + l["command"]["arguments"][1].as_str().unwrap().to_owned() + }) + .collect(); + assert_eq!(images, vec!["nginx:latest", "postgres:13"]); +}