diff --git a/examples/helm-charts/zarf.yaml b/examples/helm-charts/zarf.yaml index 2f585162a2..ce15bfbc0f 100644 --- a/examples/helm-charts/zarf.yaml +++ b/examples/helm-charts/zarf.yaml @@ -14,6 +14,8 @@ components: localPath: chart images: - ghcr.io/stefanprodan/podinfo:6.4.0 + # This is the cosign signature for the podinfo image for image signature verification + - ghcr.io/stefanprodan/podinfo:sha256-57a654ace69ec02ba8973093b6a786faa15640575fbf0dbb603db55aca2ccec8.sig actions: onDeploy: after: @@ -34,6 +36,8 @@ components: gitPath: charts/podinfo images: - ghcr.io/stefanprodan/podinfo:6.4.0 + # This is the cosign signature for the podinfo image for image signature verification + - ghcr.io/stefanprodan/podinfo:sha256-57a654ace69ec02ba8973093b6a786faa15640575fbf0dbb603db55aca2ccec8.sig actions: onDeploy: after: @@ -53,6 +57,8 @@ components: url: oci://ghcr.io/stefanprodan/charts/podinfo images: - ghcr.io/stefanprodan/podinfo:6.4.0 + # This is the cosign signature for the podinfo image for image signature verification + - ghcr.io/stefanprodan/podinfo:sha256-57a654ace69ec02ba8973093b6a786faa15640575fbf0dbb603db55aca2ccec8.sig actions: onDeploy: after: @@ -80,6 +86,8 @@ components: releaseName: cool-name images: - ghcr.io/stefanprodan/podinfo:6.4.0 + # This is the cosign signature for the podinfo image for image signature verification + - ghcr.io/stefanprodan/podinfo:sha256-57a654ace69ec02ba8973093b6a786faa15640575fbf0dbb603db55aca2ccec8.sig actions: onDeploy: after: diff --git a/src/internal/packager/images/pull.go b/src/internal/packager/images/pull.go index e83d6ec560..ed0b70d478 100644 --- a/src/internal/packager/images/pull.go +++ b/src/internal/packager/images/pull.go @@ -33,20 +33,23 @@ import ( "github.com/pterm/pterm" ) +// ImgInfo wraps references/information about an image +type ImgInfo struct { + RefInfo transform.Image + Img v1.Image + HasImageLayers bool +} + // PullAll pulls all of the images in the provided tag map. -func (i *ImageConfig) PullAll() (map[transform.Image]v1.Image, error) { +func (i *ImageConfig) PullAll() ([]ImgInfo, error) { var ( longer string imageCount = len(i.ImageList) refInfoToImage = map[transform.Image]v1.Image{} referenceToDigest = make(map[string]string) + imgInfoList []ImgInfo ) - type imgInfo struct { - refInfo transform.Image - img v1.Image - } - type digestInfo struct { refInfo transform.Image digest string @@ -65,7 +68,7 @@ func (i *ImageConfig) PullAll() (map[transform.Image]v1.Image, error) { logs.Warn.SetOutput(&message.DebugWriter{}) logs.Progress.SetOutput(&message.DebugWriter{}) - metadataImageConcurrency := utils.NewConcurrencyTools[imgInfo, error](len(i.ImageList)) + metadataImageConcurrency := utils.NewConcurrencyTools[ImgInfo, error](len(i.ImageList)) defer metadataImageConcurrency.Cancel() @@ -97,7 +100,7 @@ func (i *ImageConfig) PullAll() (map[transform.Image]v1.Image, error) { return } - img, err := i.PullImage(actualSrc, spinner) + img, hasImageLayers, err := i.PullImage(actualSrc, spinner) if err != nil { metadataImageConcurrency.ErrorChan <- fmt.Errorf("failed to pull image %s: %w", actualSrc, err) return @@ -107,13 +110,14 @@ func (i *ImageConfig) PullAll() (map[transform.Image]v1.Image, error) { return } - metadataImageConcurrency.ProgressChan <- imgInfo{refInfo: refInfo, img: img} + metadataImageConcurrency.ProgressChan <- ImgInfo{RefInfo: refInfo, Img: img, HasImageLayers: hasImageLayers} }() } - onMetadataProgress := func(finishedImage imgInfo, iteration int) { - spinner.Updatef("Fetching image metadata (%d of %d): %s", iteration+1, len(i.ImageList), finishedImage.refInfo.Reference) - refInfoToImage[finishedImage.refInfo] = finishedImage.img + onMetadataProgress := func(finishedImage ImgInfo, iteration int) { + spinner.Updatef("Fetching image metadata (%d of %d): %s", iteration+1, len(i.ImageList), finishedImage.RefInfo.Reference) + refInfoToImage[finishedImage.RefInfo] = finishedImage.Img + imgInfoList = append(imgInfoList, finishedImage) } onMetadataError := func(err error) error { @@ -426,40 +430,42 @@ func (i *ImageConfig) PullAll() (map[transform.Image]v1.Image, error) { doneSaving <- 1 progressBarWaitGroup.Wait() - return refInfoToImage, nil + return imgInfoList, nil } -// PullImage returns a v1.Image either by loading a local tarball or the wider internet. -func (i *ImageConfig) PullImage(src string, spinner *message.Spinner) (img v1.Image, err error) { +// PullImage returns a v1.Image either by loading a local tarball or pulling from the wider internet. +func (i *ImageConfig) PullImage(src string, spinner *message.Spinner) (img v1.Image, hasImageLayers bool, err error) { + cacheImage := false // Load image tarballs from the local filesystem. if strings.HasSuffix(src, ".tar") || strings.HasSuffix(src, ".tar.gz") || strings.HasSuffix(src, ".tgz") { spinner.Updatef("Reading image tarball: %s", src) - return crane.Load(src, config.GetCraneOptions(true, i.Architectures...)...) - } - - // If crane is unable to pull the image, try to load it from the local docker daemon. - if _, err := crane.Manifest(src, config.GetCraneOptions(i.Insecure, i.Architectures...)...); err != nil { + img, err = crane.Load(src, config.GetCraneOptions(true, i.Architectures...)...) + if err != nil { + return nil, false, err + } + } else if _, err := crane.Manifest(src, config.GetCraneOptions(i.Insecure, i.Architectures...)...); err != nil { + // If crane is unable to pull the image, try to load it from the local docker daemon. message.Debugf("crane unable to pull image %s: %s", src, err) spinner.Updatef("Falling back to docker for %s. This may take some time.", src) // Parse the image reference to get the image name. reference, err := name.ParseReference(src) if err != nil { - return nil, fmt.Errorf("failed to parse image reference %s: %w", src, err) + return nil, false, fmt.Errorf("failed to parse image reference %s: %w", src, err) } // Attempt to connect to the local docker daemon. ctx := context.TODO() cli, err := client.NewClientWithOpts(client.FromEnv) if err != nil { - return nil, fmt.Errorf("docker not available: %w", err) + return nil, false, fmt.Errorf("docker not available: %w", err) } cli.NegotiateAPIVersion(ctx) // Inspect the image to get the size. rawImg, _, err := cli.ImageInspectWithRaw(ctx, src) if err != nil { - return nil, fmt.Errorf("failed to inspect image %s via docker: %w", src, err) + return nil, false, fmt.Errorf("failed to inspect image %s via docker: %w", src, err) } // Warn the user if the image is large. @@ -473,21 +479,27 @@ func (i *ImageConfig) PullImage(src string, spinner *message.Spinner) (img v1.Im // Use unbuffered opener to avoid OOM Kill issues https://github.com/defenseunicorns/zarf/issues/1214. // This will also take for ever to load large images. if img, err = daemon.Image(reference, daemon.WithUnbufferedOpener()); err != nil { - return nil, fmt.Errorf("failed to load image %s from docker daemon: %w", src, err) + return nil, false, fmt.Errorf("failed to load image %s from docker daemon: %w", src, err) } + } else { + // Manifest was found, so use crane to pull the image. + if img, err = crane.Pull(src, config.GetCraneOptions(i.Insecure, i.Architectures...)...); err != nil { + return nil, false, fmt.Errorf("failed to pull image %s: %w", src, err) + } + cacheImage = true + } - // The pull from the docker daemon was successful, return the image. - return img, err + hasImageLayers, err = utils.HasImageLayers(img) + if err != nil { + return nil, false, fmt.Errorf("failed to check image %s layer mediatype: %w", src, err) } - // Manifest was found, so use crane to pull the image. - if img, err = crane.Pull(src, config.GetCraneOptions(i.Insecure, i.Architectures...)...); err != nil { - return nil, fmt.Errorf("failed to pull image %s: %w", src, err) + if hasImageLayers && cacheImage { + spinner.Updatef("Preparing image %s", src) + imageCachePath := filepath.Join(config.GetAbsCachePath(), layout.ImagesDir) + img = cache.Image(img, cache.NewFilesystemCache(imageCachePath)) } - spinner.Updatef("Preparing image %s", src) - imageCachePath := filepath.Join(config.GetAbsCachePath(), layout.ImagesDir) - img = cache.Image(img, cache.NewFilesystemCache(imageCachePath)) + return img, hasImageLayers, nil - return img, nil } diff --git a/src/pkg/packager/create.go b/src/pkg/packager/create.go index db9624beae..5e3938ff48 100755 --- a/src/pkg/packager/create.go +++ b/src/pkg/packager/create.go @@ -29,7 +29,6 @@ import ( "github.com/defenseunicorns/zarf/src/pkg/utils/helpers" "github.com/defenseunicorns/zarf/src/types" "github.com/go-git/go-git/v5/plumbing" - v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/mholt/archiver/v3" ) @@ -156,6 +155,7 @@ func (p *Packager) Create() (err error) { } imageList := helpers.Unique(combinedImageList) + var sbomImageList []transform.Image // Images are handled separately from other component assets. if len(imageList) > 0 { @@ -163,7 +163,7 @@ func (p *Packager) Create() (err error) { p.layout = p.layout.AddImages() - pulled := map[transform.Image]v1.Image{} + var pulled []images.ImgInfo doPull := func() error { imgConfig := images.ImageConfig{ @@ -182,10 +182,13 @@ func (p *Packager) Create() (err error) { return fmt.Errorf("unable to pull images after 3 attempts: %w", err) } - for _, img := range pulled { - if err := p.layout.Images.AddV1Image(img); err != nil { + for _, imgInfo := range pulled { + if err := p.layout.Images.AddV1Image(imgInfo.Img); err != nil { return err } + if imgInfo.HasImageLayers { + sbomImageList = append(sbomImageList, imgInfo.RefInfo) + } } } @@ -194,7 +197,7 @@ func (p *Packager) Create() (err error) { message.Debug("Skipping image SBOM processing per --skip-sbom flag") } else { p.layout = p.layout.AddSBOMs() - if err := sbom.Catalog(componentSBOMs, imageList, p.layout); err != nil { + if err := sbom.Catalog(componentSBOMs, sbomImageList, p.layout); err != nil { return fmt.Errorf("unable to create an SBOM catalog for the package: %w", err) } } diff --git a/src/pkg/packager/prepare.go b/src/pkg/packager/prepare.go index af2e337c50..1b72c51ae1 100644 --- a/src/pkg/packager/prepare.go +++ b/src/pkg/packager/prepare.go @@ -36,6 +36,7 @@ func (p *Packager) FindImages() (imgMap map[string][]string, err error) { imagesMap := make(map[string][]string) erroredCharts := []string{} + erroredCosignLookups := []string{} cwd, err := os.Getwd() if err != nil { @@ -255,10 +256,31 @@ func (p *Packager) FindImages() (imgMap map[string][]string, err error) { if len(validImages) > 0 { componentDefinition += fmt.Sprintf(" # Possible images - %s - %s\n", p.cfg.Pkg.Metadata.Name, component.Name) for _, image := range validImages { + imagesMap[component.Name] = append(imagesMap[component.Name], image) componentDefinition += fmt.Sprintf(" - %s\n", image) } } } + + // Handle cosign artifact lookups + if len(imagesMap[component.Name]) > 0 { + var cosignArtifactList []string + for _, image := range imagesMap[component.Name] { + cosignArtifacts, err := utils.GetCosignArtifacts(image) + if err != nil { + message.WarnErrf(err, "Problem looking up cosign artifacts for %s: %s", image, err.Error()) + erroredCosignLookups = append(erroredCosignLookups, image) + } + cosignArtifactList = append(cosignArtifactList, cosignArtifacts...) + } + if len(cosignArtifactList) > 0 { + imagesMap[component.Name] = append(imagesMap[component.Name], cosignArtifactList...) + componentDefinition += fmt.Sprintf(" # Cosign artifacts for images - %s - %s\n", p.cfg.Pkg.Metadata.Name, component.Name) + for _, cosignArtifact := range cosignArtifactList { + componentDefinition += fmt.Sprintf(" - %s\n", cosignArtifact) + } + } + } } fmt.Println(componentDefinition) @@ -268,8 +290,18 @@ func (p *Packager) FindImages() (imgMap map[string][]string, err error) { return nil, err } - if len(erroredCharts) > 0 { - return imagesMap, fmt.Errorf("the following charts had errors: %s", erroredCharts) + if len(erroredCharts) > 0 || len(erroredCosignLookups) > 0 { + errMsg := "" + if len(erroredCharts) > 0 { + errMsg = fmt.Sprintf("the following charts had errors: %s", erroredCharts) + } + if len(erroredCosignLookups) > 0 { + if errMsg != "" { + errMsg += "\n" + } + errMsg += fmt.Sprintf("the following images errored on cosign lookups: %s", erroredCosignLookups) + } + return imagesMap, fmt.Errorf(errMsg) } return imagesMap, nil @@ -277,7 +309,7 @@ func (p *Packager) FindImages() (imgMap map[string][]string, err error) { func (p *Packager) processUnstructuredImages(resource *unstructured.Unstructured, matchedImages, maybeImages k8s.ImageMap) (k8s.ImageMap, k8s.ImageMap, error) { var imageSanityCheck = regexp.MustCompile(`(?mi)"image":"([^"]+)"`) - var imageFuzzyCheck = regexp.MustCompile(`(?mi)"([a-z0-9\-.\/]+:[\w][\w.\-]{0,127})"`) + var imageFuzzyCheck = regexp.MustCompile(`(?mi)["|=]([a-z0-9\-.\/:]+:[\w.\-]*[a-z\.\-][\w.\-]*)"`) var json string contents := resource.UnstructuredContent() diff --git a/src/pkg/utils/sget.go b/src/pkg/utils/cosign.go similarity index 83% rename from src/pkg/utils/sget.go rename to src/pkg/utils/cosign.go index 221f5190de..cea1ed6105 100644 --- a/src/pkg/utils/sget.go +++ b/src/pkg/utils/cosign.go @@ -217,3 +217,48 @@ func CosignSignBlob(blobPath string, outputSigPath string, keyPath string, passw return sig, err } + +// GetCosignArtifacts returns signatures and attestations for the given image +func GetCosignArtifacts(image string) (cosignList []string, err error) { + var cosignArtifactList []string + var nameOpts []name.Option + ref, err := name.ParseReference(image, nameOpts...) + + if err != nil { + return cosignArtifactList, err + } + + var remoteOpts []ociremote.Option + simg, _ := ociremote.SignedEntity(ref, remoteOpts...) + if simg == nil { + return cosignArtifactList, nil + } + // Errors are dogsled because these functions always return a name.Tag which we can check for layers + sigRef, _ := ociremote.SignatureTag(ref, remoteOpts...) + attRef, _ := ociremote.AttestationTag(ref, remoteOpts...) + + sigs, err := simg.Signatures() + if err != nil { + return cosignArtifactList, err + } + layers, err := sigs.Layers() + if err != nil { + return cosignArtifactList, err + } + if len(layers) > 0 { + cosignArtifactList = append(cosignArtifactList, sigRef.String()) + } + + atts, err := simg.Attestations() + if err != nil { + return cosignArtifactList, err + } + layers, err = atts.Layers() + if err != nil { + return cosignArtifactList, err + } + if len(layers) > 0 { + cosignArtifactList = append(cosignArtifactList, attRef.String()) + } + return cosignArtifactList, nil +} diff --git a/src/pkg/utils/image.go b/src/pkg/utils/image.go index bd6b4d2db7..46d67e8c88 100644 --- a/src/pkg/utils/image.go +++ b/src/pkg/utils/image.go @@ -85,3 +85,22 @@ func AddImageNameAnnotation(ociPath string, referenceToDigest map[string]string) } return os.WriteFile(indexPath, indexJSONBytes, 0600) } + +// HasImageLayers checks if any layers in the v1.Image are known image layers. +func HasImageLayers(img v1.Image) (bool, error) { + layers, err := img.Layers() + if err != nil { + return false, err + } + for _, layer := range layers { + mediatype, err := layer.MediaType() + if err != nil { + return false, err + } + // Check if mediatype is a known image layer + if mediatype.IsLayer() { + return true, nil + } + } + return false, nil +} diff --git a/src/test/e2e/00_use_cli_test.go b/src/test/e2e/00_use_cli_test.go index 0da193f576..d265035b52 100644 --- a/src/test/e2e/00_use_cli_test.go +++ b/src/test/e2e/00_use_cli_test.go @@ -93,6 +93,7 @@ func TestUseCLI(t *testing.T) { stdOut, stdErr, err = e2e.Zarf("prepare", "find-images", "--kube-version=v1.22.0", "src/test/packages/00-kube-version-override") require.NoError(t, err, stdOut, stdErr) require.Contains(t, stdOut, "quay.io/jetstack/cert-manager-controller:v1.11.1", "The chart image should be found by Zarf") + require.Contains(t, stdOut, "quay.io/jetstack/cert-manager-controller:sha256-4f1782c8316f34aae6b9ab823c3e6b7e6e4d92ec5dac21de6a17c3da44c364f1.sig", "The image signature should be found by Zarf") }) t.Run("zarf deploy should fail when given a bad component input", func(t *testing.T) {