diff --git a/Makefile b/Makefile index 05393c80b0226..3db2b5dc16ea6 100644 --- a/Makefile +++ b/Makefile @@ -421,9 +421,9 @@ roxagent_%: build-prep $(eval os := $(firstword $(w))) $(eval arch := $(lastword $(w))) ifdef SKIP_CLI_BUILD - test -f bin/$(os)_$(arch)/roxagent || RACE=0 CGO_ENABLED=0 GOOS=$(os) GOARCH=$(arch) $(GOBUILD) ./compliance/virtualmachines/roxagent + test -f bin/$(os)_$(arch)/roxagent || RACE=0 CGO_ENABLED=0 GOOS=$(os) GOARCH=$(arch) GOTAGS=roxagent $(GOBUILD) ./compliance/virtualmachines/roxagent else - RACE=0 CGO_ENABLED=0 GOOS=$(os) GOARCH=$(arch) $(GOBUILD) ./compliance/virtualmachines/roxagent + RACE=0 CGO_ENABLED=0 GOOS=$(os) GOARCH=$(arch) GOTAGS=roxagent $(GOBUILD) ./compliance/virtualmachines/roxagent endif .PHONY: roxagent diff --git a/compliance/node/vm/indexer.go b/compliance/node/vm/indexer.go new file mode 100644 index 0000000000000..cfef5deb81f7d --- /dev/null +++ b/compliance/node/vm/indexer.go @@ -0,0 +1,256 @@ +package vm + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/pkg/errors" + "github.com/quay/claircore" + ccindexer "github.com/quay/claircore/indexer" + "github.com/quay/claircore/indexer/controller" + "github.com/quay/claircore/rhel" + "github.com/quay/zlog" + "github.com/rs/zerolog" + v4 "github.com/stackrox/rox/generated/internalapi/scanner/v4" + "github.com/stackrox/rox/pkg/scannerv4/mappers" + "github.com/stackrox/rox/pkg/sync" +) + +const ( + layerMediaType = "application/vnd.claircore.filesystem" + rhcosPackageDB = "sqlite:usr/share/rpm" +) + +var ( + // layerDigest is a dummy digest solely meant as a workaround to use Claircore. + layerDigest = fmt.Sprintf("sha256:%s", strings.Repeat("a", 64)) + ccLayerDigest = claircore.MustParseDigest(layerDigest) + + clientOnce sync.Once + defaultClient *http.Client + defaultClientErr error +) + +func init() { + // Set up zerolog for claircore + l := zerolog.New(os.Stderr).Level(zerolog.InfoLevel) + zlog.Set(&l) +} + +// logDebugf logs debug messages (minimal logger to avoid pkg/logging dependency) +func logDebugf(format string, args ...interface{}) { + log.Printf("[DEBUG] "+format, args...) +} + +// ignoreError ignores an error from a function (replaces pkgutils.IgnoreError) +func ignoreError(f func() error) { + if f != nil { + _ = f() + } +} + +// getProxyFunc returns a proxy function from environment variables +// This is a minimal version to avoid pulling in pkg/httputil/proxy (which pulls k8s) +func getProxyFunc() func(*http.Request) (*url.URL, error) { + return http.ProxyFromEnvironment +} + +// loadClientCertificate loads client certificate from standard locations +// This is a minimal version to avoid pkg/mtls dependency (which pulls k8s) +func loadClientCertificate() (tls.Certificate, error) { + // Standard paths for mTLS certificates in StackRox + certFile := "/run/secrets/stackrox.io/certs/cert.pem" + keyFile := "/run/secrets/stackrox.io/certs/key.pem" + + // Try loading cert/key pair + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return tls.Certificate{}, errors.Wrap(err, "loading client certificate") + } + return cert, nil +} + +func getDefaultClient() (*http.Client, error) { + clientOnce.Do(func() { + clientCert, err := loadClientCertificate() + if err != nil { + defaultClientErr = errors.Wrap(err, "obtaining client certificate") + return + } + defaultClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + Certificates: []tls.Certificate{clientCert}, + }, + Proxy: getProxyFunc(), + }, + Timeout: 30 * time.Second, + } + }) + return defaultClient, defaultClientErr +} + +// VMIndexerConfig represents VM indexer configuration parameters. +// This is a simplified version for VM scanning without registry dependencies. +type VMIndexerConfig struct { + // HostPath is the mount point of the read-only host filesystem. + HostPath string + // Client is the HTTP client used to fetch repo-to-CPE mapping. + Client *http.Client + // Repo2CPEMappingURL can be used to fetch the repo mapping file. + Repo2CPEMappingURL string + // Timeout controls the timeout for remote API calls. + Timeout time.Duration + // PackageDBFilter filters packages by packageDB. + // Empty string means no filtering. + PackageDBFilter string +} + +// IndexVM indexes a VM at the configured host path and returns an index report. +// This function is VM-specific and avoids heavy dependencies like pkg/registries. +func IndexVM(ctx context.Context, cfg VMIndexerConfig) (*v4.IndexReport, error) { + if _, err := os.Stat(cfg.HostPath); err != nil { + return nil, errors.Wrapf(err, "host path %q does not exist", cfg.HostPath) + } + + layer, err := createLayer(ctx, layerDigest, cfg.HostPath) + if err != nil { + return nil, err + } + defer ignoreError(layer.Close) + + repos, err := scanRepositories(ctx, cfg, layer) + if err != nil { + return nil, errors.Wrap(err, "failed to scan repositories") + } + + pkgs, err := scanPackages(ctx, cfg.PackageDBFilter, layer) + if err != nil { + return nil, errors.Wrap(err, "failed to scan packages") + } + + ccReport, err := coalesceReport(ctx, ccLayerDigest, repos, pkgs) + if err != nil { + return nil, errors.Wrap(err, "failed to coalesce report") + } + logDebugf("Finished coalescing report: %d repositories, %d packages", + len(ccReport.Repositories), len(ccReport.Packages)) + + ccReport.Success = true + ccReport.State = controller.IndexFinished.String() + + // Use mappers package for conversion (roxagent builds only get lightweight index functions) + report, err := mappers.ToProtoV4IndexReport(ccReport) + if err != nil { + return nil, errors.Wrap(err, "failed to convert index report") + } + return report, nil +} + +func createLayer(ctx context.Context, digest string, hostPath string) (*claircore.Layer, error) { + if hostPath == "" { + return nil, errors.New("host path is empty") + } + + absoluteHostPath, err := filepath.Abs(hostPath) + if err != nil { + return nil, errors.Wrapf(err, "resolving absolute host path %q", hostPath) + } + + hostURI := (&url.URL{ + Scheme: "file", + Path: filepath.ToSlash(absoluteHostPath), + }).String() + + desc := &claircore.LayerDescription{ + Digest: digest, + URI: hostURI, + MediaType: layerMediaType, + } + + l := &claircore.Layer{} + err = l.Init(ctx, desc, nil) + return l, errors.Wrap(err, "failed to init layer") +} + +func scanRepositories(ctx context.Context, cfg VMIndexerConfig, l *claircore.Layer) ([]*claircore.Repository, error) { + client := cfg.Client + if client == nil { + var err error + client, err = getDefaultClient() + if err != nil { + return nil, errors.Wrap(err, "creating repository scanner http client") + } + } + + scanner := rhel.RepositoryScanner{} + config := rhel.RepositoryScannerConfig{ + // Do not reach out to the Red Hat Container Catalog API. + DisableAPI: true, + Repo2CPEMappingURL: cfg.Repo2CPEMappingURL, + Timeout: cfg.Timeout, + } + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(&config); err != nil { + return nil, errors.Wrap(err, "failed to encode configuration") + } + if err := scanner.Configure(ctx, json.NewDecoder(&buf).Decode, client); err != nil { + return nil, errors.Wrap(err, "failed to configure repository scanner") + } + + repos, err := scanner.Scan(ctx, l) + if err != nil { + return nil, errors.Wrap(err, "failed to scan repositories") + } + for i, r := range repos { + r.ID = strconv.Itoa(i) + } + return repos, nil +} + +func scanPackages(ctx context.Context, packageDBFilter string, layer *claircore.Layer) ([]*claircore.Package, error) { + scanner := rhel.PackageScanner{} + pkgs, err := scanner.Scan(ctx, layer) + if err != nil { + return nil, errors.Wrap(err, "failed to invoke RHEL scanner") + } + + // Filter out packages if filter is specified + if packageDBFilter == "" { + return pkgs, nil + } + + filtered := pkgs[:0] + for _, pkg := range pkgs { + if pkg.PackageDB == packageDBFilter { + filtered = append(filtered, pkg) + } + } + return filtered, nil +} + +func coalesceReport(ctx context.Context, digest claircore.Digest, repos []*claircore.Repository, pkgs []*claircore.Package) (*claircore.IndexReport, error) { + layerArtifacts := []*ccindexer.LayerArtifacts{ + { + Hash: digest, + Repos: repos, + Pkgs: pkgs, + }, + } + + coalescer := rhel.Coalescer{} + return coalescer.Coalesce(ctx, layerArtifacts) +} diff --git a/compliance/virtualmachines/roxagent/index/index.go b/compliance/virtualmachines/roxagent/index/index.go index 03c7d0b2fd0ce..b9e749e8eeeec 100644 --- a/compliance/virtualmachines/roxagent/index/index.go +++ b/compliance/virtualmachines/roxagent/index/index.go @@ -3,20 +3,16 @@ package index import ( "context" "fmt" + "log" "math/rand" - "net/http" "time" - "github.com/stackrox/rox/compliance/node/index" + "github.com/stackrox/rox/compliance/node/vm" "github.com/stackrox/rox/compliance/virtualmachines/roxagent/common" "github.com/stackrox/rox/compliance/virtualmachines/roxagent/vsock" v4 "github.com/stackrox/rox/generated/internalapi/scanner/v4" - "github.com/stackrox/rox/pkg/httputil/proxy" - "github.com/stackrox/rox/pkg/logging" ) -var log = logging.LoggerForModule() - const ( mappingClientTimeout = 30 * time.Second ) @@ -39,7 +35,7 @@ func RunDaemon(ctx context.Context, cfg *common.Config, client *vsock.Client) er return ctx.Err() case <-ticker.C: if err := RunSingle(ctx, cfg, client); err != nil { - log.Errorf("Failed to handle index: %v", err) + log.Printf("[ERROR] Failed to handle index: %v", err) } } } @@ -60,20 +56,21 @@ func RunSingle(ctx context.Context, cfg *common.Config, client *vsock.Client) er } func runIndexer(ctx context.Context, cfg *common.Config) (*v4.IndexReport, error) { - indexerCfg := index.NodeIndexerConfig{ + // Use VM-specific indexer to avoid heavy dependencies (k8s, registries, cloud providers) + indexerCfg := vm.VMIndexerConfig{ HostPath: cfg.IndexHostPath, - // Client used to fetch the repo to cpe mapping json. - Client: &http.Client{Transport: proxy.RoundTripper()}, + // Client will use default from VM package (with simple proxy from environment) + Client: nil, // URL where to get the repo to cpe mapping json from. // In ACS, we fetch it internally from the cluster (to prevent Collector from accessing the Internet): // "https://sensor.stackrox.svc:443/scanner/definitions?file=repo2cpe" Repo2CPEMappingURL: cfg.RepoToCPEMappingURL, Timeout: mappingClientTimeout, - // Disable package filtering. + // Disable package filtering for VM scanning. PackageDBFilter: "", } - report, err := index.NewNodeIndexer(indexerCfg).IndexNode(ctx) + report, err := vm.IndexVM(ctx, indexerCfg) if err != nil { return nil, err } @@ -88,7 +85,7 @@ func applyRandomDelay(ctx context.Context, maxDelay time.Duration) error { r := rand.New(rand.NewSource(time.Now().UnixNano())) delay := time.Duration(r.Int63n(maxDelay.Nanoseconds() + 1)) - log.Infof("Delaying initial index report by %s (use --max-initial-report-delay to control this).", delay) + log.Printf("[INFO] Delaying initial index report by %s (use --max-initial-report-delay to control this).", delay) select { case <-ctx.Done(): diff --git a/pkg/scannerv4/mappers/mappers.go b/pkg/scannerv4/mappers/mappers.go index a547c9273b617..a87ddbbba942b 100644 --- a/pkg/scannerv4/mappers/mappers.go +++ b/pkg/scannerv4/mappers/mappers.go @@ -1,3 +1,5 @@ +//go:build !roxagent + package mappers import ( @@ -41,8 +43,6 @@ const ( redhatCVEURLPrefix = "https://access.redhat.com/security/cve/" // TODO(ROX-26672): Remove this when we stop tracking RHSAs as the vuln name. redhatErrataURLPrefix = "https://access.redhat.com/errata/" - - rhelRepositoryKey = "rhel-cpe-repository" ) var ( @@ -93,23 +93,6 @@ var ( rhccRepoURI = rhcc.GoldRepo.URI ) -// ToProtoV4IndexReport maps claircore.IndexReport to v4.IndexReport. -func ToProtoV4IndexReport(r *claircore.IndexReport) (*v4.IndexReport, error) { - if r == nil { - return nil, nil - } - contents, err := toProtoV4Contents(r.Packages, r.Distributions, r.Repositories, r.Environments, nil) - if err != nil { - return nil, err - } - return &v4.IndexReport{ - State: r.State, - Success: r.Success, - Err: r.Err, - Contents: contents, - }, nil -} - // ToProtoV4VulnerabilityReport maps claircore.VulnerabilityReport to v4.VulnerabilityReport. func ToProtoV4VulnerabilityReport(ctx context.Context, r *claircore.VulnerabilityReport) (*v4.VulnerabilityReport, error) { if r == nil { @@ -188,208 +171,28 @@ func toProtoV4Contents( envs map[string][]*claircore.Environment, pkgFixedBy map[string]string, ) (*v4.Contents, error) { - packages, deprecatedPackages, err := v4Packages(pkgs, pkgFixedBy) + // Use the lightweight index conversion + contents, err := toProtoV4IndexContents(pkgs, dists, repos, envs) if err != nil { return nil, err } - distributions, deprecatedDistributions, err := claircoreToV4(dists, v4Distribution) - if err != nil { - return nil, err - } - repositories, deprecatedRepositories, err := claircoreToV4(repos, v4Repository) - if err != nil { - return nil, err - } - environments, deprecatedEnrivonments := v4Environments(envs, repos) - return &v4.Contents{ - Packages: packages, - PackagesDEPRECATED: deprecatedPackages, - Distributions: distributions, - DistributionsDEPRECATED: deprecatedDistributions, - Repositories: repositories, - RepositoriesDEPRECATED: deprecatedRepositories, - Environments: environments, - EnvironmentsDEPRECATED: deprecatedEnrivonments, - }, nil -} -func v4Packages(ccPkgs map[string]*claircore.Package, pkgFixedBy map[string]string) (map[string]*v4.Package, []*v4.Package, error) { - if len(ccPkgs) == 0 { - return nil, nil, nil - } - packages := make(map[string]*v4.Package, len(ccPkgs)) - deprecatedPackages := make([]*v4.Package, 0, len(ccPkgs)) - for id, ccPkg := range ccPkgs { - v4Pkg, err := v4Package(ccPkg) - if err != nil { - return nil, nil, err - } - v4Pkg.FixedInVersion = pkgFixedBy[id] - packages[id] = v4Pkg - deprecatedPackages = append(deprecatedPackages, v4Pkg) - } - return packages, deprecatedPackages, nil -} - -func v4Package(p *claircore.Package) (*v4.Package, error) { - if p == nil { - return nil, nil - } - if p.Source != nil && p.Source.Source != nil { - return nil, fmt.Errorf("package %q: invalid source package %q: source specifies source", - p.ID, p.Source.ID) - } - // Conversion function. - toNormalizedVersion := func(version claircore.Version) *v4.NormalizedVersion { - return &v4.NormalizedVersion{ - Kind: version.Kind, - V: version.V[:], - } - } - srcPkg, err := v4Package(p.Source) - if err != nil { - return nil, err - } - return &v4.Package{ - Id: p.ID, - Name: p.Name, - Version: p.Version, - NormalizedVersion: toNormalizedVersion(p.NormalizedVersion), - Kind: p.Kind, - Source: srcPkg, - PackageDb: p.PackageDB, - RepositoryHint: p.RepositoryHint, - Module: p.Module, - Arch: p.Arch, - Cpe: toCPEString(p.CPE), - }, nil -} - -// VersionID returns the distribution version ID. -func VersionID(d *claircore.Distribution) string { - vID := d.VersionID - if vID == "" { - switch d.DID { - // TODO(ROX-21678): `VersionId` is currently not populated for Alpine[1], - // temporarily falling back to the version. - // - // [1]: https://github.com/quay/claircore/blob/88ccfbecee88d7b326b9a2fb3ab5b5f4cfa0b610/alpine/distributionscanner.go#L110-L113 - case "alpine": - vID = d.Version - } - } - return vID -} - -func claircoreToV4[K comparable, V1, V2 any](cc map[K]V1, f func(V1) (V2, error)) (map[K]V2, []V2, error) { - if len(cc) == 0 { - return nil, nil, nil - } - v4Map := make(map[K]V2, len(cc)) - v4Slice := make([]V2, 0, len(cc)) - for k, v := range cc { - v4Resource, err := f(v) - if err != nil { - return nil, nil, err - } - v4Map[k] = v4Resource - v4Slice = append(v4Slice, v4Resource) - } - return v4Map, v4Slice, nil -} - -func v4Distribution(d *claircore.Distribution) (*v4.Distribution, error) { - if d == nil { - return nil, nil - } - return &v4.Distribution{ - Id: d.ID, - Did: d.DID, - Name: d.Name, - Version: d.Version, - VersionCodeName: d.VersionCodeName, - VersionId: VersionID(d), - Arch: d.Arch, - Cpe: toCPEString(d.CPE), - PrettyName: d.PrettyName, - }, nil -} - -func v4Repository(r *claircore.Repository) (*v4.Repository, error) { - if r == nil { - return nil, nil - } - return &v4.Repository{ - Id: r.ID, - Name: r.Name, - Key: r.Key, - Uri: r.URI, - Cpe: toCPEString(r.CPE), - }, nil -} - -func v4Environments(ccEnvs map[string][]*claircore.Environment, ccRepos map[string]*claircore.Repository) (map[string]*v4.Environment_List, map[string]*v4.Environment_List) { - if len(ccEnvs) == 0 { - return nil, nil - } - environments := make(map[string]*v4.Environment_List, len(ccEnvs)) - environmentsDeprecated := make(map[string]*v4.Environment_List, len(ccEnvs)) - for id, envs := range ccEnvs { - l, ok := environments[id] - lDeprecated := environmentsDeprecated[id] - if !ok { - l = &v4.Environment_List{} - environments[id] = l - lDeprecated = &v4.Environment_List{} - environmentsDeprecated[id] = lDeprecated + // Add enrichment data (pkgFixedBy) to packages + if len(pkgFixedBy) > 0 { + for id, fixedIn := range pkgFixedBy { + if pkg, ok := contents.Packages[id]; ok { + pkg.FixedInVersion = fixedIn + } } - for _, env := range envs { - l.Environments = append(l.Environments, v4Environment(env)) - lDeprecated.Environments = append(lDeprecated.Environments, v4EnvironmentDeprecated(env, ccRepos)) + // Also update deprecated slice + for _, pkg := range contents.PackagesDEPRECATED { + if fixedIn, ok := pkgFixedBy[pkg.Id]; ok { + pkg.FixedInVersion = fixedIn + } } } - return environments, environmentsDeprecated -} -func v4Environment(e *claircore.Environment) *v4.Environment { - if e == nil { - return nil - } - return &v4.Environment{ - PackageDb: e.PackageDB, - IntroducedIn: toDigestString(e.IntroducedIn), - DistributionId: e.DistributionID, - RepositoryIds: append([]string(nil), e.RepositoryIDs...), - } -} - -func v4EnvironmentDeprecated(e *claircore.Environment, repos map[string]*claircore.Repository) *v4.Environment { - if e == nil { - return nil - } - repoIDs := make([]string, 0, len(e.RepositoryIDs)) - for _, id := range e.RepositoryIDs { - repo, ok := repos[id] - if !ok { - continue - } - // In Claircore v1.5.40+, the repositories are no longer all keyed by ID. - // RPMs in RHEL-based containers are now keyed by name. - // In older ACS versions, we assumed all repos were keyed by ID, - // so if the key is not the ID, we check if it's actually the name - // and this is, in fact, a RHEL RPM. - if repo.Key == rhelRepositoryKey { - repoIDs = append(repoIDs, repo.ID) - continue - } - repoIDs = append(repoIDs, id) - } - return &v4.Environment{ - PackageDb: e.PackageDB, - IntroducedIn: toDigestString(e.IntroducedIn), - DistributionId: e.DistributionID, - RepositoryIds: repoIDs, - } + return contents, nil } func toProtoV4PackageVulnerabilitiesMap(ccPkgVulnerabilities map[string][]string, ccVulnerabilities map[string]*claircore.Vulnerability, vulnerabilities map[string]*v4.VulnerabilityReport_Vulnerability) map[string]*v4.StringList { @@ -769,14 +572,6 @@ func toProtoV4VulnerabilitySeverityFromString(ctx context.Context, severity stri } } -func toCPEString(c cpe.WFN) string { - return c.BindFS() -} - -func toDigestString(digest claircore.Digest) string { - return digest.String() -} - func toClairCoreCPE(s string) (cpe.WFN, error) { c, err := cpe.Unbind(s) if err != nil { diff --git a/pkg/scannerv4/mappers/mappers_index.go b/pkg/scannerv4/mappers/mappers_index.go new file mode 100644 index 0000000000000..77e2a8e8db343 --- /dev/null +++ b/pkg/scannerv4/mappers/mappers_index.go @@ -0,0 +1,249 @@ +package mappers + +import ( + "fmt" + + "github.com/quay/claircore" + "github.com/quay/claircore/toolkit/types/cpe" + v4 "github.com/stackrox/rox/generated/internalapi/scanner/v4" +) + +const ( + rhelRepositoryKey = "rhel-cpe-repository" +) + +// ToProtoV4IndexReport maps claircore.IndexReport to v4.IndexReport. +func ToProtoV4IndexReport(r *claircore.IndexReport) (*v4.IndexReport, error) { + if r == nil { + return nil, nil + } + contents, err := toProtoV4IndexContents(r.Packages, r.Distributions, r.Repositories, r.Environments) + if err != nil { + return nil, err + } + return &v4.IndexReport{ + State: r.State, + Success: r.Success, + Err: r.Err, + Contents: contents, + }, nil +} + +// toProtoV4IndexContents converts index report contents without enrichment data. +// This is a lightweight version used by roxagent that doesn't require pkgFixedBy enrichment. +func toProtoV4IndexContents( + pkgs map[string]*claircore.Package, + dists map[string]*claircore.Distribution, + repos map[string]*claircore.Repository, + envs map[string][]*claircore.Environment, +) (*v4.Contents, error) { + packages, deprecatedPackages, err := v4IndexPackages(pkgs) + if err != nil { + return nil, err + } + distributions, deprecatedDistributions, err := claircoreToV4(dists, v4Distribution) + if err != nil { + return nil, err + } + repositories, deprecatedRepositories, err := claircoreToV4(repos, v4Repository) + if err != nil { + return nil, err + } + environments, deprecatedEnrivonments := v4Environments(envs, repos) + return &v4.Contents{ + Packages: packages, + PackagesDEPRECATED: deprecatedPackages, + Distributions: distributions, + DistributionsDEPRECATED: deprecatedDistributions, + Repositories: repositories, + RepositoriesDEPRECATED: deprecatedRepositories, + Environments: environments, + EnvironmentsDEPRECATED: deprecatedEnrivonments, + }, nil +} + +func v4IndexPackages(ccPkgs map[string]*claircore.Package) (map[string]*v4.Package, []*v4.Package, error) { + if len(ccPkgs) == 0 { + return nil, nil, nil + } + packages := make(map[string]*v4.Package, len(ccPkgs)) + deprecatedPackages := make([]*v4.Package, 0, len(ccPkgs)) + for id, ccPkg := range ccPkgs { + v4Pkg, err := v4Package(ccPkg) + if err != nil { + return nil, nil, err + } + packages[id] = v4Pkg + deprecatedPackages = append(deprecatedPackages, v4Pkg) + } + return packages, deprecatedPackages, nil +} + +func v4Package(p *claircore.Package) (*v4.Package, error) { + if p == nil { + return nil, nil + } + if p.Source != nil && p.Source.Source != nil { + return nil, fmt.Errorf("package %q: invalid source package %q: source specifies source", + p.ID, p.Source.ID) + } + // Conversion function. + toNormalizedVersion := func(version claircore.Version) *v4.NormalizedVersion { + return &v4.NormalizedVersion{ + Kind: version.Kind, + V: version.V[:], + } + } + srcPkg, err := v4Package(p.Source) + if err != nil { + return nil, err + } + return &v4.Package{ + Id: p.ID, + Name: p.Name, + Version: p.Version, + NormalizedVersion: toNormalizedVersion(p.NormalizedVersion), + Kind: p.Kind, + Source: srcPkg, + PackageDb: p.PackageDB, + RepositoryHint: p.RepositoryHint, + Module: p.Module, + Arch: p.Arch, + Cpe: toCPEString(p.CPE), + }, nil +} + +// VersionID returns the distribution version ID. +func VersionID(d *claircore.Distribution) string { + vID := d.VersionID + if vID == "" { + switch d.DID { + // TODO(ROX-21678): `VersionId` is currently not populated for Alpine[1], + // temporarily falling back to the version. + // + // [1]: https://github.com/quay/claircore/blob/88ccfbecee88d7b326b9a2fb3ab5b5f4cfa0b610/alpine/distributionscanner.go#L110-L113 + case "alpine": + vID = d.Version + } + } + return vID +} + +func claircoreToV4[K comparable, V1, V2 any](cc map[K]V1, f func(V1) (V2, error)) (map[K]V2, []V2, error) { + if len(cc) == 0 { + return nil, nil, nil + } + v4Map := make(map[K]V2, len(cc)) + v4Slice := make([]V2, 0, len(cc)) + for k, v := range cc { + v4Resource, err := f(v) + if err != nil { + return nil, nil, err + } + v4Map[k] = v4Resource + v4Slice = append(v4Slice, v4Resource) + } + return v4Map, v4Slice, nil +} + +func v4Distribution(d *claircore.Distribution) (*v4.Distribution, error) { + if d == nil { + return nil, nil + } + return &v4.Distribution{ + Id: d.ID, + Did: d.DID, + Name: d.Name, + Version: d.Version, + VersionCodeName: d.VersionCodeName, + VersionId: VersionID(d), + Arch: d.Arch, + Cpe: toCPEString(d.CPE), + PrettyName: d.PrettyName, + }, nil +} + +func v4Repository(r *claircore.Repository) (*v4.Repository, error) { + if r == nil { + return nil, nil + } + return &v4.Repository{ + Id: r.ID, + Name: r.Name, + Key: r.Key, + Uri: r.URI, + Cpe: toCPEString(r.CPE), + }, nil +} + +func v4Environments(ccEnvs map[string][]*claircore.Environment, ccRepos map[string]*claircore.Repository) (map[string]*v4.Environment_List, map[string]*v4.Environment_List) { + if len(ccEnvs) == 0 { + return nil, nil + } + environments := make(map[string]*v4.Environment_List, len(ccEnvs)) + environmentsDeprecated := make(map[string]*v4.Environment_List, len(ccEnvs)) + for id, envs := range ccEnvs { + l, ok := environments[id] + lDeprecated := environmentsDeprecated[id] + if !ok { + l = &v4.Environment_List{} + environments[id] = l + lDeprecated = &v4.Environment_List{} + environmentsDeprecated[id] = lDeprecated + } + for _, env := range envs { + l.Environments = append(l.Environments, v4Environment(env)) + lDeprecated.Environments = append(lDeprecated.Environments, v4EnvironmentDeprecated(env, ccRepos)) + } + } + return environments, environmentsDeprecated +} + +func v4Environment(e *claircore.Environment) *v4.Environment { + if e == nil { + return nil + } + return &v4.Environment{ + PackageDb: e.PackageDB, + IntroducedIn: toDigestString(e.IntroducedIn), + DistributionId: e.DistributionID, + RepositoryIds: append([]string(nil), e.RepositoryIDs...), + } +} + +func v4EnvironmentDeprecated(e *claircore.Environment, repos map[string]*claircore.Repository) *v4.Environment { + if e == nil { + return nil + } + repoIDs := make([]string, 0, len(e.RepositoryIDs)) + for _, id := range e.RepositoryIDs { + repo, ok := repos[id] + if !ok { + continue + } + // In Claircore v1.5.40+, the repositories are no longer all keyed by ID. + // RPMs in RHEL-based containers are now keyed by name. + // In older ACS versions, we assumed all repos were keyed by ID, + // so if the key is not the ID, we check if it's actually the name + // and this is, in fact, a RHEL RPM. + if repo.Key == rhelRepositoryKey { + repoIDs = append(repoIDs, repo.ID) + continue + } + repoIDs = append(repoIDs, id) + } + return &v4.Environment{ + PackageDb: e.PackageDB, + IntroducedIn: toDigestString(e.IntroducedIn), + DistributionId: e.DistributionID, + RepositoryIds: repoIDs, + } +} + +func toCPEString(c cpe.WFN) string { + return c.BindFS() +} + +func toDigestString(digest claircore.Digest) string { + return digest.String() +}