From 5dba85ca47a822eddd12de9e1f4427bb31a5f1c1 Mon Sep 17 00:00:00 2001 From: Abubakr-Sadik Nii Nai Davis Date: Wed, 8 Aug 2018 16:57:29 +0000 Subject: [PATCH] Move cmd/ source code up one level. --- common.go | 180 ++++++++++++++++++++++++++ database.go | 60 +++++++++ federated.go | 40 ++++++ main.go | 6 +- master.go | 40 ++++++ node.go | 40 ++++++ root.go | 115 +++++++++++++++++ types.go | 12 ++ util.go | 338 +++++++++++++++++++++++++++++++++++++++++++++++++ util_test.go | 352 +++++++++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 1178 insertions(+), 5 deletions(-) create mode 100644 common.go create mode 100644 database.go create mode 100644 federated.go create mode 100644 master.go create mode 100644 node.go create mode 100644 root.go create mode 100644 types.go create mode 100644 util.go create mode 100644 util_test.go 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) + } + }) + } +}