//go:build search // +build search package cli import ( "context" "errors" "fmt" "io" "net/url" "strconv" "strings" "sync" "github.com/dustin/go-humanize" jsoniter "github.com/json-iterator/go" "github.com/olekukonko/tablewriter" godigest "github.com/opencontainers/go-digest" ispec "github.com/opencontainers/image-spec/specs-go/v1" "gopkg.in/yaml.v2" zerr "zotregistry.io/zot/errors" "zotregistry.io/zot/pkg/api/constants" "zotregistry.io/zot/pkg/common" ) const ( jsonFormat = "json" yamlFormat = "yaml" ymlFormat = "yml" ) type SearchService interface { //nolint:interfacebloat getImagesGQL(ctx context.Context, config searchConfig, username, password string, imageName string) (*common.ImageListResponse, error) getImagesForDigestGQL(ctx context.Context, config searchConfig, username, password string, digest string) (*common.ImagesForDigest, error) getCveByImageGQL(ctx context.Context, config searchConfig, username, password, imageName string, searchedCVE string) (*cveResult, error) getTagsForCVEGQL(ctx context.Context, config searchConfig, username, password, repo, cveID string) (*common.ImagesForCve, error) getFixedTagsForCVEGQL(ctx context.Context, config searchConfig, username, password, imageName, cveID string) (*common.ImageListWithCVEFixedResponse, error) getDerivedImageListGQL(ctx context.Context, config searchConfig, username, password string, derivedImage string) (*common.DerivedImageListResponse, error) getBaseImageListGQL(ctx context.Context, config searchConfig, username, password string, baseImage string) (*common.BaseImageListResponse, error) getReferrersGQL(ctx context.Context, config searchConfig, username, password string, repo, digest string) (*common.ReferrersResp, error) globalSearchGQL(ctx context.Context, config searchConfig, username, password string, query string) (*common.GlobalSearch, error) getAllImages(ctx context.Context, config searchConfig, username, password string, channel chan stringResult, wtgrp *sync.WaitGroup) getImagesByDigest(ctx context.Context, config searchConfig, username, password, digest string, channel chan stringResult, wtgrp *sync.WaitGroup) getRepos(ctx context.Context, config searchConfig, username, password string, channel chan stringResult, wtgrp *sync.WaitGroup) getImageByName(ctx context.Context, config searchConfig, username, password, imageName string, channel chan stringResult, wtgrp *sync.WaitGroup) getReferrers(ctx context.Context, config searchConfig, username, password string, repo, digest string, ) (referrersResult, error) } type searchConfig struct { searchService SearchService servURL string user string outputFormat string verifyTLS bool fixedFlag bool verbose bool debug bool resultWriter io.Writer spinner spinnerState } type searchService struct{} func NewSearchService() SearchService { return searchService{} } func (service searchService) getDerivedImageListGQL(ctx context.Context, config searchConfig, username, password string, derivedImage string, ) (*common.DerivedImageListResponse, error) { query := fmt.Sprintf(` { DerivedImageList(image:"%s", requestedPage: {sortBy: ALPHABETIC_ASC}){ Results{ RepoName Tag Digest MediaType Manifests { Digest ConfigDigest Size Platform {Os Arch} IsSigned Layers {Size Digest} LastUpdated } LastUpdated Size IsSigned } } }`, derivedImage) result := &common.DerivedImageListResponse{} err := service.makeGraphQLQuery(ctx, config, username, password, query, result) if errResult := checkResultGraphQLQuery(ctx, err, result.Errors); errResult != nil { return nil, errResult } return result, nil } func (service searchService) getReferrersGQL(ctx context.Context, config searchConfig, username, password string, repo, digest string, ) (*common.ReferrersResp, error) { query := fmt.Sprintf(` { Referrers( repo: "%s", digest: "%s" ){ ArtifactType, Digest, MediaType, Size, Annotations{ Key Value } } }`, repo, digest) result := &common.ReferrersResp{} err := service.makeGraphQLQuery(ctx, config, username, password, query, result) if errResult := checkResultGraphQLQuery(ctx, err, result.Errors); errResult != nil { return nil, errResult } return result, nil } func (service searchService) globalSearchGQL(ctx context.Context, config searchConfig, username, password string, query string, ) (*common.GlobalSearch, error) { GQLQuery := fmt.Sprintf(` { GlobalSearch(query:"%s"){ Images { RepoName Tag MediaType Digest Size IsSigned LastUpdated Manifests { Digest ConfigDigest Platform {Os Arch} Size IsSigned Layers {Size Digest} LastUpdated } } Repos { Name Platforms { Os Arch } LastUpdated Size DownloadCount StarCount } } }`, query) result := &common.GlobalSearchResultResp{} err := service.makeGraphQLQuery(ctx, config, username, password, GQLQuery, result) if errResult := checkResultGraphQLQuery(ctx, err, result.Errors); errResult != nil { return nil, errResult } return &result.GlobalSearch, nil } func (service searchService) getBaseImageListGQL(ctx context.Context, config searchConfig, username, password string, baseImage string, ) (*common.BaseImageListResponse, error) { query := fmt.Sprintf(` { BaseImageList(image:"%s", requestedPage: {sortBy: ALPHABETIC_ASC}){ Results{ RepoName Tag Digest MediaType Manifests { Digest ConfigDigest Size Platform {Os Arch} IsSigned Layers {Size Digest} LastUpdated } LastUpdated Size IsSigned } } }`, baseImage) result := &common.BaseImageListResponse{} err := service.makeGraphQLQuery(ctx, config, username, password, query, result) if errResult := checkResultGraphQLQuery(ctx, err, result.Errors); errResult != nil { return nil, errResult } return result, nil } func (service searchService) getImagesGQL(ctx context.Context, config searchConfig, username, password string, imageName string, ) (*common.ImageListResponse, error) { query := fmt.Sprintf(` { ImageList(repo: "%s", requestedPage: {sortBy: ALPHABETIC_ASC}) { Results { RepoName Tag Digest MediaType Manifests { Digest ConfigDigest Size Platform {Os Arch} IsSigned Layers {Size Digest} LastUpdated } LastUpdated Size IsSigned } } }`, imageName) result := &common.ImageListResponse{} err := service.makeGraphQLQuery(ctx, config, username, password, query, result) if errResult := checkResultGraphQLQuery(ctx, err, result.Errors); errResult != nil { return nil, errResult } return result, nil } func (service searchService) getImagesForDigestGQL(ctx context.Context, config searchConfig, username, password string, digest string, ) (*common.ImagesForDigest, error) { query := fmt.Sprintf(` { ImageListForDigest(id: "%s", requestedPage: {sortBy: ALPHABETIC_ASC}) { Results { RepoName Tag Digest MediaType Manifests { Digest ConfigDigest Size Platform {Os Arch} IsSigned Layers {Size Digest} LastUpdated } LastUpdated Size IsSigned } } }`, digest) result := &common.ImagesForDigest{} err := service.makeGraphQLQuery(ctx, config, username, password, query, result) if errResult := checkResultGraphQLQuery(ctx, err, result.Errors); errResult != nil { return nil, errResult } return result, nil } func (service searchService) getCveByImageGQL(ctx context.Context, config searchConfig, username, password, imageName, searchedCVE string, ) (*cveResult, error) { query := fmt.Sprintf(`{ CVEListForImage (image:"%s", searchedCVE:"%s")`+ ` { Tag CVEList { Id Title Severity Description `+ `PackageList {Name InstalledVersion FixedVersion}} } }`, imageName, searchedCVE) result := &cveResult{} err := service.makeGraphQLQuery(ctx, config, username, password, query, result) if errResult := checkResultGraphQLQuery(ctx, err, result.Errors); errResult != nil { return nil, errResult } result.Data.CVEListForImage.CVEList = groupCVEsBySeverity(result.Data.CVEListForImage.CVEList) return result, nil } func (service searchService) getTagsForCVEGQL(ctx context.Context, config searchConfig, username, password, repo, cveID string, ) (*common.ImagesForCve, error) { query := fmt.Sprintf(` { ImageListForCVE(id: "%s") { Results { RepoName Tag Digest MediaType Manifests { Digest ConfigDigest Size Platform {Os Arch} IsSigned Layers {Size Digest} LastUpdated } LastUpdated Size IsSigned } } }`, cveID) result := &common.ImagesForCve{} err := service.makeGraphQLQuery(ctx, config, username, password, query, result) if errResult := checkResultGraphQLQuery(ctx, err, result.Errors); errResult != nil { return nil, errResult } if repo == "" { return result, nil } filteredResults := &common.ImagesForCve{} for _, image := range result.Results { if image.RepoName == repo { filteredResults.Results = append(filteredResults.Results, image) } } return filteredResults, nil } func (service searchService) getFixedTagsForCVEGQL(ctx context.Context, config searchConfig, username, password, imageName, cveID string, ) (*common.ImageListWithCVEFixedResponse, error) { query := fmt.Sprintf(` { ImageListWithCVEFixed(id: "%s", image: "%s") { Results { RepoName Tag Digest MediaType Manifests { Digest ConfigDigest Size Platform {Os Arch} IsSigned Layers {Size Digest} LastUpdated } LastUpdated Size IsSigned } } }`, cveID, imageName) result := &common.ImageListWithCVEFixedResponse{} err := service.makeGraphQLQuery(ctx, config, username, password, query, result) if errResult := checkResultGraphQLQuery(ctx, err, result.Errors); errResult != nil { return nil, errResult } return result, nil } func (service searchService) getReferrers(ctx context.Context, config searchConfig, username, password string, repo, digest string, ) (referrersResult, error) { referrersEndpoint, err := combineServerAndEndpointURL(config.servURL, fmt.Sprintf("/v2/%s/referrers/%s", repo, digest)) if err != nil { if isContextDone(ctx) { return referrersResult{}, nil } return referrersResult{}, err } referrerResp := &ispec.Index{} _, err = makeGETRequest(ctx, referrersEndpoint, username, password, config.verifyTLS, config.debug, &referrerResp, config.resultWriter) if err != nil { if isContextDone(ctx) { return referrersResult{}, nil } return referrersResult{}, err } referrersList := referrersResult{} for _, referrer := range referrerResp.Manifests { referrersList = append(referrersList, common.Referrer{ ArtifactType: referrer.ArtifactType, Digest: referrer.Digest.String(), Size: int(referrer.Size), }) } return referrersList, nil } func (service searchService) getImageByName(ctx context.Context, config searchConfig, username, password, imageName string, rch chan stringResult, wtgrp *sync.WaitGroup, ) { defer wtgrp.Done() defer close(rch) var localWg sync.WaitGroup rlim := newSmoothRateLimiter(&localWg, rch) localWg.Add(1) go rlim.startRateLimiter(ctx) localWg.Add(1) go getImage(ctx, config, username, password, imageName, rch, &localWg, rlim) localWg.Wait() } func (service searchService) getAllImages(ctx context.Context, config searchConfig, username, password string, rch chan stringResult, wtgrp *sync.WaitGroup, ) { defer wtgrp.Done() defer close(rch) catalog := &catalogResponse{} catalogEndPoint, err := combineServerAndEndpointURL(config.servURL, fmt.Sprintf("%s%s", constants.RoutePrefix, constants.ExtCatalogPrefix)) if err != nil { if isContextDone(ctx) { return } rch <- stringResult{"", err} return } _, err = makeGETRequest(ctx, catalogEndPoint, username, password, config.verifyTLS, config.debug, catalog, config.resultWriter) if err != nil { if isContextDone(ctx) { return } rch <- stringResult{"", err} return } var localWg sync.WaitGroup rlim := newSmoothRateLimiter(&localWg, rch) localWg.Add(1) go rlim.startRateLimiter(ctx) for _, repo := range catalog.Repositories { localWg.Add(1) go getImage(ctx, config, username, password, repo, rch, &localWg, rlim) } localWg.Wait() } func getImage(ctx context.Context, config searchConfig, username, password, imageName string, rch chan stringResult, wtgrp *sync.WaitGroup, pool *requestsPool, ) { defer wtgrp.Done() repo, imageTag := common.GetImageDirAndTag(imageName) tagListEndpoint, err := combineServerAndEndpointURL(config.servURL, fmt.Sprintf("/v2/%s/tags/list", repo)) if err != nil { if isContextDone(ctx) { return } rch <- stringResult{"", err} return } tagList := &tagListResp{} _, err = makeGETRequest(ctx, tagListEndpoint, username, password, config.verifyTLS, config.debug, &tagList, config.resultWriter) if err != nil { if isContextDone(ctx) { return } rch <- stringResult{"", err} return } for _, tag := range tagList.Tags { hasTagPrefix := strings.HasPrefix(tag, "sha256-") hasTagSuffix := strings.HasSuffix(tag, ".sig") // check if it's an image or a signature // we don't want to show signatures in cli responses if hasTagPrefix && hasTagSuffix { continue } shouldMatchTag := imageTag != "" matchesTag := tag == imageTag // when the tag is empty we match everything if shouldMatchTag && !matchesTag { continue } wtgrp.Add(1) go addManifestCallToPool(ctx, config, pool, username, password, repo, tag, rch, wtgrp) } } func (service searchService) getImagesByDigest(ctx context.Context, config searchConfig, username, password string, digest string, rch chan stringResult, wtgrp *sync.WaitGroup, ) { defer wtgrp.Done() defer close(rch) query := fmt.Sprintf( `{ ImageListForDigest(id: "%s") { Results { RepoName Tag Digest MediaType Manifests { Digest ConfigDigest Size Platform {Os Arch} IsSigned Layers {Size Digest} LastUpdated } LastUpdated Size IsSigned } } }`, digest) result := &common.ImagesForDigest{} err := service.makeGraphQLQuery(ctx, config, username, password, query, result) if err != nil { if isContextDone(ctx) { return } rch <- stringResult{"", err} return } if result.Errors != nil { var errBuilder strings.Builder for _, err := range result.Errors { fmt.Fprintln(&errBuilder, err.Message) } if isContextDone(ctx) { return } rch <- stringResult{"", errors.New(errBuilder.String())} //nolint: goerr113 return } var localWg sync.WaitGroup rlim := newSmoothRateLimiter(&localWg, rch) localWg.Add(1) go rlim.startRateLimiter(ctx) for _, image := range result.Results { localWg.Add(1) go addManifestCallToPool(ctx, config, rlim, username, password, image.RepoName, image.Tag, rch, &localWg) } localWg.Wait() } func groupCVEsBySeverity(cveList []cve) []cve { var ( unknown = make([]cve, 0) none = make([]cve, 0) high = make([]cve, 0) med = make([]cve, 0) low = make([]cve, 0) critical = make([]cve, 0) ) for _, cve := range cveList { switch cve.Severity { case "NONE": none = append(none, cve) case "LOW": low = append(low, cve) case "MEDIUM": med = append(med, cve) case "HIGH": high = append(high, cve) case "CRITICAL": critical = append(critical, cve) default: unknown = append(unknown, cve) } } vulnsCount := len(unknown) + len(none) + len(high) + len(med) + len(low) + len(critical) vulns := make([]cve, 0, vulnsCount) vulns = append(vulns, critical...) vulns = append(vulns, high...) vulns = append(vulns, med...) vulns = append(vulns, low...) vulns = append(vulns, none...) vulns = append(vulns, unknown...) return vulns } func isContextDone(ctx context.Context) bool { select { case <-ctx.Done(): return true default: return false } } // Query using GQL, the query string is passed as a parameter // errors are returned in the stringResult channel, the unmarshalled payload is in resultPtr. func (service searchService) makeGraphQLQuery(ctx context.Context, config searchConfig, username, password, query string, resultPtr interface{}, ) error { endPoint, err := combineServerAndEndpointURL(config.servURL, constants.FullSearchPrefix) if err != nil { return err } err = makeGraphQLRequest(ctx, endPoint, query, username, password, config.verifyTLS, config.debug, resultPtr, config.resultWriter) if err != nil { return err } return nil } func checkResultGraphQLQuery(ctx context.Context, err error, resultErrors []common.ErrorGQL, ) error { if err != nil { if isContextDone(ctx) { return nil //nolint:nilnil } return err } if resultErrors != nil { var errBuilder strings.Builder for _, error := range resultErrors { fmt.Fprintln(&errBuilder, error.Message) } if isContextDone(ctx) { return nil } //nolint: goerr113 return errors.New(errBuilder.String()) } return nil } func addManifestCallToPool(ctx context.Context, config searchConfig, pool *requestsPool, username, password, imageName, tagName string, rch chan stringResult, wtgrp *sync.WaitGroup, ) { defer wtgrp.Done() manifestEndpoint, err := combineServerAndEndpointURL(config.servURL, fmt.Sprintf("/v2/%s/manifests/%s", imageName, tagName)) if err != nil { if isContextDone(ctx) { return } rch <- stringResult{"", err} } job := httpJob{ url: manifestEndpoint, username: username, imageName: imageName, password: password, tagName: tagName, config: config, } wtgrp.Add(1) pool.submitJob(&job) } type cveResult struct { Errors []common.ErrorGQL `json:"errors"` Data cveData `json:"data"` } type tagListResp struct { Name string `json:"name"` Tags []string `json:"tags"` } //nolint:tagliatelle // graphQL schema type packageList struct { Name string `json:"Name"` InstalledVersion string `json:"InstalledVersion"` FixedVersion string `json:"FixedVersion"` } //nolint:tagliatelle // graphQL schema type cve struct { ID string `json:"Id"` Severity string `json:"Severity"` Title string `json:"Title"` Description string `json:"Description"` PackageList []packageList `json:"PackageList"` } //nolint:tagliatelle // graphQL schema type cveListForImage struct { Tag string `json:"Tag"` CVEList []cve `json:"CVEList"` } //nolint:tagliatelle // graphQL schema type cveData struct { CVEListForImage cveListForImage `json:"CVEListForImage"` } func (cve cveResult) string(format string) (string, error) { switch strings.ToLower(format) { case "", defaultOutputFormat: return cve.stringPlainText() case jsonFormat: return cve.stringJSON() case ymlFormat, yamlFormat: return cve.stringYAML() default: return "", zerr.ErrInvalidOutputFormat } } func (cve cveResult) stringPlainText() (string, error) { var builder strings.Builder table := getCVETableWriter(&builder) for _, c := range cve.Data.CVEListForImage.CVEList { id := ellipsize(c.ID, cveIDWidth, ellipsis) title := ellipsize(c.Title, cveTitleWidth, ellipsis) severity := ellipsize(c.Severity, cveSeverityWidth, ellipsis) row := make([]string, 3) //nolint:gomnd row[colCVEIDIndex] = id row[colCVESeverityIndex] = severity row[colCVETitleIndex] = title table.Append(row) } table.Render() return builder.String(), nil } func (cve cveResult) stringJSON() (string, error) { // Output is in json lines format - do not indent, append new line after json json := jsoniter.ConfigCompatibleWithStandardLibrary body, err := json.Marshal(cve.Data.CVEListForImage) if err != nil { return "", err } return string(body) + "\n", nil } func (cve cveResult) stringYAML() (string, error) { // Output will be a multidoc yaml - use triple-dash to indicate a new document body, err := yaml.Marshal(&cve.Data.CVEListForImage) if err != nil { return "", err } return "---\n" + string(body), nil } type referrersResult []common.Referrer func (ref referrersResult) string(format string, maxArtifactTypeLen int) (string, error) { switch strings.ToLower(format) { case "", defaultOutputFormat: return ref.stringPlainText(maxArtifactTypeLen) case jsonFormat: return ref.stringJSON() case ymlFormat, yamlFormat: return ref.stringYAML() default: return "", zerr.ErrInvalidOutputFormat } } func (ref referrersResult) stringPlainText(maxArtifactTypeLen int) (string, error) { var builder strings.Builder table := getImageTableWriter(&builder) table.SetColMinWidth(refArtifactTypeIndex, maxArtifactTypeLen) table.SetColMinWidth(refDigestIndex, digestWidth) table.SetColMinWidth(refSizeIndex, sizeWidth) for _, referrer := range ref { artifactType := ellipsize(referrer.ArtifactType, maxArtifactTypeLen, ellipsis) // digest := ellipsize(godigest.Digest(referrer.Digest).Encoded(), digestWidth, "") size := ellipsize(humanize.Bytes(uint64(referrer.Size)), sizeWidth, ellipsis) row := make([]string, refRowWidth) row[refArtifactTypeIndex] = artifactType row[refDigestIndex] = referrer.Digest row[refSizeIndex] = size table.Append(row) } table.Render() return builder.String(), nil } func (ref referrersResult) stringJSON() (string, error) { // Output is in json lines format - do not indent, append new line after json json := jsoniter.ConfigCompatibleWithStandardLibrary body, err := json.Marshal(ref) if err != nil { return "", err } return string(body) + "\n", nil } func (ref referrersResult) stringYAML() (string, error) { // Output will be a multidoc yaml - use triple-dash to indicate a new document body, err := yaml.Marshal(ref) if err != nil { return "", err } return "---\n" + string(body), nil } type repoStruct common.RepoSummary func (repo repoStruct) string(format string, maxImgNameLen, maxTimeLen int, verbose bool) (string, error) { //nolint: lll switch strings.ToLower(format) { case "", defaultOutputFormat: return repo.stringPlainText(maxImgNameLen, maxTimeLen, verbose) case jsonFormat: return repo.stringJSON() case ymlFormat, yamlFormat: return repo.stringYAML() default: return "", zerr.ErrInvalidOutputFormat } } func (repo repoStruct) stringPlainText(repoMaxLen, maxTimeLen int, verbose bool) (string, error) { var builder strings.Builder table := getImageTableWriter(&builder) table.SetColMinWidth(repoNameIndex, repoMaxLen) table.SetColMinWidth(repoSizeIndex, sizeWidth) table.SetColMinWidth(repoLastUpdatedIndex, maxTimeLen) table.SetColMinWidth(repoDownloadsIndex, downloadsWidth) table.SetColMinWidth(repoStarsIndex, signedWidth) if verbose { table.SetColMinWidth(repoPlatformsIndex, platformWidth) } repoSize, err := strconv.Atoi(repo.Size) if err != nil { return "", err } repoName := repo.Name repoLastUpdated := repo.LastUpdated repoDownloads := repo.DownloadCount repoStars := repo.StarCount repoPlatforms := repo.Platforms row := make([]string, repoRowWidth) row[repoNameIndex] = repoName row[repoSizeIndex] = ellipsize(strings.ReplaceAll(humanize.Bytes(uint64(repoSize)), " ", ""), sizeWidth, ellipsis) row[repoLastUpdatedIndex] = repoLastUpdated.String() row[repoDownloadsIndex] = strconv.Itoa(repoDownloads) row[repoStarsIndex] = strconv.Itoa(repoStars) if verbose && len(repoPlatforms) > 0 { row[repoPlatformsIndex] = getPlatformStr(repoPlatforms[0]) repoPlatforms = repoPlatforms[1:] } table.Append(row) if verbose { for _, platform := range repoPlatforms { row := make([]string, repoRowWidth) row[repoPlatformsIndex] = getPlatformStr(platform) table.Append(row) } } table.Render() return builder.String(), nil } func (repo repoStruct) stringJSON() (string, error) { // Output is in json lines format - do not indent, append new line after json json := jsoniter.ConfigCompatibleWithStandardLibrary body, err := json.Marshal(repo) if err != nil { return "", err } return string(body) + "\n", nil } func (repo repoStruct) stringYAML() (string, error) { // Output will be a multidoc yaml - use triple-dash to indicate a new document body, err := yaml.Marshal(&repo) if err != nil { return "", err } return "---\n" + string(body), nil } type imageStruct common.ImageSummary func (img imageStruct) string(format string, maxImgNameLen, maxTagLen, maxPlatformLen int, verbose bool) (string, error) { //nolint: lll switch strings.ToLower(format) { case "", defaultOutputFormat: return img.stringPlainText(maxImgNameLen, maxTagLen, maxPlatformLen, verbose) case jsonFormat: return img.stringJSON() case ymlFormat, yamlFormat: return img.stringYAML() default: return "", zerr.ErrInvalidOutputFormat } } func (img imageStruct) stringPlainText(maxImgNameLen, maxTagLen, maxPlatformLen int, verbose bool) (string, error) { var builder strings.Builder table := getImageTableWriter(&builder) table.SetColMinWidth(colImageNameIndex, maxImgNameLen) table.SetColMinWidth(colTagIndex, maxTagLen) table.SetColMinWidth(colPlatformIndex, platformWidth) table.SetColMinWidth(colDigestIndex, digestWidth) table.SetColMinWidth(colSizeIndex, sizeWidth) table.SetColMinWidth(colIsSignedIndex, isSignedWidth) if verbose { table.SetColMinWidth(colConfigIndex, configWidth) table.SetColMinWidth(colLayersIndex, layersWidth) } var imageName, tagName string imageName = img.RepoName tagName = img.Tag if imageNameWidth > maxImgNameLen { maxImgNameLen = imageNameWidth } if tagWidth > maxTagLen { maxTagLen = tagWidth } // adding spaces so that image name and tag columns are aligned // in case the name/tag are fully shown and too long var offset string if maxImgNameLen > len(imageName) { offset = strings.Repeat(" ", maxImgNameLen-len(imageName)) imageName += offset } if maxTagLen > len(tagName) { offset = strings.Repeat(" ", maxTagLen-len(tagName)) tagName += offset } err := addImageToTable(table, &img, maxPlatformLen, imageName, tagName, verbose) if err != nil { return "", err } table.Render() return builder.String(), nil } func addImageToTable(table *tablewriter.Table, img *imageStruct, maxPlatformLen int, imageName, tagName string, verbose bool, ) error { switch img.MediaType { case ispec.MediaTypeImageManifest: return addManifestToTable(table, imageName, tagName, &img.Manifests[0], maxPlatformLen, verbose) case ispec.MediaTypeImageIndex: return addImageIndexToTable(table, img, maxPlatformLen, imageName, tagName, verbose) } return nil } func addImageIndexToTable(table *tablewriter.Table, img *imageStruct, maxPlatformLen int, imageName, tagName string, verbose bool, ) error { indexDigest, err := godigest.Parse(img.Digest) if err != nil { return fmt.Errorf("error parsing index digest %s: %w", indexDigest, err) } row := make([]string, rowWidth) row[colImageNameIndex] = imageName row[colTagIndex] = tagName row[colDigestIndex] = ellipsize(indexDigest.Encoded(), digestWidth, "") row[colPlatformIndex] = "*" imgSize, _ := strconv.ParseUint(img.Size, 10, 64) row[colSizeIndex] = ellipsize(strings.ReplaceAll(humanize.Bytes(imgSize), " ", ""), sizeWidth, ellipsis) row[colIsSignedIndex] = strconv.FormatBool(img.IsSigned) if verbose { row[colConfigIndex] = "" row[colLayersIndex] = "" } table.Append(row) for i := range img.Manifests { err := addManifestToTable(table, "", "", &img.Manifests[i], maxPlatformLen, verbose) if err != nil { return err } } return nil } func addManifestToTable(table *tablewriter.Table, imageName, tagName string, manifest *common.ManifestSummary, maxPlatformLen int, verbose bool, ) error { manifestDigest, err := godigest.Parse(manifest.Digest) if err != nil { return fmt.Errorf("error parsing manifest digest %s: %w", manifest.Digest, err) } configDigest, err := godigest.Parse(manifest.ConfigDigest) if err != nil { return fmt.Errorf("error parsing config digest %s: %w", manifest.ConfigDigest, err) } platform := getPlatformStr(manifest.Platform) if maxPlatformLen > len(platform) { offset := strings.Repeat(" ", maxPlatformLen-len(platform)) platform += offset } manifestDigestStr := ellipsize(manifestDigest.Encoded(), digestWidth, "") configDigestStr := ellipsize(configDigest.Encoded(), configWidth, "") imgSize, _ := strconv.ParseUint(manifest.Size, 10, 64) size := ellipsize(strings.ReplaceAll(humanize.Bytes(imgSize), " ", ""), sizeWidth, ellipsis) isSigned := manifest.IsSigned row := make([]string, 8) //nolint:gomnd row[colImageNameIndex] = imageName row[colTagIndex] = tagName row[colDigestIndex] = manifestDigestStr row[colPlatformIndex] = platform row[colSizeIndex] = size row[colIsSignedIndex] = strconv.FormatBool(isSigned) if verbose { row[colConfigIndex] = configDigestStr row[colLayersIndex] = "" } table.Append(row) if verbose { for _, entry := range manifest.Layers { layerSize, _ := strconv.ParseUint(entry.Size, 10, 64) size := ellipsize(strings.ReplaceAll(humanize.Bytes(layerSize), " ", ""), sizeWidth, ellipsis) layerDigest, err := godigest.Parse(entry.Digest) if err != nil { return fmt.Errorf("error parsing layer digest %s: %w", entry.Digest, err) } layerDigestStr := ellipsize(layerDigest.Encoded(), digestWidth, "") layerRow := make([]string, 8) //nolint:gomnd layerRow[colImageNameIndex] = "" layerRow[colTagIndex] = "" layerRow[colDigestIndex] = "" layerRow[colPlatformIndex] = "" layerRow[colSizeIndex] = size layerRow[colConfigIndex] = "" layerRow[colLayersIndex] = layerDigestStr table.Append(layerRow) } } return nil } func getPlatformStr(platform common.Platform) string { if platform.Arch == "" && platform.Os == "" { return "" } fullPlatform := platform.Os if platform.Arch != "" { fullPlatform = fullPlatform + "/" + platform.Arch fullPlatform = strings.Trim(fullPlatform, "/") if platform.Variant != "" { fullPlatform = fullPlatform + "/" + platform.Variant } } return fullPlatform } func (img imageStruct) stringJSON() (string, error) { // Output is in json lines format - do not indent, append new line after json json := jsoniter.ConfigCompatibleWithStandardLibrary body, err := json.Marshal(img) if err != nil { return "", err } return string(body) + "\n", nil } func (img imageStruct) stringYAML() (string, error) { // Output will be a multidoc yaml - use triple-dash to indicate a new document body, err := yaml.Marshal(&img) if err != nil { return "", err } return "---\n" + string(body), nil } type catalogResponse struct { Repositories []string `json:"repositories"` } func combineServerAndEndpointURL(serverURL, endPoint string) (string, error) { if !isURL(serverURL) { return "", zerr.ErrInvalidURL } newURL, err := url.Parse(serverURL) if err != nil { return "", zerr.ErrInvalidURL } newURL, _ = newURL.Parse(endPoint) return newURL.String(), nil } func ellipsize(text string, max int, trailing string) string { text = strings.TrimSpace(text) if len(text) <= max { return text } chopLength := len(trailing) return text[:max-chopLength] + trailing } func getImageTableWriter(writer io.Writer) *tablewriter.Table { table := tablewriter.NewWriter(writer) table.SetAutoWrapText(false) table.SetAutoFormatHeaders(true) table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) table.SetAlignment(tablewriter.ALIGN_LEFT) table.SetCenterSeparator("") table.SetColumnSeparator("") table.SetRowSeparator("") table.SetHeaderLine(false) table.SetBorder(false) table.SetTablePadding(" ") table.SetNoWhiteSpace(true) return table } func getCVETableWriter(writer io.Writer) *tablewriter.Table { table := tablewriter.NewWriter(writer) table.SetAutoWrapText(false) table.SetAutoFormatHeaders(true) table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) table.SetAlignment(tablewriter.ALIGN_LEFT) table.SetCenterSeparator("") table.SetColumnSeparator("") table.SetRowSeparator("") table.SetHeaderLine(false) table.SetBorder(false) table.SetTablePadding(" ") table.SetNoWhiteSpace(true) table.SetColMinWidth(colCVEIDIndex, cveIDWidth) table.SetColMinWidth(colCVESeverityIndex, cveSeverityWidth) table.SetColMinWidth(colCVETitleIndex, cveTitleWidth) return table } func getReferrersTableWriter(writer io.Writer) *tablewriter.Table { table := tablewriter.NewWriter(writer) table.SetAutoWrapText(false) table.SetAutoFormatHeaders(true) table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) table.SetAlignment(tablewriter.ALIGN_LEFT) table.SetCenterSeparator("") table.SetColumnSeparator("") table.SetRowSeparator("") table.SetHeaderLine(false) table.SetBorder(false) table.SetTablePadding(" ") table.SetNoWhiteSpace(true) return table } func getRepoTableWriter(writer io.Writer) *tablewriter.Table { table := tablewriter.NewWriter(writer) table.SetAutoWrapText(false) table.SetAutoFormatHeaders(true) table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) table.SetAlignment(tablewriter.ALIGN_LEFT) table.SetCenterSeparator("") table.SetColumnSeparator("") table.SetRowSeparator("") table.SetHeaderLine(false) table.SetBorder(false) table.SetTablePadding(" ") table.SetNoWhiteSpace(true) return table } func (service searchService) getRepos(ctx context.Context, config searchConfig, username, password string, rch chan stringResult, wtgrp *sync.WaitGroup, ) { defer wtgrp.Done() defer close(rch) catalog := &catalogResponse{} catalogEndPoint, err := combineServerAndEndpointURL(config.servURL, fmt.Sprintf("%s%s", constants.RoutePrefix, constants.ExtCatalogPrefix)) if err != nil { if isContextDone(ctx) { return } rch <- stringResult{"", err} return } _, err = makeGETRequest(ctx, catalogEndPoint, username, password, config.verifyTLS, config.debug, catalog, config.resultWriter) if err != nil { if isContextDone(ctx) { return } rch <- stringResult{"", err} return } fmt.Fprintln(config.resultWriter, "\n\nREPOSITORY NAME") for _, repo := range catalog.Repositories { fmt.Fprintln(config.resultWriter, repo) } } const ( imageNameWidth = 10 tagWidth = 8 digestWidth = 8 platformWidth = 14 sizeWidth = 10 isSignedWidth = 8 downloadsWidth = 10 signedWidth = 10 lastUpdatedWidth = 14 configWidth = 8 layersWidth = 8 ellipsis = "..." cveIDWidth = 16 cveSeverityWidth = 8 cveTitleWidth = 48 colCVEIDIndex = 0 colCVESeverityIndex = 1 colCVETitleIndex = 2 defaultOutputFormat = "text" ) const ( colImageNameIndex = iota colTagIndex colPlatformIndex colDigestIndex colConfigIndex colIsSignedIndex colLayersIndex colSizeIndex rowWidth ) const ( repoNameIndex = iota repoSizeIndex repoLastUpdatedIndex repoDownloadsIndex repoStarsIndex repoPlatformsIndex repoRowWidth ) const ( refArtifactTypeIndex = iota refSizeIndex refDigestIndex refRowWidth )