Cybersecurity-Projects/PROJECTS/intermediate/docker-security-audit/learn/02-ARCHITECTURE.md

12 KiB

How the Scanner Works

This document explains the architecture of docksec. Not how to use it, but how it is built and why certain decisions were made.

The Big Picture

The scanner follows a simple pipeline:

Config → Docker Client → Analyzers → Findings → Filter → Reporter
  1. Parse CLI flags into a Config struct
  2. Create a single Docker client connection
  3. Build a list of analyzers based on what targets were requested
  4. Run all analyzers concurrently with a worker pool
  5. Collect findings from all analyzers
  6. Filter by severity if requested
  7. Format and output via the chosen reporter

Why a Single Docker Client

The Docker SDK uses HTTP connections over a Unix socket. Creating multiple clients would mean multiple connections, which wastes resources and can hit connection limits.

// internal/docker/client.go
var (
    instance *Client
    once     sync.Once
    initErr  error
)

func NewClient() (*Client, error) {
    once.Do(func() {
        cli, err := client.NewClientWithOpts(
            client.FromEnv,
            client.WithAPIVersionNegotiation(),
        )
        // ...
        instance = &Client{api: cli}
    })
    return instance, initErr
}

The sync.Once ensures only one client exists for the entire program. Every call to NewClient() returns the same instance. This is safe because the Docker SDK client is thread safe.

The WithAPIVersionNegotiation() option is important. Docker daemons and clients can have different API versions. Without negotiation, a newer client talking to an older daemon would fail. Negotiation picks the highest version both sides support.

Concurrency Model

Scanning can be slow. Each container inspection is a round trip to the Docker daemon. With dozens of containers, sequential scanning takes too long.

The scanner uses golang.org/x/sync/errgroup for concurrent execution:

// internal/scanner/scanner.go
func (s *Scanner) runAnalyzers(ctx context.Context, analyzers []analyzer.Analyzer) (finding.Collection, error) {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(s.cfg.Workers)

    results := make(chan finding.Collection, len(analyzers))

    for _, a := range analyzers {
        a := a  // capture loop variable
        g.Go(func() error {
            if err := s.limiter.Wait(ctx); err != nil {
                return err
            }
            findings, err := a.Analyze(ctx)
            // ...
            results <- findings
            return nil
        })
    }
    // ...
}

Why errgroup instead of raw goroutines

Raw goroutines require manual coordination:

  • You need a WaitGroup to know when all goroutines finish
  • You need to manually propagate errors
  • Context cancellation is your responsibility

errgroup handles all of this:

  • g.Wait() blocks until all goroutines complete
  • If any goroutine returns an error, the context gets cancelled
  • g.SetLimit(n) caps concurrent goroutines (built in worker pool)

The loop variable capture

for _, a := range analyzers {
    a := a  // This line is crucial
    g.Go(func() error {
        // use a
    })
}

Without a := a, all goroutines would share the same loop variable. By the time they execute, the loop has finished and a points to the last analyzer. Every goroutine would analyze the same thing.

The a := a creates a new variable scoped to each iteration, capturing the correct value.

Note: Go 1.22 fixed this behavior for for loops, but this code supports Go 1.21+ so the capture is still needed.

Rate Limiting

Even with a worker pool, you can overwhelm the Docker daemon with too many concurrent requests. The scanner uses a token bucket rate limiter:

limiter := rate.NewLimiter(
    rate.Limit(config.RateLimitPerSecond),  // 50/sec
    config.RateLimitBurst,                   // burst of 50
)

Before each analyzer runs, it must acquire a token:

if err := s.limiter.Wait(ctx); err != nil {
    return err
}

The token bucket works like this:

  • Bucket holds up to 50 tokens (burst)
  • Tokens refill at 50/second
  • Each request consumes one token
  • If no tokens available, Wait() blocks until one appears

This smooths out bursts. Even if 50 analyzers start simultaneously, they spread their Docker API calls over time.

The Analyzer Interface

All analyzers implement the same interface:

// internal/analyzer/analyzer.go
type Analyzer interface {
    Name() string
    Analyze(ctx context.Context) (finding.Collection, error)
}

This abstraction lets the scanner treat all analyzers uniformly. It does not care if an analyzer inspects containers, parses Dockerfiles, or queries the daemon. They all take a context and return findings.

Adding a new analyzer is straightforward:

  1. Implement the interface
  2. Add it to buildAnalyzers() in the scanner

The scanner never imports specific analyzer types beyond construction. It only works with the interface.

How Container Analysis Works

The container analyzer demonstrates the typical flow:

// internal/analyzer/container.go
func (a *ContainerAnalyzer) Analyze(ctx context.Context) (finding.Collection, error) {
    containers, err := a.client.ListContainers(ctx, true)
    if err != nil {
        return nil, err
    }

    var findings finding.Collection
    for _, c := range containers {
        info, err := a.client.InspectContainer(ctx, c.ID)
        if err != nil {
            continue  // Skip failed inspections
        }
        findings = append(findings, a.analyzeContainer(info)...)
    }
    return findings, nil
}
  1. List all containers (including stopped ones)
  2. Inspect each container for detailed configuration
  3. Run security checks against the inspection data
  4. Aggregate all findings

Each check method looks at specific fields:

func (a *ContainerAnalyzer) checkPrivileged(target finding.Target, info types.ContainerJSON) 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).
            WithRemediation(control.Remediation).
            // ...
        return finding.Collection{f}
    }
    return nil
}

The check:

  1. Examines a specific configuration field
  2. If misconfigured, looks up the CIS control for context
  3. Creates a finding with all relevant metadata

The Finding Type

Findings carry everything needed to understand and fix an issue:

// internal/finding/finding.go
type Finding struct {
    ID          string       // Unique hash for deduplication
    RuleID      string       // CIS control ID (e.g., "CIS-5.4")
    Title       string       // Short description
    Description string       // Full explanation
    Severity    Severity     // INFO, LOW, MEDIUM, HIGH, CRITICAL
    Category    string       // Grouping (Container Runtime, Dockerfile, etc.)
    Target      Target       // What was scanned (container:nginx, image:alpine, etc.)
    Location    *Location    // For files: path, line, column
    Remediation string       // How to fix it
    References  []string     // Links to documentation
    CISControl  *CISControl  // Original benchmark control
    Timestamp   time.Time    // When discovered
}

The ID is generated from a hash of the rule, target, and location:

func (f *Finding) generateID() string {
    data := fmt.Sprintf("%s|%s|%s|%s", f.RuleID, f.Target.Type, f.Target.Name, f.Target.ID)
    if f.Location != nil {
        data += fmt.Sprintf("|%s:%d", f.Location.Path, f.Location.Line)
    }
    hash := sha256.Sum256([]byte(data))
    return hex.EncodeToString(hash[:8])
}

This makes findings stable across scans. The same issue produces the same ID, which is useful for tracking remediation over time.

CIS Control Registry

The CIS Docker Benchmark has around 150 controls organized by section. Rather than hardcode them everywhere, they live in a central registry:

// internal/benchmark/controls.go
var controlRegistry = make(map[string]Control)

func Register(c Control) {
    controlRegistry[c.ID] = c
}

func Get(id string) (Control, bool) {
    c, ok := controlRegistry[id]
    return c, ok
}

Controls register themselves during init():

func init() {
    registerHostControls()
    registerDaemonControls()
    registerContainerRuntimeControls()
    // ...
}

func registerContainerRuntimeControls() {
    Register(Control{
        ID:          "5.4",
        Section:     "Container Runtime",
        Title:       "Ensure that privileged containers are not used",
        Severity:    finding.SeverityCritical,
        Description: "Privileged containers have all Linux kernel capabilities...",
        Remediation: "Do not run containers with --privileged flag...",
        // ...
    })
}

When an analyzer finds an issue, it looks up the control:

control, _ := benchmark.Get("5.4")

This keeps the rule metadata in one place. If the CIS benchmark updates, you change the registry, not every analyzer.

Reporter Abstraction

Output formats vary wildly (terminal colors vs JSON vs SARIF XML), but they all do the same thing: take findings and produce output.

// internal/report/reporter.go
type Reporter interface {
    Report(findings finding.Collection) error
}

Each format implements this interface:

  • TerminalReporter uses ANSI colors and tables
  • JSONReporter marshals to JSON
  • SARIFReporter produces SARIF for GitHub Security
  • JUnitReporter produces JUnit XML for CI

The scanner picks a reporter at startup based on --output:

reporter, err := report.NewReporter(cfg.Output, cfg.OutputFile)

Adding a new format means implementing Reporter and updating the factory function. The rest of the codebase stays unchanged.

Graceful Degradation

Some data sources are not always available. The /proc filesystem only exists on Linux. Some containers might not have all fields populated.

Instead of failing, the scanner degrades gracefully:

// internal/proc/proc.go
func GetProcessInfo(pid int) (*ProcessInfo, error) {
    // Status is required
    if err := info.readStatus(procPath); err != nil {
        return nil, fmt.Errorf("reading status: %w", err)
    }

    // These are optional - errors ignored
    if err := info.readCmdline(procPath); err != nil {
    }
    if err := info.readCgroups(procPath); err != nil {
    }
    // ...
}

The pattern: require critical data, ignore failures for optional data. This lets the scanner work in restricted environments (containers, non-Linux, limited permissions) while still providing value.

Error Handling Philosophy

The codebase follows Go conventions:

  • Return errors up the call stack
  • Wrap errors with context using fmt.Errorf("doing X: %w", err)
  • Let callers decide what to do with errors

Analyzer failures do not stop the scan:

findings, err := a.Analyze(ctx)
if err != nil {
    s.logger.Warn("analyzer failed", "name", a.Name(), "error", err)
    return nil  // Return nil error, not the actual error
}

One broken analyzer should not prevent others from running. The scan continues, logs the failure, and includes whatever findings succeeded.

Timeouts

Docker API calls have timeouts to prevent hangs:

// internal/config/constants.go
const (
    DefaultTimeout    = 30 * time.Second
    InspectTimeout    = 10 * time.Second
    ConnectionTimeout = 5 * time.Second
)

// internal/docker/client.go
func (c *Client) InspectContainer(ctx context.Context, containerID string) (types.ContainerJSON, error) {
    inspectCtx, cancel := context.WithTimeout(ctx, config.InspectTimeout)
    defer cancel()

    info, err := c.api.ContainerInspect(inspectCtx, containerID)
    // ...
}

Each operation creates a derived context with a specific timeout. If the Docker daemon is slow or stuck, the call times out rather than blocking forever.

The defer cancel() is important. Even if the call completes before the timeout, you must call cancel to release the timer resources. Without it, you leak goroutines.