From eabec91870df25dc296e25c86c8dc254d82297b7 Mon Sep 17 00:00:00 2001 From: Tim Thacker Date: Sat, 15 Aug 2026 10:32:43 +1000 Subject: [PATCH 1/2] fix(cli): make the CI gate fail on an unreadable findings response countFindings treated any response it could not parse as zero findings, so `nullify ci gate` printed "Gate passed: no findings above threshold". Parse the real {findings, numItems, nextToken} envelope, return an error for anything else, and exit ExitNetworkError rather than counting an unreadable response as clean. Drop the dead items/total shapes and the duplicate totalFindingsCount. The gate requests limit=1 and the API has no grand total, so the printed counts were the number of failing scanner/severity pairs; the messages now say that instead. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/cli/cmd/ci.go | 110 +++++++++++++++++++------------------ cmd/cli/cmd/ci_test.go | 68 +++++++++++++++++------ cmd/cli/cmd/status.go | 31 ++--------- cmd/cli/cmd/status_test.go | 23 +++++--- 4 files changed, 127 insertions(+), 105 deletions(-) diff --git a/cmd/cli/cmd/ci.go b/cmd/cli/cmd/ci.go index dd986cf..ef24cc4 100644 --- a/cmd/cli/cmd/ci.go +++ b/cmd/cli/cmd/ci.go @@ -2,6 +2,7 @@ package cmd import ( "encoding/json" + "errors" "fmt" "os" "sync" @@ -80,8 +81,9 @@ Exit codes: } } - var totalFindings int64 + var findingsFound int64 var apiErrors int64 + var unreadable int64 var mu sync.Mutex g, gctx := errgroup.WithContext(ctx) @@ -104,14 +106,19 @@ Exit codes: return nil } - // limit=1 keeps the payload small; the accurate count - // comes from the response's "total" field, not the - // truncated items array. - count := totalFindingsCount(body) + count, err := countFindings(body) + if err != nil { + mu.Lock() + fmt.Fprintf(os.Stderr, "Error: unreadable response from %s (%s): %v\n", ep.name, sev, err) + mu.Unlock() + atomic.AddInt64(&unreadable, 1) + return nil + } + if count > 0 { - atomic.AddInt64(&totalFindings, int64(count)) + atomic.AddInt64(&findingsFound, 1) mu.Lock() - fmt.Printf("FAIL: %s has %d %s findings\n", ep.name, count, sev) + fmt.Printf("FAIL: %s has open %s findings\n", ep.name, sev) mu.Unlock() } return nil @@ -126,10 +133,15 @@ Exit codes: return withExitCode(ExitNetworkError, err) } - if totalFindings > 0 { - fmt.Printf("\nGate failed: %d findings at or above %s severity\n", totalFindings, severityThreshold) + if unreadable > 0 { + err := fmt.Errorf("%d scanner response(s) could not be read; failing the gate (cannot confirm a clean result)", unreadable) + return withExitCode(ExitNetworkError, err) + } + + if findingsFound > 0 { + fmt.Printf("\nGate failed: open findings at or above %s severity\n", severityThreshold) cmd.SilenceErrors = true - return withExitCode(ExitFindings, fmt.Errorf("gate failed: %d findings at or above %s severity", totalFindings, severityThreshold)) + return withExitCode(ExitFindings, fmt.Errorf("gate failed: open findings at or above %s severity", severityThreshold)) } 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.`, mu.Unlock() return nil } + count, err := countFindings(body) + if err != nil { + atomic.AddInt64(&apiErrors, 1) + mu.Lock() + fmt.Fprintf(os.Stderr, "Warning: unreadable response from %s (%s): %v\n", ep.name, sev, err) + mu.Unlock() + return nil + } atomic.AddInt64(&successCount, 1) rows[i*len(severities)+j] = reportRow{ scanner: ep.name, severity: sev, - count: totalFindingsCount(body), + count: count, findings: extractFindings(body), } return nil @@ -278,55 +298,37 @@ func severitiesAboveThreshold(threshold string) []string { return []string{"critical", "high"} } -// countFindings extracts a count from API response JSON. When used with limit=1, -// it returns 0 or 1 to indicate whether findings exist at a given severity. -func countFindings(body string) int { - var result any - if err := json.Unmarshal([]byte(body), &result); err != nil { - return 0 +// errUnreadableFindings reports a findings response that does not carry the +// scanner envelope. It is never reported as a count of zero: a gate that cannot +// read the response must block the build rather than declare the repo clean. +var errUnreadableFindings = errors.New("unrecognised findings response envelope") + +// countFindings returns the number of findings in a scanner API response. Every +// endpoint in allScannerEndpoints returns {"findings":[...],"numItems":N, +// "nextToken":"..."}, except /dast/bughunt/findings which omits numItems. The +// count is page-scoped and therefore bounded by the request's limit; the API +// exposes no grand total. +func countFindings(body string) (int, error) { + var envelope struct { + Findings json.RawMessage `json:"findings"` + NumItems *int `json:"numItems"` } - - switch v := result.(type) { - case []any: - return len(v) - case map[string]any: - if items, ok := v["items"].([]any); ok { - return len(items) - } - if total, ok := v["total"].(float64); ok { - return int(total) - } + if err := json.Unmarshal([]byte(body), &envelope); err != nil { + return 0, fmt.Errorf("%w: %w", errUnreadableFindings, err) } - - return 0 -} - -// totalFindingsCount extracts an accurate finding count from an API response. -// It prefers the response's "total" field (which reflects the full result set -// regardless of the request's limit) over the length of the truncated items -// array. Falls back to array/items length when no total is present. -func totalFindingsCount(body string) int { - var result any - if err := json.Unmarshal([]byte(body), &result); err != nil { - return 0 + if len(envelope.Findings) == 0 { + return 0, fmt.Errorf("%w: no \"findings\" field", errUnreadableFindings) } - switch v := result.(type) { - case []any: - return len(v) - case map[string]any: - if total, ok := v["total"].(float64); ok { - return int(total) - } - if items, ok := v["items"].([]any); ok { - return len(items) - } - if findings, ok := v["findings"].([]any); ok { - return len(findings) - } + if envelope.NumItems != nil { + return *envelope.NumItems, nil } - return 0 + var findings []json.RawMessage + if err := json.Unmarshal(envelope.Findings, &findings); err != nil { + return 0, fmt.Errorf("%w: \"findings\" is not an array: %w", errUnreadableFindings, err) + } + return len(findings), nil } // extractFindings pulls the finding objects out of an API response body so they diff --git a/cmd/cli/cmd/ci_test.go b/cmd/cli/cmd/ci_test.go index 672f6f8..f9b7d25 100644 --- a/cmd/cli/cmd/ci_test.go +++ b/cmd/cli/cmd/ci_test.go @@ -33,41 +33,75 @@ func TestCountFindings(t *testing.T) { expected int }{ { - name: "empty array", - body: `[]`, - expected: 0, + name: "sast envelope", + body: `{"version":"1","findings":[{"id":"f1"}],"numItems":1,"nextToken":""}`, + expected: 1, }, { - name: "array with items", - body: `[{"id": 1}, {"id": 2}, {"id": 3}]`, - expected: 3, + name: "sca dependencies envelope", + body: `{"version":"1","findings":[{"id":"f1"},{"id":"f2"}],"numItems":2,"nextToken":"abc"}`, + expected: 2, }, { - name: "object with items array", - body: `{"items": [{"id": 1}]}`, + name: "sca containers envelope", + body: `{"findings":[{"id":"f1"}],"numItems":1,"nextToken":""}`, expected: 1, }, { - name: "object with total field", - body: `{"total": 42}`, - expected: 42, + name: "secrets envelope", + body: `{"findings":[],"numItems":0,"nextToken":""}`, + expected: 0, }, { - name: "invalid json", - body: `not json`, - expected: 0, + name: "pentest envelope", + body: `{"findings":[{"id":"f1"},{"id":"f2"},{"id":"f3"}],"numItems":3,"nextToken":""}`, + expected: 3, + }, + { + name: "bughunt envelope omits numItems", + body: `{"findings":[{"id":"f1"},{"id":"f2"}]}`, + expected: 2, + }, + { + name: "cspm envelope omits empty nextToken", + body: `{"findings":[{"id":"f1"}],"numItems":1,"version":"1"}`, + expected: 1, }, { - name: "empty object", - body: `{}`, + name: "nil findings slice serialises as null", + body: `{"findings":null,"numItems":0,"nextToken":""}`, expected: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := countFindings(tt.body) + result, err := countFindings(tt.body) + require.NoError(t, err) require.Equal(t, tt.expected, result) }) } } + +func TestCountFindingsRejectsUnknownEnvelopes(t *testing.T) { + tests := []struct { + name string + body string + }{ + {name: "invalid json", body: `not json`}, + {name: "empty object", body: `{}`}, + {name: "bare array", body: `[{"id":"f1"}]`}, + {name: "items envelope", body: `{"items":[{"id":"f1"}]}`}, + {name: "total envelope", body: `{"total":42}`}, + {name: "findings is not an array", body: `{"findings":{"id":"f1"}}`}, + {name: "empty body", body: ``}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + count, err := countFindings(tt.body) + require.ErrorIs(t, err, errUnreadableFindings) + require.Zero(t, count) + }) + } +} diff --git a/cmd/cli/cmd/status.go b/cmd/cli/cmd/status.go index 7b7aab7..f7106d3 100644 --- a/cmd/cli/cmd/status.go +++ b/cmd/cli/cmd/status.go @@ -113,33 +113,14 @@ func init() { // summarizeFindingsResponse extracts a human-readable summary from a findings API response. func summarizeFindingsResponse(body string) string { - var result any - if err := json.Unmarshal([]byte(body), &result); err != nil { - return "data available" + count, err := countFindings(body) + if err != nil { + return "unreadable response" } - - switch v := result.(type) { - case []any: - if len(v) == 1 { - return "1 finding returned" - } - return fmt.Sprintf("%d findings returned", len(v)) - case map[string]any: - if items, ok := v["items"].([]any); ok { - if len(items) == 1 { - return "1 finding returned" - } - return fmt.Sprintf("%d findings returned", len(items)) - } - if total, ok := v["total"].(float64); ok { - if total == 1 { - return "1 total finding" - } - return fmt.Sprintf("%.0f total findings", total) - } + if count == 1 { + return "1 finding returned" } - - return "data available" + return fmt.Sprintf("%d findings returned", count) } type securityStatusOutput struct { diff --git a/cmd/cli/cmd/status_test.go b/cmd/cli/cmd/status_test.go index 8228d48..553c596 100644 --- a/cmd/cli/cmd/status_test.go +++ b/cmd/cli/cmd/status_test.go @@ -14,29 +14,34 @@ func TestSummarizeFindingsResponse(t *testing.T) { expected string }{ { - name: "array response", - body: `[{"id":1},{"id":2}]`, + name: "scanner envelope with findings", + body: `{"findings":[{"id":"f1"},{"id":"f2"}],"numItems":2,"nextToken":""}`, expected: "2 findings returned", }, { - name: "object with items", - body: `{"items":[{"id":1}]}`, + name: "scanner envelope with one finding", + body: `{"findings":[{"id":"f1"}],"numItems":1,"nextToken":""}`, expected: "1 finding returned", }, { - name: "object with total", - body: `{"total":99}`, - expected: "99 total findings", + name: "bughunt envelope omits numItems", + body: `{"findings":[{"id":"f1"},{"id":"f2"},{"id":"f3"}]}`, + expected: "3 findings returned", + }, + { + name: "empty scanner envelope", + body: `{"findings":[],"numItems":0,"nextToken":""}`, + expected: "0 findings returned", }, { name: "empty object", body: `{}`, - expected: "data available", + expected: "unreadable response", }, { name: "invalid json", body: `nope`, - expected: "data available", + expected: "unreadable response", }, } From 163f88943c2beb957af784b1dc4e72451c2bed99 Mon Sep 17 00:00:00 2001 From: Tim Thacker Date: Sat, 15 Aug 2026 10:47:39 +1000 Subject: [PATCH 2/2] fix(cli): send the CI gate the filters the findings endpoints implement The gate sent `severity=critical` lower-case and `status=open` to all seven scanner endpoints. /cspm/findings binds severity straight into `severity_label = $N::severity_level` and the Postgres severity_level enum labels are upper-case, so every CSPM request 500'd and the pre-existing `apiErrors > 0` branch returned ExitNetworkError before the gate could reach a verdict. `status` is implemented by no endpoint, so resolved, fixed, false-positive and allowlisted findings were counted. Build the query per endpoint from a capability table: upper-case severity, send it only to /sast/findings and /cspm/findings, and send isResolved=false where the endpoint implements it. Endpoints with no server-side severity filter are queried once instead of once per severity, and say so in their FAIL line rather than implying the threshold was applied. `nullify status` drops its finding count, which limit=1 capped at 1. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/cli/cmd/ci.go | 77 ++++++++++++++++++++++++++++++-------- cmd/cli/cmd/ci_test.go | 77 ++++++++++++++++++++++++++++++++++++++ cmd/cli/cmd/scanners.go | 29 +++++++++----- cmd/cli/cmd/status.go | 11 ++++-- cmd/cli/cmd/status_test.go | 8 ++-- 5 files changed, 168 insertions(+), 34 deletions(-) diff --git a/cmd/cli/cmd/ci.go b/cmd/cli/cmd/ci.go index ef24cc4..94d0b03 100644 --- a/cmd/cli/cmd/ci.go +++ b/cmd/cli/cmd/ci.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "strings" "sync" "sync/atomic" @@ -88,19 +89,15 @@ Exit codes: g, gctx := errgroup.WithContext(ctx) for _, ep := range endpoints { - for _, sev := range severities { + for _, sev := range scannerSeverities(ep, severities) { ep, sev := ep, sev g.Go(func() error { - params := []string{"severity", sev, "status", "open", "limit", "1"} - if repo != "" { - params = append(params, "repository", repo) - } - qs := lib.BuildQueryString(queryParams, params...) + qs := lib.BuildQueryString(queryParams, scannerQueryParams(ep, sev, repo, "1")...) body, err := lib.DoGet(gctx, nullifyClient.HttpClient, nullifyClient.BaseURL, ep.path+qs) if err != nil { mu.Lock() - fmt.Fprintf(os.Stderr, "Warning: failed to query %s (%s): %v\n", ep.name, sev, err) + fmt.Fprintf(os.Stderr, "Warning: failed to query %s: %v\n", scannerLabel(ep, sev), err) mu.Unlock() atomic.AddInt64(&apiErrors, 1) return nil @@ -109,7 +106,7 @@ Exit codes: count, err := countFindings(body) if err != nil { mu.Lock() - fmt.Fprintf(os.Stderr, "Error: unreadable response from %s (%s): %v\n", ep.name, sev, err) + fmt.Fprintf(os.Stderr, "Error: unreadable response from %s: %v\n", scannerLabel(ep, sev), err) mu.Unlock() atomic.AddInt64(&unreadable, 1) return nil @@ -118,7 +115,7 @@ Exit codes: if count > 0 { atomic.AddInt64(&findingsFound, 1) mu.Lock() - fmt.Printf("FAIL: %s has open %s findings\n", ep.name, sev) + fmt.Println(gateFailLine(ep, sev)) mu.Unlock() } return nil @@ -200,17 +197,13 @@ format emits a SARIF v2.1.0 document for upload to code-scanning tools.`, for j, sev := range severities { i, j, ep, sev := i, j, ep, sev g.Go(func() error { - params := []string{"severity", sev, "status", "open", "limit", "1000"} - if repo != "" { - params = append(params, "repository", repo) - } - qs := lib.BuildQueryString(queryParams, params...) + qs := lib.BuildQueryString(queryParams, scannerQueryParams(ep, sev, repo, "1000")...) body, err := lib.DoGet(gctx, nullifyClient.HttpClient, nullifyClient.BaseURL, ep.path+qs) if err != nil { atomic.AddInt64(&apiErrors, 1) mu.Lock() - fmt.Fprintf(os.Stderr, "Warning: failed to query %s (%s): %v\n", ep.name, sev, err) + fmt.Fprintf(os.Stderr, "Warning: failed to query %s: %v\n", scannerLabel(ep, sev), err) mu.Unlock() return nil } @@ -218,7 +211,7 @@ format emits a SARIF v2.1.0 document for upload to code-scanning tools.`, if err != nil { atomic.AddInt64(&apiErrors, 1) mu.Lock() - fmt.Fprintf(os.Stderr, "Warning: unreadable response from %s (%s): %v\n", ep.name, sev, err) + fmt.Fprintf(os.Stderr, "Warning: unreadable response from %s: %v\n", scannerLabel(ep, sev), err) mu.Unlock() return nil } @@ -298,6 +291,58 @@ func severitiesAboveThreshold(threshold string) []string { return []string{"critical", "high"} } +// scannerSeverities returns the severity values to query an endpoint with. An +// endpoint with no server-side severity filter is queried once with an empty +// severity: fanning out over the threshold list there would issue identical +// requests and attribute the same findings to every severity. +func scannerSeverities(ep scannerEndpoint, severities []string) []string { + if !ep.supportsSeverity { + return []string{""} + } + return severities +} + +// scannerQueryParams builds the query string arguments for a findings request, +// sending only the filters the endpoint implements. +// +// Severity is upper-cased because /cspm/findings binds it straight into +// "severity_label = $N::severity_level" and the Postgres severity_level enum +// labels are upper-case, so a lower-case value is a 500 rather than a filter. +// /sast/findings case-folds via database.StringToNullSeverityLevel and accepts +// either. +func scannerQueryParams(ep scannerEndpoint, severity, repo, limit string) []string { + params := []string{"limit", limit} + if ep.supportsSeverity && severity != "" { + params = append(params, "severity", strings.ToUpper(severity)) + } + if ep.supportsIsResolved { + params = append(params, "isResolved", "false") + } + if repo != "" { + params = append(params, "repository", repo) + } + return params +} + +// scannerLabel names a scanner/severity pair in diagnostic output. A scanner +// queried without a severity filter is named alone. +func scannerLabel(ep scannerEndpoint, severity string) string { + if severity == "" { + return ep.name + } + return fmt.Sprintf("%s (%s)", ep.name, severity) +} + +// gateFailLine reports a scanner that returned findings. Scanners with no +// server-side severity filter say so rather than implying the threshold was +// applied to their result. +func gateFailLine(ep scannerEndpoint, severity string) string { + if severity == "" { + return fmt.Sprintf("FAIL: %s has open findings (no server-side severity filter, threshold not applied)", ep.name) + } + return fmt.Sprintf("FAIL: %s has open %s findings", ep.name, severity) +} + // errUnreadableFindings reports a findings response that does not carry the // scanner envelope. It is never reported as a count of zero: a gate that cannot // read the response must block the build rather than declare the repo clean. diff --git a/cmd/cli/cmd/ci_test.go b/cmd/cli/cmd/ci_test.go index f9b7d25..e555dc0 100644 --- a/cmd/cli/cmd/ci_test.go +++ b/cmd/cli/cmd/ci_test.go @@ -26,6 +26,83 @@ func TestSeveritiesAboveThreshold(t *testing.T) { } } +// TestScannerQueryParamsMatchEndpointFilters pins the gate's query parameters to +// the filters each endpoint actually implements. Severity must go out +// upper-case: /cspm/findings casts it into the Postgres severity_level enum, +// whose labels are upper-case, so "critical" is a 500 and not a filter. +func TestScannerQueryParamsMatchEndpointFilters(t *testing.T) { + byName := map[string]scannerEndpoint{} + for _, ep := range allScannerEndpoints() { + byName[ep.name] = ep + } + + tests := []struct { + scanner string + expected []string + }{ + { + scanner: "sast", + expected: []string{"limit", "1", "severity", "CRITICAL", "isResolved", "false", "repository", "org/repo"}, + }, + { + scanner: "cspm", + expected: []string{"limit", "1", "severity", "CRITICAL", "repository", "org/repo"}, + }, + { + scanner: "sca_dependencies", + expected: []string{"limit", "1", "isResolved", "false", "repository", "org/repo"}, + }, + { + scanner: "sca_containers", + expected: []string{"limit", "1", "isResolved", "false", "repository", "org/repo"}, + }, + { + scanner: "secrets", + expected: []string{"limit", "1", "isResolved", "false", "repository", "org/repo"}, + }, + { + scanner: "pentest", + expected: []string{"limit", "1", "isResolved", "false", "repository", "org/repo"}, + }, + { + scanner: "bughunt", + expected: []string{"limit", "1", "repository", "org/repo"}, + }, + } + + for _, tt := range tests { + t.Run(tt.scanner, func(t *testing.T) { + ep, ok := byName[tt.scanner] + require.True(t, ok) + + severities := scannerSeverities(ep, []string{"critical", "high"}) + params := scannerQueryParams(ep, severities[0], "org/repo", "1") + require.Equal(t, tt.expected, params) + require.NotContains(t, params, "status") + }) + } +} + +func TestScannerSeveritiesCollapseWithoutServerSideFilter(t *testing.T) { + byName := map[string]scannerEndpoint{} + for _, ep := range allScannerEndpoints() { + byName[ep.name] = ep + } + + threshold := []string{"critical", "high"} + + require.Equal(t, threshold, scannerSeverities(byName["sast"], threshold)) + require.Equal(t, threshold, scannerSeverities(byName["cspm"], threshold)) + require.Equal(t, []string{""}, scannerSeverities(byName["secrets"], threshold)) + require.Equal(t, []string{""}, scannerSeverities(byName["bughunt"], threshold)) + + require.Equal(t, + "FAIL: secrets has open findings (no server-side severity filter, threshold not applied)", + gateFailLine(byName["secrets"], ""), + ) + require.Equal(t, "FAIL: sast has open critical findings", gateFailLine(byName["sast"], "critical")) +} + func TestCountFindings(t *testing.T) { tests := []struct { name string diff --git a/cmd/cli/cmd/scanners.go b/cmd/cli/cmd/scanners.go index 2ced7c4..993e023 100644 --- a/cmd/cli/cmd/scanners.go +++ b/cmd/cli/cmd/scanners.go @@ -12,22 +12,31 @@ var findingTypeToAPI = map[string]string{ "cspm": "Cloud", } -// scannerEndpoint represents a scanner type and its API path. +// scannerEndpoint represents a scanner type, its API path, and the server-side +// filters that endpoint implements. An unimplemented query parameter is dropped +// without error, so a gate that sends one reports a filter it never applied. type scannerEndpoint struct { - name string - path string + name string + path string + supportsSeverity bool + supportsIsResolved bool } // allScannerEndpoints returns the canonical list of all scanner endpoints. +// +// Only /sast/findings and /cspm/findings implement a severity filter. Every +// endpoint except /cspm/findings and /dast/bughunt/findings implements +// isResolved: /cspm/findings declares a status parameter its handler never +// copies into CSPMFindingFilters, and /dast/bughunt/findings takes no filters. func allScannerEndpoints() []scannerEndpoint { return []scannerEndpoint{ - {"sast", "/sast/findings"}, - {"sca_dependencies", "/sca/dependencies/findings"}, - {"sca_containers", "/sca/containers/findings"}, - {"secrets", "/secrets/findings"}, - {"pentest", "/dast/pentest/findings"}, - {"bughunt", "/dast/bughunt/findings"}, - {"cspm", "/cspm/findings"}, + {name: "sast", path: "/sast/findings", supportsSeverity: true, supportsIsResolved: true}, + {name: "sca_dependencies", path: "/sca/dependencies/findings", supportsIsResolved: true}, + {name: "sca_containers", path: "/sca/containers/findings", supportsIsResolved: true}, + {name: "secrets", path: "/secrets/findings", supportsIsResolved: true}, + {name: "pentest", path: "/dast/pentest/findings", supportsIsResolved: true}, + {name: "bughunt", path: "/dast/bughunt/findings"}, + {name: "cspm", path: "/cspm/findings", supportsSeverity: true}, } } diff --git a/cmd/cli/cmd/status.go b/cmd/cli/cmd/status.go index f7106d3..078177c 100644 --- a/cmd/cli/cmd/status.go +++ b/cmd/cli/cmd/status.go @@ -111,16 +111,19 @@ func init() { rootCmd.AddCommand(securityStatusCmd) } -// summarizeFindingsResponse extracts a human-readable summary from a findings API response. +// summarizeFindingsResponse extracts a human-readable summary from a findings +// API response. The count is not reported: status requests limit=1, so +// countFindings is capped at 1 and any number printed here would understate a +// tenant's real backlog by orders of magnitude. func summarizeFindingsResponse(body string) string { count, err := countFindings(body) if err != nil { return "unreadable response" } - if count == 1 { - return "1 finding returned" + if count > 0 { + return "findings present" } - return fmt.Sprintf("%d findings returned", count) + return "no findings" } type securityStatusOutput struct { diff --git a/cmd/cli/cmd/status_test.go b/cmd/cli/cmd/status_test.go index 553c596..bc108aa 100644 --- a/cmd/cli/cmd/status_test.go +++ b/cmd/cli/cmd/status_test.go @@ -16,22 +16,22 @@ func TestSummarizeFindingsResponse(t *testing.T) { { name: "scanner envelope with findings", body: `{"findings":[{"id":"f1"},{"id":"f2"}],"numItems":2,"nextToken":""}`, - expected: "2 findings returned", + expected: "findings present", }, { name: "scanner envelope with one finding", body: `{"findings":[{"id":"f1"}],"numItems":1,"nextToken":""}`, - expected: "1 finding returned", + expected: "findings present", }, { name: "bughunt envelope omits numItems", body: `{"findings":[{"id":"f1"},{"id":"f2"},{"id":"f3"}]}`, - expected: "3 findings returned", + expected: "findings present", }, { name: "empty scanner envelope", body: `{"findings":[],"numItems":0,"nextToken":""}`, - expected: "0 findings returned", + expected: "no findings", }, { name: "empty object",