Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,26 @@ jobs:
cache: true

- run: make test

scan:
runs-on: ubuntu-latest
permissions:
security-events: write
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Build spinlint
run: make build

- name: Run spinlint (SARIF)
run: ./bin/spinlint validate --format sarif 'testdata/*.json' > results.sarif || true

- name: Upload SARIF to GitHub Code Scanning
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
6 changes: 3 additions & 3 deletions cmd/spinlint/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,13 @@ func newValidateCmd() *cobra.Command {
},
}

cmd.Flags().StringVarP(&format, "format", "f", "text", "Output format: text or json")
cmd.Flags().StringVarP(&format, "format", "f", "text", "Output format: text, json, or sarif")
return cmd
}

func runValidate(cmd *cobra.Command, args []string, format string) error {
if format != "text" && format != "json" {
return fmt.Errorf("unknown format %q: must be text or json", format)
if format != "text" && format != "json" && format != "sarif" {
return fmt.Errorf("unknown format %q: must be text, json, or sarif", format)
}

rep := reporter.New(cmd.OutOrStdout(), format)
Expand Down
170 changes: 151 additions & 19 deletions pkg/reporter/reporter.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
"path/filepath"

"github.com/jakeva/spinlint/pkg/rules"
)
Expand All @@ -18,42 +19,173 @@ type Result struct {
type Reporter struct {
out io.Writer
format string
results []Result // buffered for JSON output
results []Result // buffered for json/sarif output
}

// New creates a Reporter that writes to out using the given format ("text" or "json").
// New creates a Reporter that writes to out using the given format ("text", "json", or "sarif").
func New(out io.Writer, format string) *Reporter {
return &Reporter{out: out, format: format}
}

// Add records the lint result for a single file. For text format it writes
// immediately; for JSON it buffers until Flush is called.
// immediately; for json/sarif it buffers until Flush is called.
func (r *Reporter) Add(file string, violations []rules.Violation) {
if r.format == "json" {
vv := violations
if vv == nil {
vv = []rules.Violation{}
if r.format == "text" {
if len(violations) == 0 {
fmt.Fprintf(r.out, "%s: OK\n", file)
return
}
for _, v := range violations {
fmt.Fprintf(r.out, "%s: [%s] %s\n", file, v.Rule, v.Message)
}
r.results = append(r.results, Result{File: file, Violations: vv})
return
}

// text (default)
if len(violations) == 0 {
fmt.Fprintf(r.out, "%s: OK\n", file)
return
}
for _, v := range violations {
fmt.Fprintf(r.out, "%s: [%s] %s\n", file, v.Rule, v.Message)
vv := violations
if vv == nil {
vv = []rules.Violation{}
}
r.results = append(r.results, Result{File: file, Violations: vv})
}

// Flush writes buffered JSON output. It is a no-op for text format.
// Flush writes buffered output. It is a no-op for text format.
func (r *Reporter) Flush() error {
if r.format != "json" {
return nil
switch r.format {
case "json":
enc := json.NewEncoder(r.out)
enc.SetIndent("", " ")
return enc.Encode(r.results)
case "sarif":
return r.flushSARIF()
}
return nil
}

// --- SARIF 2.1.0 types -------------------------------------------------

type sarifLog struct {
Schema string `json:"$schema"`
Version string `json:"version"`
Runs []sarifRun `json:"runs"`
}

type sarifRun struct {
Tool sarifTool `json:"tool"`
Results []sarifResult `json:"results"`
}

type sarifTool struct {
Driver sarifDriver `json:"driver"`
}

type sarifDriver struct {
Name string `json:"name"`
InformationURI string `json:"informationUri"`
Rules []sarifRule `json:"rules"`
}

type sarifRule struct {
ID string `json:"id"`
ShortDescription sarifMessage `json:"shortDescription"`
}

type sarifResult struct {
RuleID string `json:"ruleId"`
Level string `json:"level"`
Message sarifMessage `json:"message"`
Locations []sarifLocation `json:"locations"`
}

type sarifLocation struct {
PhysicalLocation sarifPhysicalLocation `json:"physicalLocation"`
}

type sarifPhysicalLocation struct {
ArtifactLocation sarifArtifactLocation `json:"artifactLocation"`
}

type sarifArtifactLocation struct {
URI string `json:"uri"`
URIBaseID string `json:"uriBaseId"`
}

type sarifMessage struct {
Text string `json:"text"`
}

// -----------------------------------------------------------------------

func (r *Reporter) flushSARIF() error {
// Collect unique rule IDs from violations to populate tool.driver.rules.
seenRules := map[string]bool{}
var driverRules []sarifRule
var results []sarifResult

for _, res := range r.results {
for _, v := range res.Violations {
if !seenRules[v.Rule] {
seenRules[v.Rule] = true
driverRules = append(driverRules, sarifRule{
ID: v.Rule,
ShortDescription: sarifMessage{Text: v.Rule},
})
}
results = append(results, sarifResult{
RuleID: v.Rule,
Level: "error",
Message: sarifMessage{Text: v.Message},
Locations: []sarifLocation{
{
PhysicalLocation: sarifPhysicalLocation{
ArtifactLocation: sarifArtifactLocation{
URI: toSARIFURI(res.File),
URIBaseID: "%SRCROOT%",
},
},
},
},
})
}
}

// Ensure non-null arrays so GitHub Code Scanning parses the document correctly.
if driverRules == nil {
driverRules = []sarifRule{}
}
if results == nil {
results = []sarifResult{}
}

log := sarifLog{
Schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
Version: "2.1.0",
Runs: []sarifRun{
{
Tool: sarifTool{
Driver: sarifDriver{
Name: "spinlint",
InformationURI: "https://github.com/jakeva/spinlint",
Rules: driverRules,
},
},
Results: results,
},
},
}

enc := json.NewEncoder(r.out)
enc.SetIndent("", " ")
return enc.Encode(r.results)
return enc.Encode(log)
}

// toSARIFURI converts a file path to a forward-slash relative URI suitable
// for SARIF artifactLocation.uri. Absolute paths are made relative to the
// working directory.
func toSARIFURI(path string) string {
if filepath.IsAbs(path) {
if rel, err := filepath.Rel(".", path); err == nil {
path = rel
}
}
return filepath.ToSlash(path)
}
Loading