diff --git a/docs/sca.md b/docs/sca.md index a48168e..2fb0432 100644 --- a/docs/sca.md +++ b/docs/sca.md @@ -25,12 +25,13 @@ Every per-codebase verb takes a positional `` and an optional Dep-Track project "version". You can also supply an explicit branch name or release tag. -### Severity filter (inclusive) +### Severity filter (inclusive, server-side) `--severity` accepts case-insensitive values `critical | high | medium | low | info`. The filter is **inclusive**: `--severity=high` returns rows at HIGH or above; `--severity=medium` returns CRITICAL + HIGH + MEDIUM. `INFO` implicitly -includes `UNASSIGNED`. +includes `UNASSIGNED`. Applied server-side on both `findings` and `components`, +so any server-side cap (`truncated=true`) applies only to matching rows. ## `sca list` @@ -152,14 +153,13 @@ Filter by upstream vulnerability source (e.g. `NVD`, `GITHUB`, `OSV`): krci sca findings payments-api --source=NVD ``` -Very large projects are capped server-side at 1000 rows. `--source` is the -only flag that narrows the upstream query; `--severity` filters client-side -after the cap and cannot recover findings beyond row 1000. To audit a -truncated project for severities, drop `--severity` and post-filter the JSON, -or scope by `--branch` first: +Very large projects are capped server-side at 1000 rows. Both `--severity` +and `--source` are applied before the cap, so `truncated=true` means more +**matching** findings exist, not just more total findings. Narrow further with +`--branch` when still truncated: ``` -(findings truncated to 1000 rows server-side — only --source narrows the upstream query; --severity filters client-side and cannot recover capped rows) +(findings truncated to 1000 rows — narrow with --severity or --source to reduce the upstream result set) ``` Script-friendly CVE extraction: diff --git a/internal/portal/openapi/spec.json b/internal/portal/openapi/spec.json index a5e2f55..38e4097 100644 --- a/internal/portal/openapi/spec.json +++ b/internal/portal/openapi/spec.json @@ -1836,6 +1836,14 @@ "schema": { "type": "string" } + }, + { + "in": "query", + "name": "severity", + "description": "Comma-separated canonical Dep-Track severity set (CRITICAL,HIGH,MEDIUM,LOW,INFO,UNASSIGNED). Filter applied server-side before the 1000-row cap, so truncated=true accurately reports more matching findings exist.", + "schema": { + "type": "string" + } } ], "responses": { diff --git a/internal/portal/restapi/api_gen.go b/internal/portal/restapi/api_gen.go index 29b1041..8c974bb 100644 --- a/internal/portal/restapi/api_gen.go +++ b/internal/portal/restapi/api_gen.go @@ -649,6 +649,9 @@ type ScaFindingsParams struct { Branch *string `form:"branch,omitempty" json:"branch,omitempty"` Suppressed *ScaFindingsParamsSuppressed `form:"suppressed,omitempty" json:"suppressed,omitempty"` Source *string `form:"source,omitempty" json:"source,omitempty"` + + // Severity Comma-separated canonical Dep-Track severity set (CRITICAL,HIGH,MEDIUM,LOW,INFO,UNASSIGNED). Filter applied server-side before the 1000-row cap, so truncated=true accurately reports more matching findings exist. + Severity *string `form:"severity,omitempty" json:"severity,omitempty"` } // ScaFindingsParamsSuppressed defines parameters for ScaFindings. @@ -1638,6 +1641,22 @@ func NewScaFindingsRequest(server string, params *ScaFindingsParams) (*http.Requ } + if params.Severity != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "severity", runtime.ParamLocationQuery, *params.Severity); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + queryURL.RawQuery = queryValues.Encode() } diff --git a/internal/portal/sca.go b/internal/portal/sca.go index cb3e24f..613d798 100644 --- a/internal/portal/sca.go +++ b/internal/portal/sca.go @@ -141,8 +141,6 @@ type SCAListParams struct { } // SCAComponentsParams carries the CLI-validated inputs for `krci sca components`. -// Severity holds the canonical upper-case Dep-Track severity set to filter by; -// empty slice means no filter. type SCAComponentsParams struct { Codebase string Branch string @@ -150,7 +148,9 @@ type SCAComponentsParams struct { PageSize int OnlyOutdated bool OnlyDirect bool - Severity []string + // Severity is the canonical upper-case Dep-Track severity set to filter by; + // empty slice means no filter. Applied server-side before the cap. + Severity []string } // SCAFindingsParams carries the CLI-validated inputs for `krci sca findings`. @@ -159,6 +159,9 @@ type SCAFindingsParams struct { Branch string IncludeSuppressed bool Source string + // Severity is the canonical upper-case Dep-Track severity set to filter by; + // empty slice means no filter. Applied server-side before the cap. + Severity []string } // SCAService wraps the generated restapi ScaList/ScaGet/ScaComponents/ScaFindings @@ -355,7 +358,7 @@ func (s *SCAService) Components(ctx context.Context, params SCAComponentsParams) // Findings returns the vulnerability findings for a codebase/branch pair. // The Portal caps the server-side result at 1000 rows; when exceeded, -// Truncated is true. +// Truncated is true. Severity filter is applied server-side before the cap. func (s *SCAService) Findings(ctx context.Context, params SCAFindingsParams) (*SCAFindingList, error) { p := &restapi.ScaFindingsParams{Codebase: params.Codebase} if params.Branch != "" { @@ -365,6 +368,9 @@ func (s *SCAService) Findings(ctx context.Context, params SCAFindingsParams) (*S if params.Source != "" { p.Source = ptr.To(params.Source) } + if len(params.Severity) > 0 { + p.Severity = ptr.To(strings.Join(params.Severity, ",")) + } resp, err := s.client.ScaFindingsWithResponse(ctx, p) if err != nil { diff --git a/pkg/cmd/sca/findings/findings.go b/pkg/cmd/sca/findings/findings.go index 58c23c9..24d183a 100644 --- a/pkg/cmd/sca/findings/findings.go +++ b/pkg/cmd/sca/findings/findings.go @@ -46,10 +46,9 @@ func NewCmdFindings(f *cmdutil.Factory, runF func(*FindingsOptions) error) *cobr Long: fmt.Sprintf( "List Dep-Track vulnerability findings for a project.\n\n"+ "Unpaginated: the portal returns a single response capped at %d rows\n"+ - "server-side. Only --source narrows the upstream query; --severity is\n"+ - "applied client-side after the cap, so it cannot recover findings beyond\n"+ - "row %d.", - scainternal.FindingsServerCap, scainternal.FindingsServerCap), + "server-side. Both --severity and --source are applied server-side before\n"+ + "the cap, so truncated=true accurately reports more matching findings exist.", + scainternal.FindingsServerCap), Args: cmdutil.ExactArgs(1, "a KubeRocketCI project name", "to see available projects: krci sca list"), Example: ` # All unsuppressed findings, default branch @@ -113,25 +112,12 @@ func findingsRun(ctx context.Context, opts *FindingsOptions) error { Branch: opts.Branch, IncludeSuppressed: opts.IncludeSuppressed, Source: opts.Source, + Severity: scainternal.ExpandSeverityFlag(opts.Severity), }) if err != nil { return scainternal.HandleError(opts.IO, opts.OutputFormat, err) } - // Client-side inclusive severity filter — server does not narrow by - // severity. Truncated stays as-returned by the server: hiding it after a - // client-side filter would silently drop findings that fell off the cap - // before we ever saw them. - if inclusive := scainternal.ExpandSeverityFlag(opts.Severity); len(inclusive) > 0 { - filtered := make([]portal.SCAFinding, 0, len(result.Items)) - for _, f := range result.Items { - if scainternal.SeverityMatches(f.Vulnerability.Severity, inclusive) { - filtered = append(filtered, f) - } - } - result.Items = filtered - } - return scainternal.Render(opts.IO, opts.OutputFormat, result, func(w io.Writer, isTTY bool) error { return renderTable(w, isTTY, opts.Codebase, result) }) @@ -180,8 +166,7 @@ func renderTable(w io.Writer, isTTY bool, codebase string, result *portal.SCAFin return err } if _, err := fmt.Fprintf(w, - "(findings truncated to %d rows server-side — only --source narrows the upstream query;"+ - " --severity filters client-side and cannot recover capped rows)\n", + "(findings truncated to %d rows — narrow with --severity or --source to reduce the upstream result set)\n", scainternal.FindingsServerCap); err != nil { return err } diff --git a/pkg/cmd/sca/internal/validate.go b/pkg/cmd/sca/internal/validate.go index b2ae17e..1a42717 100644 --- a/pkg/cmd/sca/internal/validate.go +++ b/pkg/cmd/sca/internal/validate.go @@ -200,14 +200,6 @@ func InclusiveSeverities(min string) []string { return out } -// SeverityMatches returns true if severity is in the allowed set. Empty allowed -// means "no filter" — callers expected to short-circuit before calling. -// Severity is normalised to upper-case so upstream casing variations don't -// silently drop rows (allowed is always canonical upper-case). -func SeverityMatches(severity string, allowed []string) bool { - return slices.Contains(allowed, strings.ToUpper(severity)) -} - // severityRank ranks Dep-Track severities ascending (UNASSIGNED=0 is least // severe). Used by InclusiveFromSet to pick the lowest-rank threshold. var severityRank = map[string]int{ diff --git a/pkg/cmd/sca/internal/validate_test.go b/pkg/cmd/sca/internal/validate_test.go index ef985cf..b70bbda 100644 --- a/pkg/cmd/sca/internal/validate_test.go +++ b/pkg/cmd/sca/internal/validate_test.go @@ -185,19 +185,6 @@ func TestInclusiveFromSet(t *testing.T) { } } -func TestSeverityMatches(t *testing.T) { - t.Parallel() - if !SeverityMatches("CRITICAL", []string{"CRITICAL", "HIGH"}) { - t.Error("CRITICAL must match") - } - if SeverityMatches("LOW", []string{"CRITICAL", "HIGH"}) { - t.Error("LOW must not match") - } - if SeverityMatches("CRITICAL", nil) { - t.Error("empty allowed must not match") - } -} - func TestConstants(t *testing.T) { t.Parallel() if MaxPageSize != 500 {