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

622 lines
19 KiB
Go

/*
AngelaMos | 2026
compose.go
*/
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, "$")
}