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

833 lines
16 KiB
Go

/*
CarterPerez-dev | 2026
compose.go
Docker Compose YAML parser that extracts services, volumes, networks, and
secrets
Parses compose files using raw yaml.Node trees to preserve source
line numbers for accurate finding locations. All service fields
relevant to security (capabilities, mounts, environment, network_mode,
pid, ipc, security_opt, resource limits, healthcheck) are extracted
into strongly typed structs.
Key exports:
ComposeFile - parsed compose structure with Services, Networks, Volumes,
Secrets
Service - per-service fields relevant to security analysis
ParseComposeFile, ParseComposeBytes - parse from path or raw bytes
ComposeVisitor - interface for visitor pattern
Connects to:
visitor.go - ComposeVisitor interface and RuleContext reference these
types
*/
package parser
import (
"fmt"
"os"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
type ComposeFile struct {
Path string
Root *yaml.Node
Version string
Services map[string]*Service
Networks map[string]*Network
Volumes map[string]*Volume
Secrets map[string]*Secret
Configs map[string]*Config
}
type Service struct {
Name string
Node *yaml.Node
Line int
Image string
Build *BuildConfig
Ports []PortMapping
Volumes []VolumeMount
Environment map[string]EnvVar
CapAdd []string
CapDrop []string
Privileged bool
ReadOnly bool
User string
NetworkMode string
PidMode string
IpcMode string
SecurityOpt []string
Deploy *DeployConfig
DependsOn []string
Healthcheck *HealthcheckConfig
}
type BuildConfig struct {
Context string
Dockerfile string
Args map[string]string
Target string
Line int
}
type PortMapping struct {
HostIP string
HostPort string
ContainerPort string
Protocol string
Line int
}
type VolumeMount struct {
Source string
Target string
Type string
ReadOnly bool
Line int
}
type EnvVar struct {
Name string
Value string
Line int
}
type DeployConfig struct {
Replicas int
Resources *ResourceConfig
Line int
}
type ResourceConfig struct {
Limits *ResourceLimits
Reservations *ResourceLimits
}
type ResourceLimits struct {
CPUs string
Memory string
Pids int
}
type HealthcheckConfig struct {
Test []string
Interval string
Timeout string
Retries int
StartPeriod string
Disable bool
Line int
}
type Network struct {
Name string
Driver string
External bool
Line int
}
type Volume struct {
Name string
Driver string
External bool
Line int
}
type Secret struct {
Name string
File string
External bool
Line int
}
type Config struct {
Name string
File string
External bool
Line int
}
func ParseComposeFile(path string) (*ComposeFile, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading compose file: %w", err)
}
return ParseComposeBytes(path, data)
}
func ParseComposeBytes(path string, data []byte) (*ComposeFile, error) {
var root yaml.Node
if err := yaml.Unmarshal(data, &root); err != nil {
return nil, fmt.Errorf("parsing yaml: %w", err)
}
cf := &ComposeFile{
Path: path,
Root: &root,
Services: make(map[string]*Service),
Networks: make(map[string]*Network),
Volumes: make(map[string]*Volume),
Secrets: make(map[string]*Secret),
Configs: make(map[string]*Config),
}
cf.extract()
return cf, nil
}
func (cf *ComposeFile) extract() {
if cf.Root.Kind == yaml.DocumentNode && len(cf.Root.Content) > 0 {
cf.extractFromMapping(cf.Root.Content[0])
} else if cf.Root.Kind == yaml.MappingNode {
cf.extractFromMapping(cf.Root)
}
}
func (cf *ComposeFile) extractFromMapping(node *yaml.Node) {
for i := 0; i < len(node.Content); i += 2 {
if i+1 >= len(node.Content) {
break
}
key := node.Content[i].Value
value := node.Content[i+1]
switch key {
case "version":
cf.Version = value.Value
case "services":
cf.extractServices(value)
case "networks":
cf.extractNetworks(value)
case "volumes":
cf.extractVolumes(value)
case "secrets":
cf.extractSecrets(value)
case "configs":
cf.extractConfigs(value)
}
}
}
func (cf *ComposeFile) extractServices(node *yaml.Node) {
if node.Kind != yaml.MappingNode {
return
}
for i := 0; i < len(node.Content); i += 2 {
if i+1 >= len(node.Content) {
break
}
name := node.Content[i].Value
serviceNode := node.Content[i+1]
service := cf.parseService(name, serviceNode)
cf.Services[name] = service
}
}
func (cf *ComposeFile) parseService(name string, node *yaml.Node) *Service {
svc := &Service{
Name: name,
Node: node,
Line: node.Line,
Environment: make(map[string]EnvVar),
}
if node.Kind != yaml.MappingNode {
return svc
}
for i := 0; i < len(node.Content); i += 2 {
if i+1 >= len(node.Content) {
break
}
key := node.Content[i].Value
value := node.Content[i+1]
switch key {
case "image":
svc.Image = value.Value
case "build":
svc.Build = cf.parseBuildConfig(value)
case "ports":
svc.Ports = cf.parsePorts(value)
case "volumes":
svc.Volumes = cf.parseVolumeMounts(value)
case "environment":
svc.Environment = cf.parseEnvironment(value)
case "cap_add":
svc.CapAdd = cf.parseStringList(value)
case "cap_drop":
svc.CapDrop = cf.parseStringList(value)
case "privileged":
svc.Privileged = value.Value == "true"
case "read_only":
svc.ReadOnly = value.Value == "true"
case "user":
svc.User = value.Value
case "network_mode":
svc.NetworkMode = value.Value
case "pid":
svc.PidMode = value.Value
case "ipc":
svc.IpcMode = value.Value
case "security_opt":
svc.SecurityOpt = cf.parseStringList(value)
case "deploy":
svc.Deploy = cf.parseDeployConfig(value)
case "depends_on":
svc.DependsOn = cf.parseDependsOn(value)
case "healthcheck":
svc.Healthcheck = cf.parseHealthcheck(value)
}
}
return svc
}
func (cf *ComposeFile) parseBuildConfig(node *yaml.Node) *BuildConfig {
bc := &BuildConfig{Line: node.Line}
if node.Kind == yaml.ScalarNode {
bc.Context = node.Value
return bc
}
if node.Kind != yaml.MappingNode {
return bc
}
bc.Args = make(map[string]string)
for i := 0; i < len(node.Content); i += 2 {
if i+1 >= len(node.Content) {
break
}
key := node.Content[i].Value
value := node.Content[i+1]
switch key {
case "context":
bc.Context = value.Value
case "dockerfile":
bc.Dockerfile = value.Value
case "target":
bc.Target = value.Value
case "args":
bc.Args = cf.parseStringMap(value)
}
}
return bc
}
func (cf *ComposeFile) parsePorts(node *yaml.Node) []PortMapping {
var ports []PortMapping
if node.Kind != yaml.SequenceNode {
return ports
}
for _, item := range node.Content {
pm := PortMapping{Line: item.Line}
switch item.Kind {
case yaml.ScalarNode:
pm = cf.parsePortString(item.Value, item.Line)
case yaml.MappingNode:
pm = cf.parsePortMapping(item)
}
ports = append(ports, pm)
}
return ports
}
func (cf *ComposeFile) parsePortString(s string, line int) PortMapping {
pm := PortMapping{Line: line, Protocol: "tcp"}
if idx := strings.LastIndex(s, "/"); idx != -1 {
pm.Protocol = s[idx+1:]
s = s[:idx]
}
parts := strings.Split(s, ":")
switch len(parts) {
case 1:
pm.ContainerPort = parts[0]
case 2:
pm.HostPort = parts[0]
pm.ContainerPort = parts[1]
case 3:
pm.HostIP = parts[0]
pm.HostPort = parts[1]
pm.ContainerPort = parts[2]
}
return pm
}
func (cf *ComposeFile) parsePortMapping(node *yaml.Node) PortMapping {
pm := PortMapping{Line: node.Line, Protocol: "tcp"}
for i := 0; i < len(node.Content); i += 2 {
if i+1 >= len(node.Content) {
break
}
key := node.Content[i].Value
value := node.Content[i+1].Value
switch key {
case "target":
pm.ContainerPort = value
case "published":
pm.HostPort = value
case "host_ip":
pm.HostIP = value
case "protocol":
pm.Protocol = value
}
}
return pm
}
func (cf *ComposeFile) parseVolumeMounts(node *yaml.Node) []VolumeMount {
var mounts []VolumeMount
if node.Kind != yaml.SequenceNode {
return mounts
}
for _, item := range node.Content {
vm := VolumeMount{Line: item.Line}
switch item.Kind {
case yaml.ScalarNode:
vm = cf.parseVolumeString(item.Value, item.Line)
case yaml.MappingNode:
vm = cf.parseVolumeMountMapping(item)
}
mounts = append(mounts, vm)
}
return mounts
}
func (cf *ComposeFile) parseVolumeString(s string, line int) VolumeMount {
vm := VolumeMount{Line: line, Type: "bind"}
parts := strings.Split(s, ":")
switch len(parts) {
case 1:
vm.Target = parts[0]
vm.Type = "volume"
case 2:
vm.Source = parts[0]
vm.Target = parts[1]
case 3:
vm.Source = parts[0]
vm.Target = parts[1]
if parts[2] == "ro" {
vm.ReadOnly = true
}
}
if strings.HasPrefix(vm.Source, "/") ||
strings.HasPrefix(vm.Source, ".") {
vm.Type = "bind"
} else if vm.Source != "" {
vm.Type = "volume"
}
return vm
}
func (cf *ComposeFile) parseVolumeMountMapping(node *yaml.Node) VolumeMount {
vm := VolumeMount{Line: node.Line}
for i := 0; i < len(node.Content); i += 2 {
if i+1 >= len(node.Content) {
break
}
key := node.Content[i].Value
value := node.Content[i+1]
switch key {
case "type":
vm.Type = value.Value
case "source":
vm.Source = value.Value
case "target":
vm.Target = value.Value
case "read_only":
vm.ReadOnly = value.Value == "true"
}
}
return vm
}
func (cf *ComposeFile) parseEnvironment(node *yaml.Node) map[string]EnvVar {
env := make(map[string]EnvVar)
if node.Kind == yaml.MappingNode {
for i := 0; i < len(node.Content); i += 2 {
if i+1 >= len(node.Content) {
break
}
key := node.Content[i]
value := node.Content[i+1]
env[key.Value] = EnvVar{
Name: key.Value,
Value: value.Value,
Line: key.Line,
}
}
} else if node.Kind == yaml.SequenceNode {
for _, item := range node.Content {
parts := strings.SplitN(item.Value, "=", 2)
name := parts[0]
value := ""
if len(parts) > 1 {
value = parts[1]
}
env[name] = EnvVar{
Name: name,
Value: value,
Line: item.Line,
}
}
}
return env
}
func (cf *ComposeFile) parseDeployConfig(node *yaml.Node) *DeployConfig {
dc := &DeployConfig{Line: node.Line}
if node.Kind != yaml.MappingNode {
return dc
}
for i := 0; i < len(node.Content); i += 2 {
if i+1 >= len(node.Content) {
break
}
key := node.Content[i].Value
value := node.Content[i+1]
switch key {
case "replicas":
dc.Replicas, _ = strconv.Atoi(value.Value)
case "resources":
dc.Resources = cf.parseResourceConfig(value)
}
}
return dc
}
func (cf *ComposeFile) parseResourceConfig(node *yaml.Node) *ResourceConfig {
rc := &ResourceConfig{}
if node.Kind != yaml.MappingNode {
return rc
}
for i := 0; i < len(node.Content); i += 2 {
if i+1 >= len(node.Content) {
break
}
key := node.Content[i].Value
value := node.Content[i+1]
switch key {
case "limits":
rc.Limits = cf.parseResourceLimits(value)
case "reservations":
rc.Reservations = cf.parseResourceLimits(value)
}
}
return rc
}
func (cf *ComposeFile) parseResourceLimits(node *yaml.Node) *ResourceLimits {
rl := &ResourceLimits{}
if node.Kind != yaml.MappingNode {
return rl
}
for i := 0; i < len(node.Content); i += 2 {
if i+1 >= len(node.Content) {
break
}
key := node.Content[i].Value
value := node.Content[i+1]
switch key {
case "cpus":
rl.CPUs = value.Value
case "memory":
rl.Memory = value.Value
case "pids":
rl.Pids, _ = strconv.Atoi(value.Value)
}
}
return rl
}
func (cf *ComposeFile) parseHealthcheck(node *yaml.Node) *HealthcheckConfig {
hc := &HealthcheckConfig{Line: node.Line}
if node.Kind != yaml.MappingNode {
return hc
}
for i := 0; i < len(node.Content); i += 2 {
if i+1 >= len(node.Content) {
break
}
key := node.Content[i].Value
value := node.Content[i+1]
switch key {
case "test":
hc.Test = cf.parseStringList(value)
case "interval":
hc.Interval = value.Value
case "timeout":
hc.Timeout = value.Value
case "retries":
hc.Retries, _ = strconv.Atoi(value.Value)
case "start_period":
hc.StartPeriod = value.Value
case "disable":
hc.Disable = value.Value == "true"
}
}
return hc
}
func (cf *ComposeFile) parseStringList(node *yaml.Node) []string {
var result []string
if node.Kind == yaml.ScalarNode {
return []string{node.Value}
}
if node.Kind != yaml.SequenceNode {
return result
}
for _, item := range node.Content {
result = append(result, item.Value)
}
return result
}
func (cf *ComposeFile) parseStringMap(node *yaml.Node) map[string]string {
result := make(map[string]string)
if node.Kind == yaml.MappingNode {
for i := 0; i < len(node.Content); i += 2 {
if i+1 >= len(node.Content) {
break
}
result[node.Content[i].Value] = node.Content[i+1].Value
}
} else if node.Kind == yaml.SequenceNode {
for _, item := range node.Content {
parts := strings.SplitN(item.Value, "=", 2)
if len(parts) == 2 {
result[parts[0]] = parts[1]
}
}
}
return result
}
func (cf *ComposeFile) parseDependsOn(node *yaml.Node) []string {
if node.Kind == yaml.SequenceNode {
return cf.parseStringList(node)
}
if node.Kind == yaml.MappingNode {
var deps []string
for i := 0; i < len(node.Content); i += 2 {
deps = append(deps, node.Content[i].Value)
}
return deps
}
return nil
}
func (cf *ComposeFile) extractNetworks(node *yaml.Node) {
if node.Kind != yaml.MappingNode {
return
}
for i := 0; i < len(node.Content); i += 2 {
if i+1 >= len(node.Content) {
break
}
name := node.Content[i].Value
netNode := node.Content[i+1]
net := &Network{Name: name, Line: netNode.Line}
if netNode.Kind == yaml.MappingNode {
for j := 0; j < len(netNode.Content); j += 2 {
if j+1 >= len(netNode.Content) {
break
}
key := netNode.Content[j].Value
value := netNode.Content[j+1]
switch key {
case "driver":
net.Driver = value.Value
case "external":
net.External = value.Value == "true"
}
}
}
cf.Networks[name] = net
}
}
func (cf *ComposeFile) extractVolumes(node *yaml.Node) {
if node.Kind != yaml.MappingNode {
return
}
for i := 0; i < len(node.Content); i += 2 {
if i+1 >= len(node.Content) {
break
}
name := node.Content[i].Value
volNode := node.Content[i+1]
vol := &Volume{Name: name, Line: volNode.Line}
if volNode.Kind == yaml.MappingNode {
for j := 0; j < len(volNode.Content); j += 2 {
if j+1 >= len(volNode.Content) {
break
}
key := volNode.Content[j].Value
value := volNode.Content[j+1]
switch key {
case "driver":
vol.Driver = value.Value
case "external":
vol.External = value.Value == "true"
}
}
}
cf.Volumes[name] = vol
}
}
func (cf *ComposeFile) extractSecrets(node *yaml.Node) {
if node.Kind != yaml.MappingNode {
return
}
for i := 0; i < len(node.Content); i += 2 {
if i+1 >= len(node.Content) {
break
}
name := node.Content[i].Value
secNode := node.Content[i+1]
sec := &Secret{Name: name, Line: secNode.Line}
if secNode.Kind == yaml.MappingNode {
for j := 0; j < len(secNode.Content); j += 2 {
if j+1 >= len(secNode.Content) {
break
}
key := secNode.Content[j].Value
value := secNode.Content[j+1]
switch key {
case "file":
sec.File = value.Value
case "external":
sec.External = value.Value == "true"
}
}
}
cf.Secrets[name] = sec
}
}
func (cf *ComposeFile) extractConfigs(node *yaml.Node) {
if node.Kind != yaml.MappingNode {
return
}
for i := 0; i < len(node.Content); i += 2 {
if i+1 >= len(node.Content) {
break
}
name := node.Content[i].Value
cfgNode := node.Content[i+1]
cfg := &Config{Name: name, Line: cfgNode.Line}
if cfgNode.Kind == yaml.MappingNode {
for j := 0; j < len(cfgNode.Content); j += 2 {
if j+1 >= len(cfgNode.Content) {
break
}
key := cfgNode.Content[j].Value
value := cfgNode.Content[j+1]
switch key {
case "file":
cfg.File = value.Value
case "external":
cfg.External = value.Value == "true"
}
}
}
cf.Configs[name] = cfg
}
}
func (cf *ComposeFile) Visit(visitor ComposeVisitor) {
for name, svc := range cf.Services {
visitor.VisitService(name, svc)
}
}
type ComposeVisitor interface {
VisitService(name string, svc *Service)
}