Skip to content
Draft
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
485 changes: 476 additions & 9 deletions internal/naisapi/gql/generated.go

Large diffs are not rendered by default.

109 changes: 109 additions & 0 deletions internal/vulnerability/catalog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package vulnerability

import (
"context"
"fmt"

"github.com/Khan/genqlient/graphql"
"github.com/nais/cli/internal/naisapi"
"github.com/nais/cli/internal/naisapi/gql"
)

type CatalogCVE struct {
Identifier string `json:"identifier"`
Title string `json:"title"`
Severity string `json:"severity"`
Priority string `json:"priority"`
CVSSScore *float64 `json:"cvss_score"`
EPSSScore *float64 `json:"epss_score"`
EPSSPercentile *float64 `json:"epss_percentile"`
HasKEVEntry bool `json:"has_kev_entry"`
KnownRansomwareUse bool `json:"known_ransomware_use"`
AffectedWorkloads int `json:"affected_workloads"`
DetailsLink string `json:"details_link"`
}

func ListCatalogCVEs(ctx context.Context, priority *gql.CVEPriority, order gql.CVEOrder, limit int) ([]CatalogCVE, error) {
_ = `# @genqlient
query ListCatalogCVEs($first: Int!, $after: Cursor, $filter: CVEFilter, $order: CVEOrder) {
cves(first: $first, after: $after, filter: $filter, orderBy: $order) {
pageInfo { hasNextPage endCursor }
nodes {
identifier
title
severity
detailsLink
riskAssessment {
priority
# @genqlient(pointer: true)
cvssScore
# @genqlient(pointer: true)
epssScore
# @genqlient(pointer: true)
epssPercentile
hasKevEntry
knownRansomwareUse
}
workloads(first: 1) { pageInfo { totalCount } }
}
}
}
`

if limit < 0 {
return nil, fmt.Errorf("CVE limit must be zero or greater")
}
client, err := naisapi.GraphqlClient(ctx)
if err != nil {
return nil, err
}
return listCatalogCVEs(ctx, client, priority, order, limit)
}

func listCatalogCVEs(ctx context.Context, client graphql.Client, priority *gql.CVEPriority, order gql.CVEOrder, limit int) ([]CatalogCVE, error) {
if limit < 0 {
return nil, fmt.Errorf("CVE limit must be zero or greater")
}
var filter *gql.CVEFilter
if priority != nil {
filter = &gql.CVEFilter{Priority: priority}
}
ret := make([]CatalogCVE, 0)
var after *string
for {
first := 100
if limit > 0 && limit-len(ret) < first {
first = limit - len(ret)
}
resp, err := gql.ListCatalogCVEs(ctx, client, first, after, filter, &order)
if err != nil {
return nil, fmt.Errorf("listing CVEs: %w", err)
}
for _, cve := range resp.Cves.Nodes {
ret = append(ret, CatalogCVE{
Identifier: cve.Identifier,
Title: cve.Title,
Severity: string(cve.Severity),
Priority: string(cve.RiskAssessment.Priority),
CVSSScore: cve.RiskAssessment.CvssScore,
EPSSScore: cve.RiskAssessment.EpssScore,
EPSSPercentile: cve.RiskAssessment.EpssPercentile,
HasKEVEntry: cve.RiskAssessment.HasKevEntry,
KnownRansomwareUse: cve.RiskAssessment.KnownRansomwareUse,
AffectedWorkloads: cve.Workloads.PageInfo.TotalCount,
DetailsLink: cve.DetailsLink,
})
}
if limit > 0 && len(ret) >= limit {
return ret, nil
}
page := resp.Cves.PageInfo
if !page.HasNextPage {
return ret, nil
}
if page.EndCursor == nil || *page.EndCursor == "" || (after != nil && *page.EndCursor == *after) {
return nil, fmt.Errorf("listing CVEs: missing next cursor")
}
after = page.EndCursor
}
}
132 changes: 132 additions & 0 deletions internal/vulnerability/catalog_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package vulnerability

import (
"context"
"encoding/json"
"fmt"
"testing"

"github.com/Khan/genqlient/graphql"
"github.com/nais/cli/internal/naisapi/gql"
)

type catalogClientFunc func(context.Context, *graphql.Request, *graphql.Response) error

func (f catalogClientFunc) MakeRequest(ctx context.Context, req *graphql.Request, resp *graphql.Response) error {
return f(ctx, req, resp)
}

func TestListCatalogCVEsPaginatesWithFilterAndOrder(t *testing.T) {
requests := 0
client := catalogClientFunc(func(_ context.Context, req *graphql.Request, resp *graphql.Response) error {
var vars struct {
After *string `json:"after"`
Filter struct {
Priority string `json:"priority"`
} `json:"filter"`
Order struct {
Field string `json:"field"`
Direction string `json:"direction"`
} `json:"order"`
}
data, err := json.Marshal(req.Variables)
if err != nil {
return err
}
if err := json.Unmarshal(data, &vars); err != nil {
return err
}
if vars.Filter.Priority != "HIGH" || vars.Order.Field != "AFFECTED_WORKLOADS_COUNT" || vars.Order.Direction != "DESC" {
t.Errorf("variables = %+v; want HIGH sorted by affected workload count descending", vars)
}
if requests == 1 && (vars.After == nil || *vars.After != "next") {
t.Errorf("after = %v; want next", vars.After)
}
requests++
data, err = json.Marshal(map[string]any{"cves": map[string]any{
"pageInfo": map[string]any{"hasNextPage": requests == 1, "endCursor": "next"},
"nodes": []any{map[string]any{
"identifier": fmt.Sprintf("CVE-2026-%04d", requests),
"severity": "HIGH", "title": "test", "detailsLink": "https://example.org/cve",
"riskAssessment": map[string]any{"priority": "HIGH", "hasKevEntry": requests == 1, "knownRansomwareUse": false},
"workloads": map[string]any{"pageInfo": map[string]any{"totalCount": requests + 2}},
}},
}})
if err != nil {
return err
}
return json.Unmarshal(data, resp.Data)
})
priority := gql.CVEPriorityHigh
cves, err := listCatalogCVEs(context.Background(), client, &priority, gql.CVEOrder{
Field: gql.CVEOrderFieldAffectedWorkloadsCount, Direction: gql.OrderDirectionDesc,
}, 0)
if err != nil {
t.Fatal(err)
}
if requests != 2 || len(cves) != 2 || cves[0].AffectedWorkloads != 3 || !cves[0].HasKEVEntry ||
cves[1].AffectedWorkloads != 4 || cves[1].Identifier != "CVE-2026-0002" {
t.Errorf("requests = %d, CVEs = %+v; want two pages with counts and signals", requests, cves)
}
}

func TestListCatalogCVEsRejectsMissingCursor(t *testing.T) {
client := catalogClientFunc(func(_ context.Context, _ *graphql.Request, resp *graphql.Response) error {
data, err := json.Marshal(map[string]any{"cves": map[string]any{
"pageInfo": map[string]any{"hasNextPage": true}, "nodes": []any{},
}})
if err != nil {
return err
}
return json.Unmarshal(data, resp.Data)
})
cves, err := listCatalogCVEs(context.Background(), client, nil, gql.CVEOrder{
Field: gql.CVEOrderFieldPriority, Direction: gql.OrderDirectionAsc,
}, 0)
if err == nil || cves != nil {
t.Errorf("got CVEs %v, error %v; want a cursor error and no partial result", cves, err)
}
}

func TestListCatalogCVEsStopsAtLimit(t *testing.T) {
requests := 0
client := catalogClientFunc(func(_ context.Context, req *graphql.Request, resp *graphql.Response) error {
var vars struct {
First int `json:"first"`
After *string `json:"after"`
}
body, err := json.Marshal(req.Variables)
if err != nil {
return err
}
if err := json.Unmarshal(body, &vars); err != nil {
return err
}
if requests >= 2 || (requests == 0 && (vars.First != 100 || vars.After != nil)) ||
(requests == 1 && (vars.First != 1 || vars.After == nil || *vars.After != "next")) {
t.Errorf("unexpected catalog page %d: %+v", requests, vars)
}
nodes := make([]map[string]any, vars.First)
for i := range nodes {
nodes[i] = map[string]any{
"identifier": fmt.Sprintf("CVE-2026-%04d", requests*100+i+1),
"riskAssessment": map[string]any{"priority": "HIGH"},
"workloads": map[string]any{"pageInfo": map[string]any{"totalCount": 1}},
}
}
requests++
body, err = json.Marshal(map[string]any{"cves": map[string]any{
"pageInfo": map[string]any{"hasNextPage": true, "endCursor": "next"}, "nodes": nodes,
}})
if err != nil {
return err
}
return json.Unmarshal(body, resp.Data)
})
cves, err := listCatalogCVEs(context.Background(), client, nil, gql.CVEOrder{
Field: gql.CVEOrderFieldPriority, Direction: gql.OrderDirectionAsc,
}, 101)
if err != nil || requests != 2 || len(cves) != 101 || cves[100].Identifier != "CVE-2026-0101" {
t.Fatalf("got %d CVEs in %d requests, error %v; want 101 CVEs in two requests", len(cves), requests, err)
}
}
1 change: 1 addition & 0 deletions internal/vulnerability/command/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func Vulnerabilities(parentFlags *flags.GlobalFlags) *naistrix.Command {
summary(f),
list(f),
search(f),
cves(f),
},
}
}
Expand Down
47 changes: 47 additions & 0 deletions internal/vulnerability/command/completion_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package command_test

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"

"github.com/nais/cli/internal/application"
"github.com/nais/naistrix"
)

func TestCVEFlagCompletion(t *testing.T) {
for _, tt := range []struct {
flag, want string
}{
{"priority", "HIGH"},
{"sort", "AFFECTED_WORKLOADS_COUNT"},
{"direction", "DESC"},
} {
t.Run(tt.flag, func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("APPDATA", t.TempDir())
t.Setenv("NAIS_CONFIG", "")
config := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(config, []byte("{}\n"), 0o600); err != nil {
t.Fatal(err)
}
var output bytes.Buffer
app, _, err := application.New(&output)
if err != nil {
t.Fatal(err)
}
if err := app.Run(naistrix.RunWithArgs([]string{"--config", config, "--no-colors", "__complete", "vulns", "cves", "--" + tt.flag, ""})); err != nil {
t.Fatal(err)
}
for line := range strings.SplitSeq(output.String(), "\n") {
if strings.SplitN(line, "\t", 2)[0] == tt.want {
return
}
}
t.Errorf("--%s completion missing %q: %s", tt.flag, tt.want, output.String())
})
}
}
88 changes: 88 additions & 0 deletions internal/vulnerability/command/cves.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package command

import (
"context"
"fmt"
"strings"

"github.com/nais/cli/internal/naisapi/gql"
"github.com/nais/cli/internal/vulnerability"
"github.com/nais/cli/internal/vulnerability/command/flag"
"github.com/nais/naistrix"
"github.com/nais/naistrix/output"
)

func cves(parent *flag.Vulnerabilities) *naistrix.Command {
f := &flag.CVEs{Vulnerabilities: parent, Output: "table", Limit: 100, Sort: "PRIORITY", Direction: "ASC"}
return &naistrix.Command{
Name: "cves",
Title: "List CVEs affecting workloads across all teams.",
Description: "List up to 100 global CVEs by default with risk signals and affected-workload counts. Use --limit 0 for all CVEs, or 'vulns search -A <CVE_ID>' for affected workload details.",
Flags: f,
ValidateFlagsFunc: func(context.Context) error {
if f.Limit < 0 {
return fmt.Errorf("--limit must be zero or greater")
}
return nil
},
Examples: []naistrix.CommandExample{
{Description: "List high-priority CVEs by affected workload count.", Command: "--priority HIGH --sort AFFECTED_WORKLOADS_COUNT --direction DESC"},
{Description: "Export the full CVE catalog.", Command: "--limit 0 -o json"},
},
RunFunc: func(ctx context.Context, _ *naistrix.Arguments, out *naistrix.OutputWriter) error {
var priority *gql.CVEPriority
if f.Priority != "" {
p := gql.CVEPriority(strings.ToUpper(string(f.Priority)))
switch p {
case gql.CVEPriorityHigh, gql.CVEPriorityElevated, gql.CVEPriorityMonitor:
priority = &p
default:
return fmt.Errorf("invalid priority %q: choose HIGH, ELEVATED, or MONITOR (URGENT requires workload context)", f.Priority)
}
}

order := gql.CVEOrder{Field: gql.CVEOrderField(strings.ToUpper(string(f.Sort))), Direction: gql.OrderDirection(strings.ToUpper(string(f.Direction)))}
switch order.Field {
case gql.CVEOrderFieldIdentifier, gql.CVEOrderFieldSeverity, gql.CVEOrderFieldCvssScore,
gql.CVEOrderFieldAffectedWorkloadsCount, gql.CVEOrderFieldPriority:
default:
return fmt.Errorf("invalid sort %q: choose IDENTIFIER, SEVERITY, CVSS_SCORE, AFFECTED_WORKLOADS_COUNT, or PRIORITY", f.Sort)
}
if order.Direction != gql.OrderDirectionAsc && order.Direction != gql.OrderDirectionDesc {
return fmt.Errorf("invalid direction %q: choose ASC or DESC", f.Direction)
}

catalog, err := vulnerability.ListCatalogCVEs(ctx, priority, order, f.Limit)
if err != nil {
return err
}
if f.Output == "json" {
return out.JSON(output.JSONWithPrettyOutput()).Render(catalog)
}
if len(catalog) == 0 {
out.Println("No CVEs found.")
return nil
}
type row struct {
CVE output.Link `json:"cve"`
Title string `json:"title"`
Severity string `json:"severity"`
Priority string `json:"priority"`
CVSSScore any `json:"cvss_score" heading:"CVSS Score"`
KEV bool `json:"kev" heading:"KEV"`
KnownRansomware bool `json:"known_ransomware" heading:"Ransomware"`
AffectedWorkloads int `json:"affected_workloads" heading:"Workloads"`
}
rows := make([]row, 0, len(catalog))
for _, cve := range catalog {
rows = append(rows, row{
CVE: output.Link{Name: cve.Identifier, URL: cve.DetailsLink}, Title: cve.Title,
Severity: cve.Severity, Priority: cve.Priority, CVSSScore: cvssDisplay(cve.CVSSScore),
KEV: cve.HasKEVEntry, KnownRansomware: cve.KnownRansomwareUse,
AffectedWorkloads: cve.AffectedWorkloads,
})
}
return out.Table().Render(rows)
},
}
}
Loading
Loading