Skip to content

Commit eb3c13b

Browse files
authored
feat: implement Kubernetes manifest image analysis (#32)
Add support for scanning container images in Kubernetes manifests. Changes: - Add K8s manifest parser supporting Pods, Deployments, StatefulSets, DaemonSets, Jobs, and CronJobs - Enhance file type detection with content-based validation (checks for apiVersion and kind fields) - Improve diagnostic severity based on vulnerability counts instead of policy evaluation (ERROR for Critical/High, WARNING for Medium) - Add integration tests and comprehensive documentation - Document development patterns and gotchas in AGENTS.md Version: 0.7.5 -> 0.8.0
1 parent c431cb0 commit eb3c13b

13 files changed

Lines changed: 789 additions & 16 deletions

AGENTS.md

Lines changed: 193 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,14 @@ Key components:
8989
* **`DockerImageBuilder`**
9090
* Builds container images using Bollard (Docker API client).
9191

92-
* **Dockerfile / Compose AST Parsers**
92+
* **Dockerfile / Compose / K8s Manifest AST Parsers**
9393
* Parse Dockerfiles to extract image references from `FROM` instructions (including multi-stage builds).
9494
* Parse Docker Compose YAML (e.g. service `image:` fields).
95+
* Parse Kubernetes manifests YAML (e.g. `containers[].image` and `initContainers[].image` fields).
96+
* K8s manifests are detected by checking for both `apiVersion:` and `kind:` fields in YAML files.
97+
* Supports all common K8s resource types: Pods, Deployments, StatefulSets, DaemonSets, Jobs, CronJobs.
9598
* Handle complex scenarios such as build args and multi-platform images.
96-
* Implemented via modules like `ast_parser.rs`.
99+
* Implemented via modules like `dockerfile_ast_parser.rs`, `compose_ast_parser.rs`, and `k8s_manifest_ast_parser.rs`.
97100

98101
* **`ScannerBinaryManager`**
99102
* Downloads the Sysdig CLI scanner binary on demand.
@@ -165,6 +168,9 @@ The project uses `just` as a command runner to encapsulate common workflows.
165168
Additional helpful commands:
166169

167170
* `cargo test -- --nocapture` – run tests with full output when debugging.
171+
* `cargo test --lib` – run only unit tests (faster than running all tests).
172+
173+
**Important:** The tests `infra::sysdig_image_scanner::tests::it_scans_popular_images_correctly_test::case_*` are very slow because they scan real container images. These tests should only be run when making changes to the image scanner. For day-to-day development, skip them or run focused tests instead.
168174

169175
### 3.4 Pre-commit Hooks
170176

@@ -313,7 +319,191 @@ Check the workflow file in case of doubt.
313319

314320
---
315321

316-
## 8. Commit & Pull Request Guidelines
322+
## 8. Development Patterns & Common Gotchas
323+
324+
This section documents important patterns, findings, and gotchas discovered during development that are critical for maintaining consistency and avoiding common pitfalls.
325+
326+
### 8.1 Adding Support for New File Types
327+
328+
When adding support for a new file type (e.g. Kubernetes manifests, Terraform files), follow this pattern established by Docker Compose and K8s manifest implementations:
329+
330+
#### Step 1: Create a Parser Module
331+
332+
1. **Create parser in `src/infra/`**: e.g. `k8s_manifest_ast_parser.rs`
333+
- Define an `ImageInstruction` struct with `image_name` and `range` (LSP Range)
334+
- Create a `parse_*` function that returns `Result<Vec<ImageInstruction>, ParseError>`
335+
- Use `marked_yaml` for YAML parsing to preserve position information for accurate LSP ranges
336+
- Include comprehensive unit tests covering:
337+
- Simple cases
338+
- Multiple images
339+
- Edge cases (empty, null, invalid YAML)
340+
- Complex image names with registries
341+
- Quoted values
342+
343+
2. **Export the parser in `src/infra/mod.rs`**:
344+
```rust
345+
mod k8s_manifest_ast_parser;
346+
pub use k8s_manifest_ast_parser::parse_k8s_manifest;
347+
```
348+
349+
#### Step 2: Integrate into Command Generator
350+
351+
3. **Update `src/app/lsp_server/command_generator.rs`**:
352+
- Add import for the new parser
353+
- Create a detection function (e.g. `is_k8s_manifest_file()`)
354+
- **IMPORTANT**: Detect by content, not just file extension to avoid false positives
355+
- Example: K8s manifests must contain both `apiVersion:` and `kind:` fields
356+
- Add branch in `generate_commands_for_uri()` to route to the new file type
357+
- Create a `generate_*_commands()` function following the established pattern:
358+
```rust
359+
fn generate_k8s_manifest_commands(url: &Url, content: &str) -> Result<Vec<CommandInfo>, String> {
360+
let mut commands = vec![];
361+
match parse_k8s_manifest(content) {
362+
Ok(instructions) => {
363+
for instruction in instructions {
364+
commands.push(
365+
SupportedCommands::ExecuteBaseImageScan {
366+
location: Location::new(url.clone(), instruction.range),
367+
image: instruction.image_name,
368+
}
369+
.into(),
370+
);
371+
}
372+
}
373+
Err(err) => return Err(format!("{}", err)),
374+
}
375+
Ok(commands)
376+
}
377+
```
378+
379+
#### Step 3: Add Integration Tests
380+
381+
4. **Create fixture in `tests/fixtures/`**: e.g. `k8s-deployment.yaml`
382+
5. **Add integration test in `tests/general.rs`**:
383+
- Test code lens generation
384+
- Verify correct ranges and image names
385+
- Use existing patterns from compose tests as reference
386+
387+
#### Step 4: Update Documentation
388+
389+
6. **Update `README.md`**: Add feature to the features table with version number
390+
7. **Update `AGENTS.md`**: Document the parser in architecture section
391+
8. **Create feature doc**: Add `docs/features/<feature>.md` with examples
392+
9. **Update `docs/features/README.md`**: Add entry for the new feature
393+
394+
### 8.2 File Type Detection Gotchas
395+
396+
**DON'T**: Rely solely on file extensions for detection
397+
```rust
398+
// BAD: Matches ALL YAML files including compose files
399+
fn is_k8s_manifest_file(file_uri: &str) -> bool {
400+
file_uri.ends_with(".yaml") || file_uri.ends_with(".yml")
401+
}
402+
```
403+
404+
**✅ DO**: Combine file extension with content-based detection
405+
```rust
406+
// GOOD: Checks both extension AND content
407+
fn is_k8s_manifest_file(file_uri: &str, content: &str) -> bool {
408+
if !(file_uri.ends_with(".yaml") || file_uri.ends_with(".yml")) {
409+
return false;
410+
}
411+
content.contains("apiVersion:") && content.contains("kind:")
412+
}
413+
```
414+
415+
**Why**: File extensions alone can cause false positives. Docker Compose files, K8s manifests, and generic YAML files all use `.yaml`/`.yml` extensions. Content-based detection ensures accurate routing.
416+
417+
### 8.3 Diagnostic Severity Logic
418+
419+
The diagnostic severity shown in the editor should reflect the **actual vulnerability severity**, not just policy evaluation results.
420+
421+
**Current Implementation** (in `src/app/lsp_server/commands/scan_base_image.rs`):
422+
```rust
423+
diagnostic.severity = Some(if *critical_count > 0 || *high_count > 0 {
424+
DiagnosticSeverity::ERROR // Red
425+
} else if *medium_count > 0 {
426+
DiagnosticSeverity::WARNING // Yellow
427+
} else {
428+
DiagnosticSeverity::INFORMATION // Blue
429+
});
430+
```
431+
432+
**Gotcha**: The previous implementation used `scan_result.evaluation_result().is_passed()` which only reflected policy pass/fail. This caused High/Critical vulnerabilities to show as INFORMATION (blue) if the policy passed, which was confusing for users.
433+
434+
**When modifying severity logic**: Always base it on vulnerability counts/severity, not policy evaluation.
435+
436+
### 8.4 LSP Range Calculation
437+
438+
When parsing files to extract ranges for code lenses:
439+
440+
1. **Use position-aware parsers**: `marked_yaml` for YAML, custom parsers for Dockerfiles
441+
2. **Account for quotes**: Image names might be quoted in YAML (`"nginx:latest"` or `'nginx:latest'`)
442+
```rust
443+
let mut raw_len = image_name.len();
444+
if let Some(c) = first_char && (c == '"' || c == '\'') {
445+
raw_len += 2; // Include quotes in range
446+
}
447+
```
448+
3. **Test with various formats**: Unquoted, single-quoted, double-quoted values
449+
4. **0-indexed LSP positions**: LSP uses 0-indexed line/character positions, but some parsers (like `marked_yaml`) use 1-indexed positions - convert accordingly:
450+
```rust
451+
let start_line = start.line() as u32 - 1;
452+
let start_char = start.column() as u32 - 1;
453+
```
454+
455+
### 8.5 Testing Patterns
456+
457+
**Unit Tests** (`#[cfg(test)]` in modules):
458+
- Test parser logic in isolation
459+
- Use string literals for test input
460+
- Cover edge cases exhaustively
461+
- Run fast (no I/O)
462+
463+
**Integration Tests** (`tests/general.rs`):
464+
- Test full LSP flow: `did_open``code_lens``execute_command`
465+
- Use fixtures from `tests/fixtures/`
466+
- Mock external dependencies (ImageScanner) with `mockall`
467+
- Verify JSON serialization of LSP responses
468+
469+
**Slow Tests to Skip**:
470+
- `infra::sysdig_image_scanner::tests::it_scans_popular_images_correctly_test::case_*`
471+
- These scan real container images over the network
472+
- Only run when changing scanner-related code
473+
- Use `cargo test --lib -- --skip it_scans_popular_images_correctly_test` for faster feedback
474+
475+
### 8.6 Common Command Patterns
476+
477+
When adding new LSP commands:
478+
479+
1. **Define in `supported_commands.rs`**: Add to `SupportedCommands` enum
480+
2. **Implement in `commands/` directory**: Create a struct implementing `LspCommand` trait
481+
3. **Wire in `lsp_server_inner.rs`**: Add execution handler
482+
4. **Generate in `command_generator.rs`**: Create CommandInfo for code lenses
483+
5. **Test in `tests/general.rs`**: Verify command execution and results
484+
485+
### 8.7 Version Bumping Strategy
486+
487+
Follow semantic versioning for unstable versions (0.X.Y):
488+
489+
- **Patch (0.X.Y → 0.X.Y+1)**: Bug fixes, documentation, refactoring
490+
- **Minor (0.X.Y → 0.X+1.0)**: New features, enhancements
491+
- **Don't stabilize (1.0.0)** unless explicitly instructed
492+
493+
**When to release**:
494+
- ✅ New feature implemented
495+
- ✅ Bug fixes
496+
- ❌ CI/refactoring/internal changes (no user impact)
497+
- ❌ Documentation-only changes
498+
499+
**Release process**:
500+
1. Update version in `Cargo.toml`
501+
2. Commit and merge to default branch
502+
3. GitHub Actions workflow automatically creates release with cross-compiled binaries
503+
504+
---
505+
506+
## 9. Commit & Pull Request Guidelines
317507

318508
To keep history clean and reviews manageable:
319509

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "sysdig-lsp"
3-
version = "0.7.5"
3+
version = "0.8.0"
44
edition = "2024"
55
authors = [ "Sysdig Inc." ]
66
readme = "README.md"

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ helping you detect vulnerabilities and misconfigurations earlier in the developm
2323
| Layered image analysis | Supported | [Supported](./docs/features/layered_analysis.md) (0.5.0+) |
2424
| Docker-compose image analysis | Supported | [Supported](./docs/features/docker_compose_image_analysis.md) (0.6.0+) |
2525
| Vulnerability explanation | Supported | [Supported](./docs/features/vulnerability_explanation.md) (0.7.0+) |
26-
| K8s Manifest image analysis | Supported | In roadmap |
26+
| K8s Manifest image analysis | Supported | [Supported](./docs/features/k8s_manifest_image_analysis.md) (0.8.0+) |
2727
| Infrastructure-as-code analysis | Supported | In roadmap |
2828

2929
## Build

docs/features/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ Sysdig LSP provides tools to integrate container security checks into your devel
2121
## [Docker-compose Image Analysis](./docker_compose_image_analysis.md)
2222
- Scans the images defined in your `docker-compose.yml` files for vulnerabilities.
2323

24+
## [Kubernetes Manifest Image Analysis](./k8s_manifest_image_analysis.md)
25+
- Scans container images defined in Kubernetes manifest files for vulnerabilities.
26+
- Supports Pods, Deployments, StatefulSets, DaemonSets, Jobs, and CronJobs.
27+
2428
## [Vulnerability Explanation](./vulnerability_explanation.md)
2529
- Displays a detailed summary of scan results when hovering over a scanned image name.
2630
- Provides immediate feedback on vulnerabilities, severities, and available fixes.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Kubernetes Manifest Image Analysis
2+
3+
Sysdig LSP scans the container images defined in your Kubernetes manifest files to identify vulnerabilities.
4+
5+
> [!IMPORTANT]
6+
> Sysdig LSP analyzes container images from `containers` and `initContainers` in your Kubernetes manifests.
7+
8+
![Sysdig LSP executing k8s manifest image scan](./k8s_manifest_image_analysis.png)
9+
10+
## Supported Kubernetes Resources
11+
12+
Sysdig LSP supports scanning images from the following Kubernetes resource types:
13+
14+
- Pods
15+
- Deployments
16+
- StatefulSets
17+
- DaemonSets
18+
- Jobs
19+
- CronJobs
20+
- ReplicaSets
21+
22+
## Example
23+
24+
```yaml
25+
apiVersion: apps/v1
26+
kind: Deployment
27+
metadata:
28+
name: web-deployment
29+
spec:
30+
replicas: 3
31+
template:
32+
spec:
33+
initContainers:
34+
- name: init-myservice
35+
image: busybox:1.28
36+
containers:
37+
- name: nginx
38+
image: nginx:1.19
39+
- name: sidecar
40+
image: busybox:latest
41+
```
42+
43+
In this example, Sysdig LSP will provide actions to scan all three images: `busybox:1.28`, `nginx:1.19`, and `busybox:latest`.
276 KB
Loading

src/app/lsp_server/command_generator.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use serde_json::{Value, json};
22
use tower_lsp::lsp_types::{CodeLens, Command, Location, Range, Url};
33

44
use crate::app::lsp_server::supported_commands::SupportedCommands;
5-
use crate::infra::{parse_compose_file, parse_dockerfile};
5+
use crate::infra::{parse_compose_file, parse_dockerfile, parse_k8s_manifest};
66

77
pub struct CommandInfo {
88
pub title: String,
@@ -64,6 +64,8 @@ pub fn generate_commands_for_uri(uri: &Url, content: &str) -> Result<Vec<Command
6464
|| file_uri.contains("compose.yaml")
6565
{
6666
generate_compose_commands(uri, content)
67+
} else if is_k8s_manifest_file(file_uri, content) {
68+
generate_k8s_manifest_commands(uri, content)
6769
} else {
6870
Ok(generate_dockerfile_commands(uri, content))
6971
}
@@ -89,6 +91,37 @@ fn generate_compose_commands(url: &Url, content: &str) -> Result<Vec<CommandInfo
8991
Ok(commands)
9092
}
9193

94+
fn is_k8s_manifest_file(file_uri: &str, content: &str) -> bool {
95+
// Must be a YAML file
96+
if !(file_uri.ends_with(".yaml") || file_uri.ends_with(".yml")) {
97+
return false;
98+
}
99+
100+
// Check for K8s manifest indicators in content
101+
// K8s manifests typically have "apiVersion" and "kind" fields
102+
content.contains("apiVersion:") && content.contains("kind:")
103+
}
104+
105+
fn generate_k8s_manifest_commands(url: &Url, content: &str) -> Result<Vec<CommandInfo>, String> {
106+
let mut commands = vec![];
107+
match parse_k8s_manifest(content) {
108+
Ok(instructions) => {
109+
for instruction in instructions {
110+
commands.push(
111+
SupportedCommands::ExecuteBaseImageScan {
112+
location: Location::new(url.clone(), instruction.range),
113+
image: instruction.image_name,
114+
}
115+
.into(),
116+
);
117+
}
118+
}
119+
Err(err) => return Err(format!("{}", err)),
120+
}
121+
122+
Ok(commands)
123+
}
124+
92125
fn generate_dockerfile_commands(uri: &Url, content: &str) -> Vec<CommandInfo> {
93126
let mut commands = vec![];
94127
let instructions = parse_dockerfile(content);

src/app/lsp_server/commands/scan_base_image.rs

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -80,20 +80,29 @@ where
8080
.vulnerabilities()
8181
.iter()
8282
.counts_by(|v| v.severity());
83+
let critical_count = vulns.get(&Severity::Critical).unwrap_or(&0_usize);
84+
let high_count = vulns.get(&Severity::High).unwrap_or(&0_usize);
85+
let medium_count = vulns.get(&Severity::Medium).unwrap_or(&0_usize);
86+
let low_count = vulns.get(&Severity::Low).unwrap_or(&0_usize);
87+
let negligible_count = vulns.get(&Severity::Negligible).unwrap_or(&0_usize);
88+
8389
diagnostic.message = format!(
8490
"Vulnerabilities found for {}: {} Critical, {} High, {} Medium, {} Low, {} Negligible",
8591
image_name,
86-
vulns.get(&Severity::Critical).unwrap_or(&0_usize),
87-
vulns.get(&Severity::High).unwrap_or(&0_usize),
88-
vulns.get(&Severity::Medium).unwrap_or(&0_usize),
89-
vulns.get(&Severity::Low).unwrap_or(&0_usize),
90-
vulns.get(&Severity::Negligible).unwrap_or(&0_usize),
92+
critical_count,
93+
high_count,
94+
medium_count,
95+
low_count,
96+
negligible_count,
9197
);
9298

93-
diagnostic.severity = Some(if scan_result.evaluation_result().is_passed() {
94-
DiagnosticSeverity::INFORMATION
95-
} else {
99+
// Determine severity based on vulnerability counts, not just policy evaluation
100+
diagnostic.severity = Some(if *critical_count > 0 || *high_count > 0 {
96101
DiagnosticSeverity::ERROR
102+
} else if *medium_count > 0 {
103+
DiagnosticSeverity::WARNING
104+
} else {
105+
DiagnosticSeverity::INFORMATION
97106
});
98107
}
99108

0 commit comments

Comments
 (0)