Skip to content

Commit e3ec581

Browse files
tommytroensindrerh2
andcommitted
feat: rpc and db query for summary history
Co-authored-by: sindrerh2 <[email protected]>
1 parent 3926021 commit e3ec581

File tree

9 files changed

+1231
-488
lines changed

9 files changed

+1231
-488
lines changed

cmd/cli/main.go

+121-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"google.golang.org/grpc/credentials"
1919
"google.golang.org/grpc/credentials/insecure"
2020
"os"
21+
"strconv"
2122
"strings"
2223
"time"
2324
)
@@ -34,6 +35,7 @@ type options struct {
3435
workload string
3536
limit int64
3637
order string
38+
since string
3739
}
3840

3941
func main() {
@@ -89,7 +91,8 @@ func main() {
8991
Action: func(ctx context.Context, cmd *cli.Command) error {
9092
return listVulnz(ctx, cmd, c, opts)
9193
},
92-
}, {
94+
},
95+
{
9396
Name: "summary",
9497
Aliases: []string{"s"},
9598
Usage: "list vulnerability summary for filter",
@@ -98,6 +101,15 @@ func main() {
98101
return listSummaries(ctx, cmd, c, opts)
99102
},
100103
},
104+
{
105+
Name: "history",
106+
Aliases: []string{"h"},
107+
Usage: "list vulnerability summary history for filter",
108+
Flags: commonFlags(opts),
109+
Action: func(ctx context.Context, cmd *cli.Command) error {
110+
return listSummaryHistory(ctx, cmd, c, opts)
111+
},
112+
},
101113
},
102114
},
103115
{
@@ -309,6 +321,107 @@ func listSummaries(ctx context.Context, cmd *cli.Command, c vulnerabilities.Clie
309321
return nil
310322
}
311323

324+
// convertDuration converts a duration string with Y (years), M (months), W (weeks), D (days) into hours (h).
325+
func convertDuration(duration string) (string, error) {
326+
multipliers := map[string]int{
327+
"Y": 365 * 24, // 1 year = 8760 hours
328+
"M": 30 * 24, // 1 month = 30 days = 30 * 24 hours
329+
"W": 7 * 24, // 1 week = 7 days = 7 * 24 hours
330+
"D": 24, // 1 day = 24 hours
331+
}
332+
333+
for unit, multiplier := range multipliers {
334+
if strings.HasSuffix(duration, unit) {
335+
trimmed, _ := strings.CutSuffix(duration, unit)
336+
num, err := strconv.Atoi(trimmed)
337+
if err != nil {
338+
return "", fmt.Errorf("invalid duration: %s", duration)
339+
}
340+
return strconv.Itoa(num*multiplier) + "h", nil
341+
}
342+
}
343+
344+
// If no recognized suffix, return as-is
345+
return duration, nil
346+
}
347+
348+
func listSummaryHistory(ctx context.Context, cmd *cli.Command, c vulnerabilities.Client, o *options) error {
349+
offset := 0
350+
s, err := convertDuration(o.since)
351+
if err != nil {
352+
return err
353+
}
354+
duration, err := time.ParseDuration(s)
355+
if err != nil {
356+
return fmt.Errorf("invalid duration: %s", o.since)
357+
}
358+
// Compute past timestamp
359+
sinceTime := time.Now().Add(-duration)
360+
361+
for {
362+
opts := parseOptions(cmd, o)
363+
opts = append(opts, vulnerabilities.Offset(int32(offset)))
364+
365+
start := time.Now()
366+
resp, err := c.ListVulnerabilitySummaryHistory(ctx, sinceTime, opts...)
367+
if err != nil {
368+
return err
369+
}
370+
371+
headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc()
372+
columnFmt := color.New(color.FgYellow).SprintfFunc()
373+
374+
tbl := table.New("Workload", "Cluster", "Namespace", "Has SBOM", "Critical", "High", "Medium", "Low", "Unassigned", "RiskScore", "LastUpdated")
375+
tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt)
376+
377+
for _, n := range resp.GetNodes() {
378+
tbl.AddRow(
379+
// kills the layout
380+
// n.Workload.GetImageName()+":"+n.GetWorkload().GetImageTag(),
381+
n.Workload.GetName(),
382+
n.Workload.GetCluster(),
383+
n.Workload.GetNamespace(),
384+
n.GetVulnerabilitySummary().GetHasSbom(),
385+
n.GetVulnerabilitySummary().GetCritical(),
386+
n.GetVulnerabilitySummary().GetHigh(),
387+
n.GetVulnerabilitySummary().GetMedium(),
388+
n.GetVulnerabilitySummary().GetLow(),
389+
n.GetVulnerabilitySummary().GetUnassigned(),
390+
n.GetVulnerabilitySummary().GetRiskScore(),
391+
n.GetVulnerabilitySummary().GetLastUpdated().AsTime().Format(time.RFC3339),
392+
)
393+
}
394+
395+
tbl.Print()
396+
numFetched := offset + int(o.limit)
397+
if numFetched > int(resp.PageInfo.TotalCount) {
398+
numFetched = int(resp.PageInfo.TotalCount)
399+
}
400+
fmt.Printf("Fetched %d of total '%d' summaries in %f seconds.\n", numFetched, resp.PageInfo.TotalCount, time.Since(start).Seconds())
401+
402+
// Check if there is another page
403+
if !resp.GetPageInfo().GetHasNextPage() {
404+
fmt.Printf("No more pages available.\n")
405+
break
406+
}
407+
408+
// Ask user for input to continue pagination
409+
fmt.Println("Press 'n' for next page, 'q' to quit:")
410+
reader := bufio.NewReader(os.Stdin)
411+
input, _ := reader.ReadString('\n')
412+
input = strings.TrimSpace(input)
413+
414+
if input == "q" {
415+
break
416+
} else if input == "n" {
417+
offset += int(o.limit)
418+
} else {
419+
fmt.Println("Invalid input. Use 'n' for next page or 'q' to quit.")
420+
}
421+
}
422+
return nil
423+
}
424+
312425
func listVulnz(ctx context.Context, cmd *cli.Command, c vulnerabilities.Client, o *options) error {
313426
offset := 0
314427
for {
@@ -473,6 +586,13 @@ func commonFlags(opts *options, excludes ...string) []cli.Flag {
473586
Usage: "order by field, use 'field:desc' for descending order",
474587
Destination: &opts.order,
475588
},
589+
&cli.StringFlag{
590+
Name: "since",
591+
Aliases: []string{"s"},
592+
Value: "24h",
593+
Usage: "Specify a relative time (e.g. '1Y' for last year, '1M' for last month, '2D' for last 2 days, '12h' for last 12 hours, '30m' for last 30 minutes)",
594+
Destination: &opts.since,
595+
},
476596
}
477597
for _, f := range cFlags {
478598
exclude := false

internal/api/grpcvulnerabilities/summary.go

+84-4
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ import (
55
"errors"
66
"fmt"
77
"github.com/jackc/pgx/v5"
8+
"github.com/jackc/pgx/v5/pgtype"
89
"github.com/nais/v13s/internal/api/grpcpagination"
910
"github.com/nais/v13s/internal/collections"
1011
"github.com/nais/v13s/internal/database/sql"
1112
"github.com/nais/v13s/pkg/api/vulnerabilities"
1213
"google.golang.org/protobuf/types/known/timestamppb"
1314
)
1415

15-
// TODO: do we want image_name and image_tag as filter aswell? must update sql query
1616
func (s *Server) ListVulnerabilitySummaries(ctx context.Context, request *vulnerabilities.ListVulnerabilitySummariesRequest) (*vulnerabilities.ListVulnerabilitySummariesResponse, error) {
1717
limit, offset, err := grpcpagination.Pagination(request)
1818
if err != nil {
@@ -55,24 +55,104 @@ func (s *Server) ListVulnerabilitySummaries(ctx context.Context, request *vulner
5555
Low: safeInt(row.Low),
5656
Unassigned: safeInt(row.Unassigned),
5757
RiskScore: safeInt(row.RiskScore),
58-
LastUpdated: timestamppb.New(row.VulnerabilityUpdatedAt.Time),
58+
LastUpdated: timestamppb.New(row.SummaryUpdatedAt.Time),
5959
HasSbom: row.HasSbom,
6060
},
6161
}
6262
})
6363

64-
pageInfo, err := grpcpagination.PageInfo(request, len(ws))
64+
total, err := s.querier.CountVulnerabilitySummaries(ctx, sql.CountVulnerabilitySummariesParams{
65+
Cluster: request.GetFilter().Cluster,
66+
Namespace: request.GetFilter().Namespace,
67+
WorkloadType: request.GetFilter().FuzzyWorkloadType(),
68+
WorkloadName: request.GetFilter().Workload,
69+
})
6570
if err != nil {
66-
return nil, err
71+
return nil, fmt.Errorf("failed to count summaries: %w", err)
6772
}
6873

74+
pageInfo, err := grpcpagination.PageInfo(request, int(total))
75+
if err != nil {
76+
return nil, err
77+
}
6978
response := &vulnerabilities.ListVulnerabilitySummariesResponse{
7079
Nodes: ws,
7180
PageInfo: pageInfo,
7281
}
7382
return response, nil
7483
}
7584

85+
func (s *Server) ListVulnerabilitySummaryHistory(ctx context.Context, request *vulnerabilities.ListVulnerabilitySummaryHistoryRequest) (*vulnerabilities.ListVulnerabilitySummaryHistoryResponse, error) {
86+
limit, offset, err := grpcpagination.Pagination(request)
87+
if err != nil {
88+
return nil, err
89+
}
90+
91+
if request.GetFilter() == nil {
92+
request.Filter = &vulnerabilities.Filter{}
93+
}
94+
95+
summaries, err := s.querier.ListVulnerabilitySummaryHistory(ctx, sql.ListVulnerabilitySummaryHistoryParams{
96+
Cluster: request.GetFilter().Cluster,
97+
Namespace: request.GetFilter().Namespace,
98+
WorkloadType: request.GetFilter().WorkloadType,
99+
WorkloadName: request.GetFilter().Workload,
100+
OrderBy: sanitizeOrderBy(request.OrderBy, vulnerabilities.OrderByCritical),
101+
Limit: limit,
102+
Offset: offset,
103+
From: pgtype.Timestamptz{Time: request.From.AsTime(), Valid: true},
104+
})
105+
if err != nil {
106+
return nil, fmt.Errorf("failed to list vulnerability summaries: %w", err)
107+
}
108+
109+
ws := collections.Map(summaries, func(row *sql.ListVulnerabilitySummaryHistoryRow) *vulnerabilities.WorkloadSummary {
110+
return &vulnerabilities.WorkloadSummary{
111+
Id: row.ID.String(),
112+
Workload: &vulnerabilities.Workload{
113+
Cluster: row.Cluster,
114+
Namespace: row.Namespace,
115+
Name: row.WorkloadName,
116+
Type: row.WorkloadType,
117+
ImageName: row.ImageName,
118+
ImageTag: row.ImageTag,
119+
},
120+
// TODO: Summary rows in the is not guaranteed to have a value, so we need to check if it's nil
121+
VulnerabilitySummary: &vulnerabilities.Summary{
122+
Critical: safeInt(row.Critical),
123+
High: safeInt(row.High),
124+
Medium: safeInt(row.Medium),
125+
Low: safeInt(row.Low),
126+
Unassigned: safeInt(row.Unassigned),
127+
RiskScore: safeInt(row.RiskScore),
128+
LastUpdated: timestamppb.New(row.SummaryUpdatedAt.Time),
129+
HasSbom: row.HasSbom,
130+
},
131+
}
132+
})
133+
134+
total, err := s.querier.CountVulnerabilitySummaryHistory(ctx, sql.CountVulnerabilitySummaryHistoryParams{
135+
Cluster: request.GetFilter().Cluster,
136+
Namespace: request.GetFilter().Namespace,
137+
WorkloadType: request.GetFilter().FuzzyWorkloadType(),
138+
WorkloadName: request.GetFilter().Workload,
139+
})
140+
if err != nil {
141+
return nil, fmt.Errorf("failed to count summaries: %w", err)
142+
}
143+
144+
pageInfo, err := grpcpagination.PageInfo(request, int(total))
145+
if err != nil {
146+
return nil, err
147+
}
148+
149+
response := &vulnerabilities.ListVulnerabilitySummaryHistoryResponse{
150+
Nodes: ws,
151+
PageInfo: pageInfo,
152+
}
153+
return response, nil
154+
}
155+
76156
// TODO: if no summaries are found, handle this case by not returning the summary? and maybe handle it in the sql query, right now we return 0 on all fields
77157
// TLDR: make distinction between no summary found and summary found with 0 values
78158
func (s *Server) GetVulnerabilitySummary(ctx context.Context, request *vulnerabilities.GetVulnerabilitySummaryRequest) (*vulnerabilities.GetVulnerabilitySummaryResponse, error) {

internal/database/queries/vulnerbility_summary.sql

+87-2
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,8 @@ SELECT
8383
v.risk_score,
8484
w.created_at AS workload_created_at,
8585
w.updated_at AS workload_updated_at,
86-
v.created_at AS vulnerability_created_at,
87-
v.updated_at AS vulnerability_updated_at,
86+
v.created_at AS summary_created_at,
87+
v.updated_at AS summary_updated_at,
8888
CASE WHEN v.image_name IS NOT NULL THEN TRUE ELSE FALSE END AS has_sbom
8989
FROM workloads w
9090
LEFT JOIN vulnerability_summary v
@@ -122,6 +122,91 @@ OFFSET
122122
sqlc.arg('offset')
123123
;
124124

125+
-- name: CountVulnerabilitySummaries :one
126+
SELECT COUNT(*) AS total
127+
FROM workloads w
128+
LEFT JOIN vulnerability_summary v
129+
ON w.image_name = v.image_name AND w.image_tag = v.image_tag
130+
WHERE
131+
(CASE WHEN sqlc.narg('cluster')::TEXT is not null THEN w.cluster = sqlc.narg('cluster')::TEXT ELSE TRUE END)
132+
AND (CASE WHEN sqlc.narg('namespace')::TEXT is not null THEN w.namespace = sqlc.narg('namespace')::TEXT ELSE TRUE END)
133+
AND (CASE WHEN sqlc.narg('workload_type')::TEXT is not null THEN w.workload_type = sqlc.narg('workload_type')::TEXT ELSE TRUE END)
134+
AND (CASE WHEN sqlc.narg('workload_name')::TEXT is not null THEN w.name = sqlc.narg('workload_name')::TEXT ELSE TRUE END)
135+
AND (CASE WHEN sqlc.narg('image_name')::TEXT is not null THEN v.image_name = sqlc.narg('image_name')::TEXT ELSE TRUE END)
136+
AND (CASE WHEN sqlc.narg('image_tag')::TEXT is not null THEN v.image_tag = sqlc.narg('image_tag')::TEXT ELSE TRUE END)
137+
;
138+
139+
-- name: ListVulnerabilitySummaryHistory :many
140+
SELECT
141+
w.id,
142+
w.name AS workload_name,
143+
w.workload_type,
144+
w.namespace,
145+
w.cluster,
146+
w.image_name,
147+
w.image_tag,
148+
v.critical,
149+
v.high,
150+
v.medium,
151+
v.low,
152+
v.unassigned,
153+
v.risk_score,
154+
w.created_at AS workload_created_at,
155+
w.updated_at AS workload_updated_at,
156+
v.created_at AS summary_created_at,
157+
v.updated_at AS summary_updated_at,
158+
CASE WHEN v.image_name IS NOT NULL THEN TRUE ELSE FALSE END AS has_sbom
159+
FROM workloads w
160+
LEFT JOIN vulnerability_summary v
161+
ON w.image_name = v.image_name
162+
WHERE
163+
v.updated_at > sqlc.arg('from')::TIMESTAMP WITH TIME ZONE
164+
AND (CASE WHEN sqlc.narg('cluster')::TEXT is not null THEN w.cluster = sqlc.narg('cluster')::TEXT ELSE TRUE END)
165+
AND (CASE WHEN sqlc.narg('namespace')::TEXT is not null THEN w.namespace = sqlc.narg('namespace')::TEXT ELSE TRUE END)
166+
AND (CASE WHEN sqlc.narg('workload_type')::TEXT is not null THEN w.workload_type = sqlc.narg('workload_type')::TEXT ELSE TRUE END)
167+
AND (CASE WHEN sqlc.narg('workload_name')::TEXT is not null THEN w.name = sqlc.narg('workload_name')::TEXT ELSE TRUE END)
168+
AND (CASE WHEN sqlc.narg('image_name')::TEXT is not null THEN v.image_name = sqlc.narg('image_name')::TEXT ELSE TRUE END)
169+
AND (CASE WHEN sqlc.narg('image_tag')::TEXT is not null THEN v.image_tag = sqlc.narg('image_tag')::TEXT ELSE TRUE END)
170+
ORDER BY
171+
CASE WHEN sqlc.narg('order_by') = 'workload_asc' THEN w.name END ASC,
172+
CASE WHEN sqlc.narg('order_by') = 'workload_desc' THEN w.name END DESC,
173+
CASE WHEN sqlc.narg('order_by') = 'namespace_asc' THEN namespace END ASC,
174+
CASE WHEN sqlc.narg('order_by') = 'namespace_desc' THEN namespace END DESC,
175+
CASE WHEN sqlc.narg('order_by') = 'cluster_asc' THEN cluster END ASC,
176+
CASE WHEN sqlc.narg('order_by') = 'cluster_desc' THEN cluster END DESC,
177+
CASE WHEN sqlc.narg('order_by') = 'critical_asc' THEN v.critical END ASC,
178+
CASE WHEN sqlc.narg('order_by') = 'critical_desc' THEN v.critical END DESC,
179+
CASE WHEN sqlc.narg('order_by') = 'high_asc' THEN v.high END ASC,
180+
CASE WHEN sqlc.narg('order_by') = 'high_desc' THEN v.high END DESC,
181+
CASE WHEN sqlc.narg('order_by') = 'medium_asc' THEN v.medium END ASC,
182+
CASE WHEN sqlc.narg('order_by') = 'medium_desc' THEN v.medium END DESC,
183+
CASE WHEN sqlc.narg('order_by') = 'low_asc' THEN v.low END ASC,
184+
CASE WHEN sqlc.narg('order_by') = 'low_desc' THEN v.low END DESC,
185+
CASE WHEN sqlc.narg('order_by') = 'unassigned_asc' THEN v.unassigned END ASC,
186+
CASE WHEN sqlc.narg('order_by') = 'unassigned_desc' THEN v.unassigned END DESC,
187+
CASE WHEN sqlc.narg('order_by') = 'risk_score_asc' THEN v.risk_score END ASC,
188+
CASE WHEN sqlc.narg('order_by') = 'risk_score_desc' THEN v.risk_score END DESC,
189+
v.updated_at DESC, v.id DESC
190+
LIMIT
191+
sqlc.arg('limit')
192+
OFFSET
193+
sqlc.arg('offset')
194+
;
195+
196+
-- name: CountVulnerabilitySummaryHistory :one
197+
SELECT COUNT(*) AS total
198+
FROM workloads w
199+
LEFT JOIN vulnerability_summary v
200+
ON w.image_name = v.image_name
201+
WHERE
202+
(CASE WHEN sqlc.narg('cluster')::TEXT is not null THEN w.cluster = sqlc.narg('cluster')::TEXT ELSE TRUE END)
203+
AND (CASE WHEN sqlc.narg('namespace')::TEXT is not null THEN w.namespace = sqlc.narg('namespace')::TEXT ELSE TRUE END)
204+
AND (CASE WHEN sqlc.narg('workload_type')::TEXT is not null THEN w.workload_type = sqlc.narg('workload_type')::TEXT ELSE TRUE END)
205+
AND (CASE WHEN sqlc.narg('workload_name')::TEXT is not null THEN w.name = sqlc.narg('workload_name')::TEXT ELSE TRUE END)
206+
AND (CASE WHEN sqlc.narg('image_name')::TEXT is not null THEN v.image_name = sqlc.narg('image_name')::TEXT ELSE TRUE END)
207+
AND (CASE WHEN sqlc.narg('image_tag')::TEXT is not null THEN v.image_tag = sqlc.narg('image_tag')::TEXT ELSE TRUE END)
208+
;
209+
125210
-- name: GetVulnerabilitySummary :one
126211
WITH filtered_workloads AS (
127212
SELECT w.id, w.image_name, w.image_tag

0 commit comments

Comments
 (0)