Cybersecurity-Projects/PROJECTS/intermediate/docker-security-audit/internal/analyzer/compose.go

642 lines
20 KiB
Go

/*
©AngelaMos | 2026
compose.go
ComposeAnalyzer scans docker-compose files for CIS Docker Benchmark
violations
Parses compose YAML using a raw yaml.Node tree to preserve line
numbers, then checks each service for privileged mode, dangerous
capabilities, sensitive volume mounts, host namespace sharing, missing
resource limits, hardcoded secrets in environment variables, and
missing user or read-only configuration.
Key exports:
ComposeAnalyzer - implements Analyzer for docker-compose files
NewComposeAnalyzer - constructor taking file path
Connects to:
analyzer.go - implements Analyzer interface, uses Category constants
rules/capabilities.go - checks cap_add capability severity
rules/paths.go - checks volume mount paths
rules/secrets.go - detects secrets and sensitive variable names
finding.go - creates findings with line-accurate locations
*/
package analyzer
import (
"context"
"os"
"strconv"
"strings"
"github.com/CarterPerez-dev/docksec/internal/finding"
"github.com/CarterPerez-dev/docksec/internal/rules"
"gopkg.in/yaml.v3"
)
type ComposeAnalyzer struct {
path string
}
func NewComposeAnalyzer(path string) *ComposeAnalyzer {
return &ComposeAnalyzer{path: path}
}
func (a *ComposeAnalyzer) Name() string {
return "compose:" + a.path
}
func (a *ComposeAnalyzer) Analyze(
ctx context.Context,
) (finding.Collection, error) {
data, err := os.ReadFile(a.path)
if err != nil {
return nil, err
}
var root yaml.Node
if err := yaml.Unmarshal(data, &root); err != nil {
return nil, err
}
target := finding.Target{
Type: finding.TargetCompose,
Name: a.path,
}
var findings finding.Collection
services := findNode(&root, "services")
if services == nil {
return findings, nil
}
for i := 0; i < len(services.Content); i += 2 {
if i+1 >= len(services.Content) {
break
}
serviceName := services.Content[i].Value
serviceNode := services.Content[i+1]
findings = append(
findings,
a.analyzeService(target, serviceName, serviceNode)...)
}
return findings, nil
}
func (a *ComposeAnalyzer) analyzeService(
target finding.Target,
serviceName string,
node *yaml.Node,
) finding.Collection {
var findings finding.Collection
findings = append(
findings,
a.checkPrivileged(target, serviceName, node)...)
findings = append(
findings,
a.checkCapabilities(target, serviceName, node)...)
findings = append(findings, a.checkVolumes(target, serviceName, node)...)
findings = append(
findings,
a.checkNetworkMode(target, serviceName, node)...)
findings = append(findings, a.checkPidMode(target, serviceName, node)...)
findings = append(findings, a.checkIpcMode(target, serviceName, node)...)
findings = append(
findings,
a.checkSecurityOpt(target, serviceName, node)...)
findings = append(
findings,
a.checkResourceLimits(target, serviceName, node)...)
findings = append(
findings,
a.checkEnvironment(target, serviceName, node)...)
findings = append(findings, a.checkPorts(target, serviceName, node)...)
findings = append(findings, a.checkUser(target, serviceName, node)...)
findings = append(findings, a.checkReadOnly(target, serviceName, node)...)
return findings
}
func (a *ComposeAnalyzer) checkPrivileged(
target finding.Target,
serviceName string,
node *yaml.Node,
) finding.Collection {
var findings finding.Collection
privNode := findNode(node, "privileged")
if privNode != nil &&
(privNode.Value == "true" || privNode.Value == "yes") {
loc := &finding.Location{Path: a.path, Line: privNode.Line}
f := finding.New("CIS-5.4", "Service '"+serviceName+"' runs in privileged mode", finding.SeverityCritical, target).
WithDescription("Privileged containers have full access to host devices and bypass security features.").
WithCategory(string(CategoryCompose)).
WithLocation(loc).
WithRemediation("Remove 'privileged: true' and use specific capabilities instead.")
findings = append(findings, f)
}
return findings
}
func (a *ComposeAnalyzer) checkCapabilities(
target finding.Target,
serviceName string,
node *yaml.Node,
) finding.Collection {
var findings finding.Collection
capAddNode := findNode(node, "cap_add")
if capAddNode == nil {
return findings
}
for _, capNode := range capAddNode.Content {
capName := strings.ToUpper(capNode.Value)
capInfo, exists := rules.GetCapabilityInfo(capName)
if !exists {
continue
}
if capInfo.Severity >= finding.SeverityHigh {
loc := &finding.Location{Path: a.path, Line: capNode.Line}
title := "Service '" + serviceName + "' adds dangerous capability: " + capName
if capInfo.Severity == finding.SeverityCritical {
title = "Service '" + serviceName + "' adds critical capability: " + capName
}
f := finding.New("CIS-5.3", title, capInfo.Severity, target).
WithDescription(capInfo.Description).
WithCategory(string(CategoryCompose)).
WithLocation(loc).
WithRemediation("Remove unnecessary capabilities. Use --cap-drop=ALL and add only required capabilities.")
findings = append(findings, f)
}
}
return findings
}
func (a *ComposeAnalyzer) checkVolumes(
target finding.Target,
serviceName string,
node *yaml.Node,
) finding.Collection {
var findings finding.Collection
volumesNode := findNode(node, "volumes")
if volumesNode == nil {
return findings
}
for _, volNode := range volumesNode.Content {
var hostPath string
switch volNode.Kind {
case yaml.ScalarNode:
parts := strings.SplitN(volNode.Value, ":", 2)
hostPath = parts[0]
case yaml.MappingNode:
sourceNode := findNode(volNode, "source")
if sourceNode != nil {
hostPath = sourceNode.Value
}
}
if hostPath == "" {
continue
}
if rules.IsDockerSocket(hostPath) {
loc := &finding.Location{Path: a.path, Line: volNode.Line}
f := finding.New("CIS-5.31", "Service '"+serviceName+"' mounts Docker socket", finding.SeverityCritical, target).
WithDescription("Mounting Docker socket gives the container full control over the Docker daemon.").
WithCategory(string(CategoryCompose)).
WithLocation(loc).
WithRemediation("Do not mount /var/run/docker.sock inside containers.")
findings = append(findings, f)
continue
}
if rules.IsSensitivePath(hostPath) {
loc := &finding.Location{Path: a.path, Line: volNode.Line}
severity := rules.GetPathSeverity(hostPath)
pathInfo, _ := rules.GetPathInfo(hostPath)
description := "Mounting sensitive host paths can enable container escape."
if pathInfo.Description != "" {
description = pathInfo.Description
}
f := finding.New("CIS-5.5", "Service '"+serviceName+"' mounts sensitive path: "+hostPath, severity, target).
WithDescription(description).
WithCategory(string(CategoryCompose)).
WithLocation(loc).
WithRemediation("Do not mount sensitive host directories. Use Docker volumes instead.")
findings = append(findings, f)
}
}
return findings
}
func (a *ComposeAnalyzer) checkNetworkMode(
target finding.Target,
serviceName string,
node *yaml.Node,
) finding.Collection {
var findings finding.Collection
netNode := findNode(node, "network_mode")
if netNode != nil && netNode.Value == "host" {
loc := &finding.Location{Path: a.path, Line: netNode.Line}
f := finding.New("CIS-5.9", "Service '"+serviceName+"' uses host network mode", finding.SeverityHigh, target).
WithDescription("Host network mode allows the container to access all host network interfaces.").
WithCategory(string(CategoryCompose)).
WithLocation(loc).
WithRemediation("Use bridge networking instead of network_mode: host.")
findings = append(findings, f)
}
return findings
}
func (a *ComposeAnalyzer) checkPidMode(
target finding.Target,
serviceName string,
node *yaml.Node,
) finding.Collection {
var findings finding.Collection
pidNode := findNode(node, "pid")
if pidNode != nil && pidNode.Value == "host" {
loc := &finding.Location{Path: a.path, Line: pidNode.Line}
f := finding.New("CIS-5.15", "Service '"+serviceName+"' shares host PID namespace", finding.SeverityHigh, target).
WithDescription("Sharing PID namespace allows container to see and signal host processes.").
WithCategory(string(CategoryCompose)).
WithLocation(loc).
WithRemediation("Remove 'pid: host' from the service definition.")
findings = append(findings, f)
}
return findings
}
func (a *ComposeAnalyzer) checkIpcMode(
target finding.Target,
serviceName string,
node *yaml.Node,
) finding.Collection {
var findings finding.Collection
ipcNode := findNode(node, "ipc")
if ipcNode != nil && ipcNode.Value == "host" {
loc := &finding.Location{Path: a.path, Line: ipcNode.Line}
f := finding.New("CIS-5.16", "Service '"+serviceName+"' shares host IPC namespace", finding.SeverityHigh, target).
WithDescription("Sharing IPC namespace allows container to access host shared memory.").
WithCategory(string(CategoryCompose)).
WithLocation(loc).
WithRemediation("Remove 'ipc: host' from the service definition.")
findings = append(findings, f)
}
return findings
}
func (a *ComposeAnalyzer) checkSecurityOpt(
target finding.Target,
serviceName string,
node *yaml.Node,
) finding.Collection {
var findings finding.Collection
secOptNode := findNode(node, "security_opt")
if secOptNode == nil {
return findings
}
for _, optNode := range secOptNode.Content {
opt := strings.ToLower(optNode.Value)
if opt == "seccomp:unconfined" || opt == "seccomp=unconfined" {
loc := &finding.Location{Path: a.path, Line: optNode.Line}
f := finding.New("CIS-5.21", "Service '"+serviceName+"' disables seccomp profile", finding.SeverityHigh, target).
WithDescription("Disabling seccomp removes syscall restrictions from the container.").
WithCategory(string(CategoryCompose)).
WithLocation(loc).
WithRemediation("Remove 'seccomp:unconfined' and use default or custom seccomp profile.")
findings = append(findings, f)
}
if opt == "apparmor:unconfined" || opt == "apparmor=unconfined" {
loc := &finding.Location{Path: a.path, Line: optNode.Line}
f := finding.New("CIS-5.1", "Service '"+serviceName+"' disables AppArmor profile", finding.SeverityHigh, target).
WithDescription("Disabling AppArmor removes mandatory access control from the container.").
WithCategory(string(CategoryCompose)).
WithLocation(loc).
WithRemediation("Remove 'apparmor:unconfined' and use default or custom AppArmor profile.")
findings = append(findings, f)
}
}
return findings
}
func (a *ComposeAnalyzer) checkResourceLimits(
target finding.Target,
serviceName string,
node *yaml.Node,
) finding.Collection {
var findings finding.Collection
deployNode := findNode(node, "deploy")
var resourcesNode *yaml.Node
if deployNode != nil {
resourcesNode = findNode(deployNode, "resources")
}
memLimitNode := findNode(node, "mem_limit")
cpuLimitNode := findNode(node, "cpus")
pidsLimitNode := findNode(node, "pids_limit")
hasMemLimit := memLimitNode != nil
hasCpuLimit := cpuLimitNode != nil
hasPidsLimit := pidsLimitNode != nil
if resourcesNode != nil {
limitsNode := findNode(resourcesNode, "limits")
if limitsNode != nil {
if findNode(limitsNode, "memory") != nil {
hasMemLimit = true
}
if findNode(limitsNode, "cpus") != nil {
hasCpuLimit = true
}
if findNode(limitsNode, "pids") != nil {
hasPidsLimit = true
}
}
}
if !hasMemLimit {
loc := &finding.Location{Path: a.path, Line: node.Line}
f := finding.New("CIS-5.10", "Service '"+serviceName+"' has no memory limit", finding.SeverityMedium, target).
WithDescription("Without memory limits, a container can exhaust all available host memory.").
WithCategory(string(CategoryCompose)).
WithLocation(loc).
WithRemediation("Set mem_limit or deploy.resources.limits.memory for the service.")
findings = append(findings, f)
}
if !hasCpuLimit {
loc := &finding.Location{Path: a.path, Line: node.Line}
f := finding.New("CIS-5.11", "Service '"+serviceName+"' has no CPU limit", finding.SeverityMedium, target).
WithDescription("Without CPU limits, a container can consume all available CPU resources.").
WithCategory(string(CategoryCompose)).
WithLocation(loc).
WithRemediation("Set cpus or deploy.resources.limits.cpus for the service.")
findings = append(findings, f)
}
if !hasPidsLimit {
loc := &finding.Location{Path: a.path, Line: node.Line}
f := finding.New("CIS-5.28", "Service '"+serviceName+"' has no PIDs limit", finding.SeverityMedium, target).
WithDescription("Without PIDs limits, a container can fork-bomb and exhaust process table.").
WithCategory(string(CategoryCompose)).
WithLocation(loc).
WithRemediation("Set pids_limit or deploy.resources.limits.pids for the service.")
findings = append(findings, f)
}
return findings
}
func (a *ComposeAnalyzer) checkEnvironment(
target finding.Target,
serviceName string,
node *yaml.Node,
) finding.Collection {
var findings finding.Collection
envNode := findNode(node, "environment")
if envNode == nil {
return findings
}
if envNode.Kind == yaml.MappingNode {
for i := 0; i < len(envNode.Content); i += 2 {
if i+1 >= len(envNode.Content) {
break
}
keyNode := envNode.Content[i]
valueNode := envNode.Content[i+1]
if rules.IsSensitiveEnvName(keyNode.Value) &&
valueNode.Value != "" {
if !isVariableReference(valueNode.Value) {
loc := &finding.Location{Path: a.path, Line: keyNode.Line}
f := finding.New("CIS-4.10", "Service '"+serviceName+"' has sensitive variable '"+keyNode.Value+"' with hardcoded value", finding.SeverityHigh, target).
WithDescription("Hardcoding secrets in compose files exposes them in version control.").
WithCategory(string(CategoryCompose)).
WithLocation(loc).
WithRemediation("Use environment variable substitution: ${" + keyNode.Value + "} or Docker secrets.")
findings = append(findings, f)
}
}
secrets := rules.DetectSecrets(valueNode.Value)
for _, secret := range secrets {
loc := &finding.Location{Path: a.path, Line: valueNode.Line}
f := finding.New("CIS-4.10", "Service '"+serviceName+"' may contain "+string(secret.Type)+" in environment", finding.SeverityHigh, target).
WithDescription(secret.Description + " detected in environment variable.").
WithCategory(string(CategoryCompose)).
WithLocation(loc).
WithRemediation("Remove secrets from compose file. Use Docker secrets or external secret management.")
findings = append(findings, f)
}
}
} else if envNode.Kind == yaml.SequenceNode {
for _, itemNode := range envNode.Content {
parts := strings.SplitN(itemNode.Value, "=", 2)
if len(parts) < 2 {
continue
}
varName := parts[0]
varValue := parts[1]
if rules.IsSensitiveEnvName(varName) && varValue != "" {
if !isVariableReference(varValue) {
loc := &finding.Location{
Path: a.path,
Line: itemNode.Line,
}
f := finding.New("CIS-4.10", "Service '"+serviceName+"' has sensitive variable '"+varName+"' with hardcoded value", finding.SeverityHigh, target).
WithDescription("Hardcoding secrets in compose files exposes them in version control.").
WithCategory(string(CategoryCompose)).
WithLocation(loc).
WithRemediation("Use environment variable substitution: ${" + varName + "} or Docker secrets.")
findings = append(findings, f)
}
}
secrets := rules.DetectSecrets(varValue)
for _, secret := range secrets {
loc := &finding.Location{Path: a.path, Line: itemNode.Line}
f := finding.New("CIS-4.10", "Service '"+serviceName+"' may contain "+string(secret.Type)+" in environment", finding.SeverityHigh, target).
WithDescription(secret.Description + " detected in environment variable.").
WithCategory(string(CategoryCompose)).
WithLocation(loc).
WithRemediation("Remove secrets from compose file. Use Docker secrets or external secret management.")
findings = append(findings, f)
}
}
}
return findings
}
func (a *ComposeAnalyzer) checkPorts(
target finding.Target,
serviceName string,
node *yaml.Node,
) finding.Collection {
var findings finding.Collection
portsNode := findNode(node, "ports")
if portsNode == nil {
return findings
}
for _, portNode := range portsNode.Content {
var portSpec string
switch portNode.Kind {
case yaml.ScalarNode:
portSpec = portNode.Value
case yaml.MappingNode:
publishedNode := findNode(portNode, "published")
hostIPNode := findNode(portNode, "host_ip")
if publishedNode != nil {
portSpec = publishedNode.Value
}
if hostIPNode != nil && hostIPNode.Value == "0.0.0.0" {
loc := &finding.Location{Path: a.path, Line: hostIPNode.Line}
f := finding.New("DS-COMPOSE-BIND", "Service '"+serviceName+"' explicitly binds to 0.0.0.0", finding.SeverityInfo, target).
WithDescription("Binding to 0.0.0.0 exposes the port on all network interfaces.").
WithCategory(string(CategoryCompose)).
WithLocation(loc).
WithRemediation("Consider binding to 127.0.0.1 for local-only access.")
findings = append(findings, f)
}
}
if portSpec == "" {
continue
}
parts := strings.Split(portSpec, ":")
var hostPort string
if len(parts) >= 2 {
hostPort = parts[0]
if strings.Contains(hostPort, ".") {
hostPort = parts[1]
}
}
if hostPort != "" {
portNum, err := strconv.Atoi(hostPort)
if err == nil && portNum > 0 && portNum < 1024 {
loc := &finding.Location{Path: a.path, Line: portNode.Line}
f := finding.New("DS-COMPOSE-PRIVPORT", "Service '"+serviceName+"' exposes privileged port "+hostPort, finding.SeverityInfo, target).
WithDescription("Privileged ports (below 1024) typically require root privileges on the host.").
WithCategory(string(CategoryCompose)).
WithLocation(loc).
WithRemediation("Consider using non-privileged ports (>1024) with port mapping.")
findings = append(findings, f)
}
}
}
return findings
}
func (a *ComposeAnalyzer) checkUser(
target finding.Target,
serviceName string,
node *yaml.Node,
) finding.Collection {
var findings finding.Collection
userNode := findNode(node, "user")
if userNode == nil {
loc := &finding.Location{Path: a.path, Line: node.Line}
f := finding.New("CIS-4.1", "Service '"+serviceName+"' does not specify user", finding.SeverityMedium, target).
WithDescription("Without a user specification, the container may run as root.").
WithCategory(string(CategoryCompose)).
WithLocation(loc).
WithRemediation("Add 'user: \"1000:1000\"' or use the USER directive in the Dockerfile.")
findings = append(findings, f)
} else if userNode.Value == "root" || userNode.Value == "0" || userNode.Value == "0:0" {
loc := &finding.Location{Path: a.path, Line: userNode.Line}
f := finding.New("DS-COMPOSE-ROOT", "Service '"+serviceName+"' explicitly runs as root", finding.SeverityMedium, target).
WithDescription("Running containers as root increases the risk of container escape.").
WithCategory(string(CategoryCompose)).
WithLocation(loc).
WithRemediation("Create and use a non-root user in the Dockerfile or compose file.")
findings = append(findings, f)
}
return findings
}
func (a *ComposeAnalyzer) checkReadOnly(
target finding.Target,
serviceName string,
node *yaml.Node,
) finding.Collection {
var findings finding.Collection
readOnlyNode := findNode(node, "read_only")
if readOnlyNode == nil || readOnlyNode.Value != "true" {
loc := &finding.Location{Path: a.path, Line: node.Line}
f := finding.New("CIS-5.12", "Service '"+serviceName+"' does not use read-only root filesystem", finding.SeverityMedium, target).
WithDescription("A writable root filesystem allows attackers to modify container binaries.").
WithCategory(string(CategoryCompose)).
WithLocation(loc).
WithRemediation("Add 'read_only: true' and use tmpfs volumes for writable directories.")
findings = append(findings, f)
}
return findings
}
func findNode(node *yaml.Node, key string) *yaml.Node {
if node == nil {
return nil
}
if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
return findNode(node.Content[0], key)
}
if node.Kind != yaml.MappingNode {
return nil
}
for i := 0; i < len(node.Content); i += 2 {
if i+1 >= len(node.Content) {
break
}
if node.Content[i].Value == key {
return node.Content[i+1]
}
}
return nil
}
func isVariableReference(value string) bool {
return strings.HasPrefix(value, "${") || strings.HasPrefix(value, "$")
}