mirror of
https://github.com/yannh/kubeconform.git
synced 2026-04-11 23:44:16 +00:00
feat: Add SARIF output support
This commit is contained in:
parent
e60892483e
commit
94cd001ba0
255 changed files with 60021 additions and 7 deletions
|
|
@ -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
205
pkg/output/sarif.go
Normal 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
319
pkg/output/sarif_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue