diff --git a/pkg/cli/register.go b/pkg/cli/register.go index c1c847a83..81fd7fc3a 100644 --- a/pkg/cli/register.go +++ b/pkg/cli/register.go @@ -56,7 +56,7 @@ func setDefaultCommandIfNonePresent() { func Execute() { rootCmd.PersistentFlags().StringVarP(&LogLevel, "log-level", "l", "info", "log level (debug, info, warn, error, panic, fatal)") rootCmd.PersistentFlags().StringVarP(&LogType, "log-type", "x", "console", "log output type (console, json)") - rootCmd.PersistentFlags().StringVarP(&OutputType, "output", "o", "yaml", "output type (json, yaml, xml)") + rootCmd.PersistentFlags().StringVarP(&OutputType, "output", "o", "human", "output type (human, json, yaml, xml)") rootCmd.PersistentFlags().StringVarP(&ConfigFile, "config-path", "c", "", "config file path") // Function to execute before processing commands diff --git a/pkg/cli/root.go b/pkg/cli/root.go index 814daed43..f7872e995 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -25,7 +25,7 @@ var ( LogLevel string // LogType Logging output type (console, json) LogType string - // OutputType Violation output type (text, json, yaml, xml) + // OutputType Violation output type (human, json, yaml, xml) OutputType string // ConfigFile Config file path ConfigFile string diff --git a/pkg/cli/run.go b/pkg/cli/run.go index 14c298ee0..629f26603 100644 --- a/pkg/cli/run.go +++ b/pkg/cli/run.go @@ -17,9 +17,11 @@ package cli import ( + "errors" "flag" "os" "path/filepath" + "strings" "github.com/accurics/terrascan/pkg/downloader" "github.com/accurics/terrascan/pkg/runtime" @@ -28,26 +30,26 @@ import ( "go.uber.org/zap" ) +const ( + humanOutputFormat = "human" +) + // Run executes terrascan in CLI mode func Run(iacType, iacVersion string, cloudType []string, iacFilePath, iacDirPath, configFile string, policyPath []string, - format, remoteType, remoteURL string, configOnly, useColors bool) { + format, remoteType, remoteURL string, configOnly, useColors, verbose bool) { // temp dir to download the remote repo tempDir := filepath.Join(os.TempDir(), utils.GenRandomString(6)) defer os.RemoveAll(tempDir) // download remote repository - d := downloader.NewDownloader() - path, err := d.DownloadWithType(remoteType, remoteURL, tempDir) - if err == downloader.ErrEmptyURLType { - // url and type empty, proceed with regular scanning - zap.S().Debugf("remote url and type not configured, proceeding with regular scanning") - } else if err != nil { - // some error while downloading remote repository + path, err := downloadRemoteRepository(remoteType, remoteURL, tempDir) + if err != nil { return - } else { - // successfully downloaded remote repository + } + + if path != "" { iacDirPath = path } @@ -64,16 +66,48 @@ func Run(iacType, iacVersion string, cloudType []string, return } + // write results to console + err = writeResults(results, useColors, verbose, configOnly, format) + if err != nil { + zap.S().Error("failed to write results", zap.Error(err)) + return + } + + if results.Violations.ViolationStore.Summary.ViolatedPolicies != 0 && flag.Lookup("test.v") == nil { + os.RemoveAll(tempDir) + os.Exit(3) + } +} + +func downloadRemoteRepository(remoteType, remoteURL, tempDir string) (string, error) { + d := downloader.NewDownloader() + path, err := d.DownloadWithType(remoteType, remoteURL, tempDir) + if err == downloader.ErrEmptyURLType { + // url and type empty, proceed with regular scanning + zap.S().Debugf("remote url and type not configured, proceeding with regular scanning") + } else if err != nil { + // some error while downloading remote repository + return path, err + } + return path, nil +} + +func writeResults(results runtime.Output, useColors, verbose, configOnly bool, format string) error { + // add verbose flag to the scan summary + results.Violations.ViolationStore.Summary.ShowViolationDetails = verbose + outputWriter := NewOutputWriter(useColors) if configOnly { + // human readable output doesn't support --config-only flag + // if --config-only flag is set, then exit with an error + // asking the user to use yaml or json output format + if strings.EqualFold(format, humanOutputFormat) { + return errors.New("please use yaml or json output format when using --config-only flag") + } writer.Write(format, results.ResourceConfig, outputWriter) } else { writer.Write(format, results.Violations, outputWriter) } - - if results.Violations.ViolationStore.Count.TotalCount != 0 && flag.Lookup("test.v") == nil { - os.RemoveAll(tempDir) - os.Exit(3) - } + return nil } diff --git a/pkg/cli/run_test.go b/pkg/cli/run_test.go index dd327e30b..589f2d453 100644 --- a/pkg/cli/run_test.go +++ b/pkg/cli/run_test.go @@ -17,7 +17,15 @@ package cli import ( + "os" + "path/filepath" "testing" + + "github.com/accurics/terrascan/pkg/iac-providers/output" + "github.com/accurics/terrascan/pkg/policy" + "github.com/accurics/terrascan/pkg/results" + "github.com/accurics/terrascan/pkg/runtime" + "github.com/accurics/terrascan/pkg/utils" ) func TestRun(t *testing.T) { @@ -26,10 +34,12 @@ func TestRun(t *testing.T) { iacType string iacVersion string cloudType []string + format string iacFilePath string iacDirPath string configFile string configOnly bool + verbose bool stdOut string want string wantErr error @@ -56,11 +66,135 @@ func TestRun(t *testing.T) { iacFilePath: "testdata/run-test/config-only.yaml", configOnly: true, }, + { + name: "config-only flag true with human readable format", + cloudType: []string{"terraform"}, + iacFilePath: "testdata/run-test/config-only.tf", + configOnly: true, + format: "human", + }, + { + name: "config-only flag false with human readable format", + cloudType: []string{"k8s"}, + iacFilePath: "testdata/run-test/config-only.yaml", + format: "human", + }, } for _, tt := range table { t.Run(tt.name, func(t *testing.T) { - Run(tt.iacType, tt.iacVersion, tt.cloudType, tt.iacFilePath, tt.iacDirPath, tt.configFile, []string{}, "", "", "", tt.configOnly, false) + Run(tt.iacType, tt.iacVersion, tt.cloudType, tt.iacFilePath, tt.iacDirPath, tt.configFile, []string{}, tt.format, "", "", tt.configOnly, false, tt.verbose) + }) + } +} + +func TestWriteResults(t *testing.T) { + testInput := runtime.Output{ + ResourceConfig: output.AllResourceConfigs{}, + Violations: policy.EngineOutput{ + ViolationStore: &results.ViolationStore{}, + }, + } + type args struct { + results runtime.Output + useColors bool + verbose bool + configOnly bool + format string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "config only true with human readable output format", + args: args{ + results: testInput, + configOnly: true, + format: "human", + }, + wantErr: true, + }, + { + name: "config only true with non human readable output format", + args: args{ + results: testInput, + configOnly: true, + format: "json", + }, + wantErr: false, + }, + { + name: "config only false", + args: args{ + results: testInput, + configOnly: false, + format: "human", + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := writeResults(tt.args.results, tt.args.useColors, tt.args.verbose, tt.args.configOnly, tt.args.format); (err != nil) != tt.wantErr { + t.Errorf("writeResults() error = gotErr: %v, wantErr: %v", err, tt.wantErr) + } + }) + } +} + +func TestDownloadRemoteRepository(t *testing.T) { + testTempdir := filepath.Join(os.TempDir(), utils.GenRandomString(6)) + + type args struct { + remoteType string + remoteURL string + tempDir string + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "blank input paramters", + args: args{ + remoteType: "", + remoteURL: "", + tempDir: "", + }, + }, + { + name: "invalid input parameters", + args: args{ + remoteType: "test", + remoteURL: "test", + tempDir: "test", + }, + wantErr: true, + }, + { + name: "valid inputs paramters", + args: args{ + remoteType: "git", + remoteURL: "github.com/accurics/terrascan", + tempDir: testTempdir, + }, + want: testTempdir, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := downloadRemoteRepository(tt.args.remoteType, tt.args.remoteURL, tt.args.tempDir) + if (err != nil) != tt.wantErr { + t.Errorf("downloadRemoteRepository() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("downloadRemoteRepository() = %v, want %v", got, tt.want) + } }) } } diff --git a/pkg/cli/scan.go b/pkg/cli/scan.go index 478ee7431..54568ac6a 100644 --- a/pkg/cli/scan.go +++ b/pkg/cli/scan.go @@ -60,6 +60,9 @@ var ( // UseColors indicates whether to use color output UseColors bool useColors string // used for flag processing + + // Verbose indicates whether to display all fields in default human readlbe output + Verbose bool ) var scanCmd = &cobra.Command{ @@ -100,7 +103,7 @@ Detect compliance and security violations across Infrastructure as Code to mitig func scan(cmd *cobra.Command, args []string) { zap.S().Debug("running terrascan in cli mode") Run(IacType, IacVersion, PolicyType, IacFilePath, IacDirPath, ConfigFile, - PolicyPath, OutputType, RemoteType, RemoteURL, ConfigOnly, UseColors) + PolicyPath, OutputType, RemoteType, RemoteURL, ConfigOnly, UseColors, Verbose) } func init() { @@ -115,5 +118,6 @@ func init() { scanCmd.Flags().BoolVarP(&ConfigOnly, "config-only", "", false, "will output resource config (should only be used for debugging purposes)") // flag passes a string, but we normalize to bool in PreRun scanCmd.Flags().StringVar(&useColors, "use-colors", "auto", "color output (auto, t, f)") + scanCmd.Flags().BoolVarP(&Verbose, "verbose", "v", false, "will show violations with details (applicable for default output)") RegisterCommand(rootCmd, scanCmd) } diff --git a/pkg/policy/opa/engine.go b/pkg/policy/opa/engine.go index 00bc22f7f..b849e1e4c 100644 --- a/pkg/policy/opa/engine.go +++ b/pkg/policy/opa/engine.go @@ -303,16 +303,16 @@ func (e *Engine) reportViolation(regoData *RegoData, resource *output.ResourceCo severity := regoData.Metadata.Severity if strings.ToLower(severity) == "high" { - e.results.ViolationStore.Count.HighCount++ + e.results.ViolationStore.Summary.HighCount++ } else if strings.ToLower(severity) == "medium" { - e.results.ViolationStore.Count.MediumCount++ + e.results.ViolationStore.Summary.MediumCount++ } else if strings.ToLower(severity) == "low" { - e.results.ViolationStore.Count.LowCount++ + e.results.ViolationStore.Summary.LowCount++ } else { zap.S().Warn("invalid severity found in rule definition", zap.String("rule id", violation.RuleID), zap.String("severity", severity)) } - e.results.ViolationStore.Count.TotalCount++ + e.results.ViolationStore.Summary.ViolatedPolicies++ e.results.ViolationStore.AddResult(&violation) } @@ -390,5 +390,8 @@ func (e *Engine) Evaluate(engineInput policy.EngineInput) (policy.EngineOutput, } e.stats.runTime = time.Since(start) + + // add the rule count of the policy engine to result summary + e.results.ViolationStore.Summary.TotalPolicies += e.stats.ruleCount return e.results, nil } diff --git a/pkg/policy/types.go b/pkg/policy/types.go index 3de2a1591..3250ea60e 100644 --- a/pkg/policy/types.go +++ b/pkg/policy/types.go @@ -33,6 +33,6 @@ func (me EngineOutput) AsViolationStore() results.ViolationStore { } return results.ViolationStore{ Violations: me.Violations, - Count: me.Count, + Summary: me.Summary, } } diff --git a/pkg/results/types.go b/pkg/results/types.go index 0942f76f9..f7a0ac16e 100644 --- a/pkg/results/types.go +++ b/pkg/results/types.go @@ -16,6 +16,10 @@ package results +import ( + "time" +) + // Violation Contains data for each violation type Violation struct { RuleName string `json:"rule_name" yaml:"rule_name" xml:"rule_name,attr"` @@ -32,18 +36,23 @@ type Violation struct { LineNumber int `json:"line" yaml:"line" xml:"line,attr"` } -// ViolationStats Contains stats related to the violation data -type ViolationStats struct { - LowCount int `json:"low" yaml:"low" xml:"low,attr"` - MediumCount int `json:"medium" yaml:"medium" xml:"medium,attr"` - HighCount int `json:"high" yaml:"high" xml:"high,attr"` - TotalCount int `json:"total" yaml:"total" xml:"total,attr"` -} - // ViolationStore Storage area for violation data type ViolationStore struct { - Violations []*Violation `json:"violations" yaml:"violations" xml:"violations>violation"` - Count ViolationStats `json:"count" yaml:"count" xml:"count"` + Violations []*Violation `json:"violations" yaml:"violations" xml:"violations>violation"` + Summary ScanSummary `json:"scan_summary" yaml:"scan_summary" xml:"scan_summary"` +} + +// ScanSummary will hold the default scan summary data +type ScanSummary struct { + ResourcePath string `json:"file/folder" yaml:"file/folder" xml:"file/folder,attr"` + IacType string `json:"iac_type" yaml:"iac_type" xml:"iac_type,attr"` + Timestamp string `json:"scanned_at" yaml:"scanned_at" xml:"scanned_at,attr"` + ShowViolationDetails bool `json:"-" yaml:"-" xml:"-"` + TotalPolicies int `json:"policies_validated" yaml:"policies_validated" xml:"policies_validated,attr"` + ViolatedPolicies int `json:"violated_policies" yaml:"violated_policies" xml:"violated_policies,attr"` + LowCount int `json:"low" yaml:"low" xml:"low,attr"` + MediumCount int `json:"medium" yaml:"medium" xml:"medium,attr"` + HighCount int `json:"high" yaml:"high" xml:"high,attr"` } // Add adds two ViolationStores @@ -51,11 +60,20 @@ func (vs ViolationStore) Add(extra ViolationStore) ViolationStore { // Just concatenate the slices, since order shouldn't be important vs.Violations = append(vs.Violations, extra.Violations...) - // Add the counts - vs.Count.LowCount += extra.Count.LowCount - vs.Count.MediumCount += extra.Count.MediumCount - vs.Count.HighCount += extra.Count.HighCount - vs.Count.TotalCount += extra.Count.TotalCount + // Add the scan summary + vs.Summary.LowCount += extra.Summary.LowCount + vs.Summary.MediumCount += extra.Summary.MediumCount + vs.Summary.HighCount += extra.Summary.HighCount + vs.Summary.ViolatedPolicies += extra.Summary.ViolatedPolicies + vs.Summary.TotalPolicies += extra.Summary.TotalPolicies return vs } + +// AddSummary will update the summary with remaining details +func (vs *ViolationStore) AddSummary(iacType, iacResourcePath string) { + + vs.Summary.IacType = iacType + vs.Summary.ResourcePath = iacResourcePath + vs.Summary.Timestamp = time.Now().UTC().String() +} diff --git a/pkg/runtime/executor.go b/pkg/runtime/executor.go index fd3520676..6357ff084 100644 --- a/pkg/runtime/executor.go +++ b/pkg/runtime/executor.go @@ -123,6 +123,14 @@ func (e *Executor) Execute() (results Output, err error) { results.Violations = policy.EngineOutputFromViolationStore(&violations) + resourcePath := e.filePath + if resourcePath == "" { + resourcePath = e.dirPath + } + + // add other summary details after policies are evaluated + results.Violations.ViolationStore.AddSummary(e.iacType, resourcePath) + // send notifications, if configured if err = e.SendNotifications(results); err != nil { return results, err diff --git a/pkg/termcolor/colorpatterns.go b/pkg/termcolor/colorpatterns.go index 60397e62d..eb7f4616e 100644 --- a/pkg/termcolor/colorpatterns.go +++ b/pkg/termcolor/colorpatterns.go @@ -3,10 +3,11 @@ package termcolor import ( "encoding/json" "fmt" - "go.uber.org/zap" "io/ioutil" "os" "regexp" + + "go.uber.org/zap" ) var ( @@ -53,17 +54,17 @@ type colorPatternSerialized struct { **/ var defaultColorPatterns = map[FieldSpec]FieldStyle{ - {"description", defaultValuePattern}: {"", "Fg#0c0"}, - {"severity", defaultValuePattern}: {"", "?HIGH=Fg#f00?MEDIUM=Fg#c84?LOW=Fg#cc0"}, - {"resource_name", defaultValuePattern}: {"", "Fg#0ff|Bold"}, - {"resource_type", defaultValuePattern}: {"", "Fg#0cc"}, - {"file", defaultValuePattern}: {"", "Fg#fff|Bold"}, - {"low", `\d+`}: {"Fg#cc0", "Fg#cc0"}, - {"medium", `\d+`}: {"Fg#c84", "Fg#c84"}, - {"high", `\d+`}: {"Fg#f00", "Fg#f00"}, - - {"count", ""}: {"Bg#ccc|Fg#000", ""}, - {"rule_name", defaultValuePattern}: {"Bg#ccc|Fg#000", ""}, + {"[dD]escription", defaultValuePattern}: {"", "Fg#0c0"}, + {"[sS]everity", defaultValuePattern}: {"", "?HIGH=Fg#f00?MEDIUM=Fg#c84?LOW=Fg#cc0"}, + {`[rR]esource[_\s][nN]ame`, defaultValuePattern}: {"", "Fg#0ff|Bold"}, + {`[rR]esource[_\s][tT]ype`, defaultValuePattern}: {"", "Fg#0cc"}, + {"[fF]ile", defaultValuePattern}: {"", "Fg#00768B|Bold"}, + {"[lL]ow", `\d+`}: {"Fg#cc0", "Fg#cc0"}, + {"[mM]edium", `\d+`}: {"Fg#c84", "Fg#c84"}, + {"[hH]igh", `\d+`}: {"Fg#f00", "Fg#f00"}, + {`[rR]ule[_\s][nN]ame`, defaultValuePattern}: {"Bg#ccc|Fg#000", ""}, + {"[fF]ile/[fF]older", defaultValuePattern}: {"", "Fg#00768B|Bold"}, + {`[pP]olicies[_\s][vV]alidated`, defaultValuePattern}: {"Bg#ccc|Fg#000", ""}, } func init() { @@ -131,9 +132,9 @@ func GetColorPatterns() map[*regexp.Regexp]FieldStyle { /* rePtn should process a whole line and have 5 subgroups */ if len(ptn.ValuePattern) == 0 { - rePtn = fmt.Sprintf(`^([-\s]*"?)(%s)("?:\s*?)()(.*?)\s*$`, ptn.KeyPattern) + rePtn = fmt.Sprintf(`^([-\s]*"?)(%s)("?\s*:\s*?)()(.*?)\s*$`, ptn.KeyPattern) } else { - rePtn = fmt.Sprintf(`^([-\s]*"?)(%s)("?: "?)(%s)("?,?)\s*$`, ptn.KeyPattern, ptn.ValuePattern) + rePtn = fmt.Sprintf(`^([-\s]*"?)(%s)("?\s*:\s*"?)(%s)("?,?)\s*$`, ptn.KeyPattern, ptn.ValuePattern) } ColorPatterns[regexp.MustCompile("(?m)"+rePtn)] = fmts } diff --git a/pkg/termcolor/writer_test.go b/pkg/termcolor/writer_test.go index 9e41161e4..42159b272 100644 --- a/pkg/termcolor/writer_test.go +++ b/pkg/termcolor/writer_test.go @@ -103,8 +103,8 @@ func TestYAMLFileIsColorized(t *testing.T) { verifyLineWithStringIsColorized("file", yamlData.String(), t) } -func TestYAMLCountIsColorized(t *testing.T) { - verifyLineWithStringIsColorized("count", yamlData.String(), t) +func TestYAMLPoliciesValidatedIsColorized(t *testing.T) { + verifyLineWithStringIsColorized("policies_validated", yamlData.String(), t) } func TestYAMLCountLowIsColorized(t *testing.T) { verifyLineWithStringIsColorized("low", yamlData.String(), t) @@ -162,8 +162,8 @@ func TestJSONFileIsColorized(t *testing.T) { verifyLineWithStringIsColorized("file", jsonData.String(), t) } -func TestJSONCountIsColorized(t *testing.T) { - verifyLineWithStringIsColorized("count", jsonData.String(), t) +func TestJSONPoliciesValidatedIsColorized(t *testing.T) { + verifyLineWithStringIsColorized("policies_validated", jsonData.String(), t) } func TestJSONCountLowIsColorized(t *testing.T) { verifyLineWithStringIsColorized("low", jsonData.String(), t) diff --git a/pkg/writer/human_readable.go b/pkg/writer/human_readable.go new file mode 100644 index 000000000..27fe4ca36 --- /dev/null +++ b/pkg/writer/human_readable.go @@ -0,0 +1,83 @@ +/* + Copyright (C) 2020 Accurics, Inc. + + 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 writer + +import ( + "bytes" + "io" + "text/template" + + "go.uber.org/zap" +) + +const ( + humanReadbleFormat supportedFormat = "human" + + defaultTemplate string = ` +{{if (gt (len .ViolationStore.Violations) 0) }} +Violation Details - + {{- $showDetails := .ViolationStore.Summary.ShowViolationDetails}} + {{range $index, $element := .ViolationStore.Violations}} + {{printf "%-15v" "Description"}}:{{"\t"}}{{$element.Description}} + {{printf "%-15v" "File"}}:{{"\t"}}{{$element.File}} + {{printf "%-15v" "Line"}}:{{"\t"}}{{$element.LineNumber}} + {{printf "%-15v" "Severity"}}:{{"\t"}}{{$element.Severity}} + {{if $showDetails -}} + {{printf "%-15v" "Rule Name"}}:{{"\t"}}{{$element.RuleName}} + {{printf "%-15v" "Rule ID"}}:{{"\t"}}{{$element.RuleID}} + {{printf "%-15v" "Resource Name"}}:{{"\t"}}{{if $element.ResourceName}}{{$element.ResourceName}}{{else}}""{{end}} + {{printf "%-15v" "Resource Type"}}:{{"\t"}}{{$element.ResourceType}} + {{printf "%-15v" "Category"}}:{{"\t"}}{{$element.Category}} + {{end}} + ----------------------------------------------------------------------- + {{end}} +{{end}} +Scan Summary - + + {{printf "%-20v" "File/Folder"}}:{{"\t"}}{{.ViolationStore.Summary.ResourcePath}} + {{printf "%-20v" "IaC Type"}}:{{"\t"}}{{.ViolationStore.Summary.IacType}} + {{printf "%-20v" "Scanned At"}}:{{"\t"}}{{.ViolationStore.Summary.Timestamp}} + {{printf "%-20v" "Policies Validated"}}:{{"\t"}}{{.ViolationStore.Summary.TotalPolicies}} + {{printf "%-20v" "Violated Policies"}}:{{"\t"}}{{.ViolationStore.Summary.ViolatedPolicies}} + {{printf "%-20v" "Low"}}:{{"\t"}}{{.ViolationStore.Summary.LowCount}} + {{printf "%-20v" "Medium"}}:{{"\t"}}{{.ViolationStore.Summary.MediumCount}} + {{printf "%-20v" "High"}}:{{"\t"}}{{.ViolationStore.Summary.HighCount}} +` +) + +func init() { + RegisterWriter(humanReadbleFormat, HumanReadbleWriter) +} + +// HumanReadbleWriter display scan summary in human readable format +func HumanReadbleWriter(data interface{}, writer io.Writer) error { + tmpl, err := template.New("Report").Parse(defaultTemplate) + if err != nil { + zap.S().Errorf("failed to write human readable output. error: '%v'", err) + return err + } + + buffer := bytes.Buffer{} + tmpl.Execute(&buffer, data) + + _, err = writer.Write(buffer.Bytes()) + if err != nil { + return err + } + writer.Write([]byte{'\n'}) + return nil +} diff --git a/pkg/writer/human_readable_test.go b/pkg/writer/human_readable_test.go new file mode 100644 index 000000000..7a83beb37 --- /dev/null +++ b/pkg/writer/human_readable_test.go @@ -0,0 +1,52 @@ +package writer + +import ( + "bytes" + "testing" + + "github.com/accurics/terrascan/pkg/policy" + "github.com/accurics/terrascan/pkg/results" +) + +// TODO: string comparision - expected and output + +func TestHumanReadbleWriter(t *testing.T) { + type funcInput interface{} + tests := []struct { + name string + input funcInput + expectedError bool + }{ + { + name: "Human Readable Writer: Violations", + input: violationsInput, + }, + { + name: "Human Readable Writer: No Violations", + input: policy.EngineOutput{ + ViolationStore: &results.ViolationStore{ + Summary: results.ScanSummary{ + ResourcePath: "test", + IacType: "terraform", + Timestamp: "2020-12-12 11:21:29.902796 +0000 UTC", + TotalPolicies: 566, + LowCount: 0, + MediumCount: 0, + HighCount: 1, + ViolatedPolicies: 1, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + writer := &bytes.Buffer{} + if err := HumanReadbleWriter(tt.input, writer); (err != nil) != tt.expectedError { + t.Errorf("HumanReadbleWriter() error = gotErr: %v, wantErr: %v", err, tt.expectedError) + return + } + }) + } +} diff --git a/pkg/writer/json_test.go b/pkg/writer/json_test.go new file mode 100644 index 000000000..c7a5325c7 --- /dev/null +++ b/pkg/writer/json_test.go @@ -0,0 +1,83 @@ +package writer + +import ( + "bytes" + "strings" + "testing" +) + +const ( + configOnlyTestOutputJSON = `{ + "aws_s3_bucket": [ + { + "id": "aws_s3_bucket.bucket", + "name": "bucket", + "source": "modules/m1/main.tf", + "line": 20, + "type": "aws_s3_bucket", + "config": { + "bucket": "${module.m3.fullbucketname}", + "policy": "${module.m2.fullbucketpolicy}" + } + } + ] +}` + + scanTestOutputJSON = `{ + "results": { + "violations": [ + { + "rule_name": "s3EnforceUserACL", + "description": "S3 bucket Access is allowed to all AWS Account Users.", + "rule_id": "AWS.S3Bucket.DS.High.1043", + "severity": "HIGH", + "category": "S3", + "resource_name": "bucket", + "resource_type": "aws_s3_bucket", + "file": "modules/m1/main.tf", + "line": 20 + } + ], + "scan_summary": { + "file/folder": "test", + "iac_type": "terraform", + "scanned_at": "2020-12-12 11:21:29.902796 +0000 UTC", + "policies_validated": 566, + "violated_policies": 1, + "low": 0, + "medium": 0, + "high": 1 + } + } +}` +) + +func TestJSONWriter(t *testing.T) { + type funcInput interface{} + tests := []struct { + name string + input funcInput + expectedOutput string + }{ + { + name: "JSON Writer: ResourceConfig", + input: resourceConfigInput, + expectedOutput: configOnlyTestOutputJSON, + }, + { + name: "JSON Writer: Violations", + input: violationsInput, + expectedOutput: scanTestOutputJSON, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + writer := &bytes.Buffer{} + JSONWriter(tt.input, writer) + if gotOutput := writer.String(); !strings.EqualFold(strings.TrimSpace(gotOutput), strings.TrimSpace(tt.expectedOutput)) { + t.Errorf("JSONWriter() = got: %v, want: %v", gotOutput, tt.expectedOutput) + } + }) + } +} diff --git a/pkg/writer/writer_test.go b/pkg/writer/writer_test.go new file mode 100644 index 000000000..c2fcc06e2 --- /dev/null +++ b/pkg/writer/writer_test.go @@ -0,0 +1,20 @@ +package writer + +import ( + "bytes" + "testing" +) + +func TestWriteWithNonRegisteredWriter(t *testing.T) { + err := Write("test", nil, &bytes.Buffer{}) + if err != errNotSupported { + t.Errorf("Expected error = %v but got %v", errNotSupported, err) + } +} + +func TestWriteWithRegisteredWriter(t *testing.T) { + err := Write("json", nil, &bytes.Buffer{}) + if err != nil { + t.Errorf("Unexpected error for json writer = %v", err) + } +} diff --git a/pkg/writer/xml_test.go b/pkg/writer/xml_test.go new file mode 100644 index 000000000..f7cba8baf --- /dev/null +++ b/pkg/writer/xml_test.go @@ -0,0 +1,45 @@ +package writer + +import ( + "bytes" + "strings" + "testing" +) + +const ( + // TODO: --config-only test for XML Writer (xml.Marshal doesn't support maps) + + scanTestOutputXML = ` + + + + + + + ` +) + +func TestXMLWriter(t *testing.T) { + type funcInput interface{} + tests := []struct { + name string + input funcInput + expectedOutput string + }{ + { + name: "XML Writer: Violations", + input: violationsInput, + expectedOutput: scanTestOutputXML, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + writer := &bytes.Buffer{} + XMLWriter(tt.input, writer) + if gotOutput := writer.String(); !strings.EqualFold(strings.TrimSpace(gotOutput), strings.TrimSpace(tt.expectedOutput)) { + t.Errorf("XMLWriter() = got: %v, want: %v", gotOutput, tt.expectedOutput) + } + }) + } +} diff --git a/pkg/writer/yaml_test.go b/pkg/writer/yaml_test.go new file mode 100644 index 000000000..6c7daec7a --- /dev/null +++ b/pkg/writer/yaml_test.go @@ -0,0 +1,121 @@ +package writer + +import ( + "bytes" + "strings" + "testing" + + "github.com/accurics/terrascan/pkg/iac-providers/output" + "github.com/accurics/terrascan/pkg/policy" + "github.com/accurics/terrascan/pkg/results" +) + +// these variables would be used as test input accross the writer package +var ( + resourceConfigInput = output.AllResourceConfigs{ + "aws_s3_bucket": []output.ResourceConfig{ + { + ID: "aws_s3_bucket.bucket", + Name: "bucket", + Source: "modules/m1/main.tf", + Line: 20, + Type: "aws_s3_bucket", + Config: map[string]string{ + "bucket": "${module.m3.fullbucketname}", + "policy": "${module.m2.fullbucketpolicy}", + }, + }, + }, + } + + violationsInput = policy.EngineOutput{ + ViolationStore: &results.ViolationStore{ + Violations: []*results.Violation{ + { + RuleName: "s3EnforceUserACL", + Description: "S3 bucket Access is allowed to all AWS Account Users.", + RuleID: "AWS.S3Bucket.DS.High.1043", + Severity: "HIGH", + Category: "S3", + ResourceName: "bucket", + ResourceType: "aws_s3_bucket", + File: "modules/m1/main.tf", + LineNumber: 20, + }, + }, + Summary: results.ScanSummary{ + ResourcePath: "test", + IacType: "terraform", + Timestamp: "2020-12-12 11:21:29.902796 +0000 UTC", + TotalPolicies: 566, + LowCount: 0, + MediumCount: 0, + HighCount: 1, + ViolatedPolicies: 1, + }, + }, + } +) + +const ( + configOnlyTestOutputYAML = `aws_s3_bucket: + - id: aws_s3_bucket.bucket + name: bucket + source: modules/m1/main.tf + line: 20 + type: aws_s3_bucket + config: + bucket: ${module.m3.fullbucketname} + policy: ${module.m2.fullbucketpolicy}` + + scanTestOutputYAML = `results: + violations: + - rule_name: s3EnforceUserACL + description: S3 bucket Access is allowed to all AWS Account Users. + rule_id: AWS.S3Bucket.DS.High.1043 + severity: HIGH + category: S3 + resource_name: bucket + resource_type: aws_s3_bucket + file: modules/m1/main.tf + line: 20 + scan_summary: + file/folder: test + iac_type: terraform + scanned_at: 2020-12-12 11:21:29.902796 +0000 UTC + policies_validated: 566 + violated_policies: 1 + low: 0 + medium: 0 + high: 1` +) + +func TestYAMLWriter(t *testing.T) { + type funcInput interface{} + tests := []struct { + name string + input funcInput + expectedOutput string + }{ + { + name: "YAML Writer: ResourceConfig", + input: resourceConfigInput, + expectedOutput: configOnlyTestOutputYAML, + }, + { + name: "YAML Writer: Violations", + input: violationsInput, + expectedOutput: scanTestOutputYAML, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + writer := &bytes.Buffer{} + YAMLWriter(tt.input, writer) + if gotOutput := writer.String(); !strings.EqualFold(strings.TrimSpace(gotOutput), strings.TrimSpace(tt.expectedOutput)) { + t.Errorf("YAMLWriter() = got: %v, want: %v", gotOutput, tt.expectedOutput) + } + }) + } +}