642 lines
20 KiB
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, "$")
|
|
}
|