diff --git a/common.go b/common.go new file mode 100644 index 0000000..bad9985 --- /dev/null +++ b/common.go @@ -0,0 +1,180 @@ +// Copyright © 2017 Aqua Security Software Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + + "github.com/aquasecurity/bench-common/check" + "github.com/golang/glog" + "github.com/spf13/viper" +) + +var ( + errmsgs string +) + +func runChecks(nodetype nodeType) { + var summary check.Summary + var file string + var err error + var typeConf *viper.Viper + + switch nodetype { + case MASTER: + file = masterFile + case NODE: + file = nodeFile + case FEDERATED: + file = federatedFile + } + + runningVersion, err := getKubeVersion() + if err != nil && kubeVersion == "" { + exitWithError(fmt.Errorf("Version check failed: %s\nAlternatively, you can specify the version with --version", err)) + } + path, err := getConfigFilePath(kubeVersion, runningVersion, file) + if err != nil { + exitWithError(fmt.Errorf("can't find %s controls file in %s: %v", nodetype, cfgDir, err)) + } + + def := filepath.Join(path, file) + in, err := ioutil.ReadFile(def) + if err != nil { + exitWithError(fmt.Errorf("error opening %s controls file: %v", nodetype, err)) + } + + glog.V(1).Info(fmt.Sprintf("Using benchmark file: %s\n", def)) + + // Merge kubernetes version specific config if any. + viper.SetConfigFile(path + "/config.yaml") + err = viper.MergeInConfig() + if err != nil { + if os.IsNotExist(err) { + glog.V(2).Info(fmt.Sprintf("No version-specific config.yaml file in %s", path)) + } else { + exitWithError(fmt.Errorf("couldn't read config file %s: %v", path+"/config.yaml", err)) + } + } else { + glog.V(1).Info(fmt.Sprintf("Using config file: %s\n", viper.ConfigFileUsed())) + } + + // Get the set of exectuables and config files we care about on this type of node. This also + // checks that the executables we need for the node type are running. + typeConf = viper.Sub(string(nodetype)) + binmap := getBinaries(typeConf) + confmap := getConfigFiles(typeConf) + + // Variable substitutions. Replace all occurrences of variables in controls files. + s := string(in) + s = makeSubstitutions(s, "bin", binmap) + s = makeSubstitutions(s, "conf", confmap) + + controls, err := check.NewControls([]byte(s)) + if err != nil { + exitWithError(fmt.Errorf("error setting up %s controls: %v", nodetype, err)) + } + + if groupList != "" && checkList == "" { + ids := cleanIDs(groupList) + summary = controls.RunGroup(ids...) + } else if checkList != "" && groupList == "" { + ids := cleanIDs(checkList) + summary = controls.RunChecks(ids...) + } else if checkList != "" && groupList != "" { + exitWithError(fmt.Errorf("group option and check option can't be used together")) + } else { + summary = controls.RunGroup() + } + + // if we successfully ran some tests and it's json format, ignore the warnings + if (summary.Fail > 0 || summary.Warn > 0 || summary.Pass > 0) && jsonFmt { + out, err := controls.JSON() + if err != nil { + exitWithError(fmt.Errorf("failed to output in JSON format: %v", err)) + } + + fmt.Println(string(out)) + } else { + // if we want to store in PostgreSQL, convert to JSON and save it + if (summary.Fail > 0 || summary.Warn > 0 || summary.Pass > 0) && pgSQL { + out, err := controls.JSON() + if err != nil { + exitWithError(fmt.Errorf("failed to output in JSON format: %v", err)) + } + + savePgsql(string(out)) + } else { + prettyPrint(controls, summary) + } + } +} + +// colorPrint outputs the state in a specific colour, along with a message string +func colorPrint(state check.State, s string) { + colors[state].Printf("[%s] ", state) + fmt.Printf("%s", s) +} + +// prettyPrint outputs the results to stdout in human-readable format +func prettyPrint(r *check.Controls, summary check.Summary) { + // Print check results. + if !noResults { + colorPrint(check.INFO, fmt.Sprintf("%s %s\n", r.ID, r.Description)) + for _, g := range r.Groups { + colorPrint(check.INFO, fmt.Sprintf("%s %s\n", g.ID, g.Description)) + for _, c := range g.Checks { + colorPrint(c.State, fmt.Sprintf("%s %s\n", c.ID, c.Description)) + } + } + + fmt.Println() + } + + // Print remediations. + if !noRemediations { + if summary.Fail > 0 || summary.Warn > 0 { + colors[check.WARN].Printf("== Remediations ==\n") + for _, g := range r.Groups { + for _, c := range g.Checks { + if c.State != check.PASS { + fmt.Printf("%s %s\n", c.ID, c.Remediation) + } + } + } + fmt.Println() + } + } + + // Print summary setting output color to highest severity. + if !noSummary { + var res check.State + if summary.Fail > 0 { + res = check.FAIL + } else if summary.Warn > 0 { + res = check.WARN + } else { + res = check.PASS + } + + colors[res].Printf("== Summary ==\n") + fmt.Printf("%d checks PASS\n%d checks FAIL\n%d checks WARN\n", + summary.Pass, summary.Fail, summary.Warn, + ) + } +} diff --git a/database.go b/database.go new file mode 100644 index 0000000..1130033 --- /dev/null +++ b/database.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "os" + "time" + + "github.com/golang/glog" + "github.com/jinzhu/gorm" + _ "github.com/jinzhu/gorm/dialects/postgres" // database packages get blank imports + "github.com/spf13/viper" +) + +func savePgsql(jsonInfo string) { + envVars := map[string]string{ + "PGSQL_HOST": viper.GetString("PGSQL_HOST"), + "PGSQL_USER": viper.GetString("PGSQL_USER"), + "PGSQL_DBNAME": viper.GetString("PGSQL_DBNAME"), + "PGSQL_SSLMODE": viper.GetString("PGSQL_SSLMODE"), + "PGSQL_PASSWORD": viper.GetString("PGSQL_PASSWORD"), + } + + for k, v := range envVars { + if v == "" { + exitWithError(fmt.Errorf("environment variable %s is missing", envVarsPrefix+"_"+k)) + } + } + + connInfo := fmt.Sprintf("host=%s user=%s dbname=%s sslmode=%s password=%s", + envVars["PGSQL_HOST"], + envVars["PGSQL_USER"], + envVars["PGSQL_DBNAME"], + envVars["PGSQL_SSLMODE"], + envVars["PGSQL_PASSWORD"], + ) + + hostname, err := os.Hostname() + if err != nil { + exitWithError(fmt.Errorf("received error looking up hostname: %s", err)) + } + + timestamp := time.Now() + + type ScanResult struct { + gorm.Model + ScanHost string `gorm:"type:varchar(63) not null"` // https://www.ietf.org/rfc/rfc1035.txt + ScanTime time.Time `gorm:"not null"` + ScanInfo string `gorm:"type:jsonb not null"` + } + + db, err := gorm.Open("postgres", connInfo) + defer db.Close() + if err != nil { + exitWithError(fmt.Errorf("received error connecting to database: %s", err)) + } + + db.Debug().AutoMigrate(&ScanResult{}) + db.Save(&ScanResult{ScanHost: hostname, ScanTime: timestamp, ScanInfo: jsonInfo}) + glog.V(2).Info(fmt.Sprintf("successfully stored result to: %s", envVars["PGSQL_HOST"])) +} diff --git a/federated.go b/federated.go new file mode 100644 index 0000000..65deb3e --- /dev/null +++ b/federated.go @@ -0,0 +1,40 @@ +// Copyright © 2017 Aqua Security Software Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "github.com/spf13/cobra" +) + +// nodeCmd represents the node command +var federatedCmd = &cobra.Command{ + Use: "federated", + Short: "Run benchmark checks for a Kubernetes federated deployment.", + Long: `Run benchmark checks for a Kubernetes federated deployment.`, + Run: func(cmd *cobra.Command, args []string) { + runChecks(FEDERATED) + }, +} + +func init() { + federatedCmd.PersistentFlags().StringVarP(&federatedFile, + "file", + "f", + "/federated.yaml", + "Alternative YAML file for federated checks", + ) + + RootCmd.AddCommand(federatedCmd) +} diff --git a/main.go b/main.go index 934c962..d35a3cd 100644 --- a/main.go +++ b/main.go @@ -14,10 +14,6 @@ package main -import ( - "github.com/aquasecurity/kube-bench/cmd" -) - func main() { - cmd.Execute() + Execute() } diff --git a/master.go b/master.go new file mode 100644 index 0000000..4f33bbf --- /dev/null +++ b/master.go @@ -0,0 +1,40 @@ +// Copyright © 2017 Aqua Security Software Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "github.com/spf13/cobra" +) + +// masterCmd represents the master command +var masterCmd = &cobra.Command{ + Use: "master", + Short: "Run benchmark checks for a Kubernetes master node.", + Long: `Run benchmark checks for a Kubernetes master node.`, + Run: func(cmd *cobra.Command, args []string) { + runChecks(MASTER) + }, +} + +func init() { + masterCmd.PersistentFlags().StringVarP(&masterFile, + "file", + "f", + "/master.yaml", + "Alternative YAML file for master checks", + ) + + RootCmd.AddCommand(masterCmd) +} diff --git a/node.go b/node.go new file mode 100644 index 0000000..005508a --- /dev/null +++ b/node.go @@ -0,0 +1,40 @@ +// Copyright © 2017 Aqua Security Software Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "github.com/spf13/cobra" +) + +// nodeCmd represents the node command +var nodeCmd = &cobra.Command{ + Use: "node", + Short: "Run benchmark checks for a Kubernetes node.", + Long: `Run benchmark checks for a Kubernetes node.`, + Run: func(cmd *cobra.Command, args []string) { + runChecks(NODE) + }, +} + +func init() { + nodeCmd.PersistentFlags().StringVarP(&nodeFile, + "file", + "f", + "/node.yaml", + "Alternative YAML file for node checks", + ) + + RootCmd.AddCommand(nodeCmd) +} diff --git a/root.go b/root.go new file mode 100644 index 0000000..bd1a888 --- /dev/null +++ b/root.go @@ -0,0 +1,115 @@ +// Copyright © 2017 Aqua Security Software Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + goflag "flag" + "fmt" + "os" + + "github.com/aquasecurity/bench-common/check" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + envVarsPrefix = "KUBE_BENCH" + defaultKubeVersion = "1.6" + kubeVersion string + cfgFile string + cfgDir string + jsonFmt bool + pgSQL bool + checkList string + groupList string + masterFile string + nodeFile string + federatedFile string + noResults bool + noSummary bool + noRemediations bool +) + +// RootCmd represents the base command when called without any subcommands +var RootCmd = &cobra.Command{ + Use: os.Args[0], + Short: "Run CIS Benchmarks checks against a Kubernetes deployment", + Long: `This tool runs the CIS Kubernetes Benchmark (http://www.cisecurity.org/benchmark/kubernetes/)`, +} + +// Execute adds all child commands to the root command sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + goflag.Set("logtostderr", "true") + goflag.CommandLine.Parse([]string{}) + + if err := RootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(-1) + } +} + +func init() { + cobra.OnInitialize(initConfig) + + // Output control + RootCmd.PersistentFlags().BoolVar(&noResults, "noresults", false, "Disable printing of results section") + RootCmd.PersistentFlags().BoolVar(&noSummary, "nosummary", false, "Disable printing of summary section") + RootCmd.PersistentFlags().BoolVar(&noRemediations, "noremediations", false, "Disable printing of remediations section") + RootCmd.PersistentFlags().BoolVar(&jsonFmt, "json", false, "Prints the results as JSON") + RootCmd.PersistentFlags().BoolVar(&pgSQL, "pgsql", false, "Save the results to PostgreSQL") + + RootCmd.PersistentFlags().StringVarP( + &checkList, + "check", + "c", + "", + `A comma-delimited list of checks to run as specified in CIS document. Example --check="1.1.1,1.1.2"`, + ) + RootCmd.PersistentFlags().StringVarP( + &groupList, + "group", + "g", + "", + `Run all the checks under this comma-delimited list of groups. Example --group="1.1"`, + ) + RootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is ./cfg/config.yaml)") + RootCmd.PersistentFlags().StringVarP(&cfgDir, "config-dir", "D", "./cfg/", "config directory") + RootCmd.PersistentFlags().StringVar(&kubeVersion, "version", "", "Manually specify Kubernetes version, automatically detected if unset") + + goflag.CommandLine.VisitAll(func(goflag *goflag.Flag) { + RootCmd.PersistentFlags().AddGoFlag(goflag) + }) + +} + +// initConfig reads in config file and ENV variables if set. +func initConfig() { + if cfgFile != "" { // enable ability to specify config file via flag + viper.SetConfigFile(cfgFile) + } else { + viper.SetConfigName("config") // name of config file (without extension) + viper.AddConfigPath(cfgDir) // adding ./cfg as first search path + } + + viper.SetEnvPrefix(envVarsPrefix) + viper.AutomaticEnv() // read in environment variables that match + + // If a config file is found, read it in. + if err := viper.ReadInConfig(); err != nil { + colorPrint(check.FAIL, fmt.Sprintf("Failed to read config file: %v\n", err)) + os.Exit(1) + } +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..3633cb0 --- /dev/null +++ b/types.go @@ -0,0 +1,12 @@ +package main + +type nodeType string + +const ( + // MASTER a master node + MASTER nodeType = "master" + // NODE a node + NODE nodeType = "node" + // FEDERATED a federated deployment. + FEDERATED nodeType = "federated" +) diff --git a/util.go b/util.go new file mode 100644 index 0000000..65c416c --- /dev/null +++ b/util.go @@ -0,0 +1,338 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/aquasecurity/bench-common/check" + "github.com/fatih/color" + "github.com/golang/glog" + "github.com/spf13/viper" +) + +var ( + // Print colors + colors = map[check.State]*color.Color{ + check.PASS: color.New(color.FgGreen), + check.FAIL: color.New(color.FgRed), + check.WARN: color.New(color.FgYellow), + check.INFO: color.New(color.FgBlue), + } +) + +var psFunc func(string) string +var statFunc func(string) (os.FileInfo, error) + +func init() { + psFunc = ps + statFunc = os.Stat +} + +func printlnWarn(msg string) { + fmt.Fprintf(os.Stderr, "[%s] %s\n", + colors[check.WARN].Sprintf("%s", check.WARN), + msg, + ) +} + +func sprintlnWarn(msg string) string { + return fmt.Sprintf("[%s] %s", + colors[check.WARN].Sprintf("%s", check.WARN), + msg, + ) +} + +func exitWithError(err error) { + fmt.Fprintf(os.Stderr, "\n%v\n", err) + os.Exit(1) +} + +func continueWithError(err error, msg string) string { + if err != nil { + glog.V(2).Info(err) + } + + if msg != "" { + fmt.Fprintf(os.Stderr, "%s\n", msg) + } + + return "" +} + +func cleanIDs(list string) []string { + list = strings.Trim(list, ",") + ids := strings.Split(list, ",") + + for _, id := range ids { + id = strings.Trim(id, " ") + } + + return ids +} + +// ps execs out to the ps command; it's separated into a function so we can write tests +func ps(proc string) string { + cmd := exec.Command("ps", "-C", proc, "-o", "cmd", "--no-headers") + out, err := cmd.Output() + if err != nil { + continueWithError(fmt.Errorf("%s: %s", cmd.Args, err), "") + } + + return string(out) +} + +// getBinaries finds which of the set of candidate executables are running +func getBinaries(v *viper.Viper) map[string]string { + binmap := make(map[string]string) + + for _, component := range v.GetStringSlice("components") { + s := v.Sub(component) + if s == nil { + continue + } + + optional := s.GetBool("optional") + bins := s.GetStringSlice("bins") + if len(bins) > 0 { + bin, err := findExecutable(bins) + if err != nil && !optional { + exitWithError(fmt.Errorf("need %s executable but none of the candidates are running", component)) + } + + // Default the executable name that we'll substitute to the name of the component + if bin == "" { + bin = component + glog.V(2).Info(fmt.Sprintf("Component %s not running", component)) + } else { + glog.V(2).Info(fmt.Sprintf("Component %s uses running binary %s", component, bin)) + } + binmap[component] = bin + } + } + + return binmap +} + +// getConfigFilePath locates the config files we should be using based on either the specified +// version, or the running version of kubernetes if not specified +func getConfigFilePath(specifiedVersion string, runningVersion string, filename string) (path string, err error) { + var fileVersion string + + if specifiedVersion != "" { + fileVersion = specifiedVersion + } else { + fileVersion = runningVersion + } + + glog.V(2).Info(fmt.Sprintf("Looking for config for version %s", fileVersion)) + + for { + path = filepath.Join(cfgDir, fileVersion) + file := filepath.Join(path, string(filename)) + glog.V(2).Info(fmt.Sprintf("Looking for config file: %s\n", file)) + + if _, err = os.Stat(file); !os.IsNotExist(err) { + if specifiedVersion == "" && fileVersion != runningVersion { + glog.V(1).Info(fmt.Sprintf("No test file found for %s - using tests for Kubernetes %s\n", runningVersion, fileVersion)) + } + return path, nil + } + + // If we were given an explicit version to look for, don't look for any others + if specifiedVersion != "" { + return "", err + } + + fileVersion = decrementVersion(fileVersion) + if fileVersion == "" { + return "", fmt.Errorf("no test files found <= runningVersion") + } + } +} + +// decrementVersion decrements the version number +// We want to decrement individually even through versions where we don't supply test files +// just in case someone wants to specify their own test files for that version +func decrementVersion(version string) string { + split := strings.Split(version, ".") + minor, err := strconv.Atoi(split[1]) + if err != nil { + return "" + } + if minor <= 1 { + return "" + } + split[1] = strconv.Itoa(minor - 1) + return strings.Join(split, ".") +} + +// getConfigFiles finds which of the set of candidate config files exist +// accepts a string 't' which indicates the type of config file, conf, +// podspec or untifile. +func getConfigFiles(v *viper.Viper) map[string]string { + confmap := make(map[string]string) + + for _, component := range v.GetStringSlice("components") { + s := v.Sub(component) + if s == nil { + continue + } + + // See if any of the candidate config files exist + conf := findConfigFile(s.GetStringSlice("confs")) + if conf == "" { + if s.IsSet("defaultconf") { + conf = s.GetString("defaultconf") + glog.V(2).Info(fmt.Sprintf("Using default config file name '%s' for component %s", conf, component)) + } else { + // Default the config file name that we'll substitute to the name of the component + glog.V(2).Info(fmt.Sprintf("Missing config file for %s", component)) + conf = component + } + } else { + glog.V(2).Info(fmt.Sprintf("Component %s uses config file '%s'", component, conf)) + } + + confmap[component] = conf + } + + return confmap +} + +// verifyBin checks that the binary specified is running +func verifyBin(bin string) bool { + + // Strip any quotes + bin = strings.Trim(bin, "'\"") + + // bin could consist of more than one word + // We'll search for running processes with the first word, and then check the whole + // proc as supplied is included in the results + proc := strings.Fields(bin)[0] + out := psFunc(proc) + + // There could be multiple lines in the ps output + // The binary needs to be the first word in the ps output, except that it could be preceded by a path + // e.g. /usr/bin/kubelet is a match for kubelet + // but apiserver is not a match for kube-apiserver + reFirstWord := regexp.MustCompile(`^(\S*\/)*` + bin) + lines := strings.Split(out, "\n") + for _, l := range lines { + if reFirstWord.Match([]byte(l)) { + return true + } + } + + return false +} + +// fundConfigFile looks through a list of possible config files and finds the first one that exists +func findConfigFile(candidates []string) string { + for _, c := range candidates { + _, err := statFunc(c) + if err == nil { + return c + } + if !os.IsNotExist(err) { + exitWithError(fmt.Errorf("error looking for file %s: %v", c, err)) + } + } + + return "" +} + +// findExecutable looks through a list of possible executable names and finds the first one that's running +func findExecutable(candidates []string) (string, error) { + for _, c := range candidates { + if verifyBin(c) { + return c, nil + } + glog.V(1).Info(fmt.Sprintf("executable '%s' not running", c)) + } + + return "", fmt.Errorf("no candidates running") +} + +func multiWordReplace(s string, subname string, sub string) string { + f := strings.Fields(sub) + if len(f) > 1 { + sub = "'" + sub + "'" + } + + return strings.Replace(s, subname, sub, -1) +} + +func getKubeVersion() (string, error) { + // These executables might not be on the user's path. + _, err := exec.LookPath("kubectl") + + if err != nil { + _, err = exec.LookPath("kubelet") + if err != nil { + return "", fmt.Errorf("need kubectl or kubelet binaries to get kubernetes version") + } + return getKubeVersionFromKubelet(), nil + } + + return getKubeVersionFromKubectl(), nil +} + +func getKubeVersionFromKubectl() string { + cmd := exec.Command("kubectl", "version", "--short") + out, err := cmd.CombinedOutput() + if err != nil { + continueWithError(fmt.Errorf("%s", out), "") + } + + return getVersionFromKubectlOutput(string(out)) +} + +func getKubeVersionFromKubelet() string { + cmd := exec.Command("kubelet", "--version") + out, err := cmd.CombinedOutput() + + if err != nil { + continueWithError(fmt.Errorf("%s", out), "") + } + + return getVersionFromKubeletOutput(string(out)) +} + +func getVersionFromKubectlOutput(s string) string { + serverVersionRe := regexp.MustCompile(`Server Version: v(\d+.\d+)`) + subs := serverVersionRe.FindStringSubmatch(s) + if len(subs) < 2 { + printlnWarn(fmt.Sprintf("Unable to get kubectl version, using default version: %s", defaultKubeVersion)) + return defaultKubeVersion + } + return subs[1] +} + +func getVersionFromKubeletOutput(s string) string { + serverVersionRe := regexp.MustCompile(`Kubernetes v(\d+.\d+)`) + subs := serverVersionRe.FindStringSubmatch(s) + if len(subs) < 2 { + printlnWarn(fmt.Sprintf("Unable to get kubelet version, using default version: %s", defaultKubeVersion)) + return defaultKubeVersion + } + return subs[1] +} + +func makeSubstitutions(s string, ext string, m map[string]string) string { + for k, v := range m { + subst := "$" + k + ext + if v == "" { + glog.V(2).Info(fmt.Sprintf("No subsitution for '%s'\n", subst)) + continue + } + glog.V(2).Info(fmt.Sprintf("Substituting %s with '%s'\n", subst, v)) + s = multiWordReplace(s, subst, v) + } + + return s +} diff --git a/util_test.go b/util_test.go new file mode 100644 index 0000000..845b9f8 --- /dev/null +++ b/util_test.go @@ -0,0 +1,352 @@ +// Copyright © 2017 Aqua Security Software Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "io/ioutil" + "os" + "path/filepath" + "reflect" + "strconv" + "testing" + + "github.com/spf13/viper" +) + +var g string +var e []error +var eIndex int + +func fakeps(proc string) string { + return g +} + +func fakestat(file string) (os.FileInfo, error) { + err := e[eIndex] + eIndex++ + return nil, err +} + +func TestVerifyBin(t *testing.T) { + cases := []struct { + proc string + psOut string + exp bool + }{ + {proc: "single", psOut: "single", exp: true}, + {proc: "single", psOut: "", exp: false}, + {proc: "two words", psOut: "two words", exp: true}, + {proc: "two words", psOut: "", exp: false}, + {proc: "cmd", psOut: "cmd param1 param2", exp: true}, + {proc: "cmd param", psOut: "cmd param1 param2", exp: true}, + {proc: "cmd param", psOut: "cmd", exp: false}, + {proc: "cmd", psOut: "cmd x \ncmd y", exp: true}, + {proc: "cmd y", psOut: "cmd x \ncmd y", exp: true}, + {proc: "cmd", psOut: "/usr/bin/cmd", exp: true}, + {proc: "cmd", psOut: "kube-cmd", exp: false}, + {proc: "cmd", psOut: "/usr/bin/kube-cmd", exp: false}, + } + + psFunc = fakeps + for id, c := range cases { + t.Run(strconv.Itoa(id), func(t *testing.T) { + g = c.psOut + v := verifyBin(c.proc) + if v != c.exp { + t.Fatalf("Expected %v got %v", c.exp, v) + } + }) + } +} + +func TestFindExecutable(t *testing.T) { + cases := []struct { + candidates []string // list of executables we'd consider + psOut string // fake output from ps + exp string // the one we expect to find in the (fake) ps output + expErr bool + }{ + {candidates: []string{"one", "two", "three"}, psOut: "two", exp: "two"}, + {candidates: []string{"one", "two", "three"}, psOut: "two three", exp: "two"}, + {candidates: []string{"one double", "two double", "three double"}, psOut: "two double is running", exp: "two double"}, + {candidates: []string{"one", "two", "three"}, psOut: "blah", expErr: true}, + {candidates: []string{"one double", "two double", "three double"}, psOut: "two", expErr: true}, + {candidates: []string{"apiserver", "kube-apiserver"}, psOut: "kube-apiserver", exp: "kube-apiserver"}, + {candidates: []string{"apiserver", "kube-apiserver", "hyperkube-apiserver"}, psOut: "kube-apiserver", exp: "kube-apiserver"}, + } + + psFunc = fakeps + for id, c := range cases { + t.Run(strconv.Itoa(id), func(t *testing.T) { + g = c.psOut + e, err := findExecutable(c.candidates) + if e != c.exp { + t.Fatalf("Expected %v got %v", c.exp, e) + } + + if err == nil && c.expErr { + t.Fatalf("Expected error") + } + + if err != nil && !c.expErr { + t.Fatalf("Didn't expect error: %v", err) + } + }) + } +} + +func TestGetBinaries(t *testing.T) { + cases := []struct { + config map[string]interface{} + psOut string + exp map[string]string + }{ + { + config: map[string]interface{}{"components": []string{"apiserver"}, "apiserver": map[string]interface{}{"bins": []string{"apiserver", "kube-apiserver"}}}, + psOut: "kube-apiserver", + exp: map[string]string{"apiserver": "kube-apiserver"}, + }, + { + // "thing" is not in the list of components + config: map[string]interface{}{"components": []string{"apiserver"}, "apiserver": map[string]interface{}{"bins": []string{"apiserver", "kube-apiserver"}}, "thing": map[string]interface{}{"bins": []string{"something else", "thing"}}}, + psOut: "kube-apiserver thing", + exp: map[string]string{"apiserver": "kube-apiserver"}, + }, + { + // "anotherthing" in list of components but doesn't have a defintion + config: map[string]interface{}{"components": []string{"apiserver", "anotherthing"}, "apiserver": map[string]interface{}{"bins": []string{"apiserver", "kube-apiserver"}}, "thing": map[string]interface{}{"bins": []string{"something else", "thing"}}}, + psOut: "kube-apiserver thing", + exp: map[string]string{"apiserver": "kube-apiserver"}, + }, + { + // more than one component + config: map[string]interface{}{"components": []string{"apiserver", "thing"}, "apiserver": map[string]interface{}{"bins": []string{"apiserver", "kube-apiserver"}}, "thing": map[string]interface{}{"bins": []string{"something else", "thing"}}}, + psOut: "kube-apiserver \nthing", + exp: map[string]string{"apiserver": "kube-apiserver", "thing": "thing"}, + }, + { + // default binary to component name + config: map[string]interface{}{"components": []string{"apiserver", "thing"}, "apiserver": map[string]interface{}{"bins": []string{"apiserver", "kube-apiserver"}}, "thing": map[string]interface{}{"bins": []string{"something else", "thing"}, "optional": true}}, + psOut: "kube-apiserver \notherthing some params", + exp: map[string]string{"apiserver": "kube-apiserver", "thing": "thing"}, + }, + } + + v := viper.New() + psFunc = fakeps + + for id, c := range cases { + t.Run(strconv.Itoa(id), func(t *testing.T) { + g = c.psOut + for k, val := range c.config { + v.Set(k, val) + } + m := getBinaries(v) + if !reflect.DeepEqual(m, c.exp) { + t.Fatalf("Got %v\nExpected %v", m, c.exp) + } + }) + } +} + +func TestMultiWordReplace(t *testing.T) { + cases := []struct { + input string + sub string + subname string + output string + }{ + {input: "Here's a file with no substitutions", sub: "blah", subname: "blah", output: "Here's a file with no substitutions"}, + {input: "Here's a file with a substitution", sub: "blah", subname: "substitution", output: "Here's a file with a blah"}, + {input: "Here's a file with multi-word substitutions", sub: "multi word", subname: "multi-word", output: "Here's a file with 'multi word' substitutions"}, + {input: "Here's a file with several several substitutions several", sub: "blah", subname: "several", output: "Here's a file with blah blah substitutions blah"}, + } + for id, c := range cases { + t.Run(strconv.Itoa(id), func(t *testing.T) { + s := multiWordReplace(c.input, c.subname, c.sub) + if s != c.output { + t.Fatalf("Expected %s got %s", c.output, s) + } + }) + } +} + +func TestKubeVersionRegex(t *testing.T) { + ver := getVersionFromKubectlOutput(`Client Version: v1.8.0 + Server Version: v1.8.12 + `) + if ver != "1.8" { + t.Fatalf("Expected 1.8 got %s", ver) + } + + ver = getVersionFromKubectlOutput("Something completely different") + if ver != "1.6" { + t.Fatalf("Expected 1.6 got %s", ver) + } +} + +func TestFindConfigFile(t *testing.T) { + cases := []struct { + input []string + statResults []error + exp string + }{ + {input: []string{"myfile"}, statResults: []error{nil}, exp: "myfile"}, + {input: []string{"thisfile", "thatfile"}, statResults: []error{os.ErrNotExist, nil}, exp: "thatfile"}, + {input: []string{"thisfile", "thatfile"}, statResults: []error{os.ErrNotExist, os.ErrNotExist}, exp: ""}, + } + + statFunc = fakestat + for id, c := range cases { + t.Run(strconv.Itoa(id), func(t *testing.T) { + e = c.statResults + eIndex = 0 + conf := findConfigFile(c.input) + if conf != c.exp { + t.Fatalf("Got %s expected %s", conf, c.exp) + } + }) + } +} + +func TestGetConfigFiles(t *testing.T) { + cases := []struct { + config map[string]interface{} + exp map[string]string + statResults []error + }{ + { + config: map[string]interface{}{"components": []string{"apiserver"}, "apiserver": map[string]interface{}{"confs": []string{"apiserver", "kube-apiserver"}}}, + statResults: []error{os.ErrNotExist, nil}, + exp: map[string]string{"apiserver": "kube-apiserver"}, + }, + { + // Component "thing" isn't included in the list of components + config: map[string]interface{}{ + "components": []string{"apiserver"}, + "apiserver": map[string]interface{}{"confs": []string{"apiserver", "kube-apiserver"}}, + "thing": map[string]interface{}{"confs": []string{"/my/file/thing"}}}, + statResults: []error{os.ErrNotExist, nil}, + exp: map[string]string{"apiserver": "kube-apiserver"}, + }, + { + // More than one component + config: map[string]interface{}{ + "components": []string{"apiserver", "thing"}, + "apiserver": map[string]interface{}{"confs": []string{"apiserver", "kube-apiserver"}}, + "thing": map[string]interface{}{"confs": []string{"/my/file/thing"}}}, + statResults: []error{os.ErrNotExist, nil, nil}, + exp: map[string]string{"apiserver": "kube-apiserver", "thing": "/my/file/thing"}, + }, + { + // Default thing to specified default config + config: map[string]interface{}{ + "components": []string{"apiserver", "thing"}, + "apiserver": map[string]interface{}{"confs": []string{"apiserver", "kube-apiserver"}}, + "thing": map[string]interface{}{"confs": []string{"/my/file/thing"}, "defaultconf": "another/thing"}}, + statResults: []error{os.ErrNotExist, nil, os.ErrNotExist}, + exp: map[string]string{"apiserver": "kube-apiserver", "thing": "another/thing"}, + }, + { + // Default thing to component name + config: map[string]interface{}{ + "components": []string{"apiserver", "thing"}, + "apiserver": map[string]interface{}{"confs": []string{"apiserver", "kube-apiserver"}}, + "thing": map[string]interface{}{"confs": []string{"/my/file/thing"}}}, + statResults: []error{os.ErrNotExist, nil, os.ErrNotExist}, + exp: map[string]string{"apiserver": "kube-apiserver", "thing": "thing"}, + }, + } + + v := viper.New() + statFunc = fakestat + + for id, c := range cases { + t.Run(strconv.Itoa(id), func(t *testing.T) { + for k, val := range c.config { + v.Set(k, val) + } + e = c.statResults + eIndex = 0 + + m := getConfigFiles(v) + if !reflect.DeepEqual(m, c.exp) { + t.Fatalf("Got %v\nExpected %v", m, c.exp) + } + }) + } +} + +func TestMakeSubsitutions(t *testing.T) { + cases := []struct { + input string + subst map[string]string + exp string + }{ + {input: "Replace $thisbin", subst: map[string]string{"this": "that"}, exp: "Replace that"}, + {input: "Replace $thisbin", subst: map[string]string{"this": "that", "here": "there"}, exp: "Replace that"}, + {input: "Replace $thisbin and $herebin", subst: map[string]string{"this": "that", "here": "there"}, exp: "Replace that and there"}, + } + for _, c := range cases { + t.Run(c.input, func(t *testing.T) { + s := makeSubstitutions(c.input, "bin", c.subst) + if s != c.exp { + t.Fatalf("Got %s expected %s", s, c.exp) + } + }) + } +} + +func TestGetConfigFilePath(t *testing.T) { + var err error + cfgDir, err = ioutil.TempDir("", "kube-bench-test") + if err != nil { + t.Fatalf("Failed to create temp directory") + } + defer os.RemoveAll(cfgDir) + d := filepath.Join(cfgDir, "1.8") + err = os.Mkdir(d, 0666) + if err != nil { + t.Fatalf("Failed to create temp file") + } + ioutil.WriteFile(filepath.Join(d, "master.yaml"), []byte("hello world"), 0666) + + cases := []struct { + specifiedVersion string + runningVersion string + succeed bool + exp string + }{ + {runningVersion: "1.8", succeed: true, exp: d}, + {runningVersion: "1.9", succeed: true, exp: d}, + {runningVersion: "1.10", succeed: true, exp: d}, + {runningVersion: "1.1", succeed: false}, + {specifiedVersion: "1.8", succeed: true, exp: d}, + {specifiedVersion: "1.9", succeed: false}, + {specifiedVersion: "1.10", succeed: false}, + } + + for _, c := range cases { + t.Run(c.specifiedVersion+"-"+c.runningVersion, func(t *testing.T) { + path, err := getConfigFilePath(c.specifiedVersion, c.runningVersion, "/master.yaml") + if err != nil && c.succeed { + t.Fatalf("Error %v", err) + } + if path != c.exp { + t.Fatalf("Got %s expected %s", path, c.exp) + } + }) + } +}