Skip to content

Commit c740b98

Browse files
fix(output): handle findings object + locations/rules in SARIF, wire into ci (#155)
The SARIF formatter only accepted a top-level JSON array or a {type,data} wrapper, so `findings -o sarif` failed because the findings command emits `{"findings":[...],"total":N}`. It also emitted only ruleId/level/message, with no `locations[]` (consumers couldn't place findings) and no `rules[]` driver section (so ruleId references dangled). Changes: - internal/output/sarif.go: unwrap the {findings,total} object shape (plus the existing array and {type,data} wrapper); emit a `locations[]` entry from common location keys (filePath/file/path + startLine/line/lineNumber for code, uri/url for DAST); emit distinct `rules[]` under tool.driver.rules so references resolve. Defensive against missing fields. Expose SARIFBytes() for reuse. - cmd/cli/cmd/root.go: include `sarif` in the --output flag usage string. - cmd/cli/cmd/ci.go: - `ci report` gains a --format flag (markdown default, or sarif). - `ci gate` now counts via the response `total` field instead of the limit-truncated items array, so reported counts are accurate. - `ci gate` is fail-closed: any scanner request error fails the gate (exit non-zero) rather than only failing when all requests error. - internal/output: table-driven SARIF tests covering the {findings,total} object, locations, rules, dedup, and missing-field defensiveness. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a930c16 commit c740b98

4 files changed

Lines changed: 501 additions & 52 deletions

File tree

‎cmd/cli/cmd/ci.go‎

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/nullify-platform/cli/internal/client"
1313
"github.com/nullify-platform/cli/internal/lib"
1414
"github.com/nullify-platform/cli/internal/logger"
15+
"github.com/nullify-platform/cli/internal/output"
1516
"github.com/spf13/cobra"
1617
"golang.org/x/sync/errgroup"
1718
)
@@ -100,7 +101,6 @@ Exit codes:
100101

101102
var totalFindings int64
102103
var apiErrors int64
103-
totalRequests := int64(len(endpoints) * len(severities))
104104
var mu sync.Mutex
105105
g, gctx := errgroup.WithContext(ctx)
106106

@@ -123,7 +123,10 @@ Exit codes:
123123
return nil
124124
}
125125

126-
count := countFindings(body)
126+
// limit=1 keeps the payload small; the accurate count
127+
// comes from the response's "total" field, not the
128+
// truncated items array.
129+
count := totalFindingsCount(body)
127130
if count > 0 {
128131
atomic.AddInt64(&totalFindings, int64(count))
129132
mu.Lock()
@@ -137,8 +140,10 @@ Exit codes:
137140

138141
_ = g.Wait()
139142

140-
if apiErrors > 0 && apiErrors == totalRequests {
141-
fmt.Fprintf(os.Stderr, "Error: all API requests failed, cannot determine gate status\n")
143+
// Fail-closed: any scanner request error means we cannot prove the
144+
// gate is clean, so treat it as a failure rather than passing.
145+
if apiErrors > 0 {
146+
fmt.Fprintf(os.Stderr, "Error: %d scanner request(s) failed; failing the gate (cannot confirm a clean result)\n", apiErrors)
142147
os.Exit(ExitNetworkError)
143148
}
144149

@@ -153,10 +158,13 @@ Exit codes:
153158

154159
var ciReportCmd = &cobra.Command{
155160
Use: "report",
156-
Short: "Generate a markdown summary for PR comments",
157-
Long: "Output a markdown summary of security findings suitable for PR comments. Shows counts by type and severity.",
161+
Short: "Generate a findings report (markdown or SARIF)",
162+
Long: `Output a report of security findings. The default markdown format produces a
163+
summary table suitable for PR comments (counts by type and severity). The sarif
164+
format emits a SARIF v2.1.0 document for upload to code-scanning tools.`,
158165
Example: ` nullify ci report
159-
nullify ci report --repo my-org/my-repo`,
166+
nullify ci report --repo my-org/my-repo
167+
nullify ci report --format sarif > nullify.sarif`,
160168
Run: func(cmd *cobra.Command, args []string) {
161169
ctx := setupLogger(cmd.Context())
162170
defer logger.Close(ctx)
@@ -187,13 +195,22 @@ var ciReportCmd = &cobra.Command{
187195
repo = lib.DetectRepoFromGit()
188196
}
189197

198+
format, _ := cmd.Flags().GetString("format")
199+
switch format {
200+
case "markdown", "sarif":
201+
default:
202+
fmt.Fprintf(os.Stderr, "Error: invalid --format %q. Valid values: markdown, sarif\n", format)
203+
os.Exit(1)
204+
}
205+
190206
endpoints := allScannerEndpoints()
191207
severities := []string{"critical", "high", "medium", "low"}
192208

193209
type reportRow struct {
194210
scanner string
195211
severity string
196212
count int
213+
findings []json.RawMessage
197214
}
198215

199216
rows := make([]reportRow, len(endpoints)*len(severities))
@@ -225,7 +242,8 @@ var ciReportCmd = &cobra.Command{
225242
rows[i*len(severities)+j] = reportRow{
226243
scanner: ep.name,
227244
severity: sev,
228-
count: countFindings(body),
245+
count: totalFindingsCount(body),
246+
findings: extractFindings(body),
229247
}
230248
return nil
231249
})
@@ -242,6 +260,21 @@ var ciReportCmd = &cobra.Command{
242260
fmt.Fprintf(os.Stderr, "Warning: %d API requests failed while generating the report\n", apiErrors)
243261
}
244262

263+
if format == "sarif" {
264+
all := make([]json.RawMessage, 0)
265+
for _, row := range rows {
266+
all = append(all, row.findings...)
267+
}
268+
wrapped, _ := json.Marshal(map[string]any{"findings": all, "total": len(all)})
269+
sarifBytes, err := output.SARIFBytes(wrapped)
270+
if err != nil {
271+
fmt.Fprintf(os.Stderr, "Error: failed to build SARIF report: %v\n", err)
272+
os.Exit(ExitNetworkError)
273+
}
274+
fmt.Println(string(sarifBytes))
275+
return
276+
}
277+
245278
fmt.Println("## Nullify Security Report")
246279
fmt.Println()
247280
fmt.Println("| Scanner | Severity | Count |")
@@ -268,6 +301,7 @@ func init() {
268301
ciGateCmd.Flags().String("repo", "", "Repository name (auto-detected from git if not set)")
269302

270303
ciReportCmd.Flags().String("repo", "", "Repository name (auto-detected from git if not set)")
304+
ciReportCmd.Flags().String("format", "markdown", "Report format (markdown, sarif)")
271305
}
272306

273307
func severitiesAboveThreshold(threshold string) []string {
@@ -302,3 +336,56 @@ func countFindings(body string) int {
302336

303337
return 0
304338
}
339+
340+
// totalFindingsCount extracts an accurate finding count from an API response.
341+
// It prefers the response's "total" field (which reflects the full result set
342+
// regardless of the request's limit) over the length of the truncated items
343+
// array. Falls back to array/items length when no total is present.
344+
func totalFindingsCount(body string) int {
345+
var result any
346+
if err := json.Unmarshal([]byte(body), &result); err != nil {
347+
return 0
348+
}
349+
350+
switch v := result.(type) {
351+
case []any:
352+
return len(v)
353+
case map[string]any:
354+
if total, ok := v["total"].(float64); ok {
355+
return int(total)
356+
}
357+
if items, ok := v["items"].([]any); ok {
358+
return len(items)
359+
}
360+
if findings, ok := v["findings"].([]any); ok {
361+
return len(findings)
362+
}
363+
}
364+
365+
return 0
366+
}
367+
368+
// extractFindings pulls the finding objects out of an API response body so they
369+
// can be rendered (e.g. as SARIF). It understands a top-level array as well as
370+
// the common {findings:[...]} and {items:[...]} envelopes.
371+
func extractFindings(body string) []json.RawMessage {
372+
var arr []json.RawMessage
373+
if err := json.Unmarshal([]byte(body), &arr); err == nil {
374+
return arr
375+
}
376+
377+
var obj struct {
378+
Findings []json.RawMessage `json:"findings"`
379+
Items []json.RawMessage `json:"items"`
380+
}
381+
if err := json.Unmarshal([]byte(body), &obj); err == nil {
382+
if obj.Findings != nil {
383+
return obj.Findings
384+
}
385+
if obj.Items != nil {
386+
return obj.Items
387+
}
388+
}
389+
390+
return nil
391+
}

‎cmd/cli/cmd/root.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func init() {
5252
rootCmd.PersistentFlags().StringVar(&host, "host", "", "The base URL of your Nullify API instance (e.g., acme.nullify.ai)")
5353
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose logging")
5454
rootCmd.PersistentFlags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
55-
rootCmd.PersistentFlags().StringVarP(&outputFmt, "output", "o", "json", "Output format (json, table, yaml)")
55+
rootCmd.PersistentFlags().StringVarP(&outputFmt, "output", "o", "json", "Output format (json, table, yaml, sarif)")
5656
rootCmd.PersistentFlags().StringVar(&nullifyToken, "nullify-token", "", "Nullify API token")
5757
rootCmd.PersistentFlags().StringVar(&githubToken, "github-token", "", "GitHub actions job token to exchange for a Nullify API token")
5858
rootCmd.PersistentFlags().BoolVarP(&quiet, "quiet", "q", false, "Suppress informational output")

0 commit comments

Comments
 (0)