Cybersecurity-Projects/PROJECTS/intermediate/docker-security-audit/learn/04-CHALLENGES.md

46 KiB

Hands On Challenges

These challenges are based on the actual codebase. Each one extends docksec with real security checks that matter in production environments.

Start with Level 1 if you're new to the codebase. Work through them sequentially because later challenges build on earlier concepts.

Level 1: Add Simple Checks

These challenges teach you the analyzer pattern without requiring deep Docker or security knowledge.

Challenge 1.1: Detect :latest Image Tags

What to build: Add a check that flags containers running images with the :latest tag or no tag at all.

Why it matters: docker run nginx pulls nginx:latest. Tomorrow's nginx:latest might be completely different. Version pinning prevents surprise breakage and security regressions.

Real incident: In 2019, Docker Hub compromise meant :latest tags pointed to backdoored images for several hours. Pinned versions were unaffected.

Where to start: File: internal/analyzer/container.go

Look at checkPrivileged() method (lines 72-86). Your check follows the same pattern:

func (a *ContainerAnalyzer) checkImageTag(
    target finding.Target,
    info types.ContainerJSON,
) finding.Collection {
    // Your code here
    // Hint: info.Config.Image contains the full image reference
    // Examples: "nginx:latest", "postgres:13.4", "redis" (no tag)
}

Implementation steps:

  1. Check if image string ends with :latest
  2. Check if image has no : character (defaults to :latest)
  3. Create finding using benchmark.Get("5.27") for CIS control
  4. Add call to this method in analyzeContainer() around line 60
  5. Test: docker run -d --name test nginx:latest && docksec scan

Expected output:

[MEDIUM] Container uses :latest tag
  Container: test
  CIS Control: 5.27
  Description: Using :latest makes image version unpredictable...

Hints:

  • Use strings.HasSuffix() and strings.Contains()
  • Empty tag defaults to :latest, so nginx and nginx:latest are equivalent
  • Don't flag nginx@sha256:abc123... (digest pinning is good)

Going deeper: After basic implementation works, handle edge cases:

  • Multi-arch manifests: nginx:latest@sha256:... has latest tag but digest pinned
  • Private registries: registry.example.com:5000/nginx:latest
  • Official images: nginx vs library/nginx vs docker.io/library/nginx

Challenge 1.2: Check for Exposed Sensitive Ports

What to build: Flag containers with published ports in sensitive ranges (like 22, 3389, 5432).

Why it matters: Accidentally exposing SSH (22) or RDP (3389) to 0.0.0.0 is common. Automated scanners find these in minutes.

Real example: In 2020, 45,000+ Docker hosts exposed port 2375 (Docker API) to the internet. Attackers used them for cryptomining.

Where to start: File: internal/analyzer/container.go

Container port bindings are in info.NetworkSettings.Ports. This is a map where keys are container ports and values are host bindings.

func (a *ContainerAnalyzer) checkExposedPorts(
    target finding.Target,
    info types.ContainerJSON,
) finding.Collection {
    // Your code here
    // Hint: info.NetworkSettings.Ports is map[nat.Port][]nat.PortBinding
}

Sensitive ports to check:

  • 22 (SSH)
  • 23 (Telnet)
  • 3306 (MySQL)
  • 5432 (PostgreSQL)
  • 6379 (Redis)
  • 27017 (MongoDB)
  • 3389 (RDP)
  • 5900 (VNC)

Implementation steps:

  1. Create internal/rules/ports.go with sensitive ports map:
   var SensitivePorts = map[int]string{
       22: "SSH",
       23: "Telnet",
       // ... rest
   }
  1. Iterate over info.NetworkSettings.Ports
  2. Parse port number from nat.Port (format: "8080/tcp")
  3. Check if HostIP is "0.0.0.0" or empty (empty means all interfaces)
  4. Create finding if port is sensitive and exposed to all interfaces

Expected output:

[HIGH] Sensitive port exposed to all interfaces
  Container: database
  Port: 5432 (PostgreSQL) -> 0.0.0.0:5432
  Remediation: Bind to localhost only: docker run -p 127.0.0.1:5432:5432

Hints:

  • nat.Port format is "3306/tcp" or "53/udp"
  • Use strconv.Atoi() to parse port number
  • HostIP "0.0.0.0" means all interfaces, empty string also means all interfaces
  • Binding to 127.0.0.1 limits access to local machine only

Going deeper:

  • Check for port ranges: -p 8000-9000:8000-9000 exposes 1000 ports
  • Detect IPv6 exposures: :: is IPv6 equivalent of 0.0.0.0
  • Kubernetes consideration: ClusterIP services don't show in container ports

Challenge 1.3: Find Containers Without Resource Limits

What to build: Check if containers have memory and CPU limits set.

Why it matters: Container without memory limit can consume all host RAM. One runaway Node.js process can kill every container on the host.

Real incident: Kubernetes cluster at Shopify took down production because one pod without memory limits leaked 64GB, triggered OOM killer that killed critical system pods.

Where to start: Resource limits are in info.HostConfig.Memory and info.HostConfig.NanoCpus.

func (a *ContainerAnalyzer) checkResourceLimits(
    target finding.Target,
    info types.ContainerJSON,
) finding.Collection {
    var findings finding.Collection
    
    // Memory limit
    if info.HostConfig.Memory == 0 {
        // Create finding
    }
    
    // CPU limit
    if info.HostConfig.NanoCpus == 0 {
        // Create finding
    }
    
    return findings
}

Implementation steps:

  1. Check Memory field (in bytes, 0 means unlimited)
  2. Check NanoCpus field (1 CPU = 1e9 nanocpus, 0 means unlimited)
  3. Create separate findings for each missing limit
  4. Use CIS control 5.10 for memory, 5.11 for CPU

Expected output:

[MEDIUM] Container has no memory limit
  Container: web-app
  CIS Control: 5.10
  Current: unlimited
  Remediation: docker run --memory=512m ...

[MEDIUM] Container has no CPU limit
  Container: web-app
  CIS Control: 5.11
  Current: unlimited
  Remediation: docker run --cpus=1.5 ...

Hints:

  • Memory is in bytes: 512MB = 536870912
  • NanoCpus uses scientific notation: 2 CPUs = 2000000000
  • Some platforms set minimum limits automatically (check MemoryReservation too)
  • CPU shares vs CPU quota: different controls, both matter

Going deeper:

  • Check MemorySwap (should be same as Memory or containers can swap to disk)
  • Check CPUShares (relative weights between containers)
  • Validate limits are reasonable (1MB memory limit will crash immediately)

Level 2: Build New Analyzers

These challenges require understanding Docker APIs and file parsing.

Challenge 2.1: Add Network Security Analyzer

What to build: New analyzer that checks Docker network configurations for security issues.

Security issues to detect:

  • Default bridge network usage (containers can talk to each other)
  • Networks without encryption
  • Networks with IPv6 enabled (often forgotten and unmonitored)
  • Custom networks without proper IPAM configuration

Why it matters: Docker's default bridge network has no isolation between containers. Compromised container can pivot to others on same network.

Real attack: 2018 Tesla Kubernetes cryptojacking used container networking to spread between pods, eventually compromising admin credentials.

Where to start: Create internal/analyzer/network.go.

The pattern:

type NetworkAnalyzer struct {
    client *docker.Client
}

func NewNetworkAnalyzer(client *docker.Client) *NetworkAnalyzer {
    return &NetworkAnalyzer{client: client}
}

func (a *NetworkAnalyzer) Name() string {
    return "network"
}

func (a *NetworkAnalyzer) Analyze(ctx context.Context) (finding.Collection, error) {
    networks, err := a.client.ListNetworks(ctx)
    if err != nil {
        return nil, err
    }
    
    var findings finding.Collection
    for _, network := range networks {
        findings = append(findings, a.checkNetwork(network)...)
    }
    
    return findings, nil
}

Docker SDK calls you'll need:

// List all networks
client.NetworkList(ctx, types.NetworkListOptions{})

// Inspect specific network
client.NetworkInspect(ctx, networkID, types.NetworkInspectOptions{})

Checks to implement:

  1. Default bridge detection:

    • Network name is "bridge" and Driver is "bridge"
    • Finding: Recommend user-defined networks with --network flag
  2. Encryption check:

    • User-defined overlay networks (Driver == "overlay")
    • Check Options["encrypted"] != "true"
    • Finding: Enable encryption with docker network create --opt encrypted
  3. IPv6 enabled:

    • Check EnableIPv6 == true
    • Finding: IPv6 often bypasses firewalls, ensure proper rules

Implementation steps:

  1. Create analyzer file following pattern above
  2. Add to buildAnalyzers() in scanner.go when target is "networks"
  3. Add network target to CLI flags
  4. Add CIS controls for network security (2.1 section)
  5. Test with: docker network ls && docksec scan --target networks

Expected output:

[MEDIUM] Containers using default bridge network
  Network: bridge
  Containers: web-1, web-2, db-1
  CIS Control: 2.1
  Remediation: Create user-defined network: docker network create --driver bridge app-network

Hints:

  • Network inspect shows which containers are connected
  • Some networks are system managed (ingress, docker_gwbridge) - filter these
  • Network driver determines features (bridge vs overlay vs macvlan)

Going deeper:

  • Check for network overlap with host subnets (routing conflicts)
  • Validate IPAM configuration prevents IP exhaustion
  • Detect networks exposed via published ports without firewall rules

Challenge 2.2: Add Volume Security Analyzer

What to build: Analyzer checking Docker volumes for sensitive data exposure and improper permissions.

Security checks:

  • Anonymous volumes (not managed, persist after container removal)
  • Volumes containing secrets or credentials
  • Volumes with world-readable permissions
  • Volumes mounted in multiple containers (unintended sharing)

Why it matters: Docker volumes persist data outside container filesystem. Deleted container leaves volume behind with potentially sensitive data.

Real problem: AWS found 2000+ publicly accessible Docker volumes containing database credentials and API keys from abandoned containers.

Where to start: Create internal/analyzer/volume.go.

func (a *VolumeAnalyzer) Analyze(ctx context.Context) (finding.Collection, error) {
    volumes, err := a.client.VolumeList(ctx, filters.Args{})
    if err != nil {
        return nil, err
    }
    
    var findings finding.Collection
    for _, vol := range volumes.Volumes {
        // Inspect volume
        // Check mountpoint permissions
        // Scan for secrets in volume data
        // Check usage (anonymous vs named)
    }
    
    return findings, nil
}

Docker SDK calls:

// List volumes
client.VolumeList(ctx, filters.Args{})

// Inspect volume (get mountpoint path)
client.VolumeInspect(ctx, volumeID)

// Find containers using volume
client.ContainerList(ctx, types.ContainerListOptions{
    Filters: filters.NewArgs(filters.Arg("volume", volumeName)),
})

Checks to implement:

  1. Anonymous volumes:

    • Name is hex string (64 chars)
    • No labels
    • Not referenced in any compose file
  2. Permission checks:

    • Read volume mountpoint: /var/lib/docker/volumes/<name>/_data
    • Check directory mode with os.Stat()
    • Flag if world-readable (mode & 0004)
  3. Secret scanning:

    • Recursively scan volume files
    • Use existing rules.DetectSecrets() on file contents
    • Limit scan to text files under 1MB
  4. Shared volumes:

    • Check if multiple containers mount same volume
    • Flag as potential unintended data sharing

Implementation steps:

  1. Create analyzer with volume listing
  2. Implement permission checking using os.Stat
  3. Add recursive file scanner for secrets (careful: volumes can be huge)
  4. Add container cross-reference to find shared volumes
  5. Handle permission errors gracefully (volumes may not be readable)

Expected output:

[HIGH] Volume contains potential secrets
  Volume: postgres_data
  File: /var/lib/docker/volumes/postgres_data/_data/pg_hba.conf
  Secret Type: Password
  Remediation: Use Docker secrets instead of files in volumes

[MEDIUM] Anonymous volume detected
  Volume: a4f32bc8d3e9f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6
  Containers: none (orphaned)
  Remediation: Remove with docker volume rm or use named volumes

Hints:

  • Volume mountpoint requires root access on host, handle permission denied
  • Don't scan binary files (check file extension and magic bytes)
  • Anonymous volume names are SHA256 hashes (64 hex chars)
  • Some volumes are managed by plugins (check Driver field)

Going deeper:

  • Integrate with cloud provider APIs to check volume encryption at rest
  • Check volume drivers for security (local vs cloud storage plugins)
  • Detect volumes mounted from untrusted sources (NFS, CIFS)

Challenge 2.3: Runtime Behavior Analyzer

What to build: Analyzer that monitors container runtime behavior and detects anomalies.

What to detect:

  • Processes running as root inside container
  • Processes with unexpected capabilities (from /proc//status)
  • Network connections to suspicious IPs/ports
  • File writes to sensitive paths

Why it matters: Static analysis catches build-time issues. Runtime analysis catches active exploitation.

Real use case: Kubernetes runtime security tools like Falco detect crypto miners by watching for processes with names like "xmrig" or CPU usage spikes.

Where to start: This is advanced. You'll need to:

  1. List container processes using /proc filesystem
  2. Read process capabilities from /proc/<pid>/status
  3. Monitor network connections from /proc/net/tcp
  4. Track file operations (requires inotify or BPF)

Implementation:

func (a *RuntimeAnalyzer) Analyze(ctx context.Context) (finding.Collection, error) {
    containers, _ := a.client.ListContainers(ctx, true)
    
    var findings finding.Collection
    for _, c := range containers {
        // Get container PID namespace
        inspect, _ := a.client.InspectContainer(ctx, c.ID)
        pid := inspect.State.Pid
        
        // Read /proc/<pid>/status for capabilities
        // Read /proc/<pid>/net/tcp for network connections
        // Check process user (UID 0 = root)
    }
    
    return findings, nil
}

Challenges within the challenge:

  1. Process enumeration:

    • Container processes are in host's PID namespace
    • Find container's root PID from inspect
    • Walk /proc/<pid>/task/ for threads
  2. Capability parsing:

    • /proc/<pid>/status has CapEff line (hex bitmask)
    • Convert hex to capability names using existing rules.Capabilities map
    • Flag unexpected capabilities (SYS_ADMIN in web server?)
  3. Network monitoring:

    • /proc/<pid>/net/tcp shows open connections
    • Parse hex IP addresses and ports
    • Flag connections to known bad IPs or unusual ports

Hints:

  • You'll need to exec into container or read host's /proc
  • Container PID in inspect is host PID, not container's PID 1
  • Capability mask is 64-bit hex, parse with strconv.ParseUint
  • Network monitoring creates race conditions (connections are transient)

Going deeper:

  • Use BPF/eBPF for low-overhead syscall monitoring
  • Integrate with threat intel feeds for IP reputation
  • Detect container escape attempts (suspicious syscalls)
  • Monitor file descriptor leaks and resource exhaustion

Level 3: Advanced Features

These challenges add significant functionality and require architectural changes.

Challenge 3.1: Add Remediation Scripts

What to build: Auto-generate shell scripts that fix detected issues.

Example: Finding: "Container running with --privileged" Generated script:

#!/bin/bash
# Fix for container 'web-1': Remove privileged flag

# Stop container
docker stop web-1

# Get current run command
docker inspect web-1 --format='{{.Config.Cmd}}'

# Recreate without --privileged
docker run -d \
  --name web-1 \
  --network bridge \
  --volume /app:/app \
  nginx:1.21.3

docker rm web-1  # Remove old container

Why it matters: Showing the problem is good. Showing how to fix it is better. Auto-generated scripts reduce time from finding to remediation.

Where to start: Add GenerateRemediation() method to each finding type.

File: internal/finding/finding.go

type Finding struct {
    // ... existing fields
    RemediationScript string  // Add this field
}

func (f *Finding) GenerateScript() string {
    switch f.RuleID {
    case "CIS-5.4":  // Privileged container
        return generatePrivilegedFix(f.Target)
    case "CIS-5.3":  // Dangerous capability
        return generateCapabilityFix(f.Target, extractCapability(f.Title))
    // ... other cases
    }
    return ""
}

Implementation:

  1. Extract container config from Docker inspect
  2. Build new docker run command without the security issue
  3. Include steps to backup/restore data if needed
  4. Add validation checks before executing

For privileged containers:

func generatePrivilegedFix(target finding.Target) string {
    // Get current container config
    // Remove --privileged flag
    // Regenerate docker run command
    // Include capability adds if needed
}

For capability issues:

func generateCapabilityFix(target finding.Target, cap string) string {
    // Get current caps
    // Remove dangerous cap
    // Suggest alternatives (SYS_ADMIN -> specific caps)
}

Output format:

# Generated by docksec
# WARNING: Review before executing

# Backup container volumes
docker run --rm --volumes-from web-1 -v $(pwd):/backup alpine tar czf /backup/web-1-volumes.tar.gz /data

# Stop and remove old container
docker stop web-1
docker rm web-1

# Recreate with security fixes
docker run -d \
  --name web-1 \
  --memory=512m \
  --cpu-shares=1024 \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  --network=app-network \
  -v /app/data:/data \
  nginx:1.21.3

echo "Container recreated. Verify functionality before deleting backup."

Hints:

  • Get original command using container inspect (Config.Cmd, HostConfig)
  • Preserve environment variables and labels
  • Handle volume mounts carefully (data loss risk)
  • Test generated scripts in non-production first

Going deeper:

  • Support docker-compose regeneration for compose-managed containers
  • Add rollback scripts (revert to original config)
  • Validate scripts with shellcheck before output
  • Support Kubernetes YAML patching for K8s deployments

Challenge 3.2: Policy-as-Code Engine

What to build: Let users define custom security policies in YAML, then enforce them.

Policy example:

# security-policy.yaml
policies:
  - id: company-baseline
    name: "Company Security Baseline"
    rules:
      - check: no-privileged
        severity: critical
        
      - check: require-user
        severity: high
        exclude:
          - images: ["mysql:*", "postgres:*"]  # DB containers need root
        
      - check: memory-limit
        severity: medium
        parameters:
          minimum: 128Mi
          maximum: 2Gi
          
      - check: allowed-registries
        severity: high
        parameters:
          registries:
            - "registry.company.com"
            - "gcr.io/company-*"

Why it matters: Different environments have different requirements. Dev tolerates :latest tags, prod doesn't. Policies encode these rules as code.

Real usage: Netflix Titus enforces policies via admission controllers. Rejected 40% of initial container requests due to policy violations.

Where to start: Create internal/policy/ package.

// internal/policy/policy.go
type Policy struct {
    ID    string
    Name  string
    Rules []Rule
}

type Rule struct {
    Check      string
    Severity   finding.Severity
    Parameters map[string]interface{}
    Exclude    *ExcludeConfig
}

type ExcludeConfig struct {
    Images     []string
    Containers []string
    Namespaces []string
}

func LoadPolicy(path string) (*Policy, error) {
    // Parse YAML file
}

func (p *Policy) Evaluate(findings finding.Collection) (finding.Collection, error) {
    // Filter findings based on policy rules
    // Apply exclusions
    // Override severities
}

Implementation steps:

  1. Define policy schema in Go structs
  2. Use gopkg.in/yaml.v3 for parsing
  3. Add policy evaluation layer between scanner and reporter
  4. Support pattern matching for image names (glob patterns)
  5. Add policy validation (catch invalid check names)

Policy evaluation logic:

func (r *Rule) Matches(f *finding.Finding) bool {
    // Check if finding matches rule check type
    if !r.matchesCheck(f.RuleID) {
        return false
    }
    
    // Apply exclusions
    if r.Exclude != nil && r.Exclude.Matches(f.Target) {
        return false
    }
    
    // Check parameters (memory limits, etc.)
    return r.matchesParameters(f)
}

Exclusion matching:

func (e *ExcludeConfig) Matches(target finding.Target) bool {
    for _, pattern := range e.Images {
        if matched, _ := filepath.Match(pattern, target.Name); matched {
            return true
        }
    }
    return false
}

Integrate with scanner:

// internal/scanner/scanner.go
func (s *Scanner) Scan(ctx context.Context) error {
    findings, _ := s.runAnalyzers(ctx, analyzers)
    
    // Apply policy if configured
    if s.cfg.PolicyFile != "" {
        policy, _ := policy.LoadPolicy(s.cfg.PolicyFile)
        findings, _ = policy.Evaluate(findings)
    }
    
    filtered := s.filterFindings(findings)
    s.reporter.Report(filtered)
}

Expected output:

Loading policy: security-policy.yaml
Policy: Company Security Baseline (company-baseline)
Evaluating 147 findings against 5 rules...

Excluded 12 findings:
  - mysql:8.0 (database images allowed to run as root)
  - postgres:13 (database images allowed to run as root)

Severity overrides applied: 3
  - CIS-5.27: LOW → MEDIUM (company policy)

Final results: 132 findings
  CRITICAL: 5
  HIGH: 23
  MEDIUM: 67
  LOW: 37

Hints:

  • Use yaml.v3 for parsing (better error messages than v2)
  • Validate policy on load (unknown check names should error)
  • Support multiple policies (layered: baseline + environment-specific)
  • Cache policy evaluation results (same finding checked multiple times)

Going deeper:

  • Support policy inheritance (extend base policy)
  • Add policy testing framework (test-policy.yaml)
  • Implement policy versioning and migration
  • Create policy library with common patterns (PCI-DSS, SOC2, etc.)

Challenge 3.3: Continuous Monitoring Mode

What to build: Long-running daemon that monitors Docker events and scans in real time.

Features:

  • Watch Docker event stream for container start/stop
  • Auto-scan new containers within seconds of creation
  • Send alerts on security violations (webhook, Slack, PagerDuty)
  • Maintain state of known-good containers vs flagged ones

Why it matters: Scheduled scans run hourly. Real-time monitoring catches issues in seconds.

Real scenario: Developer runs docker run --privileged to debug. Without monitoring, you discover it in next scan (1 hour). With monitoring, alert fires in 5 seconds.

Architecture:

Docker Event Stream
        ↓
Event Processor → Analyzer Queue → Scanner
        ↓
   Finding Store → Alert Router
        ↓
   Webhooks/Slack/etc

Where to start: Create cmd/docksec/daemon.go for daemon command.

func newDaemonCmd(cfg *config.Config) *cobra.Command {
    cmd := &cobra.Command{
        Use:   "daemon",
        Short: "Run continuous monitoring daemon",
        RunE: func(cmd *cobra.Command, args []string) error {
            return runDaemon(cmd.Context(), cfg)
        },
    }
    
    flags := cmd.Flags()
    flags.StringVar(&cfg.WebhookURL, "webhook", "", "Webhook URL for alerts")
    flags.DurationVar(&cfg.ScanInterval, "interval", 10*time.Second, "Scan interval")
    
    return cmd
}

Event processing:

// internal/daemon/daemon.go
func (d *Daemon) watchEvents(ctx context.Context) error {
    events, errs := d.client.Events(ctx, types.EventsOptions{
        Filters: filters.NewArgs(
            filters.Arg("type", "container"),
            filters.Arg("event", "start"),
            filters.Arg("event", "stop"),
        ),
    })
    
    for {
        select {
        case event := <-events:
            d.handleEvent(event)
        case err := <-errs:
            return err
        case <-ctx.Done():
            return ctx.Err()
        }
    }
}

func (d *Daemon) handleEvent(event events.Message) {
    switch event.Action {
    case "start":
        // Queue container for scanning
        d.scanQueue <- event.Actor.ID
    case "stop":
        // Remove from active tracking
        d.tracker.Remove(event.Actor.ID)
    }
}

Scanning queue:

func (d *Daemon) processScanQueue(ctx context.Context) {
    for {
        select {
        case containerID := <-d.scanQueue:
            // Scan single container
            findings := d.scanContainer(ctx, containerID)
            
            // Compare with baseline
            if d.hasNewFindings(containerID, findings) {
                d.sendAlert(containerID, findings)
            }
            
            // Update baseline
            d.baseline[containerID] = findings
            
        case <-ctx.Done():
            return
        }
    }
}

Alert sending:

func (d *Daemon) sendAlert(containerID string, findings finding.Collection) {
    critical := findings.FilterBySeverity(finding.SeverityCritical)
    if len(critical) == 0 {
        return
    }
    
    payload := map[string]interface{}{
        "container": containerID,
        "findings":  critical,
        "timestamp": time.Now(),
    }
    
    // Send to webhook
    d.webhook.Send(payload)
    
    // Send to Slack
    d.slack.Send(formatSlackMessage(payload))
}

State management:

// Track baseline findings per container
type FindingBaseline struct {
    mu        sync.RWMutex
    baselines map[string]finding.Collection  // containerID -> findings
}

func (fb *FindingBaseline) Update(containerID string, findings finding.Collection) {
    fb.mu.Lock()
    defer fb.mu.Unlock()
    fb.baselines[containerID] = findings
}

func (fb *FindingBaseline) HasChanged(containerID string, findings finding.Collection) bool {
    fb.mu.RLock()
    defer fb.mu.RUnlock()
    
    baseline, exists := fb.baselines[containerID]
    if !exists {
        return true  // New container
    }
    
    return !finding.Equal(baseline, findings)
}

Implementation steps:

  1. Create daemon command in cmd/docksec/daemon.go
  2. Implement event stream watching
  3. Add scan queue with rate limiting
  4. Build finding comparison logic
  5. Implement webhook alerts
  6. Add graceful shutdown handling

Expected output:

$ docksec daemon --webhook=https://hooks.slack.com/... --interval=10s

Starting docksec daemon...
Watching Docker events...
Baseline: 127 containers scanned

[2025-01-31 10:23:45] Container started: web-prod-3
[2025-01-31 10:23:50] Scan complete: 3 findings (1 CRITICAL)
[2025-01-31 10:23:50] ⚠️  Alert sent: Privileged container detected

[2025-01-31 10:25:12] Container stopped: worker-7
[2025-01-31 10:25:12] Removed from tracking

Slack alert format:

🚨 Critical Security Finding

Container: web-prod-3
Image: nginx:latest
Finding: Container running with --privileged flag
Severity: CRITICAL
CIS Control: 5.4

Started: 2025-01-31 10:23:45
Scanned: 2025-01-31 10:23:50

Remediation: Recreate container without --privileged flag

Hints:

  • Docker Events API can miss events if processing is slow (use buffered channel)
  • Rate limit scans to avoid overloading daemon
  • Persist baseline to disk (survive daemon restarts)
  • Handle container rename events (ID stays same, name changes)

Going deeper:

  • Add Prometheus metrics exporter (/metrics endpoint)
  • Implement alert deduplication (don't spam same alert)
  • Support alert routing rules (critical → PagerDuty, low → email)
  • Add web UI showing real-time container status
  • Integrate with SIEM systems (Splunk, Elasticsearch)

Level 4: Production Readiness

These challenges make docksec production-grade.

Challenge 4.1: Performance Optimization

What to build: Make scanner handle 10,000+ containers without timing out or consuming excessive memory.

Current bottlenecks:

  1. Sequential CIS control lookups: benchmark.Get("5.4") called for every finding

  2. String operations in hot path: strings.ToUpper() on every capability check

  3. Unbounded memory growth: All findings held in memory until end

Optimization 1: Pre-compute control lookups

Before:

func (a *ContainerAnalyzer) checkPrivileged(...) finding.Collection {
    control, _ := benchmark.Get("5.4")  // Map lookup every call
    f := finding.New("CIS-5.4", control.Title, ...)
}

After:

var (
    controlPrivileged = benchmark.MustGet("5.4")  // Lookup once at init
    controlCapabilities = benchmark.MustGet("5.3")
    controlMounts = benchmark.MustGet("5.5")
)

func (a *ContainerAnalyzer) checkPrivileged(...) finding.Collection {
    f := finding.New("CIS-5.4", controlPrivileged.Title, ...)
}

Optimization 2: Capability map keygen

Before:

cap = strings.ToUpper(string(cap))  // Allocates new string
capInfo, exists := rules.GetCapabilityInfo(cap)

After:

// Build lookup with both cases at init
var capabilityLookup = buildCapabilityLookup()

func buildCapabilityLookup() map[string]CapabilityInfo {
    m := make(map[string]CapabilityInfo, len(Capabilities)*2)
    for cap, info := range Capabilities {
        m[cap] = info
        m[strings.ToLower(cap)] = info  // Support lowercase
        m[strings.TrimPrefix(cap, "CAP_")] = info  // Without prefix
    }
    return m
}

// Now direct lookup without string operations
capInfo, exists := capabilityLookup[string(cap)]

Optimization 3: Streaming output

Before:

// Collect all findings
findings = append(findings, ...)

// Write at end
reporter.Report(findings)

After:

// Stream findings as discovered
findingsChan := make(chan finding.Finding, 100)

go func() {
    for f := range findingsChan {
        reporter.ReportOne(f)  // Write immediately
    }
}()

// Analyzer sends to channel
findingsChan <- f

Benchmark results:

Before optimizations:
  1000 containers: 12.3s, 847MB
  10000 containers: 183s, 8.4GB

After optimizations:
  1000 containers: 3.1s, 124MB
  10000 containers: 35s, 980MB

Improvement: 5.2x faster, 8.6x less memory

Implementation steps:

  1. Profile with go test -cpuprofile=cpu.prof -memprofile=mem.prof
  2. Analyze with go tool pprof cpu.prof
  3. Identify hot paths (capability checking, control lookups)
  4. Pre-compute lookups at package init
  5. Add streaming output option to reporters
  6. Re-benchmark and compare

Hints:

  • Use benchmark_test.go to measure improvements
  • pprof shows exact functions consuming CPU/memory
  • Prematurely optimizing is bad, but these are measured bottlenecks
  • Trade memory for speed (pre-computed maps) when map is small

Challenge 4.2: Comprehensive Test Suite

What to build: Achieve 80%+ code coverage with meaningful tests, not just line coverage.

Test categories:

  1. Unit tests: Individual functions in isolation
  2. Integration tests: Components working together
  3. End-to-end tests: Full scan workflow
  4. Fuzzing: Random input handling

Unit test example:

File: internal/rules/capabilities_test.go

func TestCapabilityRiskLevels(t *testing.T) {
    tests := []struct {
        capability string
        minSeverity finding.Severity
    }{
        {"CAP_SYS_ADMIN", finding.SeverityCritical},
        {"CAP_SYS_PTRACE", finding.SeverityCritical},
        {"CAP_NET_ADMIN", finding.SeverityHigh},
        {"CAP_NET_BIND_SERVICE", finding.SeverityLow},
    }
    
    for _, tt := range tests {
        t.Run(tt.capability, func(t *testing.T) {
            info, exists := GetCapabilityInfo(tt.capability)
            if !exists {
                t.Fatalf("capability %s not found", tt.capability)
            }
            if info.Severity < tt.minSeverity {
                t.Errorf("severity = %v, want >= %v", 
                    info.Severity, tt.minSeverity)
            }
        })
    }
}

Integration test example:

File: internal/analyzer/container_test.go

func TestContainerAnalyzer_PrivilegedDetection(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping integration test")
    }
    
    // Start test Docker container
    ctx := context.Background()
    client, _ := docker.NewClient()
    
    containerID, cleanup := createPrivilegedContainer(t, ctx, client)
    defer cleanup()
    
    // Run analyzer
    analyzer := NewContainerAnalyzer(client)
    findings, err := analyzer.Analyze(ctx)
    
    if err != nil {
        t.Fatalf("Analyze() error = %v", err)
    }
    
    // Verify finding
    var found bool
    for _, f := range findings {
        if f.RuleID == "CIS-5.4" && f.Target.ID == containerID {
            found = true
            if f.Severity != finding.SeverityCritical {
                t.Errorf("severity = %v, want CRITICAL", f.Severity)
            }
        }
    }
    
    if !found {
        t.Error("privileged container not detected")
    }
}

func createPrivilegedContainer(t *testing.T, ctx context.Context, client *docker.Client) (string, func()) {
    resp, err := client.CreateContainer(ctx, &container.Config{
        Image: "alpine:latest",
        Cmd:   []string{"sleep", "3600"},
    }, &container.HostConfig{
        Privileged: true,
    }, nil, nil, "")
    
    if err != nil {
        t.Fatalf("create container: %v", err)
    }
    
    client.StartContainer(ctx, resp.ID, types.ContainerStartOptions{})
    
    cleanup := func() {
        client.StopContainer(ctx, resp.ID, 1)
        client.RemoveContainer(ctx, resp.ID, types.ContainerRemoveOptions{Force: true})
    }
    
    return resp.ID, cleanup
}

End-to-end test example:

File: cmd/docksec/main_test.go

func TestFullScanWorkflow(t *testing.T) {
    // Setup test environment
    client, _ := docker.NewClient()
    ctx := context.Background()
    
    // Create containers with known issues
    privilegedID, _ := createPrivilegedContainer(t, ctx, client)
    noUserID, _ := createRootUserContainer(t, ctx, client)
    defer cleanupContainers(t, ctx, client, privilegedID, noUserID)
    
    // Run scan
    output := runDocksec(t, []string{"scan", "--target", "containers", "--output", "json"})
    
    // Parse JSON output
    var result struct {
        Findings []finding.Finding `json:"findings"`
    }
    json.Unmarshal([]byte(output), &result)
    
    // Verify expected findings
    if len(result.Findings) < 2 {
        t.Errorf("got %d findings, want at least 2", len(result.Findings))
    }
    
    hasPrivileged := false
    hasRootUser := false
    for _, f := range result.Findings {
        if f.RuleID == "CIS-5.4" {
            hasPrivileged = true
        }
        if f.RuleID == "CIS-4.1" {
            hasRootUser = true
        }
    }
    
    if !hasPrivileged {
        t.Error("privileged container not detected")
    }
    if !hasRootUser {
        t.Error("root user not detected")
    }
}

func runDocksec(t *testing.T, args []string) string {
    cmd := exec.Command("./docksec", args...)
    output, err := cmd.CombinedOutput()
    if err != nil {
        t.Fatalf("docksec failed: %v\n%s", err, output)
    }
    return string(output)
}

Fuzzing test example:

File: internal/parser/dockerfile_test.go

func FuzzDockerfileParsing(f *testing.F) {
    // Seed corpus with valid Dockerfiles
    f.Add("FROM alpine\nRUN echo hello")
    f.Add("FROM scratch\nCOPY binary /")
    f.Add("FROM ubuntu:20.04\nUSER nobody")
    
    f.Fuzz(func(t *testing.T, input string) {
        // Should never panic
        result, err := parser.Parse(strings.NewReader(input))
        
        if err != nil {
            return  // Invalid syntax is fine
        }
        
        // If parsed successfully, AST should be valid
        if result.AST == nil {
            t.Error("nil AST with no error")
        }
    })
}

Run fuzzing: go test -fuzz=FuzzDockerfileParsing -fuzztime=1m

Coverage measurement:

# Generate coverage
go test -coverprofile=coverage.out ./...

# View in browser
go tool cover -html=coverage.out

# Check percentage
go tool cover -func=coverage.out | grep total

Target coverage:

  • Rules package: 90% (pure logic, easy to test)
  • Analyzers: 75% (Docker integration, some paths hard to test)
  • Scanner: 80% (orchestration logic)
  • Reporter: 85% (output formatting)
  • Overall: 80%

Implementation steps:

  1. Add unit tests for all rule packages
  2. Add integration tests requiring Docker (use build tags)
  3. Add e2e tests in cmd/ package
  4. Add fuzzing for parsers
  5. Set up CI to fail if coverage drops below 75%

Hints:

  • Use table-driven tests (easier to add cases)
  • Mock Docker client for unit tests (testify/mock)
  • Use build tags to separate integration tests: // +build integration
  • golden files for expected outputs (terminal reporter)

Challenge 4.3: Kubernetes Support

What to build: Extend scanner to work with Kubernetes pods, analyzing containers via K8s API instead of Docker API.

Why it matters: Production deployments use Kubernetes. Need to scan pods, check Pod Security Standards, validate security contexts.

Kubernetes-specific checks:

  1. Pod running as privileged
  2. hostNetwork, hostPID, hostIPC enabled
  3. Capabilities added beyond defaults
  4. securityContext missing or permissive
  5. Service accounts with excessive permissions
  6. Pod Security Standards violations

Where to start: Create internal/k8s/ package with Kubernetes client wrapper.

// internal/k8s/client.go
import (
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/rest"
)

type Client struct {
    clientset *kubernetes.Clientset
}

func NewClient() (*Client, error) {
    // In-cluster config (when running as pod)
    config, err := rest.InClusterConfig()
    if err != nil {
        // Fallback to kubeconfig
        config, err = clientcmd.BuildConfigFromFlags("", kubeconfig)
    }
    
    clientset, err := kubernetes.NewForConfig(config)
    return &Client{clientset: clientset}, nil
}

Create Kubernetes analyzer:

File: internal/analyzer/kubernetes.go

type KubernetesAnalyzer struct {
    client *k8s.Client
}

func (a *KubernetesAnalyzer) Analyze(ctx context.Context) (finding.Collection, error) {
    pods, err := a.client.ListPods(ctx, metav1.ListOptions{})
    if err != nil {
        return nil, err
    }
    
    var findings finding.Collection
    for _, pod := range pods.Items {
        findings = append(findings, a.analyzePod(pod)...)
    }
    
    return findings, nil
}

func (a *KubernetesAnalyzer) analyzePod(pod v1.Pod) finding.Collection {
    var findings finding.Collection
    
    target := finding.Target{
        Type: finding.TargetPod,
        Name: pod.Namespace + "/" + pod.Name,
        ID:   string(pod.UID),
    }
    
    // Check pod-level security context
    if pod.Spec.HostNetwork {
        f := finding.New("K8S-PSS-BASELINE", "Pod uses host network", 
            finding.SeverityHigh, target).
            WithDescription("hostNetwork: true gives pod access to host's network stack.")
        findings = append(findings, f)
    }
    
    // Check each container
    for _, container := range pod.Spec.Containers {
        findings = append(findings, a.analyzeContainer(target, container)...)
    }
    
    return findings
}

func (a *KubernetesAnalyzer) analyzeContainer(
    podTarget finding.Target, 
    container v1.Container,
) finding.Collection {
    var findings finding.Collection
    
    // Check security context
    if container.SecurityContext != nil {
        if container.SecurityContext.Privileged != nil && 
           *container.SecurityContext.Privileged {
            f := finding.New("K8S-PRIVILEGED", "Privileged container in pod", 
                finding.SeverityCritical, podTarget).
                WithDescription("Container " + container.Name + " runs as privileged.")
            findings = append(findings, f)
        }
        
        // Check capabilities
        if container.SecurityContext.Capabilities != nil {
            for _, cap := range container.SecurityContext.Capabilities.Add {
                capInfo, exists := rules.GetCapabilityInfo(string(cap))
                if exists && capInfo.Severity >= finding.SeverityHigh {
                    f := finding.New("K8S-CAP-ADD", "Dangerous capability added",
                        capInfo.Severity, podTarget).
                        WithDescription("Container " + container.Name + 
                            " adds capability " + string(cap))
                    findings = append(findings, f)
                }
            }
        }
    }
    
    // Check if running as root
    if container.SecurityContext == nil || 
       container.SecurityContext.RunAsNonRoot == nil ||
       !*container.SecurityContext.RunAsNonRoot {
        f := finding.New("K8S-ROOT-USER", "Container may run as root",
            finding.SeverityMedium, podTarget).
            WithDescription("Container " + container.Name + 
                " does not enforce non-root user.")
        findings = append(findings, f)
    }
    
    return findings
}

Pod Security Standards mapping:

// internal/k8s/pss.go
type PodSecurityStandard string

const (
    PSSPrivileged PodSecurityStandard = "privileged"
    PSSBaseline   PodSecurityStandard = "baseline"
    PSSRestricted PodSecurityStandard = "restricted"
)

func EvaluatePSS(pod v1.Pod) (PodSecurityStandard, []string) {
    violations := []string{}
    
    // Privileged: no restrictions
    // Baseline: minimal restrictions
    // Restricted: hardened configuration
    
    // Check baseline violations
    if pod.Spec.HostNetwork {
        violations = append(violations, "hostNetwork must be false")
    }
    if pod.Spec.HostPID {
        violations = append(violations, "hostPID must be false")
    }
    if pod.Spec.HostIPC {
        violations = append(violations, "hostIPC must be false")
    }
    
    // If baseline violated, return baseline
    if len(violations) > 0 {
        return PSSBaseline, violations
    }
    
    // Check restricted violations
    for _, container := range pod.Spec.Containers {
        if container.SecurityContext == nil {
            violations = append(violations, container.Name + ": missing securityContext")
            continue
        }
        
        if container.SecurityContext.AllowPrivilegeEscalation == nil ||
           *container.SecurityContext.AllowPrivilegeEscalation {
            violations = append(violations, container.Name + 
                ": allowPrivilegeEscalation must be false")
        }
        
        if container.SecurityContext.RunAsNonRoot == nil ||
           !*container.SecurityContext.RunAsNonRoot {
            violations = append(violations, container.Name + 
                ": runAsNonRoot must be true")
        }
        
        if container.SecurityContext.SeccompProfile == nil ||
           container.SecurityContext.SeccompProfile.Type != v1.SeccompProfileTypeRuntimeDefault {
            violations = append(violations, container.Name + 
                ": seccompProfile must be RuntimeDefault")
        }
    }
    
    if len(violations) > 0 {
        return PSSRestricted, violations
    }
    
    return PSSRestricted, nil
}

CLI integration:

// cmd/docksec/main.go
func newScanCmd(cfg *config.Config) *cobra.Command {
    cmd := &cobra.Command{
        Use:   "scan",
        Short: "Scan containers for security issues",
        RunE:  runScan,
    }
    
    flags := cmd.Flags()
    flags.StringSliceVarP(&cfg.Targets, "target", "t", []string{"all"},
        "Scan targets: docker, kubernetes, all")
    flags.BoolVar(&cfg.K8sEnabled, "k8s", false, "Enable Kubernetes scanning")
    flags.StringVar(&cfg.Kubeconfig, "kubeconfig", "", "Path to kubeconfig")
    
    return cmd
}

Example output:

Scanning Kubernetes cluster...
Namespace: default

[CRITICAL] Privileged container in pod
  Pod: default/nginx-deployment-7d6c4f8b9-x7k2m
  Container: nginx
  CIS Control: K8S-PRIVILEGED
  Remediation: Remove privileged: true from securityContext

[HIGH] Pod uses host network
  Pod: default/monitoring-agent-dw8qz
  PSS Violation: Baseline
  Remediation: Set hostNetwork: false

[MEDIUM] Container may run as root
  Pod: default/web-app-5b8c9d6f4-mz3lp
  Container: app
  PSS Violation: Restricted
  Remediation: Set securityContext.runAsNonRoot: true

Summary:
  Pods scanned: 47
  PSS Restricted: 12
  PSS Baseline: 23
  PSS Privileged: 12
  Total findings: 89

Implementation steps:

  1. Add k8s.io/client-go dependency
  2. Create k8s client wrapper
  3. Implement Kubernetes analyzer
  4. Add PSS evaluation logic
  5. Extend Target type for pods
  6. Add K8s-specific CIS controls

Hints:

  • Use k8s.io/client-go v0.28.0 or later
  • Handle both in-cluster and kubeconfig authentication
  • Pod security context is different from container security context
  • Some checks apply to pod, some to containers
  • Watch for nil pointer dereference (many K8s fields are pointers)

Going deeper:

  • Add NetworkPolicy analysis (check for default deny)
  • Scan PodSecurityPolicy/PodSecurityAdmission configs
  • Check RBAC permissions (overly permissive service accounts)
  • Validate admission controller configurations
  • Scan Helm charts before deployment

Bonus Challenges

Bonus 1: CVE Scanning Integration

Integrate with Trivy or Grype to add vulnerability scanning.

Bonus 2: Compliance Report Generator

Generate compliance reports for SOC2, PCI-DSS, HIPAA based on findings.

Bonus 3: Machine Learning Anomaly Detection

Use ML to detect containers behaving abnormally compared to historical patterns.

Bonus 4: Docker Compose Graph Analyzer

Build dependency graph from compose files, detect circular dependencies and over-permissive network configurations.

Bonus 5: Browser Extension

Create browser extension that runs docksec when viewing Dockerfiles on GitHub.

Getting Help

Stuck on a challenge?

  1. Check the existing code for similar patterns
  2. Read Docker SDK documentation: https://pkg.go.dev/github.com/docker/docker
  3. Review CIS Docker Benchmark: https://www.cisecurity.org/benchmark/docker
  4. Look at similar tools: Docker Bench, Trivy, Falco source code

Found a bug while implementing?

That's part of the learning process. Debug it:

  • Add log statements in analyzer
  • Run with --verbose flag
  • Use docker inspect to verify expected values
  • Check Docker daemon logs: journalctl -u docker

Want to contribute your solution?

Write clean code, add tests, update documentation. These challenges make good portfolio projects.