diff --git a/cmd/harbor/root/vulnerability/cmd.go b/cmd/harbor/root/vulnerability/cmd.go index d432c4a54..defcc4aa4 100644 --- a/cmd/harbor/root/vulnerability/cmd.go +++ b/cmd/harbor/root/vulnerability/cmd.go @@ -26,6 +26,7 @@ func Vulnerability() *cobra.Command { cmd.AddCommand( GetVulnerabilitySummaryCommand(), + ListVulnerabilitiesCommand(), ) return cmd diff --git a/cmd/harbor/root/vulnerability/list.go b/cmd/harbor/root/vulnerability/list.go new file mode 100644 index 000000000..29bdbf757 --- /dev/null +++ b/cmd/harbor/root/vulnerability/list.go @@ -0,0 +1,134 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package vulnerability + +import ( + "fmt" + + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/goharbor/harbor-cli/pkg/views" + vulnlist "github.com/goharbor/harbor-cli/pkg/views/vulnerability/list" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func ListVulnerabilitiesCommand() *cobra.Command { + var opts api.ListVulnerabilityOptions + + cmd := &cobra.Command{ + Use: "list", + Short: "List vulnerabilities in Security Hub", + Long: `List vulnerabilities from Harbor Security Hub. +Supports exporting results to a CSV file using the --output-format (-o) flag.`, + Example: `# Export results to CSV + harbor vulnerability list -o csv`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.Page < 1 { + return fmt.Errorf("page number must be greater than or equal to 1") + } + + if opts.PageSize <= 0 || opts.PageSize > 100 { + return fmt.Errorf("page size must be greater than 0 and less than or equal to 100") + } + + allVulnerabilities, hasNext, err := fetchVulnerabilities(opts) + if err != nil { + return fmt.Errorf("failed to list vulnerabilities: %v", utils.ParseHarborErrorMsg(err)) + } + + if len(allVulnerabilities) == 0 { + log.Info("No vulnerabilities found") + return nil + } + + formatFlag := viper.GetString("output-format") + if formatFlag != "" { + var csvFile string + if formatFlag == "csv" { + savePath, err := views.PromptExportPath("vulnerabilities", "csv") + if err != nil { + return err + } + csvFile = savePath + } + err = utils.PrintFormat(allVulnerabilities, formatFlag, csvFile) + if err != nil { + return err + } + } else { + vulnlist.ViewVulnerabilityList(allVulnerabilities, hasNext) + } + + return nil + }, + } + + flags := cmd.Flags() + flags.Int64VarP(&opts.Page, "page", "", 1, "Page number") + flags.Int64VarP(&opts.PageSize, "page-size", "", 10, "Size of per page") + flags.StringVarP(&opts.CVEID, "cve-id", "", "", "Filter by exact CVE ID") + flags.StringVarP(&opts.CVSSScore, "cvss-score", "", "", "Filter by CVSS v3 score range (e.g. 7.0~10.0) or exact score (e.g. 7.0)") + flags.StringVarP(&opts.Severity, "severity", "", "", "Filter by severity level") + flags.StringVarP(&opts.Repository, "repository", "", "", "Filter by exact repository name") + flags.StringVarP(&opts.ProjectName, "project-name", "", "", "Filter by exact project name") + flags.StringVarP(&opts.Package, "package", "", "", "Filter by exact package name") + flags.StringVarP(&opts.Tag, "tag", "", "", "Filter by exact artifact tag") + flags.StringVarP(&opts.Digest, "digest", "", "", "Filter by exact artifact digest") + flags.StringVarP(&opts.Exclude, "exclude", "", "", "Exclude vulnerabilities using a ',' separated query string (e.g., k=v or k=[min~max])") + flags.BoolVarP(&opts.Fixable, "fixable", "", false, "Only show fixable vulnerabilities") + flags.BoolVarP(&opts.All, "all", "", false, "Show all vulnerabilities (up to 1000)") + + return cmd +} + +func fetchVulnerabilities(opts api.ListVulnerabilityOptions) ([]*models.VulnerabilityItem, bool, error) { + if opts.All { + var allVulnerabilities []*models.VulnerabilityItem + + log.Debug("Fetching all vulnerabilities") + opts.PageSize = 100 + opts.Page = 1 + + for { + vulnerabilities, err := api.ListVulnerabilities(opts) + if err != nil { + return nil, false, err + } + + if len(vulnerabilities) == 0 { + break + } + + allVulnerabilities = append(allVulnerabilities, vulnerabilities...) + opts.Page++ + + if opts.Page > 10 { + return allVulnerabilities, true, nil + } + } + + return allVulnerabilities, false, nil + } + + vulnerabilities, err := api.ListVulnerabilities(opts) + if err != nil { + return nil, false, err + } + + return vulnerabilities, false, nil +} diff --git a/doc/cli-docs/harbor-vulnerability-list.md b/doc/cli-docs/harbor-vulnerability-list.md new file mode 100644 index 000000000..56a12b845 --- /dev/null +++ b/doc/cli-docs/harbor-vulnerability-list.md @@ -0,0 +1,57 @@ +--- +title: harbor vulnerability list +weight: 90 +--- +## harbor vulnerability list + +### Description + +##### List vulnerabilities in Security Hub + +### Synopsis + +List vulnerabilities from Harbor Security Hub. +Supports exporting results to a CSV file using the --output-format (-o) flag. + +```sh +harbor vulnerability list [flags] +``` + +### Examples + +```sh +# Export results to CSV + harbor vulnerability list -o csv +``` + +### Options + +```sh + --all Show all vulnerabilities (up to 1000) + --cve-id string Filter by exact CVE ID + --cvss-score string Filter by CVSS v3 score range (e.g. 7.0~10.0) or exact score (e.g. 7.0) + --digest string Filter by exact artifact digest + --exclude string Exclude vulnerabilities using a ',' separated query string (e.g., k=v or k=[min~max]) + --fixable Only show fixable vulnerabilities + -h, --help help for list + --package string Filter by exact package name + --page int Page number (default 1) + --page-size int Size of per page (default 10) + --project-name string Filter by exact project name + --repository string Filter by exact repository name + --severity string Filter by severity level + --tag string Filter by exact artifact tag +``` + +### Options inherited from parent commands + +```sh + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) + -o, --output-format string Output format. One of: json|yaml + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor vulnerability](harbor-vulnerability.md) - Manage vulnerabilities in Security Hub + diff --git a/doc/cli-docs/harbor-vulnerability.md b/doc/cli-docs/harbor-vulnerability.md index 3ba923b4d..695b57392 100644 --- a/doc/cli-docs/harbor-vulnerability.md +++ b/doc/cli-docs/harbor-vulnerability.md @@ -35,5 +35,6 @@ List vulnerabilities and view vulnerability summary from Harbor Security Hub ### SEE ALSO * [harbor](harbor.md) - Official Harbor CLI +* [harbor vulnerability list](harbor-vulnerability-list.md) - List vulnerabilities in Security Hub * [harbor vulnerability summary](harbor-vulnerability-summary.md) - Get Security Hub vulnerability summary diff --git a/doc/man-docs/man1/harbor-vulnerability-list.1 b/doc/man-docs/man1/harbor-vulnerability-list.1 new file mode 100644 index 000000000..17a91dd1c --- /dev/null +++ b/doc/man-docs/man1/harbor-vulnerability-list.1 @@ -0,0 +1,95 @@ +.nh +.TH "HARBOR" "1" "Harbor Community" "Harbor User Manuals" + +.SH NAME +harbor-vulnerability-list - List vulnerabilities in Security Hub + + +.SH SYNOPSIS +\fBharbor vulnerability list [flags]\fP + + +.SH DESCRIPTION +List vulnerabilities from Harbor Security Hub. +Supports exporting results to a CSV file using the --output-format (-o) flag. + + +.SH OPTIONS +\fB--all\fP[=false] + Show all vulnerabilities (up to 1000) + +.PP +\fB--cve-id\fP="" + Filter by exact CVE ID + +.PP +\fB--cvss-score\fP="" + Filter by CVSS v3 score range (e.g. 7.0~10.0) or exact score (e.g. 7.0) + +.PP +\fB--digest\fP="" + Filter by exact artifact digest + +.PP +\fB--exclude\fP="" + Exclude vulnerabilities using a ',' separated query string (e.g., k=v or k=[min~max]) + +.PP +\fB--fixable\fP[=false] + Only show fixable vulnerabilities + +.PP +\fB-h\fP, \fB--help\fP[=false] + help for list + +.PP +\fB--package\fP="" + Filter by exact package name + +.PP +\fB--page\fP=1 + Page number + +.PP +\fB--page-size\fP=10 + Size of per page + +.PP +\fB--project-name\fP="" + Filter by exact project name + +.PP +\fB--repository\fP="" + Filter by exact repository name + +.PP +\fB--severity\fP="" + Filter by severity level + +.PP +\fB--tag\fP="" + Filter by exact artifact tag + + +.SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB-c\fP, \fB--config\fP="" + config file (default is $HOME/.config/harbor-cli/config.yaml) + +.PP +\fB-o\fP, \fB--output-format\fP="" + Output format. One of: json|yaml + +.PP +\fB-v\fP, \fB--verbose\fP[=false] + verbose output + + +.SH EXAMPLE +.EX +# Export results to CSV + harbor vulnerability list -o csv +.EE + + +.SH SEE ALSO +\fBharbor-vulnerability(1)\fP \ No newline at end of file diff --git a/doc/man-docs/man1/harbor-vulnerability.1 b/doc/man-docs/man1/harbor-vulnerability.1 index 161ab03a4..d015c2a2d 100644 --- a/doc/man-docs/man1/harbor-vulnerability.1 +++ b/doc/man-docs/man1/harbor-vulnerability.1 @@ -38,4 +38,4 @@ List vulnerabilities and view vulnerability summary from Harbor Security Hub .SH SEE ALSO -\fBharbor(1)\fP, \fBharbor-vulnerability-summary(1)\fP \ No newline at end of file +\fBharbor(1)\fP, \fBharbor-vulnerability-list(1)\fP, \fBharbor-vulnerability-summary(1)\fP \ No newline at end of file diff --git a/pkg/api/types.go b/pkg/api/types.go index 27cf95e8e..d44fa28cf 100644 --- a/pkg/api/types.go +++ b/pkg/api/types.go @@ -81,3 +81,20 @@ type GetMemberOptions struct { ID int64 ProjectNameOrID string } + +type ListVulnerabilityOptions struct { + CVEID string + CVSSScore string + Severity string + Repository string + ProjectName string + Package string + Tag string + Digest string + Exclude string + WithTag bool + Fixable bool + All bool + Page int64 + PageSize int64 +} diff --git a/pkg/api/vulnerability_handler.go b/pkg/api/vulnerability_handler.go index e759a30f7..708cac231 100644 --- a/pkg/api/vulnerability_handler.go +++ b/pkg/api/vulnerability_handler.go @@ -14,7 +14,14 @@ package api import ( + "context" + "fmt" + "slices" + "strings" + + v2client "github.com/goharbor/go-client/pkg/sdk/v2.0/client" "github.com/goharbor/go-client/pkg/sdk/v2.0/client/securityhub" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" "github.com/goharbor/harbor-cli/pkg/utils" ) @@ -34,3 +41,217 @@ func GetVulnerabilitySummary(withDangerousArtifact, withDangerousCVE bool) (*sec return response, nil } + +func ListVulnerabilities(opts ...ListVulnerabilityOptions) ([]*models.VulnerabilityItem, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return nil, err + } + + var listFlags ListVulnerabilityOptions + if len(opts) > 0 { + listFlags = opts[0] + } + + q, err := buildVulnerabilityQuery(listFlags) + if err != nil { + return nil, err + } + + listFlags.WithTag = true + + if listFlags.Fixable || listFlags.Exclude != "" { + excludeMap := parseVulnerabilityExcludeMap(listFlags.Exclude) + + res, err := listFilteredVulnerabilities( + ctx, + client, + listFlags.Page, + listFlags.PageSize, + q, + listFlags.WithTag, + excludeMap, + listFlags.Fixable, + ) + if err != nil { + return nil, err + } + return res, nil + } + + response, err := client.Securityhub.ListVulnerabilities(ctx, &securityhub.ListVulnerabilitiesParams{ + Page: &listFlags.Page, + PageSize: &listFlags.PageSize, + Q: &q, + WithTag: &listFlags.WithTag, + }) + if err != nil { + return nil, err + } + if len(response.Payload) == 0 { + return nil, nil + } + + return response.Payload, nil +} + +func listFilteredVulnerabilities( + ctx context.Context, + client *v2client.HarborAPI, + page int64, + pageSize int64, + query string, + withTag bool, + excludeMap map[string]string, + fixable bool, +) ([]*models.VulnerabilityItem, error) { + excludeProvided := len(excludeMap) > 0 + start := int((page - 1) * pageSize) + end := int(page * pageSize) + + filteredCount := 0 + var filteredVulnerabilities []*models.VulnerabilityItem + + for currentPage := int64(1); filteredCount < end; currentPage++ { + response, err := client.Securityhub.ListVulnerabilities(ctx, &securityhub.ListVulnerabilitiesParams{ + Page: ¤tPage, + PageSize: &pageSize, + Q: &query, + WithTag: &withTag, + }) + if err != nil { + return nil, err + } + + payload := response.Payload + if len(payload) == 0 { + break + } + + payload = filterVulnerabilities( + payload, + fixable, + excludeProvided, + excludeMap, + ) + + for _, vul := range payload { + if filteredCount >= start && filteredCount < end { + filteredVulnerabilities = append(filteredVulnerabilities, vul) + } + + filteredCount++ + if filteredCount >= end { + break + } + } + } + + if len(filteredVulnerabilities) == 0 { + return nil, nil + } + + return filteredVulnerabilities, nil +} + +func buildVulnerabilityQuery(opts ListVulnerabilityOptions) (string, error) { + var queries []string + if opts.CVEID != "" { + queries = append(queries, fmt.Sprintf("cve_id=%s", opts.CVEID)) + } + if opts.CVSSScore != "" { + cvssRange := opts.CVSSScore + if strings.Contains(cvssRange, "~") { + parts := strings.Split(cvssRange, "~") + if len(parts) == 2 { + cvssRange = fmt.Sprintf("[%s~%s]", parts[0], parts[1]) + } else { + return "", fmt.Errorf("invalid cvss score range: %s. Expected format: min~max", cvssRange) + } + } else { + cvssRange = fmt.Sprintf("[%s~%s]", opts.CVSSScore, opts.CVSSScore) + } + queries = append(queries, fmt.Sprintf("cvss_score_v3=%s", cvssRange)) + } + if opts.Severity != "" { + if err := validateVulnerabilitySeverity(opts.Severity); err != nil { + return "", err + } + queries = append(queries, fmt.Sprintf("severity=%s", opts.Severity)) + } + if opts.Repository != "" { + queries = append(queries, fmt.Sprintf("repository_name=%s", opts.Repository)) + } + if opts.ProjectName != "" { + projectId, err := GetProjectIDFromName(opts.ProjectName) + if err != nil { + return "", err + } + queries = append(queries, fmt.Sprintf("project_id=%d", projectId)) + } + if opts.Package != "" { + queries = append(queries, fmt.Sprintf("package=%s", opts.Package)) + } + if opts.Tag != "" { + queries = append(queries, fmt.Sprintf("tag=%s", opts.Tag)) + } + if opts.Digest != "" { + queries = append(queries, fmt.Sprintf("digest=%s", opts.Digest)) + } + + return strings.Join(queries, ","), nil +} + +func validateVulnerabilitySeverity(severity string) error { + switch severity { + case "Critical", "High", "Medium", "Low", "n/a", "None": + return nil + default: + return fmt.Errorf("invalid severity value: %s. Allowed values are: Low, Medium, High, Critical, n/a, None", severity) + } +} + +func parseVulnerabilityExcludeMap(exclude string) map[string]string { + excludeMap := make(map[string]string) + for _, query := range strings.Split(exclude, ",") { + parts := strings.Split(strings.TrimSpace(query), "=") + if len(parts) == 2 { + excludeMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } + return excludeMap +} + +func filterVulnerabilities( + payload []*models.VulnerabilityItem, + fixable bool, + excludeProvided bool, + excludeMap map[string]string, +) []*models.VulnerabilityItem { + return slices.DeleteFunc(payload, func(vul *models.VulnerabilityItem) bool { + if fixable && vul.FixedVersion == "" { + return true + } + + if !excludeProvided { + return false + } + + if val, ok := excludeMap["cve_id"]; ok && vul.CVEID == val { + return true + } + if val, ok := excludeMap["severity"]; ok && vul.Severity == val { + return true + } + if val, ok := excludeMap["package"]; ok && vul.Package == val { + return true + } + if val, ok := excludeMap["repository_name"]; ok && vul.RepositoryName == val { + return true + } + if val, ok := excludeMap["digest"]; ok && vul.Digest == val { + return true + } + return false + }) +} diff --git a/pkg/utils/helper.go b/pkg/utils/helper.go index cd50ba3b0..e982af8c7 100644 --- a/pkg/utils/helper.go +++ b/pkg/utils/helper.go @@ -203,7 +203,7 @@ func ValidateURL(rawURL string) error { return nil } -func PrintFormat[T any](resp T, format string) error { +func PrintFormat[T any](resp T, format string, args ...string) error { if format == "json" { PrintPayloadInJSONFormat(resp) return nil @@ -212,6 +212,12 @@ func PrintFormat[T any](resp T, format string) error { PrintPayloadInYAMLFormat(resp) return nil } + if format == "csv" { + if len(args) == 0 || args[0] == "" { + return fmt.Errorf("csv export is not supported for this command") + } + return DownloadPayloadAsCSV(resp, args[0]) + } return fmt.Errorf("unable to output in the specified '%s' format", format) } diff --git a/pkg/utils/helper_test.go b/pkg/utils/helper_test.go index 8f22fdba0..2ce707856 100644 --- a/pkg/utils/helper_test.go +++ b/pkg/utils/helper_test.go @@ -198,6 +198,18 @@ func TestPrintFormat(t *testing.T) { assert.NoError(t, err) assert.Contains(t, buf.String(), "foo: bar") + // CSV + csvFile := t.TempDir() + "/test_print_format.csv" + err = utils.PrintFormat(obj, "csv", csvFile) + assert.NoError(t, err) + csvContent, readErr := os.ReadFile(csvFile) + assert.NoError(t, readErr) + assert.Contains(t, string(csvContent), "foo") + assert.Contains(t, string(csvContent), "bar") + err = utils.PrintFormat(obj, "csv") + assert.Error(t, err) + assert.EqualError(t, err, "csv export is not supported for this command") + // unsupported err = utils.PrintFormat(obj, "xml") assert.Error(t, err) diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index de1b83bc5..c1fa88c27 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -15,10 +15,12 @@ package utils import ( "context" + "encoding/csv" "encoding/json" "errors" "fmt" "os" + "reflect" "regexp" "strconv" "strings" @@ -63,6 +65,148 @@ func PrintPayloadInYAMLFormat(payload any) { fmt.Println(string(yamlStr)) } +func DownloadPayloadAsCSV(payload any, filename string) error { + if payload == nil { + return nil + } + + records, err := buildCSVRecords(payload) + if err != nil { + return err + } + if len(records) == 0 { + return nil + } + + csvFile, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create CSV file: %w", err) + } + defer csvFile.Close() + + writer := csv.NewWriter(csvFile) + if err := writer.WriteAll(records); err != nil { + return err + } + + fmt.Printf("CSV saved to %s\n", filename) + return nil +} + +func buildCSVRecords(payload any) ([][]string, error) { + value := dereferenceCSVValue(reflect.ValueOf(payload)) + if !value.IsValid() { + return nil, nil + } + + switch value.Kind() { + case reflect.Slice: + if value.Len() == 0 { + return nil, nil + } + + records := make([][]string, 0, value.Len()+1) + for i := 0; i < value.Len(); i++ { + recordValue := dereferenceCSVValue(value.Index(i)) + if !recordValue.IsValid() { + return nil, fmt.Errorf("csv format: nil item at index %d", i) + } + + headers, row, err := buildCSVRecord(recordValue) + if err != nil { + return nil, fmt.Errorf("csv format: %w", err) + } + if i == 0 { + records = append(records, headers) + } + records = append(records, row) + } + return records, nil + + case reflect.Struct: + headers, row, err := buildCSVRecord(value) + if err != nil { + return nil, fmt.Errorf("csv format: %w", err) + } + return [][]string{headers, row}, nil + + default: + return nil, fmt.Errorf("csv format: unsupported type %s", value.Kind()) + } +} + +func dereferenceCSVValue(value reflect.Value) reflect.Value { + if !value.IsValid() { + return reflect.Value{} + } + + for value.Kind() == reflect.Ptr { + if value.IsNil() { + return reflect.Value{} + } + value = value.Elem() + } + + return value +} + +func buildCSVRecord(value reflect.Value) ([]string, []string, error) { + if value.Kind() != reflect.Struct { + return nil, nil, fmt.Errorf("unsupported type %s", value.Kind()) + } + + headers := make([]string, 0, value.NumField()) + row := make([]string, 0, value.NumField()) + + for i := 0; i < value.NumField(); i++ { + fieldType := value.Type().Field(i) + fieldValue := value.Field(i) + if !fieldValue.CanInterface() { + continue + } + + header, include := csvHeaderName(fieldType) + if !include { + continue + } + headers = append(headers, header) + row = append(row, buildCSVCell(fieldValue)) + } + + return headers, row, nil +} + +func buildCSVCell(value reflect.Value) string { + value = dereferenceCSVValue(value) + if !value.IsValid() { + return "" + } + + if value.Kind() == reflect.Slice { + values := make([]string, 0, value.Len()) + for i := 0; i < value.Len(); i++ { + values = append(values, buildCSVCell(value.Index(i))) + } + return strings.Join(values, "; ") + } + + return fmt.Sprintf("%v", value.Interface()) +} + +func csvHeaderName(field reflect.StructField) (string, bool) { + name := field.Tag.Get("json") + if commaIdx := strings.Index(name, ","); commaIdx != -1 { + name = name[:commaIdx] + } + if name == "-" { + return "", false + } + if name == "" { + return field.Name, true + } + return name, true +} + func ParseProjectRepo(projectRepo string) (project, repo string, err error) { split := strings.SplitN(projectRepo, "/", 2) // splits only at first slash if len(split) != 2 { diff --git a/pkg/views/export.go b/pkg/views/export.go new file mode 100644 index 000000000..3348a82ad --- /dev/null +++ b/pkg/views/export.go @@ -0,0 +1,65 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package views + +import ( + "errors" + "os" + "path/filepath" + + "github.com/charmbracelet/huh" +) + +func PromptExportPath(defaultName string, fileType string) (string, error) { + var saveDir string + fileName := defaultName + + homeDir, err := os.UserHomeDir() + if err != nil { + homeDir = "." + } + + theme := huh.ThemeCharm() + err = huh.NewForm( + huh.NewGroup( + huh.NewFilePicker(). + Title("Choose save directory"). + Picking(true). + DirAllowed(true). + FileAllowed(false). + ShowHidden(false). + ShowSize(false). + ShowPermissions(false). + Height(14). + CurrentDirectory(homeDir). + Value(&saveDir), + ), + huh.NewGroup( + huh.NewInput(). + Title("Filename (."+fileType+")"). + Value(&fileName). + Validate(func(str string) error { + if str == "" { + return errors.New("filename cannot be empty") + } + return nil + }), + ), + ).WithTheme(theme).Run() + if err != nil { + return "", err + } + + return filepath.Join(saveDir, fileName+"."+fileType), nil +} diff --git a/pkg/views/vulnerability/list/view.go b/pkg/views/vulnerability/list/view.go new file mode 100644 index 000000000..59aff3f6b --- /dev/null +++ b/pkg/views/vulnerability/list/view.go @@ -0,0 +1,96 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package list + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/x/ansi" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/goharbor/harbor-cli/pkg/views/base/tablelist" +) + +var columns = []table.Column{ + {Title: "CVE ID", Width: tablelist.WidthL}, + {Title: "Description", Width: tablelist.WidthXXL}, + {Title: "Repository", Width: tablelist.WidthL}, + {Title: "Digest", Width: tablelist.WidthM}, + {Title: "Tags", Width: tablelist.WidthM}, + {Title: "CVSSV3", Width: tablelist.WidthS}, + {Title: "Severity", Width: tablelist.WidthS}, + {Title: "Package", Width: tablelist.WidthM}, + {Title: "Version", Width: tablelist.WidthM}, + {Title: "Fixed", Width: tablelist.WidthM}, +} + +func ViewVulnerabilityList(vulnerabilities []*models.VulnerabilityItem, hasNext bool) { + if vulnerabilities == nil { + fmt.Println("No vulnerabilities found") + return + } + + var rows []table.Row + cveLinks := make(map[string]string) + for _, vulnerability := range vulnerabilities { + link := "" + if len(vulnerability.Links) > 0 { + link = vulnerability.Links[0] + } + + cveLinks[vulnerability.CVEID] = link + rows = append(rows, table.Row{ + vulnerability.CVEID, + vulnerability.Desc, + vulnerability.RepositoryName, + vulnerability.Digest, + strings.Join(vulnerability.Tags, ", "), + strconv.FormatFloat(float64(vulnerability.CvssV3Score), 'f', 1, 32), + vulnerability.Severity, + vulnerability.Package, + vulnerability.Version, + vulnerability.FixedVersion, + }) + } + + m := tablelist.NewModel(columns, rows, len(rows)) + tableOutput := m.View() + + for cve, link := range cveLinks { + hyperlinkedID := ansi.SetHyperlink(link) + cve + ansi.ResetHyperlink() + tableOutput = strings.ReplaceAll(tableOutput, cve, hyperlinkedID) + } + + fmt.Println(tableOutput) + if hasNext { + var hintURL string + if cfg, err := utils.GetCurrentHarborConfig(); err == nil { + for _, cred := range cfg.Credentials { + if cred.Name == cfg.CurrentCredentialName { + hintURL = strings.TrimRight(cred.ServerAddress, "/") + "/harbor/interrogation-services/security-hub" + break + } + } + } + + if hintURL != "" { + fmt.Printf("More results at: %s%s%s\n", ansi.SetHyperlink(hintURL), hintURL, ansi.ResetHyperlink()) + } else { + fmt.Println("More results available in the Harbor web console") + } + } +}