Files
kubescape/core/core/patch.go
dependabot[bot] 1c531c923d build(deps): Bump github.com/moby/buildkit from 0.26.1 to 0.28.1
Bumps [github.com/moby/buildkit](https://github.com/moby/buildkit) from 0.26.1 to 0.28.1.
- [Release notes](https://github.com/moby/buildkit/releases)
- [Commits](https://github.com/moby/buildkit/compare/v0.26.1...v0.28.1)

---
updated-dependencies:
- dependency-name: github.com/moby/buildkit
  dependency-version: 0.28.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Matthias Bertschy <matthias.bertschy@gmail.com>
2026-04-14 22:29:55 +02:00

394 lines
13 KiB
Go

package core
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"slices"
"strings"
"time"
"github.com/anchore/clio"
grypejson "github.com/anchore/grype/grype/presenter/json"
"github.com/anchore/grype/grype/presenter/models"
copaGrype "github.com/anubhav06/copa-grype/grype"
"github.com/containerd/platforms"
"github.com/docker/buildx/build"
"github.com/docker/cli/cli/config"
"github.com/kubescape/go-logger"
"github.com/kubescape/go-logger/helpers"
"github.com/kubescape/kubescape/v3/core/cautils"
ksmetav1 "github.com/kubescape/kubescape/v3/core/meta/datastructures/v1"
"github.com/kubescape/kubescape/v3/core/pkg/resultshandling"
"github.com/kubescape/kubescape/v3/core/pkg/resultshandling/printer"
"github.com/kubescape/kubescape/v3/pkg/imagescan"
"github.com/moby/buildkit/client"
"github.com/moby/buildkit/client/llb"
"github.com/moby/buildkit/exporter/containerimage/exptypes"
gwclient "github.com/moby/buildkit/frontend/gateway/client"
"github.com/moby/buildkit/session"
"github.com/moby/buildkit/session/auth/authprovider"
"github.com/project-copacetic/copacetic/pkg/buildkit"
"github.com/project-copacetic/copacetic/pkg/pkgmgr"
"github.com/project-copacetic/copacetic/pkg/types/unversioned"
"github.com/project-copacetic/copacetic/pkg/utils"
"github.com/quay/claircore/osrelease"
log "github.com/sirupsen/logrus"
)
const (
copaProduct = "copa"
)
func (ks *Kubescape) Patch(patchInfo *ksmetav1.PatchInfo, scanInfo *cautils.ScanInfo) (bool, error) {
// ===================== Scan the image =====================
logger.L().Start(fmt.Sprintf("Scanning image: %s", patchInfo.Image))
// Setup the scan service
distCfg, installCfg, _, err := imagescan.NewDefaultDBConfig(scanInfo.ListingURL)
if err != nil {
logger.L().StopError(fmt.Sprintf("Invalid Grype database URL '%s': %v", scanInfo.ListingURL, err))
return false, err
}
svc, err := imagescan.NewScanServiceWithMatchers(distCfg, installCfg, scanInfo.UseDefaultMatchers)
if err != nil {
logger.L().StopError(fmt.Sprintf("Failed to initialize image scanner: %s", err))
return false, err
}
defer svc.Close()
creds := imagescan.RegistryCredentials{
Username: patchInfo.Username,
Password: patchInfo.Password,
}
// Scan the image
scanResults, err := svc.Scan(ks.Context(), patchInfo.Image, creds, nil, nil)
if err != nil {
return false, err
}
model, err := models.NewDocument(clio.Identification{}, scanResults.Packages, scanResults.Context,
*scanResults.RemainingMatches, scanResults.IgnoredMatches, scanResults.VulnerabilityProvider, nil, nil, models.DefaultSortStrategy, false)
if err != nil {
return false, fmt.Errorf("failed to create document: %w", err)
}
// If the scan results ID is empty, set it to "grype"
if model.Descriptor.Name == "" {
model.Descriptor.Name = "grype"
}
// Save the scan results to a file in json format
pres := grypejson.NewPresenter(models.PresenterConfig{Document: model, SBOM: scanResults.SBOM})
fileName := fmt.Sprintf("%s:%s.json", patchInfo.ImageName, patchInfo.ImageTag)
fileName = strings.ReplaceAll(fileName, "/", "-")
writer := printer.GetWriter(ks.Context(), fileName)
if err = pres.Present(writer); err != nil {
return false, err
}
logger.L().StopSuccess(fmt.Sprintf("Successfully scanned image: %s", patchInfo.Image))
// ===================== Patch the image using copacetic =====================
logger.L().Start("Patching image...")
patchedImageName := fmt.Sprintf("%s:%s", patchInfo.ImageName, patchInfo.PatchedImageTag)
sout, serr := os.Stdout, os.Stderr
if logger.L().GetLevel() != "debug" {
disableCopaLogger()
}
if err = copaPatch(ks.Context(), patchInfo.Timeout, patchInfo.BuildkitAddress, patchInfo.Image, fileName, patchedImageName, "", patchInfo.IgnoreError, patchInfo.BuildKitOpts); err != nil {
return false, err
}
// Restore the output streams
os.Stdout, os.Stderr = sout, serr
logger.L().StopSuccess(fmt.Sprintf("Patched image successfully. Loaded image: %s", patchedImageName))
// ===================== Re-scan the image =====================
logger.L().Start(fmt.Sprintf("Re-scanning image: %s", patchedImageName))
scanResultsPatched, err := svc.Scan(ks.Context(), patchedImageName, creds, nil, nil)
if err != nil {
return false, err
}
logger.L().StopSuccess(fmt.Sprintf("Successfully re-scanned image: %s", patchedImageName))
// ===================== Clean up =====================
// Remove the scan results file, which was used to patch the image
if err := os.Remove(fileName); err != nil {
logger.L().Warning(fmt.Sprintf("failed to remove residual file: %v", fileName), helpers.Error(err))
}
// ===================== Results Handling =====================
scanInfo.SetScanType(cautils.ScanTypeImage)
outputPrinters := GetOutputPrinters(scanInfo, ks.Context(), "")
uiPrinter := GetUIPrinter(ks.Context(), scanInfo, "")
resultsHandler := resultshandling.NewResultsHandler(nil, outputPrinters, uiPrinter)
resultsHandler.ImageScanData = []cautils.ImageScanData{*scanResultsPatched}
return svc.ExceedsSeverityThreshold(imagescan.ParseSeverity(scanInfo.FailThresholdSeverity), scanResultsPatched.Matches), resultsHandler.HandleResults(ks.Context(), scanInfo)
}
func disableCopaLogger() {
os.Stdout, os.Stderr = nil, nil
null, _ := os.Open(os.DevNull)
log.SetOutput(null)
}
// copaPatch is a slightly modified copy of the Patch function from the original "project-copacetic/copacetic" repo
// https://github.com/project-copacetic/copacetic/blob/main/pkg/patch/patch.go
func copaPatch(ctx context.Context, timeout time.Duration, buildkitAddr, image, reportFile, patchedImageName, workingFolder string, ignoreError bool, bkOpts buildkit.Opts) error {
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
ch := make(chan error)
go func() {
ch <- patchWithContext(timeoutCtx, buildkitAddr, image, reportFile, patchedImageName, workingFolder, ignoreError, bkOpts)
}()
select {
case err := <-ch:
return err
case <-timeoutCtx.Done():
// add a grace period for long running deferred cleanup functions to complete
<-time.After(1 * time.Second)
err := fmt.Errorf("patch exceeded timeout %v", timeout)
log.Error(err)
return err
}
}
func patchWithContext(ctx context.Context, buildkitAddr, image, reportFile, patchedImageName, workingFolder string, ignoreError bool, bkOpts buildkit.Opts) error {
// Ensure working folder exists for call to InstallUpdates
if workingFolder == "" {
var err error
workingFolder, err = os.MkdirTemp("", "copa-*")
if err != nil {
return err
}
defer os.RemoveAll(workingFolder)
if err := os.Chmod(workingFolder, 0o744); err != nil {
return err
}
} else {
if isNew, err := utils.EnsurePath(workingFolder, 0o744); err != nil {
log.Errorf("failed to create workingFolder %s", workingFolder)
return err
} else if isNew {
defer os.RemoveAll(workingFolder)
}
}
var updates *unversioned.UpdateManifest
// Parse report for update packages
updates, err := tryParseScanReport(reportFile)
if err != nil {
return err
}
bkClient, err := buildkit.NewClient(ctx, bkOpts)
if err != nil {
return fmt.Errorf("copa: error creating buildkit client :: %w", err)
}
defer bkClient.Close()
dockerConfig := config.LoadDefaultConfigFile(os.Stderr)
cfg := authprovider.DockerAuthProviderConfig{AuthConfigProvider: authprovider.LoadAuthConfig(dockerConfig)}
attachable := []session.Attachable{authprovider.NewDockerAuthProvider(cfg)}
solveOpt := client.SolveOpt{
Exports: []client.ExportEntry{
{
Type: client.ExporterImage,
Attrs: map[string]string{
"name": patchedImageName,
"push": "true",
},
},
},
Frontend: "", // i.e. we are passing in the llb.Definition directly
Session: attachable, // used for authprovider, sshagentprovider and secretprovider
}
solveOpt.SourcePolicy, err = build.ReadSourcePolicy()
if err != nil {
return fmt.Errorf("copa: error reading source policy :: %w", err)
}
buildChannel := make(chan *client.SolveStatus)
_, err = bkClient.Build(ctx, solveOpt, copaProduct, func(ctx context.Context, c gwclient.Client) (*gwclient.Result, error) {
// Configure buildctl/client for use by package manager
config, err := buildkit.InitializeBuildkitConfig(ctx, c, image)
if err != nil {
return nil, fmt.Errorf("copa: error initializing buildkit config for image %s :: %w", image, err)
}
// Create package manager helper
var manager pkgmgr.PackageManager
if reportFile == "" {
// determine OS family
fileBytes, err := buildkit.ExtractFileFromState(ctx, c, &config.ImageState, "/etc/os-release")
if err != nil {
return nil, fmt.Errorf("unable to extract /etc/os-release file from state %w", err)
}
osType, err := getOSType(ctx, fileBytes)
if err != nil {
return nil, fmt.Errorf("copa: error getting os type :: %w", err)
}
osVersion, err := getOSVersion(ctx, fileBytes)
if err != nil {
return nil, fmt.Errorf("copa: error getting os version :: %w", err)
}
// get package manager based on os family type
manager, err = pkgmgr.GetPackageManager(osType, osVersion, config, workingFolder)
if err != nil {
return nil, fmt.Errorf("copa: error getting package manager for ostype=%s, version=%s :: %w", osType, osVersion, err)
}
// do not specify updates, will update all
updates = nil
} else {
// get package manager based on os family type
manager, err = pkgmgr.GetPackageManager(updates.Metadata.OS.Type, updates.Metadata.OS.Version, config, workingFolder)
if err != nil {
return nil, fmt.Errorf("copa: error getting package manager by family type: ostype=%s, osversion=%s :: %w", updates.Metadata.OS.Type, updates.Metadata.OS.Version, err)
}
}
// Export the patched image state to Docker
// TODO: Add support for other output modes as buildctl does.
log.Infof("Patching %d vulnerabilities", len(updates.Updates))
patchedImageState, errPkgs, err := manager.InstallUpdates(ctx, updates, ignoreError)
log.Infof("Error is: %v", err)
if err != nil {
return nil, nil
}
platform := platforms.Normalize(platforms.DefaultSpec())
if platform.OS != "linux" {
platform.OS = "linux"
}
def, err := patchedImageState.Marshal(ctx, llb.Platform(platform))
if err != nil {
return nil, err
}
res, err := c.Solve(ctx, gwclient.SolveRequest{
Definition: def.ToPB(),
Evaluate: true,
})
if err != nil {
return nil, err
}
res.AddMeta(exptypes.ExporterImageConfigKey, config.ConfigData)
// Currently can only validate updates if updating via scanner
if reportFile != "" {
// create a new manifest with the successfully patched packages
validatedManifest := &unversioned.UpdateManifest{
Metadata: unversioned.Metadata{
OS: unversioned.OS{
Type: updates.Metadata.OS.Type,
Version: updates.Metadata.OS.Version,
},
Config: unversioned.Config{
Arch: updates.Metadata.Config.Arch,
},
},
Updates: []unversioned.UpdatePackage{},
}
for _, update := range updates.Updates {
if !slices.Contains(errPkgs, update.Name) {
validatedManifest.Updates = append(validatedManifest.Updates, update)
}
}
}
return res, nil
}, buildChannel)
return err
}
func getOSType(ctx context.Context, osreleaseBytes []byte) (string, error) {
r := bytes.NewReader(osreleaseBytes)
osData, err := osrelease.Parse(ctx, r)
if err != nil {
return "", fmt.Errorf("unable to parse os-release data %w", err)
}
osType := strings.ToLower(osData["NAME"])
switch {
case strings.Contains(osType, "alpine"):
return "alpine", nil
case strings.Contains(osType, "debian"):
return "debian", nil
case strings.Contains(osType, "ubuntu"):
return "ubuntu", nil
case strings.Contains(osType, "amazon"):
return "amazon", nil
case strings.Contains(osType, "centos"):
return "centos", nil
case strings.Contains(osType, "mariner"):
return "cbl-mariner", nil
case strings.Contains(osType, "azure linux"):
return "azurelinux", nil
case strings.Contains(osType, "red hat"):
return "redhat", nil
case strings.Contains(osType, "rocky"):
return "rocky", nil
case strings.Contains(osType, "oracle"):
return "oracle", nil
case strings.Contains(osType, "alma"):
return "alma", nil
default:
log.Error("unsupported osType ", osType)
return "", errors.ErrUnsupported
}
}
func getOSVersion(ctx context.Context, osreleaseBytes []byte) (string, error) {
r := bytes.NewReader(osreleaseBytes)
osData, err := osrelease.Parse(ctx, r)
if err != nil {
return "", fmt.Errorf("unable to parse os-release data %w", err)
}
return osData["VERSION_ID"], nil
}
// This function adds support to copa for patching Kubescape produced results
func tryParseScanReport(file string) (*unversioned.UpdateManifest, error) {
parser := copaGrype.GrypeParser{}
manifest, err := parser.Parse(file)
if err != nil {
return nil, err
}
// Convert from v1alpha1 to unversioned.UpdateManifest
var um unversioned.UpdateManifest
um.Metadata.OS.Type = manifest.Metadata.OS.Type
um.Metadata.OS.Version = manifest.Metadata.OS.Version
um.Metadata.Config.Arch = manifest.Metadata.Config.Arch
um.Updates = make([]unversioned.UpdatePackage, len(manifest.Updates))
for i, update := range manifest.Updates {
um.Updates[i].Name = update.Name
um.Updates[i].InstalledVersion = update.InstalledVersion
um.Updates[i].FixedVersion = update.FixedVersion
um.Updates[i].VulnerabilityID = update.VulnerabilityID
}
return &um, nil
}