Repository: zegl/kube-score
Pull request #304 (2020-08-22 19:10:27)

SARIF output format
```
RELNOTE: Add support for the [SARIF](https://sarifweb.azurewebsites.net/) output format (`--output-format sarif`). This API is considered to be in Alpha.
```

The support is fairly basic, but it works!

Here's a screenshot of the SARIF Viewer in VS Code, using a SARIF-file generated from some of kube-scores testdata:

```bash
kube-score score -o sarif \
    score/testdata/deployment-host-antiaffinity-preffered-selector-expression.yaml \
    score/testdata/deployment-poddisruptionbudget-expression-matches.yaml > output.sarif
```

<img width="1742" alt="Screen Shot 2020-08-22 at 21 07 48 " src="https://user-images.githubusercontent.com/47952/90963834-8e8f6180-e4bb-11ea-8a70-d52c697e500d.png">



Patch:
diff --git a/cmd/kube-score/main.go b/cmd/kube-score/main.go
index 7c08122..a67d11b 100644
--- a/cmd/kube-score/main.go
+++ b/cmd/kube-score/main.go
@@ -9,6 +9,7 @@ import (
 	"io"
 	"io/ioutil"
 	"os"
+	"path/filepath"
 
 	flag "github.com/spf13/pflag"
 	"golang.org/x/crypto/ssh/terminal"
@@ -19,6 +20,7 @@ import (
 	"github.com/zegl/kube-score/renderer/ci"
 	"github.com/zegl/kube-score/renderer/human"
 	"github.com/zegl/kube-score/renderer/json_v2"
+	"github.com/zegl/kube-score/renderer/sarif"
 	"github.com/zegl/kube-score/score"
 	"github.com/zegl/kube-score/scorecard"
 )
@@ -117,9 +119,9 @@ func scoreFiles(binName string, args []string) error {
 		return nil
 	}
 
-	if *outputFormat != "human" && *outputFormat != "ci" && *outputFormat != "json" {
+	if *outputFormat != "human" && *outputFormat != "ci" && *outputFormat != "json" && *outputFormat != "sarif" {
 		fs.Usage()
-		return fmt.Errorf("Error: --output-format must be set to: 'human', 'json' or 'ci'")
+		return fmt.Errorf("Error: --output-format must be set to: 'human', 'json', 'sarif' or 'ci'")
 	}
 
 	filesToRead := fs.Args()
@@ -135,18 +137,20 @@ Use "-" as filename to read from STDIN.`, execName(binName))
 
 	for _, file := range filesToRead {
 		var fp io.Reader
+		var filename string
 
 		if file == "-" {
 			fp = os.Stdin
+			filename = "STDIN"
 		} else {
 			var err error
 			fp, err = os.Open(file)
 			if err != nil {
 				return err
 			}
+			filename, _ = filepath.Abs(file)
 		}
-
-		allFilePointers = append(allFilePointers, namedReader{Reader: fp, name: file})
+		allFilePointers = append(allFilePointers, namedReader{Reader: fp, name: filename})
 	}
 
 	ignoredTests := listToStructMap(ignoreTests)
@@ -207,6 +211,8 @@ Use "-" as filename to read from STDIN.`, execName(binName))
 		r = human.Human(scoreCard, *verboseOutput, termWidth)
 	} else if *outputFormat == "ci" && version == "v1" {
 		r = ci.CI(scoreCard)
+	} else if *outputFormat == "sarif" {
+		r = sarif.Output(scoreCard)
 	} else {
 		return fmt.Errorf("error: Unknown --output-format or --output-version")
 	}
diff --git a/parser/parse.go b/parser/parse.go
index c9ed7ef..4c2895e 100644
--- a/parser/parse.go
+++ b/parser/parse.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"io/ioutil"
 	"log"
+	"strings"
 
 	"gopkg.in/yaml.v3"
 	appsv1 "k8s.io/api/apps/v1"
@@ -133,10 +134,19 @@ func ParseFiles(cnf config.Configuration) (ks.AllTypes, error) {
 
 		offset := 1 // Line numbers are 1 indexed
 
+		// Remove initial "---\n" if present
+		if bytes.HasPrefix(fullFile, []byte("---\n")) {
+			fullFile = fullFile[4:]
+			offset = 2
+		}
+
 		for _, fileContents := range bytes.Split(fullFile, []byte("\n---\n")) {
-			err := detectAndDecode(cnf, s, namedReader.Name(), offset, fileContents)
-			if err != nil {
-				return nil, err
+
+			if len(bytes.TrimSpace(fileContents)) > 0 {
+				err := detectAndDecode(cnf, s, namedReader.Name(), offset, fileContents)
+				if err != nil {
+					return nil, err
+				}
 			}
 
 			offset += 2 + bytes.Count(fileContents, []byte("\n"))
@@ -188,16 +198,31 @@ func decode(data []byte, object runtime.Object) error {
 	return nil
 }
 
+func detectFileLocation(fileName string, fileOffset int, fileContents []byte) ks.FileLocation {
+	// If the object YAML begins with a Helm style "# Source: " comment
+	// Use the information in there as the file name
+	firstRow := string(bytes.Split(fileContents, []byte("\n"))[0])
+	helmTemplatePrefix := "# Source: "
+	if strings.HasPrefix(firstRow, helmTemplatePrefix) {
+		return ks.FileLocation{
+			Name: firstRow[len(helmTemplatePrefix):],
+			Line: 1, // Set line to 1 as the line definition gets lost in Helm
+		}
+	}
+
+	return ks.FileLocation{
+		Name: fileName,
+		Line: fileOffset,
+	}
+}
+
 func decodeItem(cnf config.Configuration, s *parsedObjects, detectedVersion schema.GroupVersionKind, fileName string, fileOffset int, fileContents []byte) error {
 	addPodSpeccer := func(ps ks.PodSpecer) {
 		s.podspecers = append(s.podspecers, ps)
 		s.bothMetas = append(s.bothMetas, ks.BothMeta{ps.GetTypeMeta(), ps.GetObjectMeta(), ps})
 	}
 
-	fileLocation := ks.FileLocation{
-		Name: fileName,
-		Line: fileOffset,
-	}
+	fileLocation := detectFileLocation(fileName, fileOffset, fileContents)
 
 	var errs parseError
 
diff --git a/renderer/sarif/sarif.go b/renderer/sarif/sarif.go
new file mode 100644
index 0000000..d784133
--- /dev/null
+++ b/renderer/sarif/sarif.go
@@ -0,0 +1,96 @@
+package sarif
+
+import (
+	"bytes"
+	"encoding/json"
+	"io"
+
+	"github.com/zegl/kube-score/domain"
+	"github.com/zegl/kube-score/sarif"
+	"github.com/zegl/kube-score/scorecard"
+)
+
+func Output(input *scorecard.Scorecard) io.Reader {
+	var results []sarif.Results
+	var rules []sarif.Rules
+
+	addRule := func(check domain.Check) {
+		for _, r := range rules {
+			if r.ID == check.ID {
+				return
+			}
+		}
+
+		rules = append(rules, sarif.Rules{
+			ID:   check.ID,
+			Name: check.Name,
+		})
+	}
+
+	for _, v := range *input {
+		for _, check := range v.Checks {
+			if check.Skipped {
+				continue
+			}
+
+			var level string
+			switch check.Grade {
+			case scorecard.GradeCritical:
+				level = "error"
+			case scorecard.GradeWarning:
+				level = "warning"
+			default:
+				continue
+			}
+
+			addRule(check.Check)
+
+			for _, comment := range check.Comments {
+				results = append(results, sarif.Results{
+					Message: sarif.Message{
+						Text: comment.Summary,
+					},
+					RuleID: check.Check.ID,
+					Level:  level,
+					Properties: sarif.ResultsProperties{
+						IssueConfidence: "HIGH",
+						IssueSeverity:   "HIGH",
+					},
+					Locations: []sarif.Locations{
+						{
+							PhysicalLocation: sarif.PhysicalLocation{
+								ArtifactLocation: sarif.ArtifactLocation{
+									URI: "file://" + v.FileLocation.Name,
+								},
+								ContextRegion: sarif.ContextRegion{
+									StartLine: v.FileLocation.Line,
+								},
+							},
+						},
+					},
+				})
+			}
+		}
+	}
+
+	run := sarif.Run{
+		Tool: sarif.Tool{
+			Driver: sarif.Driver{
+				Name:  "kube-score",
+				Rules: rules,
+			},
+		},
+		Results: results,
+	}
+	res := sarif.Sarif{
+		Runs:    []sarif.Run{run},
+		Version: "2.1.0",
+		Schema:  "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
+	}
+
+	j, err := json.MarshalIndent(res, "", "    ")
+	if err != nil {
+		panic(err)
+	}
+	return bytes.NewBuffer(j)
+}
diff --git a/sarif/sarif.go b/sarif/sarif.go
new file mode 100644
index 0000000..7746c02
--- /dev/null
+++ b/sarif/sarif.go
@@ -0,0 +1,107 @@
+package sarif
+
+import (
+	"time"
+)
+
+type Sarif struct {
+	Runs    []Run  `json:"runs,omitempty"`
+	Version string `json:"version,omitempty"`
+	Schema  string `json:"$schema,omitempty"`
+}
+
+type Rules struct {
+	ID      string `json:"id,omitempty"`
+	Name    string `json:"name,omitempty"`
+	HelpURI string `json:"helpUri,omitempty"`
+}
+
+type Driver struct {
+	Name  string  `json:"name,omitempty"`
+	Rules []Rules `json:"rules,omitempty"`
+}
+
+type Tool struct {
+	Driver Driver `json:"driver,omitempty"`
+}
+
+type WorkingDirectory struct {
+	URI string `json:"uri,omitempty"`
+}
+
+type Invocation struct {
+	Arguments           []string         `json:"arguments,omitempty"`
+	ExecutionSuccessful bool             `json:"executionSuccessful,omitempty"`
+	CommandLine         string           `json:"commandLine,omitempty"`
+	EndTimeUtc          time.Time        `json:"endTimeUtc,omitempty"`
+	WorkingDirectory    WorkingDirectory `json:"workingDirectory,omitempty"`
+}
+
+type Conversion struct {
+	Tool       Tool       `json:"tool,omitempty"`
+	Invocation Invocation `json:"invocation,omitempty"`
+}
+
+type Invocations struct {
+	ExecutionSuccessful bool             `json:"executionSuccessful,omitempty"`
+	EndTimeUtc          time.Time        `json:"endTimeUtc,omitempty"`
+	WorkingDirectory    WorkingDirectory `json:"workingDirectory,omitempty"`
+}
+
+type Properties struct {
+}
+
+type Message struct {
+	Text string `json:"text,omitempty"`
+}
+
+type Snippet struct {
+	Text string `json:"text,omitempty"`
+}
+
+type Region struct {
+	Snippet   Snippet `json:"snippet,omitempty"`
+	StartLine int     `json:"startLine,omitempty"`
+}
+
+type ArtifactLocation struct {
+	URI string `json:"uri,omitempty"`
+}
+
+type ContextRegion struct {
+	Snippet   Snippet `json:"snippet,omitempty"`
+	EndLine   int     `json:"endLine,omitempty"`
+	StartLine int     `json:"startLine,omitempty"`
+}
+
+type PhysicalLocation struct {
+	Region           Region           `json:"region,omitempty"`
+	ArtifactLocation ArtifactLocation `json:"artifactLocation,omitempty"`
+	ContextRegion    ContextRegion    `json:"contextRegion,omitempty"`
+}
+
+type Locations struct {
+	PhysicalLocation PhysicalLocation `json:"physicalLocation,omitempty"`
+}
+
+type ResultsProperties struct {
+	IssueConfidence string `json:"issue_confidence,omitempty"`
+	IssueSeverity   string `json:"issue_severity,omitempty"`
+}
+
+type Results struct {
+	Message    Message           `json:"message,omitempty"`
+	Level      string            `json:"level,omitempty"`
+	Locations  []Locations       `json:"locations,omitempty"`
+	Properties ResultsProperties `json:"properties,omitempty"`
+	RuleID     string            `json:"ruleId,omitempty"`
+	RuleIndex  int               `json:"ruleIndex,omitempty"`
+}
+
+type Run struct {
+	Tool        Tool          `json:"tool,omitempty"`
+	Conversion  Conversion    `json:"conversion,omitempty"`
+	Invocations []Invocations `json:"invocations,omitempty"`
+	Properties  Properties    `json:"properties,omitempty"`
+	Results     []Results     `json:"results,omitempty"`
+}


Test patch:
diff --git a/parser/parse_test.go b/parser/parse_test.go
index 725af27..bcf3745 100644
--- a/parser/parse_test.go
+++ b/parser/parse_test.go
@@ -39,3 +39,37 @@ func TestParse(t *testing.T) {
 		}
 	}
 }
+
+func TestFileLocationHelm(t *testing.T) {
+	doc := `# Source: app1/templates/deployment.yaml
+kind: Deployment
+apiVersion: apps/v1
+metadata:
+  name: foo
+spec:
+  template:
+    metadata:
+      labels:
+        foo: bar`
+
+	fl := detectFileLocation("someName", 1, []byte(doc))
+	assert.Equal(t, "app1/templates/deployment.yaml", fl.Name)
+	assert.Equal(t, 1, fl.Line)
+}
+
+
+func TestFileLocation(t *testing.T) {
+	doc := `kind: Deployment
+apiVersion: apps/v1
+metadata:
+  name: foo
+spec:
+  template:
+    metadata:
+      labels:
+        foo: bar`
+
+	fl := detectFileLocation("someName", 123, []byte(doc))
+	assert.Equal(t, "someName", fl.Name)
+	assert.Equal(t, 123, fl.Line)
+}
diff --git a/score/filelocation_test.go b/score/filelocation_test.go
new file mode 100644
index 0000000..e41276c
--- /dev/null
+++ b/score/filelocation_test.go
@@ -0,0 +1,36 @@
+package score
+
+import (
+	"github.com/stretchr/testify/assert"
+	"testing"
+
+	"github.com/zegl/kube-score/config"
+	ks "github.com/zegl/kube-score/domain"
+)
+
+func TestFileLocationHelm(t *testing.T) {
+	sc, err := testScore(config.Configuration{
+		AllFiles:          []ks.NamedReader{testFile("linenumbers-helm.yaml")},
+		KubernetesVersion: config.Semver{1, 18},
+	})
+	assert.Nil(t, err)
+	for _, c := range sc {
+		assert.Equal(t, "app1/templates/deployment.yaml", c.FileLocation.Name)
+	}
+	assert.Equal(t, 1, sc["Deployment/apps/v1//foo"].FileLocation.Line)
+	assert.Equal(t, 1, sc["Deployment/apps/v1//foo2"].FileLocation.Line)
+}
+
+
+func TestFileLocation(t *testing.T) {
+	sc, err := testScore(config.Configuration{
+		AllFiles:          []ks.NamedReader{testFile("linenumbers.yaml")},
+		KubernetesVersion: config.Semver{1, 18},
+	})
+	assert.Nil(t, err)
+	for _, c := range sc {
+		assert.Equal(t, "testdata/linenumbers.yaml", c.FileLocation.Name)
+	}
+	assert.Equal(t, 2, sc["Deployment/apps/v1//foo"].FileLocation.Line)
+	assert.Equal(t, 12, sc["Deployment/apps/v1//foo2"].FileLocation.Line)
+}
diff --git a/score/testdata/helm/app1/Chart.yaml b/score/testdata/helm/app1/Chart.yaml
new file mode 100644
index 0000000..cfa762f
--- /dev/null
+++ b/score/testdata/helm/app1/Chart.yaml
@@ -0,0 +1,2 @@
+name: app1
+version: v1.0.0
diff --git a/score/testdata/helm/app1/templates/deployment.yaml b/score/testdata/helm/app1/templates/deployment.yaml
new file mode 100644
index 0000000..4da84ba
--- /dev/null
+++ b/score/testdata/helm/app1/templates/deployment.yaml
@@ -0,0 +1,19 @@
+kind: Deployment
+apiVersion: apps/v1
+metadata:
+  name: foo
+spec:
+  template:
+    metadata:
+      labels:
+        foo: bar
+---
+kind: Deployment
+apiVersion: apps/v1
+metadata:
+  name: foo2
+spec:
+  template:
+    metadata:
+      labels:
+        foo: bar2
diff --git a/score/testdata/linenumbers-helm.yaml b/score/testdata/linenumbers-helm.yaml
new file mode 100644
index 0000000..a6a657b
--- /dev/null
+++ b/score/testdata/linenumbers-helm.yaml
@@ -0,0 +1,22 @@
+---
+# Source: app1/templates/deployment.yaml
+kind: Deployment
+apiVersion: apps/v1
+metadata:
+  name: foo
+spec:
+  template:
+    metadata:
+      labels:
+        foo: bar
+---
+# Source: app1/templates/deployment.yaml
+kind: Deployment
+apiVersion: apps/v1
+metadata:
+  name: foo2
+spec:
+  template:
+    metadata:
+      labels:
+        foo: bar2
\ No newline at end of file
diff --git a/score/testdata/linenumbers.yaml b/score/testdata/linenumbers.yaml
new file mode 100644
index 0000000..98a3596
--- /dev/null
+++ b/score/testdata/linenumbers.yaml
@@ -0,0 +1,20 @@
+---
+kind: Deployment
+apiVersion: apps/v1
+metadata:
+  name: foo
+spec:
+  template:
+    metadata:
+      labels:
+        foo: bar
+---
+kind: Deployment
+apiVersion: apps/v1
+metadata:
+  name: foo2
+spec:
+  template:
+    metadata:
+      labels:
+        foo: bar2
\ No newline at end of file