@@ -2,6 +2,7 @@ package cmd
22
33import (
44 "encoding/json"
5+ "errors"
56 "fmt"
67 "os"
78 "sync"
@@ -80,8 +81,9 @@ Exit codes:
8081 }
8182 }
8283
83- var totalFindings int64
84+ var findingsFound int64
8485 var apiErrors int64
86+ var unreadable int64
8587 var mu sync.Mutex
8688 g , gctx := errgroup .WithContext (ctx )
8789
@@ -104,14 +106,19 @@ Exit codes:
104106 return nil
105107 }
106108
107- // limit=1 keeps the payload small; the accurate count
108- // comes from the response's "total" field, not the
109- // truncated items array.
110- count := totalFindingsCount (body )
109+ count , err := countFindings (body )
110+ if err != nil {
111+ mu .Lock ()
112+ fmt .Fprintf (os .Stderr , "Error: unreadable response from %s (%s): %v\n " , ep .name , sev , err )
113+ mu .Unlock ()
114+ atomic .AddInt64 (& unreadable , 1 )
115+ return nil
116+ }
117+
111118 if count > 0 {
112- atomic .AddInt64 (& totalFindings , int64 ( count ) )
119+ atomic .AddInt64 (& findingsFound , 1 )
113120 mu .Lock ()
114- fmt .Printf ("FAIL: %s has %d %s findings\n " , ep .name , count , sev )
121+ fmt .Printf ("FAIL: %s has open %s findings\n " , ep .name , sev )
115122 mu .Unlock ()
116123 }
117124 return nil
@@ -126,10 +133,15 @@ Exit codes:
126133 return withExitCode (ExitNetworkError , err )
127134 }
128135
129- if totalFindings > 0 {
130- fmt .Printf ("\n Gate failed: %d findings at or above %s severity\n " , totalFindings , severityThreshold )
136+ if unreadable > 0 {
137+ err := fmt .Errorf ("%d scanner response(s) could not be read; failing the gate (cannot confirm a clean result)" , unreadable )
138+ return withExitCode (ExitNetworkError , err )
139+ }
140+
141+ if findingsFound > 0 {
142+ fmt .Printf ("\n Gate failed: open findings at or above %s severity\n " , severityThreshold )
131143 cmd .SilenceErrors = true
132- return withExitCode (ExitFindings , fmt .Errorf ("gate failed: %d findings at or above %s severity" , totalFindings , severityThreshold ))
144+ return withExitCode (ExitFindings , fmt .Errorf ("gate failed: open findings at or above %s severity" , severityThreshold ))
133145 }
134146
135147 fmt .Println ("Gate passed: no findings above threshold" )
@@ -202,12 +214,20 @@ format emits a SARIF v2.1.0 document for upload to code-scanning tools.`,
202214 mu .Unlock ()
203215 return nil
204216 }
217+ count , err := countFindings (body )
218+ if err != nil {
219+ atomic .AddInt64 (& apiErrors , 1 )
220+ mu .Lock ()
221+ fmt .Fprintf (os .Stderr , "Warning: unreadable response from %s (%s): %v\n " , ep .name , sev , err )
222+ mu .Unlock ()
223+ return nil
224+ }
205225 atomic .AddInt64 (& successCount , 1 )
206226
207227 rows [i * len (severities )+ j ] = reportRow {
208228 scanner : ep .name ,
209229 severity : sev ,
210- count : totalFindingsCount ( body ) ,
230+ count : count ,
211231 findings : extractFindings (body ),
212232 }
213233 return nil
@@ -278,55 +298,37 @@ func severitiesAboveThreshold(threshold string) []string {
278298 return []string {"critical" , "high" }
279299}
280300
281- // countFindings extracts a count from API response JSON. When used with limit=1,
282- // it returns 0 or 1 to indicate whether findings exist at a given severity.
283- func countFindings (body string ) int {
284- var result any
285- if err := json .Unmarshal ([]byte (body ), & result ); err != nil {
286- return 0
301+ // errUnreadableFindings reports a findings response that does not carry the
302+ // scanner envelope. It is never reported as a count of zero: a gate that cannot
303+ // read the response must block the build rather than declare the repo clean.
304+ var errUnreadableFindings = errors .New ("unrecognised findings response envelope" )
305+
306+ // countFindings returns the number of findings in a scanner API response. Every
307+ // endpoint in allScannerEndpoints returns {"findings":[...],"numItems":N,
308+ // "nextToken":"..."}, except /dast/bughunt/findings which omits numItems. The
309+ // count is page-scoped and therefore bounded by the request's limit; the API
310+ // exposes no grand total.
311+ func countFindings (body string ) (int , error ) {
312+ var envelope struct {
313+ Findings json.RawMessage `json:"findings"`
314+ NumItems * int `json:"numItems"`
287315 }
288-
289- switch v := result .(type ) {
290- case []any :
291- return len (v )
292- case map [string ]any :
293- if items , ok := v ["items" ].([]any ); ok {
294- return len (items )
295- }
296- if total , ok := v ["total" ].(float64 ); ok {
297- return int (total )
298- }
316+ if err := json .Unmarshal ([]byte (body ), & envelope ); err != nil {
317+ return 0 , fmt .Errorf ("%w: %w" , errUnreadableFindings , err )
299318 }
300-
301- return 0
302- }
303-
304- // totalFindingsCount extracts an accurate finding count from an API response.
305- // It prefers the response's "total" field (which reflects the full result set
306- // regardless of the request's limit) over the length of the truncated items
307- // array. Falls back to array/items length when no total is present.
308- func totalFindingsCount (body string ) int {
309- var result any
310- if err := json .Unmarshal ([]byte (body ), & result ); err != nil {
311- return 0
319+ if len (envelope .Findings ) == 0 {
320+ return 0 , fmt .Errorf ("%w: no \" findings\" field" , errUnreadableFindings )
312321 }
313322
314- switch v := result .(type ) {
315- case []any :
316- return len (v )
317- case map [string ]any :
318- if total , ok := v ["total" ].(float64 ); ok {
319- return int (total )
320- }
321- if items , ok := v ["items" ].([]any ); ok {
322- return len (items )
323- }
324- if findings , ok := v ["findings" ].([]any ); ok {
325- return len (findings )
326- }
323+ if envelope .NumItems != nil {
324+ return * envelope .NumItems , nil
327325 }
328326
329- return 0
327+ var findings []json.RawMessage
328+ if err := json .Unmarshal (envelope .Findings , & findings ); err != nil {
329+ return 0 , fmt .Errorf ("%w: \" findings\" is not an array: %w" , errUnreadableFindings , err )
330+ }
331+ return len (findings ), nil
330332}
331333
332334// extractFindings pulls the finding objects out of an API response body so they
0 commit comments