613 lines
16 KiB
Markdown
613 lines
16 KiB
Markdown
# 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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
# Short syntax
|
|
volumes:
|
|
- ./host:/container
|
|
|
|
# Long syntax
|
|
volumes:
|
|
- type: bind
|
|
source: ./host
|
|
target: /container
|
|
```
|
|
|
|
## internal/docker
|
|
|
|
Wraps the Docker SDK with timeout handling:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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.
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
type Reporter interface {
|
|
Report(findings finding.Collection) error
|
|
}
|
|
```
|
|
|
|
### terminal.go
|
|
|
|
Colored output for interactive use:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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
|
|
}
|
|
```
|
|
|
|
3. Call it from analyzeContainer:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
{
|
|
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:
|
|
|
|
```go
|
|
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()
|
|
}
|
|
```
|
|
|
|
3. Update `NewReporter` in `reporter.go` to handle the new format:
|
|
|
|
```go
|
|
case "csv":
|
|
return &CSVReporter{output: out}, nil
|
|
```
|
|
|
|
4. 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):
|
|
```go
|
|
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.
|