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.
-
Find the relevant analyzer (
container.gofor running containers) -
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
}
- 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
-
Create a new file in
internal/report/(e.g.,csv.go) -
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()
}
- Update
NewReporterinreporter.goto handle the new format:
case "csv":
return &CSVReporter{output: out}, nil
- 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.