diff --git a/Cargo.lock b/Cargo.lock index 4d47184..aad955d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -110,9 +110,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "auto_impl" -version = "1.2.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e12882f59de5360c748c4cbf569a042d5fb0eb515f7bea9c1f470b47f6ffbd73" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", @@ -216,9 +216,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" -version = "1.2.17" +version = "1.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fcb57c740ae1daf453ae85f16e37396f672b039e00d9d866e07ddb24e328e3a" +checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362" dependencies = [ "shlex", ] @@ -246,9 +246,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.35" +version = "4.5.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8aa86934b44c19c50f87cc2790e19f54f7a67aedb64101c2e1a2e5ecfb73944" +checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" dependencies = [ "clap_builder", "clap_derive", @@ -256,9 +256,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.35" +version = "4.5.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2414dbb2dd0695280da6ea9261e327479e9d37b0630f6b53ba2a11c60c679fd9" +checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" dependencies = [ "anstream", "anstyle", @@ -321,9 +321,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.4.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28cfac68e08048ae1883171632c2aef3ebc555621ae56fbccce1cbf22dd7f058" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" dependencies = [ "powerfmt", "serde", @@ -384,9 +384,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" dependencies = [ "libc", "windows-sys 0.59.0", @@ -570,9 +570,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "h2" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5017294ff4bb30944501348f6f8e42e6ad28f42c8bbef7a74029aff064a4e3c2" +checksum = "75249d144030531f8dee69fe9cea04d3edf809a017ae445e2abdff6629e86633" dependencies = [ "atomic-waker", "bytes", @@ -580,7 +580,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.8.0", + "indexmap 2.9.0", "slab", "tokio", "tokio-util", @@ -943,9 +943,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.8.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3954d50fe15b02142bf25d3b8bdadb634ec3948f103d04ffe3031bc8fe9d7058" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", "hashbrown 0.15.2", @@ -997,9 +997,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.171" +version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" [[package]] name = "libredox" @@ -1014,9 +1014,9 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe7db12097d22ec582439daf8618b8fdd1a7bef6270e9af3b1ebcd30893cf413" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" [[package]] name = "litemap" @@ -1067,9 +1067,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "miniz_oxide" -version = "0.8.5" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" +checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" dependencies = [ "adler2", ] @@ -1144,9 +1144,9 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "openssl" -version = "0.10.71" +version = "0.10.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e14130c6a98cd258fdcb0fb6d744152343ff729cbfcb28c656a9d12b999fbcd" +checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" dependencies = [ "bitflags 2.9.0", "cfg-if", @@ -1176,9 +1176,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" -version = "0.9.106" +version = "0.9.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb61ea9811cc39e3c2069f40b8b8e2e70d8569b361f879786cc7ed48b777cdd" +checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07" dependencies = [ "cc", "libc", @@ -1282,9 +1282,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.94" +version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" dependencies = [ "unicode-ident", ] @@ -1306,13 +1306,12 @@ checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" [[package]] name = "rand" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" dependencies = [ "rand_chacha", "rand_core", - "zerocopy", ] [[package]] @@ -1336,9 +1335,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.10" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" +checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" dependencies = [ "bitflags 2.9.0", ] @@ -1462,9 +1461,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.25" +version = "0.23.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "822ee9188ac4ec04a2f0531e55d035fb2de73f18b41a63c70c2712503b6fb13c" +checksum = "df51b5869f3a441595eac5e8ff14d486ff285f7b8c0df8770e49c3b56351f0f0" dependencies = [ "once_cell", "rustls-pki-types", @@ -1513,9 +1512,9 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "scc" -version = "2.3.3" +version = "2.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea091f6cac2595aa38993f04f4ee692ed43757035c36e67c180b6828356385b1" +checksum = "22b2d775fb28f245817589471dd49c5edf64237f4a19d10ce9a92ff4651a27f4" dependencies = [ "sdd", ] @@ -1635,7 +1634,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.8.0", + "indexmap 2.9.0", "serde", "serde_derive", "serde_json", @@ -1685,9 +1684,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" dependencies = [ "libc", ] @@ -1703,9 +1702,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" +checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" [[package]] name = "socket2" @@ -1768,7 +1767,7 @@ dependencies = [ [[package]] name = "sysdig-lsp" -version = "0.4.1" +version = "0.5.0" dependencies = [ "async-trait", "bollard", @@ -1913,9 +1912,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.44.1" +version = "1.44.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" +checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" dependencies = [ "backtrace", "bytes", diff --git a/Cargo.toml b/Cargo.toml index fa4fb77..6918c73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sysdig-lsp" -version = "0.4.1" +version = "0.5.0" edition = "2024" authors = [ "Sysdig Inc." ] readme = "README.md" diff --git a/README.md b/README.md index ead0c8b..616c8a7 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ helping you detect vulnerabilities and misconfigurations earlier in the developm | Scan base image in Dockerfile | Supported | [Supported](./docs/features/scan_base_image.md) (0.1.0+) | | Code lens support | Supported | [Supported](./docs/features/code_lens.md) (0.2.0+) | | Build and Scan Dockerfile | Supported | [Supported](./docs/features/build_and_scan.md) (0.4.0+) | -| Layered image analysis | Supported | In roadmap | +| Layered image analysis | Supported | [Supported](./docs/features/layered_analysis.md) (0.5.0+)| | Docker-compose image analysis | Supported | In roadmap | | K8s Manifest image analysis | Supported | In roadmap | | Infrastructure-as-code analysis | Supported | In roadmap | diff --git a/docs/features/README.md b/docs/features/README.md index 443e96b..fe99a0c 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -14,4 +14,8 @@ Sysdig LSP provides tools to integrate container security checks into your devel - Builds and scans the entire final Dockerfile image used in production. - Supports multi-stage Dockerfiles, analyzing final stage and explicitly copied artifacts from intermediate stages. +## [Layered Analysis](./layered_analysis.md) +- Scans each Dockerfile layer individually for precise vulnerability identification. +- Supports detailed analysis in single-stage and multi-stage Dockerfiles. + See the linked documents for more details. diff --git a/docs/features/layered_analysis.gif b/docs/features/layered_analysis.gif new file mode 100644 index 0000000..cf3b035 Binary files /dev/null and b/docs/features/layered_analysis.gif differ diff --git a/docs/features/layered_analysis.md b/docs/features/layered_analysis.md new file mode 100644 index 0000000..ceb261f --- /dev/null +++ b/docs/features/layered_analysis.md @@ -0,0 +1,36 @@ +# Layered Analysis + +Sysdig LSP provides Layered Analysis to scan each layer created by your Dockerfile instructions individually. +This helps you quickly identify and remediate vulnerabilities introduced at specific steps, optimizing your container security. + +> [!IMPORTANT] +> In multi-stage Dockerfiles, layers of the final runtime stage are analyzed individually. +> Intermediate stages are only considered if their layers or artifacts are explicitly copied into the final runtime stage. + +![Sysdig LSP performing Layered Analysis](./layered_analysis.gif) + +## Examples + +### Single-stage Dockerfile (fully analyzed) + +```dockerfile +FROM ubuntu:22.04 +RUN apt-get update && apt-get install -y python3 +COPY ./app /app +RUN pip install -r /app/requirements.txt +``` +In this Dockerfile, Sysdig LSP individually scans each layer, identifying exactly which step introduces vulnerabilities. + +### Multi-stage Dockerfile (layer-focused analysis) + +```dockerfile +# Intermediate build stage (layers scanned only if copied) +FROM node:18-alpine AS build +RUN npm install && npm run build + +# Final runtime stage (all layers analyzed individually) +FROM nginx:alpine +COPY --from=build /dist /usr/share/nginx/html +RUN apk add --no-cache curl +``` +Here, Sysdig LSP individually scans every layer of the final runtime stage (`nginx:alpine`). Layers from the intermediate stage (`node:18-alpine`) are scanned only if their artifacts are explicitly copied to the final stage. diff --git a/flake.lock b/flake.lock index bc6b4d7..5d42af7 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1743568003, - "narHash": "sha256-ZID5T65E8ruHqWRcdvZLsczWDOAWIE7om+vQOREwiX0=", + "lastModified": 1744868846, + "narHash": "sha256-5RJTdUHDmj12Qsv7XOhuospjAjATNiTMElplWnJE9Hs=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "b7ba7f9f45c5cd0d8625e9e217c28f8eb6a19a76", + "rev": "ebe4301cbd8f81c4f8d3244b3632338bbeb6d49c", "type": "github" }, "original": { diff --git a/src/app/commands.rs b/src/app/commands.rs index 4158302..93bb70d 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -8,8 +8,11 @@ use tower_lsp::{ lsp_types::{Diagnostic, DiagnosticSeverity, MessageType, Position, Range}, }; +use crate::infra::parse_dockerfile; + use super::{ - ImageBuilder, ImageScanner, InMemoryDocumentDatabase, LSPClient, lsp_server::WithContext, + ImageBuilder, ImageScanResult, ImageScanner, InMemoryDocumentDatabase, LSPClient, + LayerScanResult, VulnSeverity, lsp_server::WithContext, }; pub struct CommandExecutor { @@ -126,10 +129,14 @@ where }; if scan_result.has_vulnerabilities() { - let v = &scan_result.vulnerabilities; diagnostic.message = format!( "Vulnerabilities found for {}: {} Critical, {} High, {} Medium, {} Low, {} Negligible", - image_for_selected_line, v.critical, v.high, v.medium, v.low, v.negligible + image_for_selected_line, + scan_result.count_vulns_of_severity(VulnSeverity::Critical), + scan_result.count_vulns_of_severity(VulnSeverity::High), + scan_result.count_vulns_of_severity(VulnSeverity::Medium), + scan_result.count_vulns_of_severity(VulnSeverity::Low), + scan_result.count_vulns_of_severity(VulnSeverity::Negligible), ); diagnostic.severity = Some(if scan_result.is_compliant { @@ -203,42 +210,8 @@ where ) .await; - let diagnostic = { - let range_for_selected_line = Range::new( - Position::new(line, 0), - Position::new( - line, - document_text - .lines() - .nth(line as usize) - .map(|x| x.len() as u32) - .unwrap_or(u32::MAX), - ), - ); - - let mut diagnostic = Diagnostic { - range: range_for_selected_line, - severity: Some(DiagnosticSeverity::HINT), - message: "No vulnerabilities found.".to_owned(), - ..Default::default() - }; - - if scan_result.has_vulnerabilities() { - let v = &scan_result.vulnerabilities; - diagnostic.message = format!( - "Vulnerabilities found for Dockerfile in {}: {} Critical, {} High, {} Medium, {} Low, {} Negligible", - uri_without_file_path, v.critical, v.high, v.medium, v.low, v.negligible - ); - - diagnostic.severity = Some(if scan_result.is_compliant { - DiagnosticSeverity::INFORMATION - } else { - DiagnosticSeverity::ERROR - }); - } - - diagnostic - }; + let diagnostic = diagnostic_for_image(line, &document_text, &scan_result); + let diagnostics_per_layer = diagnostics_for_layers(&document_text, &scan_result)?; self.document_database .remove_diagnostics(uri.to_str().unwrap()) @@ -246,6 +219,131 @@ where self.document_database .append_document_diagnostics(uri.to_str().unwrap(), &[diagnostic]) .await; + self.document_database + .append_document_diagnostics(uri.to_str().unwrap(), &diagnostics_per_layer) + .await; self.publish_all_diagnostics().await } } + +pub fn diagnostics_for_layers( + document_text: &str, + scan_result: &ImageScanResult, +) -> Result> { + let instructions = parse_dockerfile(document_text); + let layers = &scan_result.layers; + + let mut instr_idx = instructions.len().checked_sub(1); + let mut layer_idx = layers.len().checked_sub(1); + + let mut diagnostics = Vec::new(); + + while let (Some(i), Some(l)) = (instr_idx, layer_idx) { + let instr = &instructions[i]; + let layer = &layers[l]; + + if instr.keyword == "FROM" { + break; + } + + instr_idx = instr_idx.and_then(|x| x.checked_sub(1)); + layer_idx = layer_idx.and_then(|x| x.checked_sub(1)); + + if layer.has_vulnerabilities() { + let msg = format!( + "Vulnerabilities found in layer: {} Critical, {} High, {} Medium, {} Low, {} Negligible", + layer.count_vulns_of_severity(VulnSeverity::Critical), + layer.count_vulns_of_severity(VulnSeverity::High), + layer.count_vulns_of_severity(VulnSeverity::Medium), + layer.count_vulns_of_severity(VulnSeverity::Low), + layer.count_vulns_of_severity(VulnSeverity::Negligible), + ); + let diagnostic = Diagnostic { + range: instr.range, + severity: Some(DiagnosticSeverity::WARNING), + message: msg, + ..Default::default() + }; + + diagnostics.push(diagnostic); + + fill_vulnerability_hints_for_layer(layer, instr.range, &mut diagnostics) + } + } + + Ok(diagnostics) +} + +fn fill_vulnerability_hints_for_layer( + layer: &LayerScanResult, + range: Range, + diagnostics: &mut Vec, +) { + let vulnerability_types = [ + VulnSeverity::Critical, + VulnSeverity::High, + VulnSeverity::Medium, + VulnSeverity::Low, + VulnSeverity::Negligible, + ]; + + let vulns_per_severity = vulnerability_types + .iter() + .flat_map(|sev| layer.vulnerabilities.iter().filter(|l| l.severity == *sev)); + + // TODO(fede): eventually we would want to add here a .take() to truncate the number + // of vulnerabilities shown as hint per layer. + vulns_per_severity.for_each(|vuln| { + let url = format!("https://nvd.nist.gov/vuln/detail/{}", vuln.id); + diagnostics.push(Diagnostic { + range, + severity: Some(DiagnosticSeverity::HINT), + message: format!("Vulnerability: {} ({:?}) {}", vuln.id, vuln.severity, url), + ..Default::default() + }); + }); +} + +fn diagnostic_for_image( + line: u32, + document_text: &str, + scan_result: &ImageScanResult, +) -> Diagnostic { + let range_for_selected_line = Range::new( + Position::new(line, 0), + Position::new( + line, + document_text + .lines() + .nth(line as usize) + .map(|x| x.len() as u32) + .unwrap_or(u32::MAX), + ), + ); + + let mut diagnostic = Diagnostic { + range: range_for_selected_line, + severity: Some(DiagnosticSeverity::HINT), + message: "No vulnerabilities found.".to_owned(), + ..Default::default() + }; + + if scan_result.has_vulnerabilities() { + diagnostic.message = format!( + "Total vulnerabilities found: {} Critical, {} High, {} Medium, {} Low, {} Negligible", + scan_result.count_vulns_of_severity(VulnSeverity::Critical), + scan_result.count_vulns_of_severity(VulnSeverity::High), + scan_result.count_vulns_of_severity(VulnSeverity::Medium), + scan_result.count_vulns_of_severity(VulnSeverity::Low), + scan_result.count_vulns_of_severity(VulnSeverity::Negligible), + ); + + diagnostic.severity = Some(if scan_result.is_compliant { + DiagnosticSeverity::INFORMATION + } else { + DiagnosticSeverity::ERROR + }); + } + + diagnostic +} diff --git a/src/app/image_scanner.rs b/src/app/image_scanner.rs index ad1ddaf..6fbbc29 100644 --- a/src/app/image_scanner.rs +++ b/src/app/image_scanner.rs @@ -7,20 +7,62 @@ pub trait ImageScanner { async fn scan_image(&self, image_pull_string: &str) -> Result; } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub struct ImageScanResult { - pub vulnerabilities: Vulnerabilities, + pub vulnerabilities: Vec, pub is_compliant: bool, + pub layers: Vec, } impl ImageScanResult { + pub fn count_vulns_of_severity(&self, severity: VulnSeverity) -> usize { + self.vulnerabilities + .iter() + .filter(|v| v.severity == severity) + .count() + } + + pub fn has_vulnerabilities(&self) -> bool { + !self.vulnerabilities.is_empty() + } +} + +#[derive(Clone, Debug)] +pub struct VulnerabilityEntry { + pub id: String, + pub severity: VulnSeverity, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VulnSeverity { + Critical, + High, + Medium, + Low, + Negligible, +} + +#[derive(Clone, Debug)] +pub struct LayerScanResult { + pub layer_instruction: String, + pub layer_text: String, + pub vulnerabilities: Vec, +} + +impl LayerScanResult { + pub fn count_vulns_of_severity(&self, severity: VulnSeverity) -> usize { + self.vulnerabilities + .iter() + .filter(|v| v.severity == severity) + .count() + } + pub fn has_vulnerabilities(&self) -> bool { - let v = &self.vulnerabilities; - v.critical > 0 || v.high > 0 || v.medium > 0 || v.low > 0 || v.negligible > 0 + !self.vulnerabilities.is_empty() } } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Default)] pub struct Vulnerabilities { pub critical: usize, pub high: usize, diff --git a/src/app/lsp_server.rs b/src/app/lsp_server.rs index 7ff4aa6..33c895f 100644 --- a/src/app/lsp_server.rs +++ b/src/app/lsp_server.rs @@ -144,7 +144,7 @@ where } async fn did_change(&self, params: DidChangeTextDocumentParams) { - if let Some(change) = params.content_changes.into_iter().last() { + if let Some(change) = params.content_changes.into_iter().next_back() { self.command_executor .update_document_with_text(params.text_document.uri.as_str(), &change.text) .await; diff --git a/src/app/mod.rs b/src/app/mod.rs index 2969b17..c0944b7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -9,6 +9,9 @@ mod queries; pub use document_database::*; pub use image_builder::{ImageBuildError, ImageBuildResult, ImageBuilder}; -pub use image_scanner::{ImageScanError, ImageScanResult, ImageScanner, Vulnerabilities}; +pub use image_scanner::{ + ImageScanError, ImageScanResult, ImageScanner, LayerScanResult, VulnSeverity, Vulnerabilities, + VulnerabilityEntry, +}; pub use lsp_client::LSPClient; pub use lsp_server::LSPServer; diff --git a/src/infra/docker_image_builder.rs b/src/infra/docker_image_builder.rs index 7e29200..71906d6 100644 --- a/src/infra/docker_image_builder.rs +++ b/src/infra/docker_image_builder.rs @@ -4,7 +4,6 @@ use bollard::{Docker, image::BuildImageOptions, secret::BuildInfo}; use bytes::Bytes; use futures::StreamExt; use thiserror::Error; -use tracing::info; use crate::app::{ImageBuildError, ImageBuildResult, ImageBuilder}; @@ -55,20 +54,23 @@ impl DockerImageBuilder { .and_then(|osstr| osstr.to_str()) .unwrap(), t: image_name.as_str(), - rm: true, + // rm: true, + // forcerm: true, ..Default::default() }, None, Some(Bytes::from_owner(tar_contents)), ); + let mut build_info = Err(DockerImageBuilderError::Generic( + "image was built, but no id was detected, this should have never happened".to_string(), + )); while let Some(result) = results.next().await { match result { Ok(BuildInfo { aux, .. }) if aux.is_some() => { let image_id = aux.unwrap().id.unwrap(); - info!("image built: {}", &image_id); - return Ok(ImageBuildResult { - image_name, + build_info = Ok(ImageBuildResult { + image_name: image_name.clone(), image_id, }); } @@ -77,9 +79,7 @@ impl DockerImageBuilder { } } - Err(DockerImageBuilderError::Generic( - "image was built, but no id was detected, this should have never happened".to_string(), - )) + build_info } async fn pack_containerfile_dir_into_a_tar( diff --git a/src/infra/dockerfile_ast_parser.rs b/src/infra/dockerfile_ast_parser.rs new file mode 100644 index 0000000..620f04f --- /dev/null +++ b/src/infra/dockerfile_ast_parser.rs @@ -0,0 +1,261 @@ +use tower_lsp::lsp_types::{Position, Range}; + +#[derive(Debug, PartialEq, Eq)] +pub struct Instruction { + pub keyword: String, + pub arguments: Vec, + pub arguments_str: String, + pub comment: Option, + pub range: Range, +} + +pub fn parse_dockerfile(contents: &str) -> Vec { + let lines: Vec<&str> = contents.lines().collect(); + let mut instructions = Vec::new(); + + let mut current_line_iteration = 0; + while current_line_iteration < lines.len() { + if lines[current_line_iteration].trim().is_empty() { + current_line_iteration += 1; + continue; + } + + let start_line = current_line_iteration; + let start_column = lines[current_line_iteration] + .find(|c: char| !c.is_whitespace()) + .unwrap_or(0); + + let mut aggregated_trimmed = lines[current_line_iteration].trim().to_string(); + let mut raw_instruction = String::new(); + raw_instruction.push_str(lines[current_line_iteration]); + + let mut end_line = current_line_iteration; + + while raw_instruction.trim_end().ends_with('\\') { + if raw_instruction.ends_with('\\') { + raw_instruction.pop(); + } + aggregated_trimmed.pop(); + current_line_iteration += 1; + if current_line_iteration >= lines.len() { + break; + } + + aggregated_trimmed.push(' '); + aggregated_trimmed.push_str(lines[current_line_iteration].trim()); + raw_instruction.push(' '); + raw_instruction.push_str(lines[current_line_iteration]); + end_line = current_line_iteration; + } + + let end_column = lines[end_line].trim_end().len(); + let range = Range::new( + Position::new( + start_line.try_into().unwrap(), + start_column.try_into().unwrap(), + ), + Position::new(end_line.try_into().unwrap(), end_column.try_into().unwrap()), + ); + let (actual_instruction, comment) = match aggregated_trimmed.split_once("#") { + Some((instr, comm)) => (instr, Some(comm.trim().to_string())), + None => (aggregated_trimmed.as_str(), None), + }; + + let (raw_instruction_without_comment, _) = match raw_instruction.split_once("#") { + Some((instr, _)) => (instr, ()), + None => (raw_instruction.as_str(), ()), + }; + + let trimmed_actual = actual_instruction.trim_start(); + let keyword_end = trimmed_actual + .find(char::is_whitespace) + .unwrap_or(trimmed_actual.len()); + let keyword = trimmed_actual[..keyword_end].to_uppercase(); + + let raw_trimmed = raw_instruction_without_comment.trim_start(); + let mut parts = raw_trimmed.splitn(2, char::is_whitespace); + let _ = parts.next().unwrap(); + let arguments_str = parts.next().unwrap_or("").to_string(); + + let arguments: Vec = trimmed_actual[keyword_end..] + .split_whitespace() + .map(String::from) + .collect(); + + instructions.push(Instruction { + keyword, + arguments, + arguments_str, + comment, + range, + }); + current_line_iteration += 1; + } + + instructions +} + +#[cfg(test)] +mod tests { + use tower_lsp::lsp_types::{Position, Range}; + + use crate::infra::dockerfile_ast_parser::Instruction; + + use super::parse_dockerfile; + + #[test] + fn it_parses_a_basic_dockerfile() { + let dockerfile = "FROM alpine"; + + let instructions = parse_dockerfile(dockerfile); + + assert_eq!( + instructions, + vec![Instruction { + keyword: "FROM".to_string(), + arguments: ["alpine".to_string()].to_vec(), + arguments_str: "alpine".to_string(), + comment: None, + range: Range::new(Position::new(0, 0), Position::new(0, 11)), + }] + ); + } + + #[test] + fn it_parses_a_multiline_dockerfile() { + let dockerfile = r#"FROM ubuntu:20.04 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + wget \ + ca-certificates \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* +"#; + + let instructions = parse_dockerfile(dockerfile); + + assert_eq!( + instructions, + vec![ + Instruction { + keyword: "FROM".to_string(), + arguments: ["ubuntu:20.04".to_string()].to_vec(), + arguments_str: "ubuntu:20.04".to_string(), + comment: None, + range: Range::new(Position::new(0, 0), Position::new(0,17)), + }, + Instruction { + keyword: "RUN".to_string(), + arguments: [ + "apt-get", + "update", + "&&", + "apt-get", + "install", + "-y", + "--no-install-recommends", + "curl", + "wget", + "ca-certificates", + "&&", + "apt-get", + "clean", + "&&", + "rm", + "-rf", + "/var/lib/apt/lists/*" + ] + .into_iter() + .map(ToString::to_string) + .collect(), + arguments_str: "apt-get update && apt-get install -y --no-install-recommends curl wget ca-certificates && apt-get clean && rm -rf /var/lib/apt/lists/*".to_string(), + comment: None, + range: Range::new(Position::new(2, 0), Position::new(7,31)), + } + ] + ); + } + + #[test] + fn it_parses_a_comprehensive_dockerfile() { + // This test checks multiple instructions with inline comments and multiline instructions. + let dockerfile = r#"FROM ubuntu:20.04 # Use Ubuntu 20.04 as base image + +RUN apt-get update && apt-get install -y --no-install-recommends \ +curl \ +wget \ +git \ +&& rm -rf /var/lib/apt/lists/* # Clean up apt caches + +CMD ["echo", "Hello, world!"] # Print greeting +"#; + let instructions = parse_dockerfile(dockerfile); + let expected = vec![ + Instruction { + keyword: "FROM".to_string(), + arguments: vec!["ubuntu:20.04".to_string()], + arguments_str: "ubuntu:20.04 ".to_string(), + comment: Some("Use Ubuntu 20.04 as base image".to_string()), + range: Range { + start: Position { + line: 0, + character: 0, + }, + end: Position { + line: 0, + character: 52, + }, + }, + }, + Instruction { + keyword: "RUN".to_string(), + arguments: vec![ + "apt-get".to_string(), + "update".to_string(), + "&&".to_string(), + "apt-get".to_string(), + "install".to_string(), + "-y".to_string(), + "--no-install-recommends".to_string(), + "curl".to_string(), + "wget".to_string(), + "git".to_string(), + "&&".to_string(), + "rm".to_string(), + "-rf".to_string(), + "/var/lib/apt/lists/*".to_string(), + ], + arguments_str: "apt-get update && apt-get install -y --no-install-recommends curl wget git && rm -rf /var/lib/apt/lists/* ".to_string(), + comment: Some("Clean up apt caches".to_string()), + range: Range { + start: Position { + line: 2, + character: 0, + }, + end: Position { + line: 6, + character: 54, + }, + }, + }, + Instruction { + keyword: "CMD".to_string(), + arguments: ["[\"echo\",".to_string(), "\"Hello,".to_string(), "world!\"]".to_string()].to_vec(), + arguments_str: "[\"echo\", \"Hello, world!\"] ".to_string(), + comment: Some("Print greeting".to_string()), + range: Range { + start: Position { + line: 8, + character: 0, + }, + end: Position { + line: 8, + character: 48, + }, + }, + }, + ]; + assert_eq!(instructions, expected); + } +} diff --git a/src/infra/mod.rs b/src/infra/mod.rs index b25a885..3dc34ae 100644 --- a/src/infra/mod.rs +++ b/src/infra/mod.rs @@ -1,4 +1,5 @@ mod docker_image_builder; +mod dockerfile_ast_parser; mod scanner_binary_manager; mod sysdig_image_scanner; mod sysdig_image_scanner_result; @@ -6,3 +7,4 @@ mod sysdig_image_scanner_result; pub use sysdig_image_scanner::{SysdigAPIToken, SysdigImageScanner}; pub mod lsp_logger; pub use docker_image_builder::DockerImageBuilder; +pub use dockerfile_ast_parser::{Instruction, parse_dockerfile}; diff --git a/src/infra/sysdig_image_scanner.rs b/src/infra/sysdig_image_scanner.rs index b8a92ba..58c43af 100644 --- a/src/infra/sysdig_image_scanner.rs +++ b/src/infra/sysdig_image_scanner.rs @@ -2,19 +2,15 @@ use std::{fmt::Display, sync::Arc}; -use itertools::Itertools; use serde::Deserialize; use thiserror::Error; use tokio::{process::Command, sync::Mutex}; -use crate::{ - app::{ImageScanError, ImageScanResult, ImageScanner, Vulnerabilities}, - infra::sysdig_image_scanner_result::PoliciesGlobalEvaluation, -}; +use crate::app::{ImageScanError, ImageScanResult, ImageScanner}; use super::{ scanner_binary_manager::{ScannerBinaryManager, ScannerBinaryManagerError}, - sysdig_image_scanner_result::{SysdigImageScannerReport, VulnSeverity}, + sysdig_image_scanner_result::SysdigImageScannerReport, }; #[derive(Clone)] @@ -126,50 +122,7 @@ impl SysdigImageScanner { #[async_trait::async_trait] impl ImageScanner for SysdigImageScanner { async fn scan_image(&self, image_pull_string: &str) -> Result { - let report = self.scan(image_pull_string).await?; - - let vuln_count_by_severity = report - .result - .as_ref() - .and_then(|e| e.vulnerabilities.as_ref()) - .map(|vulns| vulns.values()) - .into_iter() - .flatten() - .counts_by(|e| &e.severity); - - let is_compliant = report - .result - .as_ref() - .and_then(|result| result.policies.as_ref()) - .and_then(|policies| policies.global_evaluation.as_ref()) - .map(|global_evaluation| global_evaluation == &PoliciesGlobalEvaluation::Accepted) - .unwrap_or(false); - - Ok(ImageScanResult { - vulnerabilities: Vulnerabilities { - critical: vuln_count_by_severity - .get(&VulnSeverity::Critical) - .cloned() - .unwrap_or_default(), - high: vuln_count_by_severity - .get(&VulnSeverity::High) - .cloned() - .unwrap_or_default(), - medium: vuln_count_by_severity - .get(&VulnSeverity::Medium) - .cloned() - .unwrap_or_default(), - low: vuln_count_by_severity - .get(&VulnSeverity::Low) - .cloned() - .unwrap_or_default(), - negligible: vuln_count_by_severity - .get(&VulnSeverity::Negligible) - .cloned() - .unwrap_or_default(), - }, - is_compliant, - }) + Ok(self.scan(image_pull_string).await?.into()) } } @@ -178,7 +131,7 @@ impl ImageScanner for SysdigImageScanner { mod tests { use lazy_static::lazy_static; - use crate::app::ImageScanner; + use crate::app::{ImageScanner, VulnSeverity}; use super::{SysdigAPIToken, SysdigImageScanner}; @@ -206,13 +159,18 @@ mod tests { let scanner = SysdigImageScanner::new(SYSDIG_SECURE_URL.clone(), SYSDIG_SECURE_TOKEN.clone()); - let report = scanner.scan_image("ubuntu:22.04").await.unwrap(); + let report = scanner + .scan_image( + "ubuntu@sha256:a76d0e9d99f0e91640e35824a6259c93156f0f07b7778ba05808c750e7fa6e68", + ) + .await + .unwrap(); - assert!(report.vulnerabilities.critical == 0); - assert!(report.vulnerabilities.high == 0); - assert!(report.vulnerabilities.medium >= 15); - assert!(report.vulnerabilities.low >= 32); - assert!(report.vulnerabilities.negligible >= 7); + assert!(report.count_vulns_of_severity(VulnSeverity::Critical) == 0); + assert!(report.count_vulns_of_severity(VulnSeverity::High) == 0); + assert!(report.count_vulns_of_severity(VulnSeverity::Medium) >= 9); + assert!(report.count_vulns_of_severity(VulnSeverity::Low) >= 28); + assert!(report.count_vulns_of_severity(VulnSeverity::Negligible) >= 3); assert!(!report.is_compliant); } } diff --git a/src/infra/sysdig_image_scanner_result.rs b/src/infra/sysdig_image_scanner_result.rs index 7c3d08c..57e052a 100644 --- a/src/infra/sysdig_image_scanner_result.rs +++ b/src/infra/sysdig_image_scanner_result.rs @@ -1,9 +1,107 @@ #![allow(dead_code)] use chrono::{DateTime, NaiveDate, Utc}; +use itertools::Itertools; use serde::Deserialize; use std::collections::HashMap; +use crate::app::{self, ImageScanResult, LayerScanResult, VulnerabilityEntry}; + +impl From for ImageScanResult { + fn from(report: SysdigImageScannerReport) -> Self { + let vulnerabilities = report + .result + .as_ref() + .and_then(|r| r.vulnerabilities.as_ref()) + .map(|map| { + map.values() + .map(|v| VulnerabilityEntry { + id: v.name.clone(), + severity: severity_for(&v.severity), + }) + .collect::>() + }) + .unwrap_or_default(); + + let is_compliant = report + .result + .as_ref() + .and_then(|r| r.policies.as_ref()) + .and_then(|p| p.global_evaluation.as_ref()) + .map(|e| e == &PoliciesGlobalEvaluation::Accepted) + .unwrap_or(false); + + let scan_result_response = report.result.as_ref().expect("the report must always have a scan result response, this one didn't, which should never happen"); + let layers = layers_for_result(scan_result_response); + + ImageScanResult { + vulnerabilities, + is_compliant, + layers: layers.unwrap_or_default(), + } + } +} + +fn layers_for_result(scan: &ScanResultResponse) -> Option> { + let mut layer_map: HashMap<&String, Vec> = HashMap::new(); + + for vuln in scan.vulnerabilities.as_ref()?.values() { + let Some(package_ref) = vuln.package_ref.as_ref() else { + continue; + }; + + let Some(package) = scan.packages.get(package_ref) else { + continue; + }; + + let Some(layer_ref) = package.layer_ref.as_ref() else { + continue; + }; + + layer_map + .entry(layer_ref) + .or_default() + .push(VulnerabilityEntry { + id: vuln.name.clone(), + severity: severity_for(&vuln.severity), + }); + } + + let layers_in_scan = scan.layers.as_ref()?.values(); + + let layers_ordered = layers_in_scan.sorted_by(|left, right| left.index.cmp(&right.index)); + + let layers_converted_to_layer_scan_result = layers_ordered.map(|layer| { + let entries = layer_map.get(&layer.digest).cloned().unwrap_or_default(); + LayerScanResult { + layer_instruction: layer + .command + .as_deref() + .unwrap_or_default() + .strip_prefix("/bin/sh -c #(nop) ") + .unwrap_or_default() + .split_whitespace() + .next() + .unwrap_or_default() + .to_uppercase(), + layer_text: layer.command.clone().unwrap_or_default(), + vulnerabilities: entries, + } + }); + + Some(layers_converted_to_layer_scan_result.collect()) +} + +fn severity_for(sev: &VulnSeverity) -> app::VulnSeverity { + match sev { + VulnSeverity::Critical => app::VulnSeverity::Critical, + VulnSeverity::High => app::VulnSeverity::High, + VulnSeverity::Medium => app::VulnSeverity::Medium, + VulnSeverity::Low => app::VulnSeverity::Low, + VulnSeverity::Negligible => app::VulnSeverity::Negligible, + } +} + #[derive(Debug, Deserialize)] pub(super) struct SysdigImageScannerReport { pub info: Option, diff --git a/tests/fixtures/Dockerfile b/tests/fixtures/Dockerfile index da56154..9e08b52 100644 --- a/tests/fixtures/Dockerfile +++ b/tests/fixtures/Dockerfile @@ -1,3 +1,13 @@ +FROM alpine AS builder + +RUN apk update + +RUN apk add curl + +RUN curl -L https://ftp.belnet.be/mirror/jenkins/war/2.455/jenkins.war -o /jenkins.war + FROM nginx:latest RUN apt update && apt full-upgrade -y + +COPY --from=builder /jenkins.war /jenkins.war