From e217abc2085b298c14cef94896e2bedd94857cb8 Mon Sep 17 00:00:00 2001 From: Fede Barcelona Date: Wed, 15 Jul 2026 15:18:57 +0200 Subject: [PATCH] feat(iac): scan Infrastructure-as-Code files for misconfigurations --- AGENTS.md | 18 +- Cargo.lock | 22 +- Cargo.toml | 3 +- README.md | 33 +- docs/features/README.md | 6 + docs/features/iac_scan.md | 64 + docs/features/iac_scan.png | Bin 0 -> 91597 bytes docs/roadmap.md | 73 ++ src/app/component_factory.rs | 3 +- src/app/document_database.rs | 303 ++++- src/app/iac_scanner.rs | 44 + src/app/lsp_interactor.rs | 57 +- src/app/lsp_server/command_generator.rs | 37 +- src/app/lsp_server/commands/build_and_scan.rs | 26 +- src/app/lsp_server/commands/iac_scan.rs | 179 +++ src/app/lsp_server/commands/mod.rs | 3 + .../lsp_server/commands/scan_base_image.rs | 15 +- src/app/lsp_server/lsp_server_inner.rs | 215 +++- src/app/lsp_server/mod.rs | 9 +- src/app/lsp_server/supported_commands.rs | 94 +- src/app/mod.rs | 8 + src/domain/iacscanresult/iac_finding.rs | 8 + src/domain/iacscanresult/iac_resource.rs | 12 + src/domain/iacscanresult/iac_scan_result.rs | 6 + src/domain/iacscanresult/iac_severity.rs | 7 + src/domain/iacscanresult/mod.rs | 4 + src/domain/mod.rs | 1 + src/infra/component_factory_impl.rs | 19 +- src/infra/mod.rs | 2 + src/infra/scanner_binary_manager.rs | 4 + src/infra/sysdig_iac_scanner.rs | 171 +++ .../sysdig_iac_scanner_json_result_v1.rs | 231 ++++ src/infra/sysdig_image_scanner.rs | 31 +- tests/common.rs | 36 +- tests/general.rs | 1111 ++++++++++++++++- 35 files changed, 2693 insertions(+), 162 deletions(-) create mode 100644 docs/features/iac_scan.md create mode 100644 docs/features/iac_scan.png create mode 100644 docs/roadmap.md create mode 100644 src/app/iac_scanner.rs create mode 100644 src/app/lsp_server/commands/iac_scan.rs create mode 100644 src/domain/iacscanresult/iac_finding.rs create mode 100644 src/domain/iacscanresult/iac_resource.rs create mode 100644 src/domain/iacscanresult/iac_scan_result.rs create mode 100644 src/domain/iacscanresult/iac_severity.rs create mode 100644 src/domain/iacscanresult/mod.rs create mode 100644 src/infra/sysdig_iac_scanner.rs create mode 100644 src/infra/sysdig_iac_scanner_json_result_v1.rs 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 0000000000000000000000000000000000000000..be0632ebfee10bfb366498bb4dacd14eb54db94f GIT binary patch literal 91597 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

1jX8KBxQr*)6T+jssH3j?dlao?g0c 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, 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"]); +}