From c7b518e76bca0855b443215816b2874f8eceff2f Mon Sep 17 00:00:00 2001
From: Huang Huang <mozillazg101@gmail.com>
Date: Mon, 22 Jun 2020 15:45:31 +0800
Subject: [PATCH] Run audit as shell script instead of as single line command
 (#610)

* Run audit as shell script instead of as single line command

* Rename runExecCommands to runAudit

* Fix tests

Co-authored-by: Liz Rice <liz@lizrice.com>
---
 check/check.go      | 172 ++++++--------------------------------------
 check/check_test.go |  74 +++++++++++++++++--
 check/controls.go   |  12 ----
 3 files changed, 92 insertions(+), 166 deletions(-)

diff --git a/check/check.go b/check/check.go
index bd8bcb9..ab6edb8 100644
--- a/check/check.go
+++ b/check/check.go
@@ -17,10 +17,7 @@ package check
 import (
 	"bytes"
 	"fmt"
-	"io"
-	"os"
 	"os/exec"
-	"regexp"
 	"strings"
 
 	"github.com/golang/glog"
@@ -65,22 +62,20 @@ const (
 // Check contains information about a recommendation in the
 // CIS Kubernetes document.
 type Check struct {
-	ID             string      `yaml:"id" json:"test_number"`
-	Text           string      `json:"test_desc"`
-	Audit          string      `json:"audit"`
-	AuditConfig    string      `yaml:"audit_config"`
-	Type           string      `json:"type"`
-	Commands       []*exec.Cmd `json:"omit"`
-	ConfigCommands []*exec.Cmd `json:"omit"`
-	Tests          *tests      `json:"omit"`
-	Set            bool        `json:"omit"`
-	Remediation    string      `json:"remediation"`
-	TestInfo       []string    `json:"test_info"`
+	ID             string   `yaml:"id" json:"test_number"`
+	Text           string   `json:"test_desc"`
+	Audit          string   `json:"audit"`
+	AuditConfig    string   `yaml:"audit_config"`
+	Type           string   `json:"type"`
+	Tests          *tests   `json:"omit"`
+	Set            bool     `json:"omit"`
+	Remediation    string   `json:"remediation"`
+	TestInfo       []string `json:"test_info"`
 	State          `json:"status"`
 	ActualValue    string `json:"actual_value"`
 	Scored         bool   `json:"scored"`
 	ExpectedResult string `json:"expected_result"`
-	Reason     string `json:"reason,omitempty"`
+	Reason         string `json:"reason,omitempty"`
 }
 
 // Runner wraps the basic Run method.
@@ -128,9 +123,9 @@ func (c *Check) run() State {
 	}
 
 	lastCommand := c.Audit
-	hasAuditConfig := c.ConfigCommands != nil
+	hasAuditConfig := c.AuditConfig != ""
 
-	state, finalOutput, retErrmsgs := performTest(c.Audit, c.Commands, c.Tests)
+	state, finalOutput, retErrmsgs := performTest(c.Audit, c.Tests)
 	if len(state) > 0 {
 		c.Reason = retErrmsgs
 		c.State = state
@@ -166,7 +161,7 @@ func (c *Check) run() State {
 			currentTests.TestItems[i] = nti
 		}
 
-		state, finalOutput, retErrmsgs = performTest(c.AuditConfig, c.ConfigCommands, currentTests)
+		state, finalOutput, retErrmsgs = performTest(c.AuditConfig, currentTests)
 		if len(state) > 0 {
 			c.Reason = retErrmsgs
 			c.State = state
@@ -200,78 +195,13 @@ func (c *Check) run() State {
 	return c.State
 }
 
-// textToCommand transforms an input text representation of commands to be
-// run into a slice of commands.
-// TODO: Make this more robust.
-func textToCommand(s string) []*exec.Cmd {
-	glog.V(3).Infof("textToCommand: %q\n", s)
-	cmds := []*exec.Cmd{}
-
-	cp := strings.Split(s, "|")
-
-	for _, v := range cp {
-		v = strings.Trim(v, " ")
-
-		// TODO:
-		// GOAL: To split input text into arguments for exec.Cmd.
-		//
-		// CHALLENGE: The input text may contain quoted strings that
-		// must be passed as a unit to exec.Cmd.
-		// eg. bash -c 'foo bar'
-		// 'foo bar' must be passed as unit to exec.Cmd if not the command
-		// will fail when it is executed.
-		// eg. exec.Cmd("bash", "-c", "foo bar")
-		//
-		// PROBLEM: Current solution assumes the grouped string will always
-		// be at the end of the input text.
-		re := regexp.MustCompile(`^(.*)(['"].*['"])$`)
-		grps := re.FindStringSubmatch(v)
-
-		var cs []string
-		if len(grps) > 0 {
-			s := strings.Trim(grps[1], " ")
-			cs = strings.Split(s, " ")
-
-			s1 := grps[len(grps)-1]
-			s1 = strings.Trim(s1, "'\"")
-
-			cs = append(cs, s1)
-		} else {
-			cs = strings.Split(v, " ")
-		}
-
-		cmd := exec.Command(cs[0], cs[1:]...)
-		cmds = append(cmds, cmd)
-	}
-
-	return cmds
-}
-
-func isShellCommand(s string) bool {
-	cmd := exec.Command("/bin/sh", "-c", "command -v "+s)
-
-	out, err := cmd.Output()
-	if err != nil {
-		exitWithError(fmt.Errorf("failed to check if command: %q is valid %v", s, err))
-	}
-
-	if strings.Contains(string(out), s) {
-		return true
-	}
-	return false
-}
-
-func performTest(audit string, commands []*exec.Cmd, tests *tests) (State, *testOutput, string) {
+func performTest(audit string, tests *tests) (State, *testOutput, string) {
 	if len(strings.TrimSpace(audit)) == 0 {
 		return "", failTestItem("missing command"), "missing audit command"
 	}
 
 	var out bytes.Buffer
-	state, retErrmsgs := runExecCommands(audit, commands, &out)
-	if len(state) > 0 {
-		return state, nil, retErrmsgs
-	}
-	errmsgs := retErrmsgs
+	errmsgs := runAudit(audit, &out)
 
 	finalOutput := tests.execute(out.String())
 	if finalOutput == nil {
@@ -281,73 +211,17 @@ func performTest(audit string, commands []*exec.Cmd, tests *tests) (State, *test
 	return "", finalOutput, errmsgs
 }
 
-func runExecCommands(audit string, commands []*exec.Cmd, out *bytes.Buffer) (State, string) {
-	var err error
+func runAudit(audit string, out *bytes.Buffer) string {
 	errmsgs := ""
 
-	// Check if command exists or exit with WARN.
-	for _, cmd := range commands {
-		if !isShellCommand(cmd.Path) {
-			errmsgs += fmt.Sprintf("Command '%s' not found\n", cmd.Path)
-			return WARN, errmsgs
-		}
-	}
-
-	// Run commands.
-	n := len(commands)
-	if n == 0 {
-		// Likely a warning message.
-		return WARN, errmsgs
-	}
-
-	// Each command runs,
-	//   cmd0 out -> cmd1 in, cmd1 out -> cmd2 in ... cmdn out -> os.stdout
-	//   cmd0 err should terminate chain
-	cs := commands
-
-	// Initialize command pipeline
-	cs[n-1].Stdout = out
-	i := 1
-
-	for i < n {
-		cs[i-1].Stdout, err = cs[i].StdinPipe()
-		if err != nil {
-			errmsgs += fmt.Sprintf("failed to run: %s, command: %s, error: %s\n", audit, cs[i].Args, err)
-		}
-		i++
-	}
-
-	// Start command pipeline
-	i = 0
-	for i < n {
-		err := cs[i].Start()
-		if err != nil {
-			errmsgs += fmt.Sprintf("failed to run: %s, command: %s, error: %s\n", audit, cs[i].Args, err)
-		}
-		i++
-	}
-
-	// Complete command pipeline
-	i = 0
-	for i < n {
-		err := cs[i].Wait()
-		if err != nil {
-			errmsgs += fmt.Sprintf("failed to run: %s, command: %s, error: %s\n", audit, cs[i].Args, err)
-		}
-
-		if i < n-1 {
-			cs[i].Stdout.(io.Closer).Close()
-		}
-		i++
+	cmd := exec.Command("/bin/sh")
+	cmd.Stdin = strings.NewReader(audit)
+	cmd.Stdout = out
+	cmd.Stderr = out
+	if err := cmd.Run(); err != nil {
+		errmsgs += fmt.Sprintf("failed to run: %q, output: %q, error: %s\n", audit, out.String(), err)
 	}
 
 	glog.V(3).Infof("Command %q - Output:\n\n %q\n - Error Messages:%q \n", audit, out.String(), errmsgs)
-	return "", errmsgs
-}
-
-func exitWithError(err error) {
-	fmt.Fprintf(os.Stderr, "\n%v\n", err)
-	// flush before exit non-zero
-	glog.Flush()
-	os.Exit(1)
+	return errmsgs
 }
diff --git a/check/check_test.go b/check/check_test.go
index 4d4e695..45c37ec 100644
--- a/check/check_test.go
+++ b/check/check_test.go
@@ -15,7 +15,8 @@
 package check
 
 import (
-	"os/exec"
+	"bytes"
+	"strings"
 	"testing"
 )
 
@@ -33,8 +34,8 @@ func TestCheck_Run(t *testing.T) {
 		{
 			check: Check{ // Not scored checks with passing tests are marked pass
 				Scored: false,
-				Audit:  ":", Commands: []*exec.Cmd{exec.Command("")},
-				Tests: &tests{TestItems: []*testItem{&testItem{}}},
+				Audit:  ":",
+				Tests:  &tests{TestItems: []*testItem{&testItem{}}},
 			},
 			Expected: PASS,
 		},
@@ -44,8 +45,8 @@ func TestCheck_Run(t *testing.T) {
 		{
 			check: Check{ // Scored checks with passing tests are marked pass
 				Scored: true,
-				Audit:  ":", Commands: []*exec.Cmd{exec.Command("")},
-				Tests: &tests{TestItems: []*testItem{&testItem{}}},
+				Audit:  ":",
+				Tests:  &tests{TestItems: []*testItem{&testItem{}}},
 			},
 			Expected: PASS,
 		},
@@ -111,3 +112,66 @@ func TestCheckAuditConfig(t *testing.T) {
 		}
 	}
 }
+
+func Test_runAudit(t *testing.T) {
+	type args struct {
+		audit  string
+		out    *bytes.Buffer
+		output string
+	}
+	tests := []struct {
+		name   string
+		args   args
+		errMsg string
+		output string
+	}{
+		{
+			name: "run success",
+			args: args{
+				audit: "echo 'hello world'",
+				out:   &bytes.Buffer{},
+			},
+			errMsg: "",
+			output: "hello world\n",
+		},
+		{
+			name: "run multiple lines script",
+			args: args{
+				audit: `
+hello() {
+  echo "hello world"
+}
+
+hello
+`,
+				out: &bytes.Buffer{},
+			},
+			errMsg: "",
+			output: "hello world\n",
+		},
+		{
+			name: "run failed",
+			args: args{
+				audit: "unknown_command",
+				out:   &bytes.Buffer{},
+			},
+			errMsg: "failed to run: \"unknown_command\", output: \"/bin/sh: ",
+			output: "not found\n",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			errMsg := runAudit(tt.args.audit, tt.args.out)
+			if errMsg != "" && !strings.Contains(errMsg, tt.errMsg) {
+				t.Errorf("runAudit() errMsg = %q, want %q", errMsg, tt.errMsg)
+			}
+			output := tt.args.out.String()
+			if errMsg == "" && output != tt.output {
+				t.Errorf("runAudit() output = %q, want %q", output, tt.output)
+			}
+			if errMsg != "" && !strings.Contains(output, tt.output) {
+				t.Errorf("runAudit() output = %q, want %q", output, tt.output)
+			}
+		})
+	}
+}
diff --git a/check/controls.go b/check/controls.go
index 21d95d9..1b36dee 100644
--- a/check/controls.go
+++ b/check/controls.go
@@ -70,18 +70,6 @@ func NewControls(t NodeType, in []byte) (*Controls, error) {
 		return nil, fmt.Errorf("non-%s controls file specified", t)
 	}
 
-	// Prepare audit commands
-	for _, group := range c.Groups {
-		for _, check := range group.Checks {
-			glog.V(3).Infof("Check.ID %s", check.ID)
-			check.Commands = textToCommand(check.Audit)
-			if len(check.AuditConfig) > 0 {
-				glog.V(3).Infof("Check.ID has audit_config %s", check.ID)
-				check.ConfigCommands = textToCommand(check.AuditConfig)
-			}
-		}
-	}
-
 	return c, nil
 }
 
-- 
GitLab