/* 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) }