package main import ( "fmt" "io" "log" "os" "regexp" "strings" "code.jakeyoungdev.com/go/compose-parser/compose" "code.jakeyoungdev.com/go/compose-parser/issue" "gopkg.in/yaml.v3" ) //shoutouts and/or blame should go to: https://regex101.com/ it helped me construct some unholy regex const ( PRIVILEGE_OPT = "no-new-privileges=true" ROOT_USER_GROUP = "1000:1000" ROOT_USER = "1000" ROOT_GROUP = "1000" //this is an insane regex to detect IP:PORT:PORT in port configuration but also supports the ability to detect secrets.* and vars.* from workflows IM_SO_SORRY = `^(\${\s*\w+\s*}|[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}){1}:(\${\s*\w+\s*}|[0-9]+){1}:(\${\s*\w+\s*}|[0-9]+){1}$` ) func main() { filePath := os.Getenv("COMPOSE_FILE_PATH") if filePath == "" { filePath = "compose.yaml" } f, err := os.Open(filePath) if err != nil { log.Fatal(err) } defer f.Close() fd, err := io.ReadAll(f) if err != nil { log.Fatal(err) } var data compose.Compose err = yaml.Unmarshal(fd, &data) if err != nil { log.Fatal(err) } type report struct { Name string Issues []*issue.Issue } var issues []report for name, serviceConf := range data.Services { rprt := report{ Name: name, } i := ResourceCheck(serviceConf) if i != nil { rprt.Issues = append(rprt.Issues, i) } j := SecurityOptCheck(serviceConf) if j != nil { rprt.Issues = append(rprt.Issues, j) } k, err := UserCheck(serviceConf) if err != nil { log.Fatal(err) } if k != nil { rprt.Issues = append(rprt.Issues, k) } l, err := PortCheck(serviceConf) if err != nil { log.Fatal(err) } if len(l) > 0 { rprt.Issues = append(rprt.Issues, l...) } m := PolicyCheck(serviceConf) if m != nil { rprt.Issues = append(rprt.Issues, m) } n := PrivilegedCheck(serviceConf) if n != nil { rprt.Issues = append(rprt.Issues, n) } issues = append(issues, rprt) } //this is better printing, it should probably group up the port issues in a better printing. Not sure how lvl := os.Getenv("LOG_LEVEL") for _, p := range issues { fmt.Println() fmt.Println("----------------------------------------------------------------------------") fmt.Println(p.Name) for _, x := range p.Issues { if lvl == "all" { fmt.Printf("\tsafe: %t\n\tlevel: %s\n\tMessages:\n\t\t%s\n\n", x.Safe, x.Level, strings.Join(x.Messages, "\n\t\t")) } else if lvl == "fatal" { if x.Level == issue.FATAL { fmt.Printf("\tsafe: %t\n\tlevel: %s\n\tMessages:\n\t\t%s\n\n", x.Safe, x.Level, strings.Join(x.Messages, "\n\t\t")) } } } } } // ensure cpus and mem_limit are set on the service func ResourceCheck(srv compose.ServiceConfig) *issue.Issue { i := &issue.Issue{} if srv.Cpus == nil { i.Warning() i.Messages = append(i.Messages, "there is no cpu limit set for the service") } if srv.MemLimit == nil { i.Warning() i.Messages = append(i.Messages, "there is no memory limit set for the service") } if srv.Cpus == nil && srv.MemLimit == nil { i.Fatal() i.Messages = append(i.Messages, "there are no resource limits set for the service") } if len(i.Messages) > 0 { return i } i.Passed() i.Messages = append(i.Messages, "resource limits configuration is safe") return i } // make sure service is not ran as privileged func PrivilegedCheck(srv compose.ServiceConfig) *issue.Issue { i := &issue.Issue{} if srv.Privileged == nil { i.Passed() i.Messages = append(i.Messages, "no privileged flag set") return i } if *srv.Privileged == true { i.Fatal() i.Messages = append(i.Messages, "privileged flag is set to true") return i } i.Passed() i.Messages = append(i.Messages, "privileged flag is set to false") return i } // checks for specific security options func SecurityOptCheck(srv compose.ServiceConfig) *issue.Issue { i := &issue.Issue{} if srv.SecOpts == nil { i.Fatal() i.Messages = append(i.Messages, "set no-new-privileges=true on the service security_opts") return i } for _, opt := range *srv.SecOpts { if strings.EqualFold(opt, PRIVILEGE_OPT) { i.Passed() i.Messages = append(i.Messages, "security options are safe") return i } } i.Fatal() i.Messages = append(i.Messages, "set no-new-privileges=true on the container security_opts") return i } // checking port to ensure it is behind a reverse proxy func PortCheck(srv compose.ServiceConfig) ([]*issue.Issue, error) { il := []*issue.Issue{} if srv.Ports == nil { i := &issue.Issue{} i.Passed() i.Messages = append(i.Messages, "no ports exposed") il = append(il, i) return il, nil } for _, prt := range *srv.Ports { i := &issue.Issue{} m, err := regexp.Match(`^[0-9]*:[0-9]*`, []byte(prt)) if err != nil { return nil, err } if m { i.Fatal() i.Messages = append(i.Messages, fmt.Sprintf("%s is fully exposed", prt)) il = append(il, i) continue } ms, err := regexp.Match(`^\${\s*\w+\s*}{1}:\${\s*\w+\s*}{1}$`, []byte(prt)) if err != nil { log.Fatal(err) } if ms { i.Warning() i.Messages = append(i.Messages, fmt.Sprintf("%s could be exposed", prt)) il = append(il, i) continue } m2, err := regexp.Match(IM_SO_SORRY, []byte(prt)) if err != nil { return nil, err } if m2 { i.Passed() i.Messages = append(i.Messages, fmt.Sprintf("%s is safe", prt)) il = append(il, i) continue } else { i.Warning() i.Messages = append(i.Messages, fmt.Sprintf("unable to parse port configuration for %s", prt)) il = append(il, i) continue } } return il, nil } // ensures the user value is set and nonroot in the service func UserCheck(srv compose.ServiceConfig) (*issue.Issue, error) { i := &issue.Issue{} if srv.User == nil { i.Warning() i.Messages = append(i.Messages, "no user is specified in the compose file") return i, nil } mtch, err := regexp.Match("[0-9]*:[0-9]*", []byte(*srv.User)) if err != nil { return nil, err } if mtch { if strings.EqualFold(ROOT_USER_GROUP, *srv.User) { i.Fatal() i.Messages = append(i.Messages, "a root user set in the compose file") } else { i.Passed() i.Messages = append(i.Messages, "user configuration is safe") } return i, nil } else { users := strings.Split(*srv.User, ":") if len(users) != 2 { i.Fatal() i.Messages = append(i.Messages, "user value seems misconfigured, check compose file") return i, nil } leftUser, err := regexp.Match(`(?m)\${{\s*(vars|secrets)\..*}}`, []byte(users[0])) if err != nil { return nil, err } rightUser, err := regexp.Match(`(?m)\${{\s*(vars|secrets)\..*}}`, []byte(users[1])) if err != nil { return nil, err } if leftUser && rightUser { i.Passed() i.Messages = append(i.Messages, "user configuration is safe") return i, nil } if leftUser { if strings.EqualFold(users[1], ROOT_GROUP) { i.Warning() i.Messages = append(i.Messages, "a root user is present in configuration") } else { i.Passed() i.Messages = append(i.Messages, "user configuration is safe") } return i, nil } if rightUser { if strings.EqualFold(users[0], ROOT_USER) { i.Warning() i.Messages = append(i.Messages, "a root user is present in configuration") } else { i.Passed() i.Messages = append(i.Messages, "user configuration is safe") } return i, nil } } i.Fatal() i.Messages = append(i.Messages, "unable to parse user configuration") return i, nil } // ensures a restart policy is set in the service configuration with at least max_attempts and restart condition set func PolicyCheck(srv compose.ServiceConfig) *issue.Issue { i := &issue.Issue{} if srv.Deploy != nil { if srv.Deploy.Policy != nil { if srv.Deploy.Policy.Condition != nil { if srv.Deploy.Policy.MaxAttempts != nil { i.Passed() i.Messages = append(i.Messages, "restart policy configuration is safe") return i } else { i.Warning() i.Messages = append(i.Messages, "no max_attempts value set on restart policy container could death loop") return i } } else { i.Fatal() i.Messages = append(i.Messages, "no restart condition is set on the container") return i } } else { i.Fatal() i.Messages = append(i.Messages, "no restart policy is set on the container") return i } } i.Fatal() i.Messages = append(i.Messages, "no restart policy is set on the container") return i }