Skip to content

Commit

Permalink
feat: support cosign signatures / attestations and discover in `zarf …
Browse files Browse the repository at this point in the history
…prepare find-images` (#2027)

## Description

Output from @rjferguson21 and my dash day's explorations of this.

This PR includes:
- Logic pulled from [cosign
tree](https://github.com/sigstore/cosign/blob/main/cmd/cosign/cli/tree.go#L50)
to detect and include signatures/attestations when using `zarf prepare
find-images`
- Skips SBOMs and caching for "non-images" by checking layer mediatypes
at pull time

Several TODOs for follow-on work based on the issue/other needs:
- Add in SBOM pulling when available, skip over syft creation of SBOM
when applicable.
- Mutating digests as needed - in order for tools to identify the
signature the tag for it must be tagged `<digest>.sig`. When zarf does
its
[`AddImageAnnotation`](https://github.com/defenseunicorns/zarf/blob/main/src/pkg/utils/image.go#L43)
this could change the image digest, making it so that the signature is
no longer at the correct tag. Images already having that annotation are
unaffected (which is why this works OK for Ironbank).

## Related Issue

Relates to #475

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Other (security config, docs update, etc)

## Checklist before merging

- [ ] Test, docs, adr added or updated as needed
- [ ] [Contributor Guide
Steps](https://github.com/defenseunicorns/zarf/blob/main/CONTRIBUTING.md#developer-workflow)
followed

---------

Co-authored-by: Rob Ferguson <rjferguson21@gmail.com>
Co-authored-by: Wayne Starr <Racer159@users.noreply.github.com>
  • Loading branch information
3 people authored Oct 13, 2023
1 parent 4935cd5 commit c46a4b9
Show file tree
Hide file tree
Showing 7 changed files with 161 additions and 41 deletions.
8 changes: 8 additions & 0 deletions examples/helm-charts/zarf.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
78 changes: 45 additions & 33 deletions src/internal/packager/images/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()

Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand All @@ -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
}
13 changes: 8 additions & 5 deletions src/pkg/packager/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -156,14 +155,15 @@ 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 {
message.HeaderInfof("📦 PACKAGE IMAGES")

p.layout = p.layout.AddImages()

pulled := map[transform.Image]v1.Image{}
var pulled []images.ImgInfo

doPull := func() error {
imgConfig := images.ImageConfig{
Expand All @@ -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)
}
}
}

Expand All @@ -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)
}
}
Expand Down
38 changes: 35 additions & 3 deletions src/pkg/packager/prepare.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -268,16 +290,26 @@ 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
}

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()
Expand Down
45 changes: 45 additions & 0 deletions src/pkg/utils/sget.go → src/pkg/utils/cosign.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
19 changes: 19 additions & 0 deletions src/pkg/utils/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
1 change: 1 addition & 0 deletions src/test/e2e/00_use_cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down

0 comments on commit c46a4b9

Please sign in to comment.