Cybersecurity-Projects/PROJECTS/docker-security-audit/learn/codebase-guide.md

16 KiB

Codebase Guide

A walkthrough of the code structure and what each package does. Start here if you want to understand how the pieces fit together.

Directory Layout

cmd/docksec/          Entry point and CLI commands
internal/
  analyzer/           Security checks for different target types
  benchmark/          CIS Docker Benchmark control definitions
  config/             Runtime configuration and constants
  docker/             Docker SDK wrapper
  finding/            The Finding type and severity levels
  parser/             Dockerfile and compose file parsers
  proc/               Linux /proc filesystem inspection
  report/             Output formatters (terminal, JSON, SARIF, JUnit)
  rules/              Security rule data (capabilities, paths, secrets)
  scanner/            Orchestration layer that ties everything together

cmd/docksec

The CLI is built with Cobra. Each command is a separate file.

main.go defines version variables that get overwritten at build time:

var (
    version   = "dev"
    commit    = "none"
    buildDate = "unknown"
)

When you run go build -ldflags "-X main.version=1.0.0", the compiler replaces the string literal before creating the binary.

scan.go is where the actual work starts. It creates a Config from flags, instantiates a Scanner, and calls Run:

cfg := &config.Config{
    Targets:  targets,
    Files:    files,
    Output:   outputFormat,
    // ...
}
scanner, _ := scanner.New(cfg)
scanner.Run(ctx)

internal/scanner

This is the orchestration layer. It creates analyzers based on config, runs them concurrently, collects findings, filters them, and sends to a reporter.

The concurrency model uses errgroup with a semaphore:

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(s.cfg.Workers)  // Max concurrent goroutines

for _, a := range analyzers {
    a := a
    g.Go(func() error {
        s.limiter.Wait(ctx)  // Rate limit Docker API calls
        findings, _ := a.Analyze(ctx)
        results <- findings
        return nil
    })
}

The rate limiter prevents overwhelming the Docker daemon. Even if you set 50 workers, they spread their API calls over time.

internal/analyzer

Each analyzer implements this interface:

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

There are five implementations:

File Target What it checks
container.go Running containers Privileged mode, capabilities, mounts, namespaces, security profiles, resource limits
image.go Local images User instruction, secrets in history, base image tags
daemon.go Docker daemon Insecure registries, ICC, user namespaces, experimental features
dockerfile.go Dockerfile files USER instruction, ADD vs COPY, secrets in ENV/ARG, HEALTHCHECK
compose.go Compose files Same as container checks, but for service definitions

Container analyzer is the most complex. It lists all containers, inspects each one, and runs checks:

func (a *ContainerAnalyzer) Analyze(ctx context.Context) (finding.Collection, error) {
    containers, _ := a.client.ListContainers(ctx, true)

    var findings finding.Collection
    for _, c := range containers {
        info, _ := a.client.InspectContainer(ctx, c.ID)
        findings = append(findings, a.analyzeContainer(info)...)
    }
    return findings, nil
}

func (a *ContainerAnalyzer) analyzeContainer(info types.ContainerJSON) finding.Collection {
    // Each method checks one thing
    findings = append(findings, a.checkPrivileged(target, info)...)
    findings = append(findings, a.checkCapabilities(target, info)...)
    findings = append(findings, a.checkMounts(target, info)...)
    // ...
}

Each check looks up the relevant CIS control for metadata:

func (a *ContainerAnalyzer) checkPrivileged(...) 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
}

internal/benchmark

Contains all CIS Docker Benchmark v1.6.0 controls as Go structs. They 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...",
        Scored:      true,
        Level:       1,
    })
}

The global registry allows lookup by ID:

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

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

internal/finding

The Finding struct carries everything about a discovered issue:

type Finding struct {
    ID          string       // Hash for deduplication
    RuleID      string       // CIS control ID
    Title       string       // Short description
    Description string       // Full explanation
    Severity    Severity     // INFO, LOW, MEDIUM, HIGH, CRITICAL
    Category    string       // Container Runtime, Dockerfile, etc.
    Target      Target       // What was scanned
    Location    *Location    // For files: path and line number
    Remediation string       // How to fix it
    References  []string     // Documentation links
    CISControl  *CISControl  // Original benchmark control
    Timestamp   time.Time    // When discovered
}

Findings are created with a builder pattern:

f := finding.New("CIS-5.4", "Privileged container", finding.SeverityCritical, target).
    WithDescription("...").
    WithRemediation("...").
    WithReferences("https://...")

The ID is generated from a hash of rule, target, and location. This makes findings stable across scans:

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

Same issue on same target produces same ID. Useful for tracking remediation over time.

internal/rules

Contains security rule data organized by category:

capabilities.go

Maps all 40+ Linux capabilities to severity and descriptions:

var Capabilities = map[string]CapabilityInfo{
    "CAP_SYS_ADMIN": {
        Severity:    finding.SeverityCritical,
        Description: "Effectively root - mount filesystems, quotas, namespaces...",
    },
    "CAP_NET_RAW": {
        Severity:    finding.SeverityMedium,
        Description: "Craft arbitrary packets, ARP/DNS spoofing, packet sniffing.",
    },
    // ...
}

Pre computed lookup maps make checks fast:

var dangerousCapabilities = func() map[string]struct{} {
    m := make(map[string]struct{})
    for cap, info := range Capabilities {
        if info.Severity >= finding.SeverityHigh {
            m[cap] = struct{}{}
        }
    }
    return m
}()

func IsDangerousCapability(cap string) bool {
    _, exists := dangerousCapabilities[strings.ToUpper(cap)]
    return exists
}

paths.go

Catalogs 200+ sensitive host paths with severity and descriptions:

var SensitiveHostPaths = map[string]PathInfo{
    "/etc/shadow": {
        Severity:    finding.SeverityCritical,
        Description: "Password hashes. Direct credential access.",
    },
    "/var/run/docker.sock": {
        Severity:    finding.SeverityCritical,
        Description: "Docker daemon socket. Full control over Docker, container escape possible.",
    },
    // ...
}

Path matching handles prefixes:

func IsSensitivePath(path string) bool {
    if _, exists := sensitivePathLookup[path]; exists {
        return true
    }
    // Check if path is under a sensitive directory
    for sensitivePath := range sensitivePathLookup {
        if strings.HasPrefix(path, sensitivePath+"/") {
            return true
        }
    }
    return false
}

So /etc/shadow matches, but so does /etc/foo (because /etc is sensitive).

secrets.go

Pattern matching for secrets in Dockerfiles:

var SecretPatterns = []SecretPattern{
    {
        Type:        SecretTypeAWSKey,
        Pattern:     regexp.MustCompile(`(?i)(AKIA|ABIA|ACCA|ASIA)[0-9A-Z]{16}`),
        Description: "AWS Access Key ID",
    },
    {
        Type:        SecretTypeGitHub,
        Pattern:     regexp.MustCompile(`(?i)(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}`),
        Description: "GitHub Personal Access Token",
    },
    // 100+ more patterns
}

Also includes entropy calculation for detecting random strings that might be secrets:

func CalculateEntropy(s string) float64 {
    freq := make(map[rune]float64)
    for _, c := range s {
        freq[c]++
    }
    var entropy float64
    for _, count := range freq {
        p := count / float64(len(s))
        entropy -= p * math.Log2(p)
    }
    return entropy
}

High entropy strings (random characters) are likely secrets. Low entropy strings (repeated patterns) are not.

internal/parser

dockerfile.go

Parses Dockerfiles into an AST using BuildKit's parser:

func ParseDockerfile(path string) (*DockerfileAST, error) {
    file, _ := os.Open(path)
    result, _ := parser.Parse(file)

    ast := &DockerfileAST{
        Path: path,
        Root: result.AST,
    }
    ast.extractStructure()  // Build Commands and Stages slices
    return ast, nil
}

The AST provides helpers for common queries:

ast.HasInstruction("USER")           // Does it set a user?
ast.GetInstructions("ENV")           // All ENV instructions
ast.GetLastInstruction("USER")       // Last USER instruction
ast.FinalStage()                     // For multi-stage builds

compose.go

Parses docker-compose.yml files using the YAML library:

type ComposeFile struct {
    Path     string
    Services map[string]*Service
    Volumes  map[string]*Volume
    Networks map[string]*Network
}

type Service struct {
    Name        string
    Image       string
    Privileged  bool
    CapAdd      []string
    CapDrop     []string
    Volumes     []VolumeMount
    Ports       []PortMapping
    NetworkMode string
    // ...
}

Parsing handles both long and short syntax for volumes and ports:

# Short syntax
volumes:
  - ./host:/container

# Long syntax
volumes:
  - type: bind
    source: ./host
    target: /container

internal/docker

Wraps the Docker SDK with timeout handling:

type Client struct {
    api *client.Client
}

func (c *Client) InspectContainer(ctx context.Context, containerID string) (types.ContainerJSON, error) {
    // Create derived context with timeout
    inspectCtx, cancel := context.WithTimeout(ctx, config.InspectTimeout)
    defer cancel()

    return c.api.ContainerInspect(inspectCtx, containerID)
}

Uses a singleton pattern so the whole program shares one connection:

var (
    instance *Client
    once     sync.Once
)

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

internal/proc

Reads process information from the /proc filesystem. This works directly on the host kernel, bypassing Docker's abstractions.

func GetProcessInfo(pid int) (*ProcessInfo, error) {
    procPath := fmt.Sprintf("/proc/%d", pid)

    info := &ProcessInfo{PID: pid}
    info.readStatus(procPath)   // /proc/PID/status
    info.readCmdline(procPath)  // /proc/PID/cmdline
    info.readCgroups(procPath)  // /proc/PID/cgroup
    info.readNamespaces(procPath) // /proc/PID/ns/*
    return info, nil
}

Can detect if a process is in a container by checking cgroup paths:

func (p *ProcessInfo) IsInContainer() bool {
    for _, cg := range p.Cgroups {
        if strings.Contains(cg.Path, "docker") ||
           strings.Contains(cg.Path, "containerd") {
            return true
        }
    }
    return false
}

This package uses graceful degradation. If a file cannot be read (permissions, not Linux, etc.), it continues with what it can get rather than failing.

internal/report

Each output format implements the Reporter interface:

type Reporter interface {
    Report(findings finding.Collection) error
}

terminal.go

Colored output for interactive use:

func (r *TerminalReporter) Report(findings finding.Collection) error {
    for _, f := range findings {
        color := f.Severity.Color()  // ANSI escape code
        fmt.Printf("%s[%s]%s %s\n", color, f.Severity, reset, f.Title)
    }
    return nil
}

sarif.go

SARIF (Static Analysis Results Interchange Format) for GitHub Security tab:

type SARIFReport struct {
    Schema  string `json:"$schema"`
    Version string `json:"version"`
    Runs    []Run  `json:"runs"`
}

GitHub automatically picks up SARIF files and displays findings in the Security tab.

junit.go

JUnit XML for CI/CD integration:

type JUnitTestSuites struct {
    XMLName  xml.Name         `xml:"testsuites"`
    Tests    int              `xml:"tests,attr"`
    Failures int              `xml:"failures,attr"`
    Suites   []JUnitTestSuite `xml:"testsuite"`
}

Jenkins, GitLab CI, and others understand JUnit format for test reporting.

Adding a New Check

Say you want to add a check for containers using the latest tag.

  1. Find the relevant analyzer (container.go for running containers)

  2. Add a check method:

func (a *ContainerAnalyzer) checkImageTag(target finding.Target, info types.ContainerJSON) finding.Collection {
    if strings.HasSuffix(info.Config.Image, ":latest") || !strings.Contains(info.Config.Image, ":") {
        control, _ := benchmark.Get("5.27")
        f := finding.New("CIS-5.27", control.Title, finding.SeverityLow, target).
            WithDescription(control.Description)
        return finding.Collection{f}
    }
    return nil
}
  1. Call it from analyzeContainer:
findings = append(findings, a.checkImageTag(target, info)...)

The CIS control should already exist in benchmark/controls.go. If adding a custom check, register a new control first.

Adding a New Secret Pattern

In rules/secrets.go, add to the SecretPatterns slice:

{
    Type:        SecretTypeAPIKey,
    Pattern:     regexp.MustCompile(`myservice_[A-Za-z0-9]{32}`),
    Description: "MyService API Key",
},

The Dockerfile analyzer will automatically pick it up.

Adding a New Output Format

  1. Create a new file in internal/report/ (e.g., csv.go)

  2. Implement the Reporter interface:

type CSVReporter struct {
    output io.Writer
}

func (r *CSVReporter) Report(findings finding.Collection) error {
    w := csv.NewWriter(r.output)
    w.Write([]string{"ID", "Severity", "Title", "Target"})
    for _, f := range findings {
        w.Write([]string{f.ID, f.Severity.String(), f.Title, f.Target.String()})
    }
    return w.Flush()
}
  1. Update NewReporter in reporter.go to handle the new format:
case "csv":
    return &CSVReporter{output: out}, nil
  1. Add the format to CLI flag validation in cmd/docksec/scan.go

Testing Strategy

The codebase does not have extensive tests yet. If adding them:

Unit tests for rules packages (capabilities, paths, secrets):

func TestIsDangerousCapability(t *testing.T) {
    tests := []struct{cap string; want bool}{
        {"SYS_ADMIN", true},
        {"NET_BIND_SERVICE", false},
    }
    for _, tt := range tests {
        got := rules.IsDangerousCapability(tt.cap)
        if got != tt.want {
            t.Errorf("IsDangerousCapability(%q) = %v, want %v", tt.cap, got, tt.want)
        }
    }
}

Integration tests for analyzers using Docker SDK mocks or testcontainers.

E2E tests by running the binary against known bad configurations.