Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion internal/cmdutil/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import (
"github.com/KubeRocketCI/cli/internal/token"
)

// DefaultHTTPTimeout caps every portal REST request. Must exceed the portal's
// own 30s request timeout so its 408 or truncated:true response arrives before
// the client aborts as a transport error.
const DefaultHTTPTimeout = 45 * time.Second

// Factory holds lazy-func dependencies shared across all CLI commands.
// Each func is memoized: the first call resolves the dependency; subsequent calls
// return the cached result instantly.
Expand Down Expand Up @@ -112,7 +117,7 @@ func New() *Factory {
return
}

httpClient := &http.Client{Timeout: 30 * time.Second}
httpClient := &http.Client{Timeout: DefaultHTTPTimeout}

bearerAuth := func(ctx context.Context, req *http.Request) error {
tok, err := tp.GetToken(ctx)
Expand Down
16 changes: 14 additions & 2 deletions internal/portal/openapi/spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -1707,6 +1707,14 @@
"false"
]
}
},
{
"in": "query",
"name": "severity",
"description": "Comma-separated canonical Dep-Track severity set (CRITICAL,HIGH,MEDIUM,LOW,INFO,UNASSIGNED). Filter applied server-side across all components in the project before paginating. When set, response may include truncated=true if the auto-paging safety cap is reached.",
"schema": {
"type": "string"
}
}
],
"responses": {
Expand Down Expand Up @@ -2716,12 +2724,16 @@
},
"totalCount": {
"type": "integer"
},
"truncated": {
"type": "boolean"
}
},
"required": [
"status",
"items",
"totalCount"
"totalCount",
"truncated"
]
},
"SCAFindingComponent": {
Expand Down Expand Up @@ -3214,4 +3226,4 @@
}
}
}
}
}
20 changes: 20 additions & 0 deletions internal/portal/restapi/api_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions internal/portal/sca.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,13 @@ type SCAComponent struct {
}

// SCAComponentList is the response for `krci sca components <codebase>`.
// Truncated signals that items and totalCount reflect an incomplete view
// because the Portal could not examine every dependency before paginating.
type SCAComponentList struct {
Status SCAStatus `json:"status"`
Items []SCAComponent `json:"items"`
TotalCount int `json:"totalCount"`
Truncated bool `json:"truncated"`
}

// SCAFindingComponent is the component side of one finding row.
Expand Down Expand Up @@ -138,13 +141,16 @@ 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
Page int
PageSize int
OnlyOutdated bool
OnlyDirect bool
Severity []string
}

// SCAFindingsParams carries the CLI-validated inputs for `krci sca findings`.
Expand Down Expand Up @@ -327,6 +333,9 @@ func (s *SCAService) Components(ctx context.Context, params SCAComponentsParams)
}
p.OnlyOutdated = scaComponentsOnlyOutdated(params.OnlyOutdated)
p.OnlyDirect = scaComponentsOnlyDirect(params.OnlyDirect)
if len(params.Severity) > 0 {
p.Severity = ptr.To(strings.Join(params.Severity, ","))
}

resp, err := s.client.ScaComponentsWithResponse(ctx, p)
if err != nil {
Expand Down Expand Up @@ -439,6 +448,7 @@ func mapSCAComponentList(src *restapi.SCAComponentsResponse) *SCAComponentList {
Status: SCAStatus(src.Status),
Items: make([]SCAComponent, 0, len(src.Items)),
TotalCount: src.TotalCount,
Truncated: src.Truncated,
}
for i := range src.Items {
out.Items = append(out.Items, mapSCAComponent(&src.Items[i]))
Expand Down
50 changes: 50 additions & 0 deletions internal/portal/sca_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,9 @@ func TestSCAService_Components_OmitsOptionalFiltersWhenFalse(t *testing.T) {
if q.Get("onlyOutdated") != "" || q.Get("onlyDirect") != "" {
t.Fatalf("filters should be omitted when false, got %s", r.URL.RawQuery)
}
if _, ok := q["severity"]; ok {
t.Fatalf("severity should be omitted when not set, got %s", r.URL.RawQuery)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"OK","items":[],"totalCount":0}`))
}
Expand All @@ -330,6 +333,53 @@ func TestSCAService_Components_OmitsOptionalFiltersWhenFalse(t *testing.T) {
}
}

func TestSCAService_Components_SendsSeverityCSVWhenProvided(t *testing.T) {
t.Parallel()

handler := func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if got := q.Get("severity"); got != "CRITICAL,HIGH" {
t.Fatalf("expected severity=CRITICAL,HIGH; got %q (raw=%s)", got, r.URL.RawQuery)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"OK","items":[],"totalCount":0}`))
}

client, closer := newTestClient(t, handler)
defer closer()

_, err := NewSCAService(client).Components(context.Background(), SCAComponentsParams{
Codebase: testCodebase,
Severity: []string{"CRITICAL", "HIGH"},
})
if err != nil {
t.Fatalf("Components error: %v", err)
}
}

func TestSCAService_Components_TruncatedFieldPropagates(t *testing.T) {
t.Parallel()

handler := func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"OK","items":[],"totalCount":0,"truncated":true}`))
}

client, closer := newTestClient(t, handler)
defer closer()

got, err := NewSCAService(client).Components(context.Background(), SCAComponentsParams{
Codebase: testCodebase,
Severity: []string{"HIGH"},
})
if err != nil {
t.Fatalf("Components error: %v", err)
}
if !got.Truncated {
t.Errorf("expected Truncated=true; got %+v", got)
}
}

func TestSCAService_Components_NONE_InitialisesItemsSlice(t *testing.T) {
t.Parallel()

Expand Down
67 changes: 14 additions & 53 deletions pkg/cmd/sca/components/components.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"context"
"fmt"
"io"
"strings"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -83,8 +82,7 @@ func NewCmdComponents(f *cmdutil.Factory, runF func(*ComponentsOptions) error) *
cmd.Flags().StringVar(&opts.Branch, "branch", "", scainternal.BranchFlagUsage)
cmd.Flags().StringSliceVar(&opts.Severity, "severity", nil,
scainternal.SeverityFlagUsage+
" Applied client-side to the fetched page only; "+
"increase --page-size to examine more components.")
" Applied server-side across all dependencies of the project.")
cmd.Flags().BoolVar(&opts.OnlyOutdated, "only-outdated", false, "Only components marked outdated by Dependency-Track")
cmd.Flags().BoolVar(&opts.OnlyDirect, "only-direct", false, "Only direct (non-transitive) dependencies")
cmd.Flags().IntVar(&opts.Page, "page", 1, "Page index (1-based)")
Expand All @@ -108,64 +106,17 @@ func componentsRun(ctx context.Context, opts *ComponentsOptions) error {
PageSize: opts.PageSize,
OnlyOutdated: opts.OnlyOutdated,
OnlyDirect: opts.OnlyDirect,
Severity: scainternal.ExpandSeverityFlag(opts.Severity),
})
if err != nil {
return scainternal.HandleError(opts.IO, opts.OutputFormat, err)
}

if inclusive := scainternal.ExpandSeverityFlag(opts.Severity); len(inclusive) > 0 {
filtered := applySeverityFilter(result.Items, inclusive)
result.Items = filtered
// The server returns a TotalCount across the unfiltered page; once we
// narrow client-side, both the JSON envelope and the table footer must
// reflect the visible row count or the "page X of Y" line lies.
result.TotalCount = len(filtered)
}

return scainternal.Render(opts.IO, opts.OutputFormat, result, func(w io.Writer, isTTY bool) error {
return renderTable(w, isTTY, opts.Codebase, opts.Page, opts.PageSize, result)
})
}

// applySeverityFilter keeps only components whose metrics contain at least
// one vulnerability in the allowed severity set. Empty allowed means no
// filter. Client-side because Dep-Track's /component/project endpoint does
// not filter by severity.
func applySeverityFilter(items []portal.SCAComponent, allowed []string) []portal.SCAComponent {
if len(allowed) == 0 {
return items
}
out := make([]portal.SCAComponent, 0, len(items))
for _, c := range items {
if c.Metrics == nil {
continue
}
if scainternal.ComponentMatchesSeverity(func(sev string) int {
return metricsCountFor(c.Metrics, sev)
}, allowed) {
out = append(out, c)
}
}
return out
}

func metricsCountFor(m *portal.SCAMetrics, severity string) int {
switch strings.ToUpper(severity) {
case "CRITICAL":
return m.Critical
case "HIGH":
return m.High
case "MEDIUM":
return m.Medium
case "LOW":
return m.Low
case "INFO", "UNASSIGNED":
// Dep-Track folds these together in its component metrics.
return m.Unassigned
}
return 0
}

func renderTable(
w io.Writer, isTTY bool, codebase string, page, pageSize int, result *portal.SCAComponentList,
) error {
Expand Down Expand Up @@ -197,6 +148,16 @@ func renderTable(
return err
}

_, err := fmt.Fprintln(w, scainternal.PageFooter("component", result.TotalCount, page, pageSize))
return err
if _, err := fmt.Fprintln(w, scainternal.PageFooter("component", result.TotalCount, page, pageSize)); err != nil {
return err
}

if result.Truncated {
if _, err := fmt.Fprintln(w,
"(results truncated — not all dependencies examined; try --only-direct to narrow scope)"); err != nil {
return err
}
}

return nil
}
Loading
Loading