feat: Add SARIF output support

This commit is contained in:
Mridang Agarwalla 2025-11-04 16:24:45 +07:00
parent e60892483e
commit 94cd001ba0
255 changed files with 60021 additions and 7 deletions

View file

@ -14,6 +14,8 @@ type Output interface {
func New(w io.Writer, outputFormat string, printSummary, isStdin, verbose bool) (Output, error) {
switch {
case outputFormat == "sarif":
return sarifOutput(w, printSummary, isStdin, verbose), nil
case outputFormat == "json":
return jsonOutput(w, printSummary, isStdin, verbose), nil
case outputFormat == "junit":
@ -25,6 +27,6 @@ func New(w io.Writer, outputFormat string, printSummary, isStdin, verbose bool)
case outputFormat == "text":
return textOutput(w, printSummary, isStdin, verbose), nil
default:
return nil, fmt.Errorf("'outputFormat' must be 'json', 'junit', 'pretty', 'tap' or 'text'")
return nil, fmt.Errorf("'outputFormat' must be 'json', 'junit', 'pretty', 'tap', 'sarif' or 'text'")
}
}

205
pkg/output/sarif.go Normal file
View file

@ -0,0 +1,205 @@
package output
import (
"fmt"
"io"
"sync"
"github.com/owenrumney/go-sarif/v3/pkg/report"
"github.com/owenrumney/go-sarif/v3/pkg/report/v210/sarif"
"github.com/yannh/kubeconform/pkg/resource"
"github.com/yannh/kubeconform/pkg/validator"
)
const (
toolName = "kubeconform"
toolInfoURI = "https://github.com/yannh/kubeconform"
)
const (
ruleIDValid = "KUBE-VALID"
ruleIDInvalid = "KUBE-INVALID"
ruleIDError = "KUBE-ERROR"
ruleIDSkipped = "KUBE-SKIPPED"
)
const (
levelNote = "note"
levelError = "error"
)
var sarifReportingDescriptors = []*sarif.ReportingDescriptor{
newSarifReportingDescriptor(ruleIDValid, "ValidResource", "Resource is valid.", levelNote),
newSarifReportingDescriptor(ruleIDInvalid, "InvalidResource", "Resource is invalid against schema.", levelError),
newSarifReportingDescriptor(ruleIDError, "ProcessingError", "Error processing resource.", levelError),
newSarifReportingDescriptor(ruleIDSkipped, "SkippedResource", "Resource validation was skipped.", levelNote),
}
// newSarifReportingDescriptor creates a new SARIF reporting descriptor.
func newSarifReportingDescriptor(id, name, shortDesc, level string) *sarif.ReportingDescriptor {
shortDescMsg := sarif.NewMultiformatMessageString().WithText(shortDesc)
return sarif.NewRule(id).
WithName(name).
WithShortDescription(shortDescMsg).
WithDefaultConfiguration(sarif.NewReportingConfiguration().WithLevel(level))
}
// sarifOutputter handles the generation of SARIF format output.
// It implements the Output interface and is concurrency-safe.
type sarifOutputter struct {
mu sync.Mutex
writer io.Writer
verbose bool
results []*sarif.Result
}
// sarifOutput creates a new Outputter that formats results as SARIF.
func sarifOutput(writer io.Writer, withSummary, isStdin, verbose bool) Output {
return &sarifOutputter{
writer: writer,
verbose: verbose,
results: make([]*sarif.Result, 0),
}
}
// newSarifRun creates and initializes a new SARIF report and run
// with the standard tool and rule information.
func newSarifRun() (*sarif.Report, *sarif.Run, error) {
rep := report.NewV210Report()
if rep == nil {
return nil, nil, fmt.Errorf("failed to initialize SARIF report")
}
run := sarif.NewRunWithInformationURI(toolName, toolInfoURI)
if run == nil {
return nil, nil, fmt.Errorf("failed to initialize SARIF run")
}
if run.Tool == nil || run.Tool.Driver == nil {
return nil, nil, fmt.Errorf("SARIF run is missing required tool driver information")
}
run.Tool.Driver.WithRules(sarifReportingDescriptors)
rep.AddRun(run)
return rep, run, nil
}
// Write processes a single validation result.
// It is concurrency-safe.
func (so *sarifOutputter) Write(validationResult validator.Result) error {
so.mu.Lock()
defer so.mu.Unlock()
if validationResult.Status == validator.Empty {
return nil
}
if validationResult.Status == validator.Valid && !so.verbose {
return nil
}
signature, _ := validationResult.Resource.Signature()
if validationResult.Status == validator.Invalid && len(validationResult.ValidationErrors) > 0 {
for _, valErr := range validationResult.ValidationErrors {
sarifResult := so.newSarifResult(validationResult, signature, &valErr)
so.results = append(so.results, sarifResult)
}
} else {
sarifResult := so.newSarifResult(validationResult, signature, nil)
so.results = append(so.results, sarifResult)
}
return nil
}
// newSarifResult creates a SARIF result from a validation result.
// If valErr is provided, it populates the result with specific validation
// failure details, including the logical path.
func (so *sarifOutputter) newSarifResult(res validator.Result, sig *resource.Signature, valErr *validator.ValidationError) *sarif.Result {
result := sarif.NewResult().
AddLocation(
sarif.NewLocationWithPhysicalLocation(
sarif.NewPhysicalLocation().
WithArtifactLocation(
sarif.NewSimpleArtifactLocation(res.Resource.Path),
),
),
)
if valErr != nil {
result.Locations[0].AddLogicalLocation(
sarif.NewLogicalLocation().WithName(valErr.Path),
)
result.
WithRuleID(ruleIDInvalid).
WithLevel(levelError)
var message string
if sig.Kind != "" && sig.Name != "" {
message = fmt.Sprintf("%s %s is invalid: %s: %s", sig.Kind, sig.Name, valErr.Path, valErr.Msg)
} else {
message = fmt.Sprintf("%s is invalid: %s: %s", res.Resource.Path, valErr.Path, valErr.Msg)
}
result.WithMessage(sarif.NewTextMessage(message))
} else {
switch res.Status {
case validator.Valid:
result.
WithRuleID(ruleIDValid).
WithLevel(levelNote).
WithMessage(sarif.NewTextMessage(
fmt.Sprintf("%s %s is valid", sig.Kind, sig.Name),
))
case validator.Error:
result.
WithRuleID(ruleIDError).
WithLevel(levelError)
var message string
if sig.Kind != "" && sig.Name != "" {
message = fmt.Sprintf("%s %s failed validation: %s", sig.Kind, sig.Name, res.Err.Error())
} else {
message = fmt.Sprintf("%s failed validation: %s", res.Resource.Path, res.Err.Error())
}
result.WithMessage(sarif.NewTextMessage(message))
case validator.Skipped:
result.
WithRuleID(ruleIDSkipped).
WithLevel(levelNote).
WithMessage(sarif.NewTextMessage(
fmt.Sprintf("%s %s skipped", sig.Kind, sig.Name),
))
default:
result.
WithRuleID(ruleIDError).
WithLevel(levelError).
WithMessage(sarif.NewTextMessage(
fmt.Sprintf("Unknown validation status for %s", res.Resource.Path),
))
}
}
return result
}
// Flush generates the complete SARIF report and writes it to the output writer.
// It is concurrency-safe.
func (so *sarifOutputter) Flush() error {
so.mu.Lock()
defer so.mu.Unlock()
rep, run, err := newSarifRun()
if err != nil {
return err
}
for _, result := range so.results {
run.AddResult(result)
}
if err := rep.PrettyWrite(so.writer); err != nil {
return fmt.Errorf("failed to write SARIF report: %w", err)
}
return nil
}

319
pkg/output/sarif_test.go Normal file
View file

@ -0,0 +1,319 @@
package output
import (
"bytes"
"encoding/json"
"errors"
"testing"
"github.com/owenrumney/go-sarif/v3/pkg/report/v210/sarif"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yannh/kubeconform/pkg/resource"
"github.com/yannh/kubeconform/pkg/validator"
"gopkg.in/yaml.v2"
)
type testMetadata struct {
Name string `yaml:"name"`
}
type testResource struct {
APIVersion string `yaml:"apiVersion"`
Kind string `yaml:"kind"`
Metadata testMetadata `yaml:"metadata"`
}
func marshalTestResource(t *testing.T, apiVersion, kind, name string) []byte {
res := testResource{
APIVersion: apiVersion,
Kind: kind,
Metadata: testMetadata{Name: name},
}
bytes, err := yaml.Marshal(res)
require.NoError(t, err)
return bytes
}
// newExpectedReport creates a complete, initialized SARIF report
// for use in test assertions.
func newExpectedReport(t *testing.T, results []*sarif.Result) *sarif.Report {
report, run, err := newSarifRun()
require.NoError(t, err)
if results != nil {
run.Results = results
}
report.InlineExternalProperties = nil
report.Properties.Tags = nil
run.Artifacts = nil
for _, rule := range run.Tool.Driver.Rules {
rule.DeprecatedGuids = nil
rule.DeprecatedIds = nil
rule.DeprecatedNames = nil
}
return report
}
func newExpectedResult(ruleID, level, message, path string, logicalPath string) *sarif.Result {
result := sarif.NewResult().
WithRuleID(ruleID).
WithLevel(level).
WithMessage(sarif.NewTextMessage(message))
location := sarif.NewLocationWithPhysicalLocation(
sarif.NewPhysicalLocation().
WithArtifactLocation(
sarif.NewSimpleArtifactLocation(path),
),
)
if logicalPath != "" {
location.AddLogicalLocation(sarif.NewLogicalLocation().WithName(logicalPath))
}
result.AddLocation(location)
result.Suppressions = nil
result.WorkItemUris = nil
return result
}
func TestSarifWrite(t *testing.T) {
testCases := []struct {
name string
verbose bool
results []validator.Result
expectedReport *sarif.Report
}{
{
name: "single invalid deployment",
verbose: false,
results: []validator.Result{
{
Resource: resource.Resource{
Path: "deployment.yml",
Bytes: marshalTestResource(t, "apps/v1", "Deployment", "my-app"),
},
Status: validator.Invalid,
Err: errors.New("spec.replicas: Invalid type. Expected: [integer,null], given: string"),
ValidationErrors: []validator.ValidationError{
{Path: "spec.replicas", Msg: "Invalid type. Expected: [integer,null], given: string"},
},
},
},
expectedReport: newExpectedReport(t, []*sarif.Result{
newExpectedResult(
ruleIDInvalid,
levelError,
"Deployment my-app is invalid: spec.replicas: Invalid type. Expected: [integer,null], given: string",
"deployment.yml",
"spec.replicas",
),
}),
},
{
name: "single valid deployment verbose",
verbose: true,
results: []validator.Result{
{
Resource: resource.Resource{
Path: "deployment.yml",
Bytes: marshalTestResource(t, "apps/v1", "Deployment", "my-app"),
},
Status: validator.Valid,
Err: nil,
},
},
expectedReport: newExpectedReport(t, []*sarif.Result{
newExpectedResult(
ruleIDValid,
levelNote,
"Deployment my-app is valid",
"deployment.yml",
"",
),
}),
},
{
name: "single valid deployment non-verbose",
verbose: false,
results: []validator.Result{
{
Resource: resource.Resource{
Path: "deployment.yml",
Bytes: marshalTestResource(t, "apps/v1", "Deployment", "my-app"),
},
Status: validator.Valid,
Err: nil,
},
},
expectedReport: newExpectedReport(t, nil),
},
{
name: "skipped resource",
verbose: true,
results: []validator.Result{
{
Resource: resource.Resource{
Path: "service.yml",
Bytes: marshalTestResource(t, "v1", "Service", "my-service"),
},
Status: validator.Skipped,
Err: nil,
},
},
expectedReport: newExpectedReport(t, []*sarif.Result{
newExpectedResult(
ruleIDSkipped,
levelNote,
"Service my-service skipped",
"service.yml",
"",
),
}),
},
{
name: "error processing resource",
verbose: false,
results: []validator.Result{
{
Resource: resource.Resource{
Path: "configmap.yml",
Bytes: marshalTestResource(t, "v1", "ConfigMap", "my-config"),
},
Status: validator.Error,
Err: errors.New("failed to download schema"),
},
},
expectedReport: newExpectedReport(t, []*sarif.Result{
newExpectedResult(
ruleIDError,
levelError,
"ConfigMap my-config failed validation: failed to download schema",
"configmap.yml",
"",
),
}),
},
{
name: "empty resource filtered out",
verbose: true,
results: []validator.Result{
{
Resource: resource.Resource{
Path: "empty.yml",
Bytes: []byte(`---`),
},
Status: validator.Empty,
Err: nil,
},
},
expectedReport: newExpectedReport(t, nil),
},
{
name: "multiple invalid results from one file",
verbose: false,
results: []validator.Result{
{
Resource: resource.Resource{
Path: "deployment1.yml",
Bytes: marshalTestResource(t, "apps/v1", "Deployment", "app1"),
},
Status: validator.Invalid,
ValidationErrors: []validator.ValidationError{
{Path: "spec.template", Msg: "is missing"},
{Path: "spec.selector", Msg: "is missing"},
},
},
{
Resource: resource.Resource{
Path: "deployment2.yml",
Bytes: marshalTestResource(t, "apps/v1", "Deployment", "app2"),
},
Status: validator.Invalid,
ValidationErrors: []validator.ValidationError{
{Path: "spec.replicas", Msg: "must be positive"},
},
},
},
expectedReport: newExpectedReport(t, []*sarif.Result{
newExpectedResult(
ruleIDInvalid,
levelError,
"Deployment app1 is invalid: spec.template: is missing",
"deployment1.yml",
"spec.template",
),
newExpectedResult(
ruleIDInvalid,
levelError,
"Deployment app1 is invalid: spec.selector: is missing",
"deployment1.yml",
"spec.selector",
),
newExpectedResult(
ruleIDInvalid,
levelError,
"Deployment app2 is invalid: spec.replicas: must be positive",
"deployment2.yml",
"spec.replicas",
),
}),
},
{
name: "invalid resource with no signature",
verbose: false,
results: []validator.Result{
{
Resource: resource.Resource{
Path: "not-yaml.yml",
Bytes: []byte(`not: valid: yaml`),
},
Status: validator.Invalid,
ValidationErrors: []validator.ValidationError{
{Path: "metadata", Msg: "is missing"},
},
},
},
expectedReport: newExpectedReport(t, []*sarif.Result{
newExpectedResult(
ruleIDInvalid,
levelError,
"not-yaml.yml is invalid: metadata: is missing",
"not-yaml.yml",
"metadata",
),
}),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
w := new(bytes.Buffer)
o := sarifOutput(w, false, false, tc.verbose)
for _, res := range tc.results {
err := o.Write(res)
require.NoError(t, err, "Write() should not return an error")
}
err := o.Flush()
require.NoError(t, err, "Flush() should not return an error")
var actualReport sarif.Report
err = json.Unmarshal(w.Bytes(), &actualReport)
require.NoError(t, err, "Output should be valid JSON: %s", w.String())
require.Len(t, actualReport.Runs, 1)
assert.Equal(t, tc.expectedReport.Version, actualReport.Version)
assert.Equal(t, tc.expectedReport.Schema, actualReport.Schema)
assert.Equal(t, tc.expectedReport.Properties, actualReport.Properties)
assert.Equal(t, tc.expectedReport.Runs[0].Tool, actualReport.Runs[0].Tool)
assert.ElementsMatch(t, tc.expectedReport.Runs[0].Results, actualReport.Runs[0].Results)
})
}
}