/* ©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, "$") }