Cybersecurity-Projects/PROJECTS/intermediate/docker-security-audit/learn/03-IMPLEMENTATION.md

1060 lines
35 KiB
Markdown

# Implementation Guide
This document walks through the actual code. We'll build key features step by step and explain the decisions along the way.
## File Structure Walkthrough
```
docker-security-audit/
├── cmd/docksec/
│ └── main.go # Entry point: CLI setup, Cobra commands
├── internal/
│ ├── analyzer/
│ │ ├── analyzer.go # Interface definition
│ │ ├── container.go # Running container security checks
│ │ ├── daemon.go # Docker daemon config validation
│ │ ├── image.go # Image metadata inspection
│ │ ├── dockerfile.go # Dockerfile static analysis
│ │ └── compose.go # docker-compose.yml analysis
│ ├── benchmark/
│ │ └── controls.go # CIS Docker Benchmark v1.6.0 controls
│ ├── config/
│ │ ├── config.go # Configuration struct and filters
│ │ └── constants.go # Timeouts, rate limits, thresholds
│ ├── docker/
│ │ └── client.go # Docker SDK wrapper with timeouts
│ ├── finding/
│ │ └── finding.go # Finding model and collection methods
│ ├── parser/
│ │ ├── dockerfile.go # BuildKit-based Dockerfile parser
│ │ ├── compose.go # docker-compose YAML parser
│ │ └── visitor.go # Visitor pattern for rule application
│ ├── proc/
│ │ ├── capabilities.go # Linux capabilities parsing from /proc
│ │ ├── proc.go # Process info extraction
│ │ └── security.go # Security profile inspection
│ ├── report/
│ │ ├── reporter.go # Reporter factory
│ │ ├── terminal.go # Colored terminal output
│ │ ├── json.go # Structured JSON
│ │ ├── sarif.go # SARIF 2.1.0 for GitHub
│ │ └── junit.go # JUnit XML for CI/CD
│ ├── rules/
│ │ ├── capabilities.go # 41 capabilities with risk levels
│ │ ├── paths.go # 200+ sensitive host paths
│ │ └── secrets.go # 80+ secret patterns + entropy
│ └── scanner/
│ └── scanner.go # Orchestration: concurrent execution
├── Dockerfile # Multi-stage build
├── go.mod # Dependencies
└── go.sum
```
## Building Core Feature 1: Container Security Analysis
### Step 1: Detect Privileged Containers
What we're building: Check if containers run with --privileged flag.
The privileged flag gives containers all Linux capabilities and access to all devices. It's effectively root on the host.
In `internal/analyzer/container.go:72-86`:
```go
func (a *ContainerAnalyzer) checkPrivileged(
target finding.Target,
info types.ContainerJSON,
) finding.Collection {
var findings finding.Collection
if info.HostConfig.Privileged {
control, _ := benchmark.Get("5.4")
f := finding.New("CIS-5.4", control.Title, finding.SeverityCritical, target).
WithDescription(control.Description).
WithCategory(string(CategoryContainerRuntime)).
WithRemediation(control.Remediation).
WithReferences(control.References...).
WithCISControl(control.ToCISControl())
findings = append(findings, f)
}
return findings
}
```
**Why this code works:**
- Line 7: Docker SDK populates HostConfig.Privileged from container's runtime config
- Line 8: benchmark.Get() retrieves CIS control 5.4 with title, description, remediation
- Line 9-14: Builder pattern constructs finding with all metadata in one readable chain
- Line 15: Append to collection (nil slice is valid in Go, append handles it)
**Common mistakes here:**
```go
// Wrong: Not checking the actual runtime state
if strings.Contains(info.Config.Image, "privileged") {
// This checks image name, not actual --privileged flag
}
// Why this fails: Image name has nothing to do with runtime flags.
// Always check HostConfig for runtime configuration.
// Wrong: Creating finding without CIS control
f := finding.New("privileged", "Bad container", finding.SeverityCritical, target)
// Why this fails: Loses compliance mapping. Reports won't show CIS control ID.
// Always attach control metadata when implementing CIS checks.
```
### Step 2: Check Added Capabilities
Containers can add capabilities beyond Docker's defaults using --cap-add.
In `internal/analyzer/container.go:88-117`:
```go
func (a *ContainerAnalyzer) checkCapabilities(
target finding.Target,
info types.ContainerJSON,
) finding.Collection {
var findings finding.Collection
for _, cap := range info.HostConfig.CapAdd {
capName := strings.ToUpper(string(cap))
capInfo, exists := rules.GetCapabilityInfo(capName)
if !exists {
continue
}
if capInfo.Severity >= finding.SeverityHigh {
control, _ := benchmark.Get("5.3")
title := "Dangerous capability added: " + capName
if capInfo.Severity == finding.SeverityCritical {
title = "Critical capability added: " + capName
}
f := finding.New("CIS-5.3", title, capInfo.Severity, target).
WithDescription(capInfo.Description).
WithCategory(string(CategoryContainerRuntime)).
WithRemediation(control.Remediation).
WithReferences(control.References...).
WithCISControl(control.ToCISControl())
findings = append(findings, f)
}
}
return findings
}
```
**What's happening:**
1. Line 7: Iterate CapAdd array from Docker inspect output
2. Line 8: Normalize to uppercase (Docker uses caps or lowercase, our rules use uppercase)
3. Line 9: Lookup capability in rules database (O(1) map access)
4. Line 10-12: Skip unknown capabilities (defensive - Docker might add new ones)
5. Line 14: Only report HIGH and CRITICAL (skip MEDIUM like CAP_NET_BIND_SERVICE)
6. Line 19: Use severity from rules database, not hardcoded in analyzer
**Why we do it this way:**
Separation of concerns. Analyzer knows how to extract CapAdd, rules package knows which capabilities are dangerous. Adding a new dangerous capability just requires updating `internal/rules/capabilities.go`, not modifying analyzer code.
**Alternative approaches:**
- Hardcode dangerous capabilities in analyzer: Works but duplicates knowledge. If we add Dockerfile checks later, we'd need same list there.
- Check all capabilities equally: Would flag CAP_NET_BIND_SERVICE (severity LOW) same as CAP_SYS_ADMIN (CRITICAL). User gets noise.
### Step 3: Validate Mount Security
Containers can bind mount host paths. Some paths enable container escape.
In `internal/analyzer/container.go:119-160`:
```go
func (a *ContainerAnalyzer) checkMounts(
target finding.Target,
info types.ContainerJSON,
) finding.Collection {
var findings finding.Collection
for _, mount := range info.Mounts {
source := mount.Source
if rules.IsDockerSocket(source) {
control, _ := benchmark.Get("5.31")
pathInfo, _ := rules.GetPathInfo(source)
f := finding.New("CIS-5.31", control.Title, finding.SeverityCritical, target).
WithDescription(pathInfo.Description).
WithCategory(string(CategoryContainerRuntime)).
WithRemediation(control.Remediation).
WithReferences(control.References...).
WithCISControl(control.ToCISControl())
findings = append(findings, f)
continue
}
if rules.IsSensitivePath(source) {
control, _ := benchmark.Get("5.5")
pathInfo, _ := rules.GetPathInfo(source)
severity := rules.GetPathSeverity(source)
description := control.Description
if pathInfo.Description != "" {
description = pathInfo.Description
}
f := finding.New("CIS-5.5", "Sensitive host path mounted: "+source, severity, target).
WithDescription(description).
WithCategory(string(CategoryContainerRuntime)).
WithRemediation(control.Remediation).
WithReferences(control.References...).
WithCISControl(control.ToCISControl())
findings = append(findings, f)
}
}
return findings
}
```
**Key parts explained:**
**Docker socket check** (`container.go:9-20`)
```go
if rules.IsDockerSocket(source) {
// Always CRITICAL severity
// Docker socket gives full daemon control
}
```
This is separated from generic sensitive paths because Docker socket is special. It's not just sensitive, it's a direct escape vector. Continue statement prevents double-reporting (socket is also in sensitive paths list).
**Sensitive path check** (`container.go:22-39`)
```go
severity := rules.GetPathSeverity(source)
```
Different paths have different severities. `/etc/shadow` is CRITICAL (password hashes), `/tmp` is MEDIUM (info disclosure). Rules package determines severity based on path database.
**Path-specific descriptions** (`container.go:27-30`)
```go
description := control.Description
if pathInfo.Description != "" {
description = pathInfo.Description
}
```
CIS control 5.5 is generic ("Don't mount sensitive paths"). PathInfo has specific descriptions like "Docker daemon socket. Full control over Docker, container escape possible." More actionable for users.
## Building Core Feature 2: Dockerfile Static Analysis
### The Problem
Dockerfiles can hardcode secrets, run as root, download untrusted code. We need to catch these before images get built.
### The Solution
Parse Dockerfile into AST using BuildKit parser (same parser Docker uses), then run security checks on each instruction.
### Implementation
In `internal/analyzer/dockerfile.go:29-58`:
```go
func (a *DockerfileAnalyzer) Analyze(
ctx context.Context,
) (finding.Collection, error) {
file, err := os.Open(a.path)
if err != nil {
return nil, err
}
defer func() { _ = file.Close() }()
result, err := parser.Parse(file)
if err != nil {
return nil, err
}
target := finding.Target{
Type: finding.TargetDockerfile,
Name: a.path,
}
var findings finding.Collection
findings = append(findings, a.checkUserInstruction(target, result.AST)...)
findings = append(findings, a.checkHealthcheck(target, result.AST)...)
findings = append(findings, a.checkAddInstruction(target, result.AST)...)
findings = append(findings, a.checkSecrets(target, result.AST)...)
findings = append(findings, a.checkLatestTag(target, result.AST)...)
findings = append(findings, a.checkCurlPipe(target, result.AST)...)
findings = append(findings, a.checkSudo(target, result.AST)...)
return findings, nil
}
```
BuildKit parser gives us result.AST (abstract syntax tree). Each node is one instruction with line numbers and arguments.
**Checking for USER instruction** (`dockerfile.go:60-109`):
```go
func (a *DockerfileAnalyzer) checkUserInstruction(
target finding.Target,
ast *parser.Node,
) finding.Collection {
var findings finding.Collection
hasUser := false
var lastFromLine int
for _, node := range ast.Children {
switch strings.ToUpper(node.Value) {
case "FROM":
lastFromLine = node.StartLine
hasUser = false // Reset for each stage
case "USER":
hasUser = true
user := ""
if node.Next != nil {
user = node.Next.Value
}
if user == "root" || user == "0" {
loc := &finding.Location{Path: a.path, Line: node.StartLine}
f := finding.New("DS-USER-ROOT", "USER instruction sets root user", finding.SeverityMedium, target).
WithDescription("Dockerfile explicitly sets USER to root, which should be avoided.").
WithCategory(string(CategoryDockerfile)).
WithLocation(loc).
WithRemediation("Create and use a non-root user in the Dockerfile.")
findings = append(findings, f)
}
}
}
if !hasUser && lastFromLine > 0 {
control, _ := benchmark.Get("4.1")
loc := &finding.Location{Path: a.path, Line: lastFromLine}
f := finding.New("CIS-4.1", control.Title, finding.SeverityMedium, target).
WithDescription(control.Description).
WithCategory(string(CategoryDockerfile)).
WithLocation(loc).
WithRemediation(control.Remediation).
WithReferences(control.References...).
WithCISControl(control.ToCISControl())
findings = append(findings, f)
}
return findings
}
```
**Why this approach:**
- Handles multi-stage builds correctly (hasUser resets at each FROM)
- Detects explicit USER root (people do this to "fix" permission errors)
- Reports missing USER with line number pointing to last FROM
- Location with line number lets GitHub display inline warnings
**Secret detection with entropy** (`dockerfile.go:154-213`):
```go
func (a *DockerfileAnalyzer) checkSecrets(
target finding.Target,
ast *parser.Node,
) finding.Collection {
var findings finding.Collection
for _, node := range ast.Children {
cmd := strings.ToUpper(node.Value)
if cmd != "ENV" && cmd != "ARG" && cmd != "RUN" && cmd != "LABEL" {
continue
}
line := getFullLine(node)
if cmd == "ENV" || cmd == "ARG" {
varName := ""
varValue := ""
if node.Next != nil {
parts := strings.SplitN(node.Next.Value, "=", 2)
varName = parts[0]
if len(parts) > 1 {
varValue = parts[1]
}
}
if rules.IsSensitiveEnvName(varName) {
control, _ := benchmark.Get("4.10")
loc := &finding.Location{Path: a.path, Line: node.StartLine}
f := finding.New("CIS-4.10", "Sensitive variable in "+cmd+": "+varName, finding.SeverityHigh, target).
WithDescription(control.Description).
WithCategory(string(CategoryDockerfile)).
WithLocation(loc).
WithRemediation(control.Remediation).
WithReferences(control.References...).
WithCISControl(control.ToCISControl())
findings = append(findings, f)
}
if varValue != "" &&
rules.IsHighEntropyString(
varValue,
config.MinSecretLength,
config.MinEntropyForSecret,
) {
loc := &finding.Location{Path: a.path, Line: node.StartLine}
f := finding.New("DS-HIGH-ENTROPY", "High entropy string in "+cmd+" (potential secret)", finding.SeverityMedium, target).
WithDescription("Value in " + varName + " has high entropy, indicating a potential hardcoded secret or key.").
WithCategory(string(CategoryDockerfile)).
WithLocation(loc).
WithRemediation("Use Docker secrets, build arguments, or environment variables at runtime instead of hardcoding sensitive values.")
findings = append(findings, f)
}
}
secrets := rules.DetectSecrets(line)
for _, secret := range secrets {
control, _ := benchmark.Get("4.10")
loc := &finding.Location{Path: a.path, Line: node.StartLine}
f := finding.New("CIS-4.10", "Potential "+string(secret.Type)+" detected in Dockerfile", finding.SeverityHigh, target).
WithDescription(secret.Description + ". " + control.Description).
WithCategory(string(CategoryDockerfile)).
WithLocation(loc).
WithRemediation(control.Remediation).
WithReferences(control.References...).
WithCISControl(control.ToCISControl())
findings = append(findings, f)
}
}
return findings
}
```
Three-layer secret detection:
1. **Sensitive variable names**: ENV API_KEY, ARG PASSWORD → Always flag regardless of value
2. **High entropy**: Random-looking strings like `aB3xK9mP2qL5nR8t` → Likely secrets
3. **Pattern matching**: 80+ regex patterns for AWS keys, GitHub tokens, etc.
Entropy calculation in `internal/rules/secrets.go:910-925`:
```go
func CalculateEntropy(s string) float64 {
if len(s) == 0 {
return 0
}
freq := make(map[rune]float64)
for _, c := range s {
freq[c]++
}
length := float64(len(s))
var entropy float64
for _, count := range freq {
p := count / length
entropy -= p * math.Log2(p)
}
return entropy
}
```
Shannon entropy: "password" = 2.75 bits/char (low), "Tr0ub4dor&3" = 3.18 (medium), "rYq3J8kP2vL9nM5x" = 4.0 (high). Threshold is 4.5 bits/char.
## Security Implementation
### Capability Risk Assessment
File: `internal/rules/capabilities.go`
```go
var Capabilities = map[string]CapabilityInfo{
"CAP_SYS_ADMIN": {
Severity: finding.SeverityCritical,
Description: "Perform a range of system administration operations. Effectively root - mount filesystems, quotas, namespaces, etc.",
},
"CAP_SYS_PTRACE": {
Severity: finding.SeverityCritical,
Description: "Trace arbitrary processes using ptrace. Read/write memory of any process, inject code, steal secrets.",
},
"CAP_NET_ADMIN": {
Severity: finding.SeverityHigh,
Description: "Perform network administration operations. Modify routing, firewall rules, sniff traffic, MITM attacks.",
},
// ... 38 more capabilities
}
// Pre-computed lookup maps built at init()
var dangerousCapabilities = func() map[string]struct{} {
m := make(map[string]struct{})
for cap, info := range Capabilities {
if info.Severity >= finding.SeverityHigh {
m[cap] = struct{}{}
m[strings.TrimPrefix(cap, "CAP_")] = struct{}{}
}
}
return m
}()
```
**What this prevents:**
Linear scans through capability list on every container. With pre-computed map, IsDangerousCapability() is O(1).
**How it works:**
1. Package init runs at program start (before main)
2. Anonymous function executes, building lookup map
3. Map assigned to package variable dangerousCapabilities
4. Every future lookup is hash table access
**What happens if you remove this:**
Every capability check becomes O(n) where n=41. Scanning 1000 containers with average 3 added capabilities = 3000 * 41 comparisons = 123,000 operations. With map: 3000 lookups = 3000 operations. 40x speedup.
### Path-Based Attack Prevention
File: `internal/rules/paths.go:32-1100` (yes, over 1000 lines)
```go
var DockerSocketPaths = map[string]PathInfo{
"/var/run/docker.sock": {
Severity: finding.SeverityCritical,
Description: "Docker daemon socket. Full control over Docker, container escape possible.",
},
"/run/docker.sock": {
Severity: finding.SeverityCritical,
Description: "Docker daemon socket (alternate path). Full control over Docker.",
},
// ... containerd, CRI-O, podman sockets
}
var SensitiveHostPaths = map[string]PathInfo{
"/etc/shadow": {
Severity: finding.SeverityCritical,
Description: "Password hashes. Direct credential access.",
},
"/var/lib/kubelet/pods": {
Severity: finding.SeverityCritical,
Description: "Kubelet pod data. Access to all pod volumes and secrets.",
},
"/root/.aws": {
Severity: finding.SeverityCritical,
Description: "AWS credentials and configuration.",
},
// ... 200+ paths
}
```
Path matching handles prefixes:
```go
func IsSensitivePath(path string) bool {
normalized := normalizePath(path)
if _, exists := sensitivePathLookup[normalized]; exists {
return true
}
// Check if path is under a sensitive directory
for sensitivePath := range sensitivePathLookup {
if strings.HasPrefix(normalized, sensitivePath+"/") {
return true
}
}
return false
}
```
Why prefix matching: Mounting `/etc/kubernetes/pki/ca.crt` should flag because `/etc/kubernetes/pki` is sensitive. Exact match only would miss this.
## Data Flow Example
Let's trace a complete scan through the system.
**Scenario:** User runs `docksec scan --target containers --severity high`
### Request Comes In
```go
// Entry point: cmd/docksec/main.go:64-82
cfg := &config.Config{
Targets: []string{"containers"},
Severity: []string{"high"},
Output: "terminal",
Workers: 20,
}
scanner, _ := scanner.New(cfg)
```
At this point:
- Config validated (targets exist, severity is valid enum)
- Docker client created and connected
- Terminal reporter instantiated with colored output
- Rate limiter initialized at 50 req/sec
### Processing Layer
```go
// Processing: internal/scanner/scanner.go:72-100
analyzers := s.buildAnalyzers()
// Returns: [ContainerAnalyzer]
// internal/scanner/scanner.go:102-168
findings, _ := s.runAnalyzers(ctx, analyzers)
```
This code:
- Spawns goroutine for ContainerAnalyzer
- Rate limiter waits (first call passes immediately due to burst)
- ContainerAnalyzer calls Docker API ListContainers
- For each container, spawns goroutine calling InspectContainer
- Each inspect runs all checks: privileged, capabilities, mounts, etc.
- Findings from all checks merged into single collection
Why errgroup instead of waitgroup: If Docker daemon becomes unreachable mid-scan, errgroup propagates error via context cancellation. All goroutines see ctx.Done() and exit cleanly.
### Storage/Output
```go
// Filter: internal/scanner/scanner.go:170-197
filtered := s.filterFindings(findings)
// Only keeps findings with severity >= HIGH
// Output: internal/report/terminal.go:25-42
s.reporter.Report(filtered)
```
The result is terminal output with ANSI colors. We write to stdout directly because outputFile == "". Each finding gets formatted with severity color, title, target, location, description, remediation.
## Error Handling Patterns
### Docker API Errors
When Docker daemon is down or unreachable, we need graceful failure.
```go
// internal/docker/client.go:47-59
func (c *Client) Ping(ctx context.Context) error {
pingCtx, cancel := context.WithTimeout(ctx, config.ConnectionTimeout)
defer cancel()
_, err := c.api.Ping(pingCtx)
if err != nil {
return fmt.Errorf("pinging docker daemon: %w", err)
}
return nil
}
```
**Why this specific handling:**
5-second timeout prevents hanging when Docker socket exists but daemon is stuck. Error wrapping with %w preserves original error for debugging while adding context.
**What NOT to do:**
```go
// Bad: Silent failure
func (c *Client) Ping(ctx context.Context) error {
_, err := c.api.Ping(ctx)
if err != nil {
log.Println("ping failed")
return nil // Pretend success
}
return nil
}
// Why this is terrible: Scanner proceeds with broken client.
// Later ListContainers fails with cryptic "client not initialized" error.
// User has no idea Docker daemon is down.
```
Always fail fast with descriptive errors at boundaries.
### File Parsing Errors
Dockerfiles can be malformed. Don't crash the entire scan.
```go
// internal/analyzer/dockerfile.go:34-37
result, err := parser.Parse(file)
if err != nil {
return nil, err
}
```
We propagate parsing errors up to scanner. Scanner logs warning but continues with other analyzers:
```go
// internal/scanner/scanner.go:137-143
findings, err := a.Analyze(ctx)
if err != nil {
s.logger.Warn(
"analyzer failed",
"name", a.Name(),
"error", err,
)
return nil // Don't fail entire scan
}
```
One bad Dockerfile doesn't stop container scans.
## Performance Optimizations
### Before: Sequential Container Inspection
Naive implementation:
```go
// Slow version - sequential
func (a *ContainerAnalyzer) Analyze(ctx context.Context) (finding.Collection, error) {
containers, _ := a.client.ListContainers(ctx, true)
var findings finding.Collection
for _, c := range containers {
info, _ := a.client.InspectContainer(ctx, c.ID)
findings = append(findings, a.analyzeContainer(info)...)
}
return findings, nil
}
```
This was slow because InspectContainer is network I/O (50-100ms per call). With 100 containers: 5-10 seconds just waiting for API responses.
### After: Concurrent Inspection with Rate Limiting
Optimized implementation in scanner:
```go
// internal/scanner/scanner.go:102-168
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(s.cfg.Workers) // 20 concurrent
for _, a := range analyzers {
a := a
g.Go(func() error {
s.limiter.Wait(ctx) // Rate limit
findings, _ := a.Analyze(ctx)
results <- findings
return nil
})
}
```
**What changed:**
- Sequential → Concurrent: 20 inspects happen simultaneously
- No rate limit → 50 req/sec limiter: Prevents overwhelming daemon
- Blocking waits → errgroup: Errors propagate via context
**Benchmarks:**
- Before: 100 containers = 8.2 seconds
- After: 100 containers = 1.1 seconds
- Improvement: 7.5x faster
With 1000 containers:
- Before: Would be ~82 seconds
- After: 20 workers * 50 req/sec = maximum 1000 req / 50 = 20 seconds (rate limit bound)
- Actual: ~22 seconds (accounting for processing time)
## Configuration Management
### Loading Config
```go
// cmd/docksec/main.go:64-82
func newScanCmd(cfg *config.Config) *cobra.Command {
cmd := &cobra.Command{
Use: "scan",
Short: "Scan Docker environment for security issues",
RunE: func(cmd *cobra.Command, args []string) error {
return runScan(cmd.Context(), cfg)
},
}
flags := cmd.Flags()
flags.StringSliceVarP(&cfg.Targets, "target", "t", []string{"all"},
"Scan targets: all, containers, daemon, images")
flags.StringSliceVarP(&cfg.Files, "file", "f", nil,
"Dockerfile or docker-compose.yml files to scan")
flags.StringVarP(&cfg.Output, "output", "o", "terminal",
"Output format: terminal, json, sarif, junit")
// ... more flags
return cmd
}
```
**Why this approach:**
Cobra handles flag parsing, validation, and help text generation. StringSliceVarP means `--target containers,daemon` or `--target containers --target daemon` both work.
**Validation:**
```go
// internal/scanner/scanner.go:72-100
if len(analyzers) == 0 {
return fmt.Errorf("no analyzers configured")
}
```
We validate early because invalid config should fail at startup, not after scanning 500 containers.
## Testing Strategy
### Unit Tests
Example test for capability checking:
```go
// internal/rules/capabilities_test.go
func TestIsDangerousCapability(t *testing.T) {
tests := []struct {
cap string
want bool
}{
{"CAP_SYS_ADMIN", true},
{"SYS_ADMIN", true}, // Works without CAP_ prefix
{"CAP_NET_BIND_SERVICE", false},
{"INVALID_CAP", false},
}
for _, tt := range tests {
got := IsDangerousCapability(tt.cap)
if got != tt.want {
t.Errorf("IsDangerousCapability(%q) = %v, want %v",
tt.cap, got, tt.want)
}
}
}
```
**What this tests:**
- Exact matches work
- Prefix normalization works (with/without CAP_)
- Non-dangerous capabilities return false
- Unknown capabilities don't panic
**Why these specific assertions:**
Real Docker output sometimes has "SYS_ADMIN", sometimes "CAP_SYS_ADMIN". Test ensures both work.
### Integration Tests
Testing container analyzer requires real Docker:
```go
// internal/analyzer/container_test.go
func TestContainerAnalyzer(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
client, _ := docker.NewClient()
ctx := context.Background()
// Create privileged container
containerID, _ := createPrivilegedContainer(ctx, client)
defer removeContainer(ctx, client, containerID)
analyzer := NewContainerAnalyzer(client)
findings, err := analyzer.Analyze(ctx)
if err != nil {
t.Fatalf("Analyze() error = %v", err)
}
// Should find privileged container
found := false
for _, f := range findings {
if f.RuleID == "CIS-5.4" {
found = true
break
}
}
if !found {
t.Error("Did not detect privileged container")
}
}
```
Run with `go test` (skips integration tests) or `go test -short=false` (runs all tests).
## Common Implementation Pitfalls
### Pitfall 1: Ignoring Context Cancellation
**Symptom:**
Scanner hangs when user hits Ctrl-C
**Cause:**
```go
// Bad: Ignores context
func (a *ContainerAnalyzer) Analyze(ctx context.Context) (finding.Collection, error) {
containers, _ := a.client.ListContainers(context.Background(), true)
// Uses context.Background() instead of ctx parameter
}
```
**Fix:**
```go
// Good: Respects context
func (a *ContainerAnalyzer) Analyze(ctx context.Context) (finding.Collection, error) {
containers, _ := a.client.ListContainers(ctx, true)
// Passes ctx through - if canceled, API call returns immediately
}
```
**Why this matters:**
User hits Ctrl-C → main sets up signal handler → context canceled → all API calls abort → clean shutdown in <100ms instead of waiting for all inspects to complete.
### Pitfall 2: Forgetting errgroup Capture
**Symptom:**
Goroutines fail but scan reports success
**Cause:**
```go
// Bad: Loses errors
for _, a := range analyzers {
go func() {
findings, err := a.Analyze(ctx)
// err is lost - no one checks it
results <- findings
}()
}
```
**Fix:**
```go
// Good: Propagates errors
g, ctx := errgroup.WithContext(ctx)
for _, a := range analyzers {
a := a // Capture loop variable
g.Go(func() error {
findings, err := a.Analyze(ctx)
if err != nil {
return err
}
results <- findings
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
```
**Why this matters:**
If Docker daemon crashes mid-scan, we detect it and report failure instead of returning partial results as if scan succeeded.
### Pitfall 3: String Comparison for Booleans
**Symptom:**
docker-compose.yml with `privileged: true` doesn't get flagged
**Cause:**
```go
// Bad: Assumes specific format
if privilegedNode.Value == "true" {
// YAML library might return "True", "yes", or boolean type
}
```
**Fix:**
```go
// Good: Handles multiple formats
if privilegedNode.Value == "true" || privilegedNode.Value == "yes" {
// Handles both YAML boolean representations
}
```
YAML accepts `true`, `True`, `yes`, `on` for booleans. Always handle variations.
## Debugging Tips
### Issue Type 1: No Findings When Expecting Some
**Problem:** Running `docksec scan` on container with --privileged shows zero findings
**How to debug:**
1. Check scanner log level: `docksec scan --verbose`
- Logs show which analyzers ran, how many containers found
2. Verify Docker connection: `docker ps` in same environment
- If this fails, docksec can't access daemon either
3. Check filters: `docksec scan --severity info`
- Maybe severity filter is hiding findings
**Common causes:**
- Docker daemon on different host (set DOCKER_HOST)
- Container exited (use --all or `docker ps -a`)
- Filters too restrictive (remove --severity and --cis flags)
### Issue Type 2: Rate Limit Errors
**Problem:** Error: "rate: Wait(n=1) would exceed context deadline"
**How to debug:**
1. Check how many containers: `docker ps | wc -l`
2. Check rate limit: Currently hardcoded at 50 req/sec
3. Increase workers to compensate: `--workers 50`
**Common causes:**
- Scanning 1000+ containers on slow network
- Rate limiter too conservative for fast local Docker
- Context deadline too short (check --timeout if we add it)
## Code Organization Principles
### Why analyzer/ is Structured This Way
```
analyzer/
├── analyzer.go # Interface definition
├── container.go # 300 lines - one file per target type
├── daemon.go # 150 lines
├── image.go # 120 lines
├── dockerfile.go # 280 lines
└── compose.go # 520 lines
```
We separate container from image from daemon because:
- Each analyzer talks to different Docker APIs (ContainerList vs ImageList vs Info)
- Each has different check logic (container mounts vs image USER instruction)
- Testing is easier when each analyzer is isolated
This makes finding specific code easy. Looking for container checks? Open container.go. Looking for Dockerfile checks? Open dockerfile.go.
### Naming Conventions
- `check*` functions return findings: `checkPrivileged()`, `checkCapabilities()`
- `analyze*` functions orchestrate checks: `analyzeContainer()`, `analyzeService()`
- `*Analyzer` structs implement Analyzer interface: `ContainerAnalyzer`, `DaemonAnalyzer`
- `*Reporter` structs implement Reporter interface: `TerminalReporter`, `JSONReporter`
Following these patterns makes it easier to scan code. See function named `checkMounts()`? You know it checks mounts and returns findings.
## Extending the Code
### Adding a New Container Check
Want to check for containers using `latest` image tag?
1. **Add check method** in `internal/analyzer/container.go`
```go
func (a *ContainerAnalyzer) checkImageTag(
target finding.Target,
info types.ContainerJSON,
) finding.Collection {
if strings.HasSuffix(info.Config.Image, ":latest") ||
!strings.Contains(info.Config.Image, ":") {
f := finding.New("CIS-5.27", "Container uses :latest tag",
finding.SeverityLow, target).
WithDescription("Using :latest makes container behavior unpredictable.").
WithRemediation("Use specific version tags like nginx:1.21.3")
return finding.Collection{f}
}
return nil
}
```
2. **Call it** from `analyzeContainer` (line 51-70)
```go
findings = append(findings, a.checkImageTag(target, info)...)
```
3. **Add CIS control** in `internal/benchmark/controls.go` if needed
```go
Register(Control{
ID: "5.27",
Section: "Container Runtime",
Title: "Ensure container images are not using :latest tag",
// ... rest of control
})
```
Done. Next scan will check image tags.
### Adding a New Secret Pattern
In `internal/rules/secrets.go`, append to SecretPatterns slice:
```go
{
Type: SecretTypeAPIKey,
Pattern: regexp.MustCompile(`myservice_[A-Za-z0-9]{32}`),
Description: "MyService API Key",
},
```
Dockerfile and compose analyzers automatically use all patterns. No other changes needed.
## Next Steps
You've seen how the code works. Now:
1. **Try the challenges** - [04-CHALLENGES.md](./04-CHALLENGES.md) has specific extension ideas
2. **Add a check** - Implement the latest tag check above to verify you understand analyzer pattern
3. **Run with --verbose** - Watch the concurrent execution happen in real time