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:
- Check if image string ends with
:latest - Check if image has no
:character (defaults to :latest) - Create finding using benchmark.Get("5.27") for CIS control
- Add call to this method in
analyzeContainer()around line 60 - 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()andstrings.Contains() - Empty tag defaults to
:latest, songinxandnginx:latestare 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:
nginxvslibrary/nginxvsdocker.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:
- Create
internal/rules/ports.gowith sensitive ports map:
var SensitivePorts = map[int]string{
22: "SSH",
23: "Telnet",
// ... rest
}
- Iterate over
info.NetworkSettings.Ports - Parse port number from nat.Port (format: "8080/tcp")
- Check if HostIP is "0.0.0.0" or empty (empty means all interfaces)
- 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.Portformat 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-9000exposes 1000 ports - Detect IPv6 exposures:
::is IPv6 equivalent of0.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:
- Check
Memoryfield (in bytes, 0 means unlimited) - Check
NanoCpusfield (1 CPU = 1e9 nanocpus, 0 means unlimited) - Create separate findings for each missing limit
- 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:
Memoryis in bytes: 512MB = 536870912NanoCpususes 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:
-
Default bridge detection:
- Network name is "bridge" and Driver is "bridge"
- Finding: Recommend user-defined networks with
--networkflag
-
Encryption check:
- User-defined overlay networks (Driver == "overlay")
- Check Options["encrypted"] != "true"
- Finding: Enable encryption with
docker network create --opt encrypted
-
IPv6 enabled:
- Check EnableIPv6 == true
- Finding: IPv6 often bypasses firewalls, ensure proper rules
Implementation steps:
- Create analyzer file following pattern above
- Add to
buildAnalyzers()in scanner.go when target is "networks" - Add network target to CLI flags
- Add CIS controls for network security (2.1 section)
- 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:
-
Anonymous volumes:
- Name is hex string (64 chars)
- No labels
- Not referenced in any compose file
-
Permission checks:
- Read volume mountpoint:
/var/lib/docker/volumes/<name>/_data - Check directory mode with
os.Stat() - Flag if world-readable (mode & 0004)
- Read volume mountpoint:
-
Secret scanning:
- Recursively scan volume files
- Use existing
rules.DetectSecrets()on file contents - Limit scan to text files under 1MB
-
Shared volumes:
- Check if multiple containers mount same volume
- Flag as potential unintended data sharing
Implementation steps:
- Create analyzer with volume listing
- Implement permission checking using os.Stat
- Add recursive file scanner for secrets (careful: volumes can be huge)
- Add container cross-reference to find shared volumes
- 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:
- List container processes using
/procfilesystem - Read process capabilities from
/proc/<pid>/status - Monitor network connections from
/proc/net/tcp - 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:
-
Process enumeration:
- Container processes are in host's PID namespace
- Find container's root PID from inspect
- Walk
/proc/<pid>/task/for threads
-
Capability parsing:
/proc/<pid>/statushas CapEff line (hex bitmask)- Convert hex to capability names using existing rules.Capabilities map
- Flag unexpected capabilities (SYS_ADMIN in web server?)
-
Network monitoring:
/proc/<pid>/net/tcpshows 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:
- Extract container config from Docker inspect
- Build new docker run command without the security issue
- Include steps to backup/restore data if needed
- 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:
- Define policy schema in Go structs
- Use gopkg.in/yaml.v3 for parsing
- Add policy evaluation layer between scanner and reporter
- Support pattern matching for image names (glob patterns)
- 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:
- Create daemon command in cmd/docksec/daemon.go
- Implement event stream watching
- Add scan queue with rate limiting
- Build finding comparison logic
- Implement webhook alerts
- 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:
-
Sequential CIS control lookups:
benchmark.Get("5.4")called for every finding -
String operations in hot path:
strings.ToUpper()on every capability check -
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:
- Profile with
go test -cpuprofile=cpu.prof -memprofile=mem.prof - Analyze with
go tool pprof cpu.prof - Identify hot paths (capability checking, control lookups)
- Pre-compute lookups at package init
- Add streaming output option to reporters
- Re-benchmark and compare
Hints:
- Use
benchmark_test.goto 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:
- Unit tests: Individual functions in isolation
- Integration tests: Components working together
- End-to-end tests: Full scan workflow
- 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:
- Add unit tests for all rule packages
- Add integration tests requiring Docker (use build tags)
- Add e2e tests in cmd/ package
- Add fuzzing for parsers
- 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:
- Pod running as privileged
- hostNetwork, hostPID, hostIPC enabled
- Capabilities added beyond defaults
- securityContext missing or permissive
- Service accounts with excessive permissions
- 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:
- Add k8s.io/client-go dependency
- Create k8s client wrapper
- Implement Kubernetes analyzer
- Add PSS evaluation logic
- Extend Target type for pods
- 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?
- Check the existing code for similar patterns
- Read Docker SDK documentation: https://pkg.go.dev/github.com/docker/docker
- Review CIS Docker Benchmark: https://www.cisecurity.org/benchmark/docker
- 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 inspectto 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.