From 73557fffdb9a67280b117b8ce1db00bed79f0297 Mon Sep 17 00:00:00 2001 From: Jake Valenzuela <5650707+jakeva@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:57:24 -0400 Subject: [PATCH] feat: add SARIF format Signed-off-by: Jake Valenzuela <5650707+jakeva@users.noreply.github.com> --- .github/workflows/ci.yml | 23 ++++++ cmd/spinlint/main.go | 6 +- pkg/reporter/reporter.go | 170 ++++++++++++++++++++++++++++++++++----- 3 files changed, 177 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdea816..84f583a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/cmd/spinlint/main.go b/cmd/spinlint/main.go index eded0da..d759aa2 100644 --- a/cmd/spinlint/main.go +++ b/cmd/spinlint/main.go @@ -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) diff --git a/pkg/reporter/reporter.go b/pkg/reporter/reporter.go index 670b49f..6d59c37 100644 --- a/pkg/reporter/reporter.go +++ b/pkg/reporter/reporter.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "path/filepath" "github.com/jakeva/spinlint/pkg/rules" ) @@ -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) }