Skip to content

Commit 163f889

Browse files
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) <noreply@anthropic.com>
1 parent eabec91 commit 163f889

5 files changed

Lines changed: 168 additions & 34 deletions

File tree

‎cmd/cli/cmd/ci.go‎

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"os"
8+
"strings"
89
"sync"
910
"sync/atomic"
1011

@@ -88,19 +89,15 @@ Exit codes:
8889
g, gctx := errgroup.WithContext(ctx)
8990

9091
for _, ep := range endpoints {
91-
for _, sev := range severities {
92+
for _, sev := range scannerSeverities(ep, severities) {
9293
ep, sev := ep, sev
9394
g.Go(func() error {
94-
params := []string{"severity", sev, "status", "open", "limit", "1"}
95-
if repo != "" {
96-
params = append(params, "repository", repo)
97-
}
98-
qs := lib.BuildQueryString(queryParams, params...)
95+
qs := lib.BuildQueryString(queryParams, scannerQueryParams(ep, sev, repo, "1")...)
9996

10097
body, err := lib.DoGet(gctx, nullifyClient.HttpClient, nullifyClient.BaseURL, ep.path+qs)
10198
if err != nil {
10299
mu.Lock()
103-
fmt.Fprintf(os.Stderr, "Warning: failed to query %s (%s): %v\n", ep.name, sev, err)
100+
fmt.Fprintf(os.Stderr, "Warning: failed to query %s: %v\n", scannerLabel(ep, sev), err)
104101
mu.Unlock()
105102
atomic.AddInt64(&apiErrors, 1)
106103
return nil
@@ -109,7 +106,7 @@ Exit codes:
109106
count, err := countFindings(body)
110107
if err != nil {
111108
mu.Lock()
112-
fmt.Fprintf(os.Stderr, "Error: unreadable response from %s (%s): %v\n", ep.name, sev, err)
109+
fmt.Fprintf(os.Stderr, "Error: unreadable response from %s: %v\n", scannerLabel(ep, sev), err)
113110
mu.Unlock()
114111
atomic.AddInt64(&unreadable, 1)
115112
return nil
@@ -118,7 +115,7 @@ Exit codes:
118115
if count > 0 {
119116
atomic.AddInt64(&findingsFound, 1)
120117
mu.Lock()
121-
fmt.Printf("FAIL: %s has open %s findings\n", ep.name, sev)
118+
fmt.Println(gateFailLine(ep, sev))
122119
mu.Unlock()
123120
}
124121
return nil
@@ -200,25 +197,21 @@ format emits a SARIF v2.1.0 document for upload to code-scanning tools.`,
200197
for j, sev := range severities {
201198
i, j, ep, sev := i, j, ep, sev
202199
g.Go(func() error {
203-
params := []string{"severity", sev, "status", "open", "limit", "1000"}
204-
if repo != "" {
205-
params = append(params, "repository", repo)
206-
}
207-
qs := lib.BuildQueryString(queryParams, params...)
200+
qs := lib.BuildQueryString(queryParams, scannerQueryParams(ep, sev, repo, "1000")...)
208201

209202
body, err := lib.DoGet(gctx, nullifyClient.HttpClient, nullifyClient.BaseURL, ep.path+qs)
210203
if err != nil {
211204
atomic.AddInt64(&apiErrors, 1)
212205
mu.Lock()
213-
fmt.Fprintf(os.Stderr, "Warning: failed to query %s (%s): %v\n", ep.name, sev, err)
206+
fmt.Fprintf(os.Stderr, "Warning: failed to query %s: %v\n", scannerLabel(ep, sev), err)
214207
mu.Unlock()
215208
return nil
216209
}
217210
count, err := countFindings(body)
218211
if err != nil {
219212
atomic.AddInt64(&apiErrors, 1)
220213
mu.Lock()
221-
fmt.Fprintf(os.Stderr, "Warning: unreadable response from %s (%s): %v\n", ep.name, sev, err)
214+
fmt.Fprintf(os.Stderr, "Warning: unreadable response from %s: %v\n", scannerLabel(ep, sev), err)
222215
mu.Unlock()
223216
return nil
224217
}
@@ -298,6 +291,58 @@ func severitiesAboveThreshold(threshold string) []string {
298291
return []string{"critical", "high"}
299292
}
300293

294+
// scannerSeverities returns the severity values to query an endpoint with. An
295+
// endpoint with no server-side severity filter is queried once with an empty
296+
// severity: fanning out over the threshold list there would issue identical
297+
// requests and attribute the same findings to every severity.
298+
func scannerSeverities(ep scannerEndpoint, severities []string) []string {
299+
if !ep.supportsSeverity {
300+
return []string{""}
301+
}
302+
return severities
303+
}
304+
305+
// scannerQueryParams builds the query string arguments for a findings request,
306+
// sending only the filters the endpoint implements.
307+
//
308+
// Severity is upper-cased because /cspm/findings binds it straight into
309+
// "severity_label = $N::severity_level" and the Postgres severity_level enum
310+
// labels are upper-case, so a lower-case value is a 500 rather than a filter.
311+
// /sast/findings case-folds via database.StringToNullSeverityLevel and accepts
312+
// either.
313+
func scannerQueryParams(ep scannerEndpoint, severity, repo, limit string) []string {
314+
params := []string{"limit", limit}
315+
if ep.supportsSeverity && severity != "" {
316+
params = append(params, "severity", strings.ToUpper(severity))
317+
}
318+
if ep.supportsIsResolved {
319+
params = append(params, "isResolved", "false")
320+
}
321+
if repo != "" {
322+
params = append(params, "repository", repo)
323+
}
324+
return params
325+
}
326+
327+
// scannerLabel names a scanner/severity pair in diagnostic output. A scanner
328+
// queried without a severity filter is named alone.
329+
func scannerLabel(ep scannerEndpoint, severity string) string {
330+
if severity == "" {
331+
return ep.name
332+
}
333+
return fmt.Sprintf("%s (%s)", ep.name, severity)
334+
}
335+
336+
// gateFailLine reports a scanner that returned findings. Scanners with no
337+
// server-side severity filter say so rather than implying the threshold was
338+
// applied to their result.
339+
func gateFailLine(ep scannerEndpoint, severity string) string {
340+
if severity == "" {
341+
return fmt.Sprintf("FAIL: %s has open findings (no server-side severity filter, threshold not applied)", ep.name)
342+
}
343+
return fmt.Sprintf("FAIL: %s has open %s findings", ep.name, severity)
344+
}
345+
301346
// errUnreadableFindings reports a findings response that does not carry the
302347
// scanner envelope. It is never reported as a count of zero: a gate that cannot
303348
// read the response must block the build rather than declare the repo clean.

‎cmd/cli/cmd/ci_test.go‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,83 @@ func TestSeveritiesAboveThreshold(t *testing.T) {
2626
}
2727
}
2828

29+
// TestScannerQueryParamsMatchEndpointFilters pins the gate's query parameters to
30+
// the filters each endpoint actually implements. Severity must go out
31+
// upper-case: /cspm/findings casts it into the Postgres severity_level enum,
32+
// whose labels are upper-case, so "critical" is a 500 and not a filter.
33+
func TestScannerQueryParamsMatchEndpointFilters(t *testing.T) {
34+
byName := map[string]scannerEndpoint{}
35+
for _, ep := range allScannerEndpoints() {
36+
byName[ep.name] = ep
37+
}
38+
39+
tests := []struct {
40+
scanner string
41+
expected []string
42+
}{
43+
{
44+
scanner: "sast",
45+
expected: []string{"limit", "1", "severity", "CRITICAL", "isResolved", "false", "repository", "org/repo"},
46+
},
47+
{
48+
scanner: "cspm",
49+
expected: []string{"limit", "1", "severity", "CRITICAL", "repository", "org/repo"},
50+
},
51+
{
52+
scanner: "sca_dependencies",
53+
expected: []string{"limit", "1", "isResolved", "false", "repository", "org/repo"},
54+
},
55+
{
56+
scanner: "sca_containers",
57+
expected: []string{"limit", "1", "isResolved", "false", "repository", "org/repo"},
58+
},
59+
{
60+
scanner: "secrets",
61+
expected: []string{"limit", "1", "isResolved", "false", "repository", "org/repo"},
62+
},
63+
{
64+
scanner: "pentest",
65+
expected: []string{"limit", "1", "isResolved", "false", "repository", "org/repo"},
66+
},
67+
{
68+
scanner: "bughunt",
69+
expected: []string{"limit", "1", "repository", "org/repo"},
70+
},
71+
}
72+
73+
for _, tt := range tests {
74+
t.Run(tt.scanner, func(t *testing.T) {
75+
ep, ok := byName[tt.scanner]
76+
require.True(t, ok)
77+
78+
severities := scannerSeverities(ep, []string{"critical", "high"})
79+
params := scannerQueryParams(ep, severities[0], "org/repo", "1")
80+
require.Equal(t, tt.expected, params)
81+
require.NotContains(t, params, "status")
82+
})
83+
}
84+
}
85+
86+
func TestScannerSeveritiesCollapseWithoutServerSideFilter(t *testing.T) {
87+
byName := map[string]scannerEndpoint{}
88+
for _, ep := range allScannerEndpoints() {
89+
byName[ep.name] = ep
90+
}
91+
92+
threshold := []string{"critical", "high"}
93+
94+
require.Equal(t, threshold, scannerSeverities(byName["sast"], threshold))
95+
require.Equal(t, threshold, scannerSeverities(byName["cspm"], threshold))
96+
require.Equal(t, []string{""}, scannerSeverities(byName["secrets"], threshold))
97+
require.Equal(t, []string{""}, scannerSeverities(byName["bughunt"], threshold))
98+
99+
require.Equal(t,
100+
"FAIL: secrets has open findings (no server-side severity filter, threshold not applied)",
101+
gateFailLine(byName["secrets"], ""),
102+
)
103+
require.Equal(t, "FAIL: sast has open critical findings", gateFailLine(byName["sast"], "critical"))
104+
}
105+
29106
func TestCountFindings(t *testing.T) {
30107
tests := []struct {
31108
name string

‎cmd/cli/cmd/scanners.go‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,31 @@ var findingTypeToAPI = map[string]string{
1212
"cspm": "Cloud",
1313
}
1414

15-
// scannerEndpoint represents a scanner type and its API path.
15+
// scannerEndpoint represents a scanner type, its API path, and the server-side
16+
// filters that endpoint implements. An unimplemented query parameter is dropped
17+
// without error, so a gate that sends one reports a filter it never applied.
1618
type scannerEndpoint struct {
17-
name string
18-
path string
19+
name string
20+
path string
21+
supportsSeverity bool
22+
supportsIsResolved bool
1923
}
2024

2125
// allScannerEndpoints returns the canonical list of all scanner endpoints.
26+
//
27+
// Only /sast/findings and /cspm/findings implement a severity filter. Every
28+
// endpoint except /cspm/findings and /dast/bughunt/findings implements
29+
// isResolved: /cspm/findings declares a status parameter its handler never
30+
// copies into CSPMFindingFilters, and /dast/bughunt/findings takes no filters.
2231
func allScannerEndpoints() []scannerEndpoint {
2332
return []scannerEndpoint{
24-
{"sast", "/sast/findings"},
25-
{"sca_dependencies", "/sca/dependencies/findings"},
26-
{"sca_containers", "/sca/containers/findings"},
27-
{"secrets", "/secrets/findings"},
28-
{"pentest", "/dast/pentest/findings"},
29-
{"bughunt", "/dast/bughunt/findings"},
30-
{"cspm", "/cspm/findings"},
33+
{name: "sast", path: "/sast/findings", supportsSeverity: true, supportsIsResolved: true},
34+
{name: "sca_dependencies", path: "/sca/dependencies/findings", supportsIsResolved: true},
35+
{name: "sca_containers", path: "/sca/containers/findings", supportsIsResolved: true},
36+
{name: "secrets", path: "/secrets/findings", supportsIsResolved: true},
37+
{name: "pentest", path: "/dast/pentest/findings", supportsIsResolved: true},
38+
{name: "bughunt", path: "/dast/bughunt/findings"},
39+
{name: "cspm", path: "/cspm/findings", supportsSeverity: true},
3140
}
3241
}
3342

‎cmd/cli/cmd/status.go‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,16 +111,19 @@ func init() {
111111
rootCmd.AddCommand(securityStatusCmd)
112112
}
113113

114-
// summarizeFindingsResponse extracts a human-readable summary from a findings API response.
114+
// summarizeFindingsResponse extracts a human-readable summary from a findings
115+
// API response. The count is not reported: status requests limit=1, so
116+
// countFindings is capped at 1 and any number printed here would understate a
117+
// tenant's real backlog by orders of magnitude.
115118
func summarizeFindingsResponse(body string) string {
116119
count, err := countFindings(body)
117120
if err != nil {
118121
return "unreadable response"
119122
}
120-
if count == 1 {
121-
return "1 finding returned"
123+
if count > 0 {
124+
return "findings present"
122125
}
123-
return fmt.Sprintf("%d findings returned", count)
126+
return "no findings"
124127
}
125128

126129
type securityStatusOutput struct {

‎cmd/cli/cmd/status_test.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,22 @@ func TestSummarizeFindingsResponse(t *testing.T) {
1616
{
1717
name: "scanner envelope with findings",
1818
body: `{"findings":[{"id":"f1"},{"id":"f2"}],"numItems":2,"nextToken":""}`,
19-
expected: "2 findings returned",
19+
expected: "findings present",
2020
},
2121
{
2222
name: "scanner envelope with one finding",
2323
body: `{"findings":[{"id":"f1"}],"numItems":1,"nextToken":""}`,
24-
expected: "1 finding returned",
24+
expected: "findings present",
2525
},
2626
{
2727
name: "bughunt envelope omits numItems",
2828
body: `{"findings":[{"id":"f1"},{"id":"f2"},{"id":"f3"}]}`,
29-
expected: "3 findings returned",
29+
expected: "findings present",
3030
},
3131
{
3232
name: "empty scanner envelope",
3333
body: `{"findings":[],"numItems":0,"nextToken":""}`,
34-
expected: "0 findings returned",
34+
expected: "no findings",
3535
},
3636
{
3737
name: "empty object",

0 commit comments

Comments
 (0)