diff --git a/docs/options.md b/docs/options.md index ae7a77ac83..3826946671 100644 --- a/docs/options.md +++ b/docs/options.md @@ -173,6 +173,7 @@ As of today Conftest supports the following output types: - JUnit `--output=junit` - GitHub `--output=github` - AzureDevOps `--output=azuredevops` +- SARIF `--output=sarif` ### Plaintext @@ -322,6 +323,13 @@ success file=examples/kubernetes/deployment.yaml 1 5 tests, 1 passed, 0 warnings, 4 failures, 0 exceptions ``` +### SARIF + +```console +$ conftest test --proto-file-dirs examples/textproto/protos -p examples/textproto/policy examples/textproto/fail.textproto -o sarif +{"version":"2.1.0","$schema":"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json","runs":[{"tool":{"driver":{"informationUri":"https://github.com/open-policy-agent/conftest","name":"conftest","rules":[{"id":"main/deny","shortDescription":{"text":"Policy violation"}}]}},"invocations":[{"executionSuccessful":true,"exitCode":1,"exitCodeDescription":"Policy violations found"}],"results":[{"ruleId":"main/deny","ruleIndex":0,"level":"error","message":{"text":"fail: Power level must be over 9000"},"locations":[{"physicalLocation":{"artifactLocation":{"uri":"examples/textproto/fail.textproto"}}}]}]}]} +``` + ## `--parser` Conftest normally detects which parser to used based on the file extension of the file, even when multiple input files are passed in. However, it is possible force a specific parser to be used with the `--parser` flag. diff --git a/go.mod b/go.mod index c8fbe5a5c5..d53f6c4aa4 100644 --- a/go.mod +++ b/go.mod @@ -86,6 +86,7 @@ require ( github.com/moby/docker-image-spec v1.3.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/owenrumney/go-sarif/v2 v2.3.3 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/prometheus/client_golang v1.20.5 // indirect diff --git a/go.sum b/go.sum index 6013df3cbd..2cbd3a1ab6 100644 --- a/go.sum +++ b/go.sum @@ -1010,6 +1010,10 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/owenrumney/go-sarif v1.1.1 h1:QNObu6YX1igyFKhdzd7vgzmw7XsWN3/6NMGuDzBgXmE= +github.com/owenrumney/go-sarif v1.1.1/go.mod h1:dNDiPlF04ESR/6fHlPyq7gHKmrM0sHUvAGjsoh8ZH0U= +github.com/owenrumney/go-sarif/v2 v2.3.3 h1:ubWDJcF5i3L/EIOER+ZyQ03IfplbSU1BLOE26uKQIIU= +github.com/owenrumney/go-sarif/v2 v2.3.3/go.mod h1:MSqMMx9WqlBSY7pXoOZWgEsVB4FDNfhcaXDA1j6Sr+w= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= @@ -1149,6 +1153,7 @@ github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8= github.com/zclconf/go-cty v1.6.1/go.mod h1:VDR4+I79ubFBGm1uJac1226K5yANQFHeauxPBoP54+o= +github.com/zclconf/go-cty v1.10.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= github.com/zclconf/go-cty v1.13.2 h1:4GvrUxe/QUDYuJKAav4EYqdM47/kZa672LwmXFmEKT0= github.com/zclconf/go-cty v1.13.2/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= @@ -1870,6 +1875,7 @@ google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8i google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/cheggaaa/pb.v1 v1.0.27/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= diff --git a/output/output.go b/output/output.go index 9b1ec6a66d..981026bf9f 100644 --- a/output/output.go +++ b/output/output.go @@ -34,6 +34,7 @@ const ( OutputJUnit = "junit" OutputGitHub = "github" OutputAzureDevOps = "azuredevops" + OutputSARIF = "sarif" ) // Get returns a type that can render output in the given format. @@ -57,6 +58,8 @@ func Get(format string, options Options) Outputter { return NewGitHub(options.File) case OutputAzureDevOps: return NewAzureDevOps(options.File) + case OutputSARIF: + return NewSARIF(options.File) default: return NewStandard(options.File) } @@ -72,5 +75,6 @@ func Outputs() []string { OutputJUnit, OutputGitHub, OutputAzureDevOps, + OutputSARIF, } } diff --git a/output/output_test.go b/output/output_test.go index 4a4669a908..97ad8b1e61 100644 --- a/output/output_test.go +++ b/output/output_test.go @@ -39,6 +39,10 @@ func TestGetOutputter(t *testing.T) { input: OutputAzureDevOps, expected: NewAzureDevOps(os.Stdout), }, + { + input: OutputSARIF, + expected: NewSARIF(os.Stdout), + }, { input: "unknown_format", expected: NewStandard(os.Stdout), diff --git a/output/sarif.go b/output/sarif.go new file mode 100644 index 0000000000..c3533d218c --- /dev/null +++ b/output/sarif.go @@ -0,0 +1,210 @@ +package output + +import ( + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/open-policy-agent/opa/tester" + "github.com/owenrumney/go-sarif/v2/sarif" + "golang.org/x/exp/slices" +) + +const ( + // Tool information + toolName = "conftest" + toolURI = "https://github.com/open-policy-agent/conftest" + sarifVersion = sarif.Version210 + + // Result descriptions + successDesc = "Policy was satisfied successfully" + skippedDesc = "Policy check was skipped" + failureDesc = "Policy violation" + warningDesc = "Policy warning" + exceptionDesc = "Policy exception" + + // Exit code descriptions + exitNoViolations = "No policy violations found" + exitViolations = "Policy violations found" + exitWarnings = "Policy warnings found" +) + +// SARIF represents an Outputter that outputs results in SARIF format. +type SARIF struct { + writer io.Writer +} + +// NewSARIF creates a new SARIF with the given writer. +func NewSARIF(w io.Writer) *SARIF { + return &SARIF{ + writer: w, + } +} + +// getRuleID generates a stable rule ID based on namespace and rule type +func getRuleID(namespace string, ruleType string) string { + return fmt.Sprintf("%s/%s", namespace, ruleType) +} + +// getRuleDescription returns the appropriate description based on the rule type +func getRuleDescription(ruleID string) string { + switch { + case strings.HasSuffix(ruleID, "/success"): + return successDesc + case strings.HasSuffix(ruleID, "/skip"): + return skippedDesc + case strings.HasSuffix(ruleID, "/allow"): + return exceptionDesc + case strings.HasSuffix(ruleID, "/warn"): + return warningDesc + default: + return failureDesc + } +} + +// addRuleIndex adds a new rule to the SARIF run and returns its index. +func addRuleIndex(run *sarif.Run, ruleID string, result Result, indices map[string]int) int { + addRule(run, ruleID, result) + idx := len(run.Tool.Driver.Rules) - 1 + indices[ruleID] = idx + return idx +} + +// addRule adds a new rule to the SARIF run with the given ID and result metadata. +func addRule(run *sarif.Run, ruleID string, result Result) { + desc := getRuleDescription(ruleID) + rule := run.AddRule(ruleID). + WithDescription(desc). + WithShortDescription(&sarif.MultiformatMessageString{ + Text: &desc, + }) + + if result.Metadata != nil { + props := sarif.NewPropertyBag() + for k, v := range result.Metadata { + props.Add(k, v) + } + rule.WithProperties(props.Properties) + } +} + +// addResult adds a result to the SARIF run +func addResult(run *sarif.Run, result Result, namespace, ruleType, level, fileName string, indices map[string]int) { + ruleID := getRuleID(namespace, ruleType) + idx, ok := indices[ruleID] + if !ok { + idx = addRuleIndex(run, ruleID, result, indices) + } + + run.CreateResultForRule(ruleID). + WithRuleIndex(idx). + WithLevel(level). + WithMessage(sarif.NewTextMessage(result.Message)). + AddLocation( + sarif.NewLocationWithPhysicalLocation( + sarif.NewPhysicalLocation(). + WithArtifactLocation( + sarif.NewSimpleArtifactLocation(filepath.ToSlash(fileName)), + ), + ), + ) +} + +// Output outputs the results in SARIF format. +func (s *SARIF) Output(results []CheckResult) error { + report, err := sarif.New(sarifVersion) + if err != nil { + return fmt.Errorf("create sarif report: %w", err) + } + + run := sarif.NewRunWithInformationURI(toolName, toolURI) + indices := make(map[string]int) + + for _, result := range results { + // Process failures + for _, failure := range result.Failures { + addResult(run, failure, result.Namespace, "deny", "error", result.FileName, indices) + } + + // Process warnings + for _, warning := range result.Warnings { + addResult(run, warning, result.Namespace, "warn", "warning", result.FileName, indices) + } + + // Process exceptions (treated as successes) + hasSuccesses := result.Successes > 0 + for _, exception := range result.Exceptions { + addResult(run, exception, result.Namespace, "allow", "note", result.FileName, indices) + hasSuccesses = true + } + + // Don't add success/skip results if there are failures or warnings + hasErrors := len(result.Failures) > 0 || len(result.Warnings) > 0 + if hasErrors { + continue + } + + // Add success/exception results if there are no failures or warnings + if hasSuccesses { + statusResult := Result{ + Message: successDesc, + Metadata: map[string]interface{}{ + "description": successDesc, + }, + } + addResult(run, statusResult, result.Namespace, "success", "none", result.FileName, indices) + } else { + statusResult := Result{ + Message: skippedDesc, + Metadata: map[string]interface{}{ + "description": skippedDesc, + }, + } + addResult(run, statusResult, result.Namespace, "skip", "none", result.FileName, indices) + } + } + + // Add run metadata + exitCode := 0 + exitDesc := exitNoViolations + if hasFailures(results) { + exitCode = 1 + exitDesc = exitViolations + } else if hasWarnings(results) { + exitDesc = exitWarnings + } + + successful := true + invocation := sarif.NewInvocation() + invocation.ExecutionSuccessful = &successful + invocation.ExitCode = &exitCode + invocation.ExitCodeDescription = &exitDesc + + run.Invocations = []*sarif.Invocation{invocation} + + // Add the run to the report + report.AddRun(run) + + // Write the report + return report.Write(s.writer) +} + +// Report is not supported in SARIF output +func (s *SARIF) Report(_ []*tester.Result, _ string) error { + return fmt.Errorf("report is not supported in SARIF output") +} + +// hasFailures returns true if any of the results contain failures +func hasFailures(results []CheckResult) bool { + return slices.ContainsFunc(results, func(r CheckResult) bool { + return len(r.Failures) > 0 + }) +} + +// hasWarnings returns true if any of the results contain warnings +func hasWarnings(results []CheckResult) bool { + return slices.ContainsFunc(results, func(r CheckResult) bool { + return len(r.Warnings) > 0 + }) +} diff --git a/output/sarif_test.go b/output/sarif_test.go new file mode 100644 index 0000000000..25165843e3 --- /dev/null +++ b/output/sarif_test.go @@ -0,0 +1,599 @@ +package output + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestSARIF_Output(t *testing.T) { + tests := []struct { + name string + results []CheckResult + wantErr bool + wantJSON string + }{ + { + name: "empty results", + results: []CheckResult{}, + wantJSON: mustJSON(t, map[string]any{ + "version": "2.1.0", + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json", + "runs": []map[string]any{ + { + "tool": map[string]any{ + "driver": map[string]any{ + "informationUri": "https://github.com/open-policy-agent/conftest", + "name": "conftest", + "rules": []any{}, + }, + }, + "invocations": []map[string]any{ + { + "executionSuccessful": true, + "exitCode": 0, + "exitCodeDescription": "No policy violations found", + }, + }, + "results": []any{}, + }, + }, + }), + }, + { + name: "single failure", + results: []CheckResult{ + { + FileName: "test.yaml", + Namespace: "main", + Failures: []Result{ + { + Message: "test failure", + Metadata: map[string]interface{}{ + "package": "test", + "rule": "rule1", + }, + }, + }, + }, + }, + wantJSON: mustJSON(t, map[string]any{ + "version": "2.1.0", + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json", + "runs": []map[string]any{ + { + "tool": map[string]any{ + "driver": map[string]any{ + "informationUri": "https://github.com/open-policy-agent/conftest", + "name": "conftest", + "rules": []map[string]any{ + { + "id": "main/deny", + "shortDescription": map[string]any{ + "text": "Policy violation", + }, + "properties": map[string]any{ + "package": "test", + "rule": "rule1", + }, + }, + }, + }, + }, + "invocations": []map[string]any{ + { + "executionSuccessful": true, + "exitCode": 1, + "exitCodeDescription": "Policy violations found", + }, + }, + "results": []map[string]any{ + { + "ruleId": "main/deny", + "ruleIndex": 0, + "level": "error", + "message": map[string]any{ + "text": "test failure", + }, + "locations": []map[string]any{ + { + "physicalLocation": map[string]any{ + "artifactLocation": map[string]any{ + "uri": "test.yaml", + }, + }, + }, + }, + }, + }, + }, + }, + }), + }, + { + name: "single warning", + results: []CheckResult{ + { + FileName: "test.yaml", + Namespace: "main", + Warnings: []Result{ + { + Message: "test warning", + Metadata: map[string]interface{}{ + "foo": "bar", + }, + }, + }, + }, + }, + wantJSON: mustJSON(t, map[string]any{ + "version": "2.1.0", + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json", + "runs": []map[string]any{ + { + "tool": map[string]any{ + "driver": map[string]any{ + "informationUri": "https://github.com/open-policy-agent/conftest", + "name": "conftest", + "rules": []map[string]any{ + { + "id": "main/warn", + "shortDescription": map[string]any{ + "text": "Policy warning", + }, + "properties": map[string]any{ + "foo": "bar", + }, + }, + }, + }, + }, + "invocations": []map[string]any{ + { + "executionSuccessful": true, + "exitCode": 0, + "exitCodeDescription": "Policy warnings found", + }, + }, + "results": []map[string]any{ + { + "ruleId": "main/warn", + "ruleIndex": 0, + "level": "warning", + "message": map[string]any{ + "text": "test warning", + }, + "locations": []map[string]any{ + { + "physicalLocation": map[string]any{ + "artifactLocation": map[string]any{ + "uri": "test.yaml", + }, + }, + }, + }, + }, + }, + }, + }, + }), + }, + { + name: "single exception", + results: []CheckResult{ + { + FileName: "test.yaml", + Namespace: "main", + Exceptions: []Result{ + { + Message: "test exception", + Metadata: map[string]interface{}{ + "description": "test exception description", + }, + }, + }, + }, + }, + wantJSON: mustJSON(t, map[string]any{ + "version": "2.1.0", + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json", + "runs": []map[string]any{ + { + "tool": map[string]any{ + "driver": map[string]any{ + "informationUri": "https://github.com/open-policy-agent/conftest", + "name": "conftest", + "rules": []map[string]any{ + { + "id": "main/allow", + "shortDescription": map[string]any{ + "text": "Policy exception", + }, + "properties": map[string]any{ + "description": "test exception description", + }, + }, + { + "id": "main/success", + "shortDescription": map[string]any{ + "text": "Policy was satisfied successfully", + }, + "properties": map[string]any{ + "description": "Policy was satisfied successfully", + }, + }, + }, + }, + }, + "invocations": []map[string]any{ + { + "executionSuccessful": true, + "exitCode": 0, + "exitCodeDescription": "No policy violations found", + }, + }, + "results": []map[string]any{ + { + "ruleId": "main/allow", + "ruleIndex": 0, + "level": "note", + "message": map[string]any{ + "text": "test exception", + }, + "locations": []map[string]any{ + { + "physicalLocation": map[string]any{ + "artifactLocation": map[string]any{ + "uri": "test.yaml", + }, + }, + }, + }, + }, + { + "ruleId": "main/success", + "ruleIndex": 1, + "level": "none", + "message": map[string]any{ + "text": "Policy was satisfied successfully", + }, + "locations": []map[string]any{ + { + "physicalLocation": map[string]any{ + "artifactLocation": map[string]any{ + "uri": "test.yaml", + }, + }, + }, + }, + }, + }, + }, + }, + }), + }, + { + name: "skipped result", + results: []CheckResult{ + { + FileName: "test.yaml", + Namespace: "main", + Successes: 0, + }, + }, + wantJSON: mustJSON(t, map[string]any{ + "version": "2.1.0", + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json", + "runs": []map[string]any{ + { + "tool": map[string]any{ + "driver": map[string]any{ + "informationUri": "https://github.com/open-policy-agent/conftest", + "name": "conftest", + "rules": []map[string]any{ + { + "id": "main/skip", + "shortDescription": map[string]any{ + "text": "Policy check was skipped", + }, + "properties": map[string]any{ + "description": "Policy check was skipped", + }, + }, + }, + }, + }, + "invocations": []map[string]any{ + { + "executionSuccessful": true, + "exitCode": 0, + "exitCodeDescription": "No policy violations found", + }, + }, + "results": []map[string]any{ + { + "ruleId": "main/skip", + "ruleIndex": 0, + "level": "none", + "message": map[string]any{ + "text": "Policy check was skipped", + }, + "locations": []map[string]any{ + { + "physicalLocation": map[string]any{ + "artifactLocation": map[string]any{ + "uri": "test.yaml", + }, + }, + }, + }, + }, + }, + }, + }, + }), + }, + { + name: "multiple results same rule", + results: []CheckResult{ + { + FileName: "test1.yaml", + Namespace: "main", + Failures: []Result{ + { + Message: "test failure 1", + Metadata: map[string]interface{}{ + "package": "test", + "rule": "rule1", + }, + }, + { + Message: "test failure 2", + Metadata: map[string]interface{}{ + "package": "test", + "rule": "rule1", + }, + }, + }, + }, + }, + wantJSON: mustJSON(t, map[string]any{ + "version": "2.1.0", + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json", + "runs": []map[string]any{ + { + "tool": map[string]any{ + "driver": map[string]any{ + "informationUri": "https://github.com/open-policy-agent/conftest", + "name": "conftest", + "rules": []map[string]any{ + { + "id": "main/deny", + "shortDescription": map[string]any{ + "text": "Policy violation", + }, + "properties": map[string]any{ + "package": "test", + "rule": "rule1", + }, + }, + }, + }, + }, + "invocations": []map[string]any{ + { + "executionSuccessful": true, + "exitCode": 1, + "exitCodeDescription": "Policy violations found", + }, + }, + "results": []map[string]any{ + { + "ruleId": "main/deny", + "ruleIndex": 0, + "level": "error", + "message": map[string]any{ + "text": "test failure 1", + }, + "locations": []map[string]any{ + { + "physicalLocation": map[string]any{ + "artifactLocation": map[string]any{ + "uri": "test1.yaml", + }, + }, + }, + }, + }, + { + "ruleId": "main/deny", + "ruleIndex": 0, + "level": "error", + "message": map[string]any{ + "text": "test failure 2", + }, + "locations": []map[string]any{ + { + "physicalLocation": map[string]any{ + "artifactLocation": map[string]any{ + "uri": "test1.yaml", + }, + }, + }, + }, + }, + }, + }, + }, + }), + }, + { + name: "successful policy check", + results: []CheckResult{ + { + FileName: "test.yaml", + Namespace: "main", + Successes: 1, + }, + }, + wantJSON: mustJSON(t, map[string]any{ + "version": "2.1.0", + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json", + "runs": []map[string]any{ + { + "tool": map[string]any{ + "driver": map[string]any{ + "informationUri": "https://github.com/open-policy-agent/conftest", + "name": "conftest", + "rules": []map[string]any{ + { + "id": "main/success", + "shortDescription": map[string]any{ + "text": "Policy was satisfied successfully", + }, + "properties": map[string]any{ + "description": "Policy was satisfied successfully", + }, + }, + }, + }, + }, + "invocations": []map[string]any{ + { + "executionSuccessful": true, + "exitCode": 0, + "exitCodeDescription": "No policy violations found", + }, + }, + "results": []map[string]any{ + { + "ruleId": "main/success", + "ruleIndex": 0, + "level": "none", + "message": map[string]any{ + "text": "Policy was satisfied successfully", + }, + "locations": []map[string]any{ + { + "physicalLocation": map[string]any{ + "artifactLocation": map[string]any{ + "uri": "test.yaml", + }, + }, + }, + }, + }, + }, + }, + }, + }), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + s := NewSARIF(&buf) + + err := s.Output(tt.results) + if (err != nil) != tt.wantErr { + t.Errorf("SARIF.Output() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr { + compareJSON(t, buf.String(), tt.wantJSON) + } + }) + } +} + +func TestGetRuleID(t *testing.T) { + tests := []struct { + name string + namespace string + ruleType string + want string + }{ + { + name: "failure", + namespace: "main", + ruleType: "deny", + want: "main/deny", + }, + { + name: "warning", + namespace: "main", + ruleType: "warn", + want: "main/warn", + }, + { + name: "success", + namespace: "main", + ruleType: "success", + want: "main/success", + }, + { + name: "skipped", + namespace: "main", + ruleType: "skip", + want: "main/skip", + }, + { + name: "different namespace", + namespace: "kubernetes", + ruleType: "deny", + want: "kubernetes/deny", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getRuleID(tt.namespace, tt.ruleType); got != tt.want { + t.Errorf("getRuleID() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSARIF_Report(t *testing.T) { + var buf bytes.Buffer + s := NewSARIF(&buf) + err := s.Report(nil, "test") + if err == nil { + t.Error("SARIF.Report() should return error") + } + const expectedErr = "report is not supported in SARIF output" + if err.Error() != expectedErr { + t.Errorf("expected '%v', got: '%v'", expectedErr, err) + } +} + +// compareJSON normalizes and compares two JSON strings. +// JSON strings are normalised to their canonical form without whitespace. +func compareJSON(t *testing.T, got, want string) { + t.Helper() + var gotJSON, wantJSON interface{} + if err := json.Unmarshal([]byte(got), &gotJSON); err != nil { + t.Fatalf("failed to unmarshal actual JSON: %v", err) + } + if err := json.Unmarshal([]byte(want), &wantJSON); err != nil { + t.Fatalf("failed to unmarshal expected JSON: %v", err) + } + + if diff := cmp.Diff(wantJSON, gotJSON); diff != "" { + t.Errorf("JSON mismatch (-want +got):\n%s", diff) + } +} + +// mustJSON converts a value to a JSON string, failing the test if marshaling fails +func mustJSON(t *testing.T, value map[string]any) string { + t.Helper() + b, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatal(err) + } + return string(b) +}