From afc0c1acd9cadaef409fef3b3f214412d070284d Mon Sep 17 00:00:00 2001 From: Christopher Angelo Phillips <32073428+spiffcs@users.noreply.github.com> Date: Tue, 1 Mar 2022 23:16:42 -0500 Subject: [PATCH] 855 attest registry source only (#856) Add source.NewFromRegistry function so that the syft attest command can always explicitly ask for an OCIRegistry provider rather than rely on local daemon detection for image sources. Attestation can not be used where local images loaded in a daemon are the source. Digest values for the layer identification step in attestation can sometimes vary across workstations. This fix makes it so that attest is generating an SBOM for, and attesting to, a source that exists in an OCI registry. It should never load a source from a local user docker/podman daemon. Signed-off-by: Christopher Phillips Co-authored-by: Alex Goodman --- Makefile | 2 +- cmd/attest.go | 57 ++++-- cmd/packages.go | 34 ++-- cmd/power_user.go | 8 +- go.mod | 4 +- go.sum | 4 +- syft/source/scheme.go | 2 +- syft/source/source.go | 108 ++++++++--- syft/source/source_test.go | 32 +++- test/cli/attest_cmd_test.go | 8 +- test/cli/cosign_test.go | 175 ++++++++++++++++++ test/cli/test-fixtures/registry/.dockerignore | 1 + test/cli/test-fixtures/registry/Dockerfile | 1 + test/cli/test-fixtures/registry/Makefile | 23 +++ test/cli/utils_test.go | 53 ++++++ test/integration/catalog_packages_test.go | 6 +- test/integration/utils_test.go | 20 +- 17 files changed, 459 insertions(+), 79 deletions(-) create mode 100644 test/cli/cosign_test.go create mode 100644 test/cli/test-fixtures/registry/.dockerignore create mode 100644 test/cli/test-fixtures/registry/Dockerfile create mode 100644 test/cli/test-fixtures/registry/Makefile diff --git a/Makefile b/Makefile index 3be7e6d5a..530673722 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ COVERAGE_THRESHOLD := 62 # CI cache busting values; change these if you want CI to not use previous stored cache INTEGRATION_CACHE_BUSTER="894d8ca" -CLI_CACHE_BUSTER="894d8ca" +CLI_CACHE_BUSTER="e5cdfd8" BOOTSTRAP_CACHE="c7afb99ad" ## Build variables diff --git a/cmd/attest.go b/cmd/attest.go index 235b9b1ef..c05fcf389 100644 --- a/cmd/attest.go +++ b/cmd/attest.go @@ -19,7 +19,6 @@ import ( "github.com/anchore/syft/syft/event" "github.com/anchore/syft/syft/format" "github.com/anchore/syft/syft/source" - v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/in-toto/in-toto-golang/in_toto" "github.com/pkg/errors" "github.com/pkg/profile" @@ -27,7 +26,6 @@ import ( "github.com/sigstore/cosign/pkg/cosign" "github.com/sigstore/cosign/pkg/cosign/attestation" "github.com/sigstore/sigstore/pkg/signature/dsse" - "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -125,14 +123,26 @@ func selectPassFunc(keypath string) (cosign.PassFunc, error) { func attestExec(ctx context.Context, _ *cobra.Command, args []string) error { // can only be an image for attestation or OCI DIR userInput := args[0] - fs := afero.NewOsFs() - parsedScheme, _, _, err := source.DetectScheme(fs, image.DetectSource, userInput) + si, err := source.ParseInput(userInput, false) if err != nil { - return err + return fmt.Errorf("could not generate source input for attest command: %q", err) } - if parsedScheme != source.ImageScheme { - return fmt.Errorf("attest command can only be used with image sources but discovered %q when given %q", parsedScheme, userInput) + switch si.Scheme { + case source.ImageScheme, source.UnknownScheme: + // at this point we know that it cannot be dir: or file: schemes, so we will assume that the unknown scheme could represent an image + si.Scheme = source.ImageScheme + default: + return fmt.Errorf("attest command can only be used with image sources but discovered %q when given %q", si.Scheme, userInput) + } + + // if the original detection was from a local daemon we want to short circuit + // that and attempt to generate the image source from a registry source instead + switch si.ImageSource { + case image.UnknownSource, image.OciRegistrySource: + si.ImageSource = image.OciRegistrySource + default: + return fmt.Errorf("attest command can only be used with image sources fetch directly from the registry, but discovered an image source of %q when given %q", si.ImageSource, userInput) } if len(appConfig.Output) > 1 { @@ -162,7 +172,7 @@ func attestExec(ctx context.Context, _ *cobra.Command, args []string) error { defer sv.Close() return eventLoop( - attestationExecWorker(userInput, output, predicateType, sv), + attestationExecWorker(*si, output, predicateType, sv), setupSignals(), eventSubscription, stereoscope.Cleanup, @@ -170,12 +180,21 @@ func attestExec(ctx context.Context, _ *cobra.Command, args []string) error { ) } -func attestationExecWorker(userInput string, output format.Option, predicateType string, sv *sign.SignerVerifier) <-chan error { +func attestationExecWorker(sourceInput source.Input, output format.Option, predicateType string, sv *sign.SignerVerifier) <-chan error { errs := make(chan error) go func() { defer close(errs) - s, src, err := generateSBOM(userInput, errs) + src, cleanup, err := source.NewFromRegistry(sourceInput, appConfig.Registry.ToOptions(), appConfig.Exclusions) + if cleanup != nil { + defer cleanup() + } + if err != nil { + errs <- fmt.Errorf("failed to construct source from user input %q: %w", sourceInput.UserInput, err) + return + } + + s, err := generateSBOM(src, errs) if err != nil { errs <- err return @@ -210,10 +229,20 @@ func assertPredicateType(output format.Option) string { } } +func findValidDigest(digests []string) string { + // since we are only using the OCI repo provider for this source we are safe that this is only 1 value + // see https://github.com/anchore/stereoscope/blob/25ebd49a842b5ac0a20c2e2b4b81335b64ad248c/pkg/image/oci/registry_provider.go#L57-L63 + split := strings.Split(digests[0], "sha256:") + return split[1] +} + func generateAttestation(predicate []byte, src *source.Source, sv *sign.SignerVerifier, predicateType string) error { - h, err := v1.NewHash(src.Image.Metadata.ManifestDigest) - if err != nil { - return errors.Wrap(err, "could not hash manifest digest for image") + switch len(src.Image.Metadata.RepoDigests) { + case 0: + return fmt.Errorf("cannot generate attestation since no repo digests were found; make sure you're passing an OCI registry source for the attest command") + case 1: + default: + return fmt.Errorf("cannot generate attestation since multiple repo digests were found for the image: %+v", src.Image.Metadata.RepoDigests) } wrapped := dsse.WrapSigner(sv, intotoJSONDsseType) @@ -221,7 +250,7 @@ func generateAttestation(predicate []byte, src *source.Source, sv *sign.SignerVe sh, err := attestation.GenerateStatement(attestation.GenerateOpts{ Predicate: bytes.NewBuffer(predicate), Type: predicateType, - Digest: h.Hex, + Digest: findValidDigest(src.Image.Metadata.RepoDigests), }) if err != nil { return err diff --git a/cmd/packages.go b/cmd/packages.go index f52295a03..a5cd1a806 100644 --- a/cmd/packages.go +++ b/cmd/packages.go @@ -228,9 +228,13 @@ func packagesExec(_ *cobra.Command, args []string) error { // could be an image or a directory, with or without a scheme userInput := args[0] + si, err := source.ParseInput(userInput, true) + if err != nil { + return fmt.Errorf("could not generate source input for attest command: %q", err) + } return eventLoop( - packagesExecWorker(userInput, writer), + packagesExecWorker(*si, writer), setupSignals(), eventSubscription, stereoscope.Cleanup, @@ -249,18 +253,10 @@ func isVerbose() (result bool) { return appConfig.CliOptions.Verbosity > 0 || isPipedInput } -func generateSBOM(userInput string, errs chan error) (*sbom.SBOM, *source.Source, error) { +func generateSBOM(src *source.Source, errs chan error) (*sbom.SBOM, error) { tasks, err := tasks() if err != nil { - return nil, nil, err - } - - src, cleanup, err := source.New(userInput, appConfig.Registry.ToOptions(), appConfig.Exclusions) - if err != nil { - return nil, nil, fmt.Errorf("failed to construct source from user input %q: %w", userInput, err) - } - if cleanup != nil { - defer cleanup() + return nil, err } s := sbom.SBOM{ @@ -274,7 +270,7 @@ func generateSBOM(userInput string, errs chan error) (*sbom.SBOM, *source.Source buildRelationships(&s, src, tasks, errs) - return &s, src, nil + return &s, nil } func buildRelationships(s *sbom.SBOM, src *source.Source, tasks []task, errs chan error) { @@ -288,11 +284,21 @@ func buildRelationships(s *sbom.SBOM, src *source.Source, tasks []task, errs cha s.Relationships = append(s.Relationships, mergeRelationships(relationships...)...) } -func packagesExecWorker(userInput string, writer sbom.Writer) <-chan error { +func packagesExecWorker(si source.Input, writer sbom.Writer) <-chan error { errs := make(chan error) go func() { defer close(errs) - s, src, err := generateSBOM(userInput, errs) + + src, cleanup, err := source.New(si, appConfig.Registry.ToOptions(), appConfig.Exclusions) + if cleanup != nil { + defer cleanup() + } + if err != nil { + errs <- fmt.Errorf("failed to construct source from user input %q: %w", si.UserInput, err) + return + } + + s, err := generateSBOM(src, errs) if err != nil { errs <- err return diff --git a/cmd/power_user.go b/cmd/power_user.go index 9a68634e1..c736204c0 100644 --- a/cmd/power_user.go +++ b/cmd/power_user.go @@ -115,7 +115,13 @@ func powerUserExecWorker(userInput string, writer sbom.Writer) <-chan error { return } - src, cleanup, err := source.New(userInput, appConfig.Registry.ToOptions(), appConfig.Exclusions) + si, err := source.ParseInput(userInput, true) + if err != nil { + errs <- err + return + } + + src, cleanup, err := source.New(*si, appConfig.Registry.ToOptions(), appConfig.Exclusions) if err != nil { errs <- err return diff --git a/go.mod b/go.mod index f5d644baa..9eacc7604 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/anchore/go-testutils v0.0.0-20200925183923-d5f45b0d3c04 github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b github.com/anchore/packageurl-go v0.0.0-20210922164639-b3fa992ebd29 - github.com/anchore/stereoscope v0.0.0-20220217141419-c6f02aed9ed2 + github.com/anchore/stereoscope v0.0.0-20220301220648-8aa8a4a0bf50 github.com/antihax/optional v1.0.0 github.com/bmatcuk/doublestar/v4 v4.0.2 github.com/docker/docker v20.10.12+incompatible @@ -285,7 +285,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/golang/snappy v0.0.4 // indirect - github.com/google/go-containerregistry v0.8.1-0.20220209165246-a44adc326839 + github.com/google/go-containerregistry v0.8.1-0.20220209165246-a44adc326839 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0 // indirect diff --git a/go.sum b/go.sum index fa0811fc4..23ca7a97a 100644 --- a/go.sum +++ b/go.sum @@ -282,8 +282,8 @@ github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b h1:e1bmaoJfZV github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b/go.mod h1:Bkc+JYWjMCF8OyZ340IMSIi2Ebf3uwByOk6ho4wne1E= github.com/anchore/packageurl-go v0.0.0-20210922164639-b3fa992ebd29 h1:K9LfnxwhqvihqU0+MF325FNy7fsKV9EGaUxdfR4gnWk= github.com/anchore/packageurl-go v0.0.0-20210922164639-b3fa992ebd29/go.mod h1:Oc1UkGaJwY6ND6vtAqPSlYrptKRJngHwkwB6W7l1uP0= -github.com/anchore/stereoscope v0.0.0-20220217141419-c6f02aed9ed2 h1:QuvMG+rqqJmtFRL+jqj5pFgjQcJSnEHEbtj1lKowLLQ= -github.com/anchore/stereoscope v0.0.0-20220217141419-c6f02aed9ed2/go.mod h1:QpDHHV2h1NNfu7klzU75XC8RvSlaPK6HHgi0dy8A6sk= +github.com/anchore/stereoscope v0.0.0-20220301220648-8aa8a4a0bf50 h1:+Fe67xv6NRLdZ5V9X2kw959XsKQCUx5/6RL/wQZfs44= +github.com/anchore/stereoscope v0.0.0-20220301220648-8aa8a4a0bf50/go.mod h1:QpDHHV2h1NNfu7klzU75XC8RvSlaPK6HHgi0dy8A6sk= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= diff --git a/syft/source/scheme.go b/syft/source/scheme.go index dadfb64c0..1c90dba03 100644 --- a/syft/source/scheme.go +++ b/syft/source/scheme.go @@ -55,7 +55,6 @@ func DetectScheme(fs afero.Fs, imageDetector sourceDetector, userInput string) ( } // next: let's try more generic sources (dir, file, etc.) - location, err := homedir.Expand(userInput) if err != nil { return UnknownScheme, image.UnknownSource, "", fmt.Errorf("unable to expand potential directory path: %w", err) @@ -69,5 +68,6 @@ func DetectScheme(fs afero.Fs, imageDetector sourceDetector, userInput string) ( if fileMeta.IsDir() { return DirectoryScheme, source, location, nil } + return FileScheme, source, location, nil } diff --git a/syft/source/source.go b/syft/source/source.go index 131bde101..751dc1c0b 100644 --- a/syft/source/source.go +++ b/syft/source/source.go @@ -33,28 +33,80 @@ type Source struct { Exclusions []string } -type sourceDetector func(string) (image.Source, string, error) +// Input is an object that captures the detected user input regarding source location, scheme, and provider type. +// It acts as a struct input for some source constructors. +type Input struct { + UserInput string + Scheme Scheme + ImageSource image.Source + Location string + autoDetectAvailableImageSources bool +} -// New produces a Source based on userInput like dir: or image:tag -func New(userInput string, registryOptions *image.RegistryOptions, exclusions []string) (*Source, func(), error) { +// ParseInput generates a source Input that can be used as an argument to generate a new source +// from specific providers including a registry. +func ParseInput(userInput string, detectAvailableImageSources bool) (*Input, error) { fs := afero.NewOsFs() - parsedScheme, imageSource, location, err := DetectScheme(fs, image.DetectSource, userInput) + scheme, source, location, err := DetectScheme(fs, image.DetectSource, userInput) if err != nil { - return &Source{}, func() {}, fmt.Errorf("unable to parse input=%q: %w", userInput, err) + return nil, err } - source := &Source{} + if source == image.UnknownSource { + // only run for these two scheme + // only check on packages command, attest we automatically try to pull from userInput + switch scheme { + case ImageScheme, UnknownScheme: + if detectAvailableImageSources { + if imagePullSource := image.DetermineDefaultImagePullSource(userInput); imagePullSource != image.UnknownSource { + scheme = ImageScheme + source = imagePullSource + location = userInput + } + } + if location == "" { + location = userInput + } + default: + } + } + + // collect user input for downstream consumption + return &Input{ + UserInput: userInput, + Scheme: scheme, + ImageSource: source, + Location: location, + autoDetectAvailableImageSources: detectAvailableImageSources, + }, nil +} + +type sourceDetector func(string) (image.Source, string, error) + +func NewFromRegistry(in Input, registryOptions *image.RegistryOptions, exclusions []string) (*Source, func(), error) { + source, cleanupFn, err := generateImageSource(in, registryOptions) + if source != nil { + source.Exclusions = exclusions + } + return source, cleanupFn, err +} + +// New produces a Source based on userInput like dir: or image:tag +func New(in Input, registryOptions *image.RegistryOptions, exclusions []string) (*Source, func(), error) { + var err error + fs := afero.NewOsFs() + var source *Source cleanupFn := func() {} - switch parsedScheme { + switch in.Scheme { case FileScheme: - source, cleanupFn, err = generateFileSource(fs, location) + source, cleanupFn, err = generateFileSource(fs, in.Location) case DirectoryScheme: - source, cleanupFn, err = generateDirectorySource(fs, location) + source, cleanupFn, err = generateDirectorySource(fs, in.Location) case ImageScheme: - source, cleanupFn, err = generateImageSource(userInput, location, imageSource, registryOptions) + source, cleanupFn, err = generateImageSource(in, registryOptions) default: - err = fmt.Errorf("unable to process input for scanning: '%s'", userInput) + err = fmt.Errorf("unable to process input for scanning: %q", in.UserInput) } if err == nil { @@ -64,15 +116,15 @@ func New(userInput string, registryOptions *image.RegistryOptions, exclusions [] return source, cleanupFn, err } -func generateImageSource(userInput, location string, imageSource image.Source, registryOptions *image.RegistryOptions) (*Source, func(), error) { - img, cleanup, err := getImageWithRetryStrategy(userInput, location, imageSource, registryOptions) +func generateImageSource(in Input, registryOptions *image.RegistryOptions) (*Source, func(), error) { + img, cleanup, err := getImageWithRetryStrategy(in, registryOptions) if err != nil || img == nil { - return &Source{}, cleanup, fmt.Errorf("could not fetch image '%s': %w", location, err) + return nil, cleanup, fmt.Errorf("could not fetch image %q: %w", in.Location, err) } - s, err := NewFromImage(img, location) + s, err := NewFromImage(img, in.Location) if err != nil { - return &Source{}, cleanup, fmt.Errorf("could not populate source with image: %w", err) + return nil, cleanup, fmt.Errorf("could not populate source with image: %w", err) } return &s, cleanup, nil @@ -87,7 +139,7 @@ func parseScheme(userInput string) string { return parts[0] } -func getImageWithRetryStrategy(userInput, location string, imageSource image.Source, registryOptions *image.RegistryOptions) (*image.Image, func(), error) { +func getImageWithRetryStrategy(in Input, registryOptions *image.RegistryOptions) (*image.Image, func(), error) { ctx := context.TODO() var opts []stereoscope.Option @@ -95,13 +147,13 @@ func getImageWithRetryStrategy(userInput, location string, imageSource image.Sou opts = append(opts, stereoscope.WithRegistryOptions(*registryOptions)) } - img, err := stereoscope.GetImageFromSource(ctx, location, imageSource, opts...) + img, err := stereoscope.GetImageFromSource(ctx, in.Location, in.ImageSource, opts...) if err == nil { // Success on the first try! return img, stereoscope.Cleanup, nil } - scheme := parseScheme(userInput) + scheme := parseScheme(in.UserInput) if !(scheme == "docker" || scheme == "registry") { // Image retrieval failed, and we shouldn't retry it. It's most likely that the // user _did_ intend the parsed scheme, but there was a legitimate failure with @@ -118,14 +170,16 @@ func getImageWithRetryStrategy(userInput, location string, imageSource image.Sou "scheme %q specified, but it coincides with a common image name; re-examining user input %q"+ " without scheme parsing because image retrieval using scheme parsing was unsuccessful: %v", scheme, - userInput, + in.UserInput, err, ) // We need to determine the image source again, such that this determination // doesn't take scheme parsing into account. - imageSource = image.DetermineImagePullSource(userInput) - img, err = stereoscope.GetImageFromSource(ctx, userInput, imageSource, opts...) + if in.autoDetectAvailableImageSources { + in.ImageSource = image.DetermineDefaultImagePullSource(in.UserInput) + } + img, err = stereoscope.GetImageFromSource(ctx, in.UserInput, in.ImageSource, opts...) if err != nil { return nil, nil, err } @@ -136,16 +190,16 @@ func getImageWithRetryStrategy(userInput, location string, imageSource image.Sou func generateDirectorySource(fs afero.Fs, location string) (*Source, func(), error) { fileMeta, err := fs.Stat(location) if err != nil { - return &Source{}, func() {}, fmt.Errorf("unable to stat dir=%q: %w", location, err) + return nil, func() {}, fmt.Errorf("unable to stat dir=%q: %w", location, err) } if !fileMeta.IsDir() { - return &Source{}, func() {}, fmt.Errorf("given path is not a directory (path=%q): %w", location, err) + return nil, func() {}, fmt.Errorf("given path is not a directory (path=%q): %w", location, err) } s, err := NewFromDirectory(location) if err != nil { - return &Source{}, func() {}, fmt.Errorf("could not populate source from path=%q: %w", location, err) + return nil, func() {}, fmt.Errorf("could not populate source from path=%q: %w", location, err) } return &s, func() {}, nil @@ -154,11 +208,11 @@ func generateDirectorySource(fs afero.Fs, location string) (*Source, func(), err func generateFileSource(fs afero.Fs, location string) (*Source, func(), error) { fileMeta, err := fs.Stat(location) if err != nil { - return &Source{}, func() {}, fmt.Errorf("unable to stat dir=%q: %w", location, err) + return nil, func() {}, fmt.Errorf("unable to stat dir=%q: %w", location, err) } if fileMeta.IsDir() { - return &Source{}, func() {}, fmt.Errorf("given path is not a directory (path=%q): %w", location, err) + return nil, func() {}, fmt.Errorf("given path is not a directory (path=%q): %w", location, err) } s, cleanupFn := NewFromFile(location) diff --git a/syft/source/source_test.go b/syft/source/source_test.go index c15310b55..433f41094 100644 --- a/syft/source/source_test.go +++ b/syft/source/source_test.go @@ -22,6 +22,30 @@ import ( "github.com/anchore/stereoscope/pkg/image" ) +func TestParseInput(t *testing.T) { + tests := []struct { + name string + input string + expected Scheme + }{ + { + name: "ParseInput parses a file input", + input: "test-fixtures/image-simple/file-1.txt", + expected: FileScheme, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + sourceInput, err := ParseInput(test.input, true) + if err != nil { + t.Errorf("failed to ParseInput") + } + assert.Equal(t, sourceInput.Scheme, test.expected) + }) + } +} + func TestNewFromImageFails(t *testing.T) { t.Run("no image given", func(t *testing.T) { _, err := NewFromImage(nil, "") @@ -428,7 +452,9 @@ func TestDirectoryExclusions(t *testing.T) { registryOpts := &image.RegistryOptions{} for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - src, fn, err := New("dir:"+test.input, registryOpts, test.exclusions) + sourceInput, err := ParseInput("dir:"+test.input, false) + require.NoError(t, err) + src, fn, err := New(*sourceInput, registryOpts, test.exclusions) defer fn() if test.err { @@ -520,7 +546,9 @@ func TestImageExclusions(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { archiveLocation := imagetest.PrepareFixtureImage(t, "docker-archive", test.input) - src, fn, err := New(archiveLocation, registryOpts, test.exclusions) + sourceInput, err := ParseInput(archiveLocation, false) + require.NoError(t, err) + src, fn, err := New(*sourceInput, registryOpts, test.exclusions) defer fn() if err != nil { diff --git a/test/cli/attest_cmd_test.go b/test/cli/attest_cmd_test.go index 26b99ed67..fa385a176 100644 --- a/test/cli/attest_cmd_test.go +++ b/test/cli/attest_cmd_test.go @@ -6,7 +6,7 @@ import ( ) func TestAttestCmd(t *testing.T) { - coverageImage := "docker-archive:" + getFixtureImage(t, "image-pkg-coverage") + img := "registry:busybox:latest" tests := []struct { name string args []string @@ -26,19 +26,17 @@ func TestAttestCmd(t *testing.T) { }, { name: "can encode syft.json as the predicate given a password", - args: []string{"attest", "-o", "json", coverageImage}, + args: []string{"attest", "-o", "json", img}, assertions: []traitAssertion{ assertSuccessfulReturnCode, - // assertVerifyAttestation(coverageImage), Follow up on this assertion with verify blog or ephemperal registry }, pw: "test", }, { name: "can encode syft.json as the predicate given a blank password", - args: []string{"attest", "-o", "json", coverageImage}, + args: []string{"attest", "-o", "json", img}, assertions: []traitAssertion{ assertSuccessfulReturnCode, - // assertVerifyAttestation(coverageImage), Follow up on this assertion with verify blog or ephemperal registry }, pw: "", }, diff --git a/test/cli/cosign_test.go b/test/cli/cosign_test.go new file mode 100644 index 000000000..823180991 --- /dev/null +++ b/test/cli/cosign_test.go @@ -0,0 +1,175 @@ +package cli + +import ( + "bufio" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runAndShow(t *testing.T, cmd *exec.Cmd) { + t.Helper() + + stderr, err := cmd.StderrPipe() + require.NoErrorf(t, err, "could not get stderr: +v", err) + + stdout, err := cmd.StdoutPipe() + require.NoErrorf(t, err, "could not get stdout: +v", err) + + err = cmd.Start() + require.NoErrorf(t, err, "failed to start cmd: %+v", err) + + show := func(label string, reader io.ReadCloser) { + scanner := bufio.NewScanner(reader) + scanner.Split(bufio.ScanLines) + for scanner.Scan() { + t.Logf("%s: %s", label, scanner.Text()) + } + } + + show("out", stdout) + show("err", stderr) +} + +func TestCosignWorkflow(t *testing.T) { + // found under test-fixtures/registry/Makefile + img := "localhost:5000/attest:latest" + attestationFile := "attestation.json" + tests := []struct { + name string + syftArgs []string + cosignAttachArgs []string + cosignVerifyArgs []string + env map[string]string + assertions []traitAssertion + setup func(*testing.T) + cleanup func() + }{ + { + name: "cosign verify syft attest", + syftArgs: []string{ + "attest", + "-o", + "json", + img, + }, + // cosign attach attestation --attestation image_latest_sbom_attestation.json caphill4/attest:latest + cosignAttachArgs: []string{ + "attach", + "attestation", + "--attestation", + attestationFile, + img, + }, + // cosign verify-attestation -key cosign.pub caphill4/attest:latest + cosignVerifyArgs: []string{ + "verify-attestation", + "-key", + "cosign.pub", + img, + }, + assertions: []traitAssertion{ + assertSuccessfulReturnCode, + }, + setup: func(t *testing.T) { + cwd, err := os.Getwd() + require.NoErrorf(t, err, "unable to get cwd: %+v", err) + + // get working directory for local registry + fixturesPath := filepath.Join(cwd, "test-fixtures", "registry") + makeTask := filepath.Join(fixturesPath, "Makefile") + t.Logf("Generating Fixture from 'make %s'", makeTask) + + cmd := exec.Command("make") + cmd.Dir = fixturesPath + runAndShow(t, cmd) + + var done = make(chan struct{}) + defer close(done) + for interval := range testRetryIntervals(done) { + resp, err := http.Get("http://127.0.0.1:5000/v2/") + if err != nil { + t.Logf("waiting for registry err=%+v", err) + } else { + if resp.StatusCode == http.StatusOK { + break + } + t.Logf("waiting for registry code=%+v", resp.StatusCode) + } + + time.Sleep(interval) + } + + cmd = exec.Command("make", "push") + cmd.Dir = fixturesPath + runAndShow(t, cmd) + + }, + cleanup: func() { + cwd, err := os.Getwd() + assert.NoErrorf(t, err, "unable to get cwd: %+v", err) + + fixturesPath := filepath.Join(cwd, "test-fixtures", "registry") + makeTask := filepath.Join(fixturesPath, "Makefile") + t.Logf("Generating Fixture from 'make %s'", makeTask) + + // delete attestation file + os.Remove(attestationFile) + + cmd := exec.Command("make", "stop") + cmd.Dir = fixturesPath + + runAndShow(t, cmd) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(tt.cleanup) + tt.setup(t) + pkiCleanup := setupPKI(t, "") // blank password + defer pkiCleanup() + + // attest + cmd, stdout, stderr := runSyft(t, tt.env, tt.syftArgs...) + for _, traitFn := range tt.assertions { + traitFn(t, stdout, stderr, cmd.ProcessState.ExitCode()) + } + checkCmdFailure(t, stdout, stderr, cmd) + require.NoError(t, os.WriteFile(attestationFile, []byte(stdout), 0666)) + + // attach + cmd, stdout, stderr = runCosign(t, tt.env, tt.cosignAttachArgs...) + for _, traitFn := range tt.assertions { + traitFn(t, stdout, stderr, cmd.ProcessState.ExitCode()) + } + checkCmdFailure(t, stdout, stderr, cmd) + + // attest + cmd, stdout, stderr = runCosign(t, tt.env, tt.cosignAttachArgs...) + for _, traitFn := range tt.assertions { + traitFn(t, stdout, stderr, cmd.ProcessState.ExitCode()) + } + checkCmdFailure(t, stdout, stderr, cmd) + + }) + } +} + +func checkCmdFailure(t testing.TB, stdout, stderr string, cmd *exec.Cmd) { + require.Falsef(t, t.Failed(), "%s %s trait assertion failed", cmd.Path, strings.Join(cmd.Args, " ")) + if t.Failed() { + t.Log("STDOUT:\n", stdout) + t.Log("STDERR:\n", stderr) + t.Log("COMMAND:", strings.Join(cmd.Args, " ")) + } +} diff --git a/test/cli/test-fixtures/registry/.dockerignore b/test/cli/test-fixtures/registry/.dockerignore new file mode 100644 index 000000000..91224e5de --- /dev/null +++ b/test/cli/test-fixtures/registry/.dockerignore @@ -0,0 +1 @@ +**/* diff --git a/test/cli/test-fixtures/registry/Dockerfile b/test/cli/test-fixtures/registry/Dockerfile new file mode 100644 index 000000000..b09b037ca --- /dev/null +++ b/test/cli/test-fixtures/registry/Dockerfile @@ -0,0 +1 @@ +FROM alpine:latest diff --git a/test/cli/test-fixtures/registry/Makefile b/test/cli/test-fixtures/registry/Makefile new file mode 100644 index 000000000..4a55e2aaa --- /dev/null +++ b/test/cli/test-fixtures/registry/Makefile @@ -0,0 +1,23 @@ +all: build start + +.PHONY: stop +stop: + docker kill registry + +.PHONY: build +build: + docker build -t localhost:5000/attest:latest . + +.PHONY: start +start: + docker run --rm \ + -d \ + --name registry \ + -it \ + --privileged \ + -p 5000:5000 \ + registry:2.8 + +.PHONY: push +push: + docker push localhost:5000/attest:latest diff --git a/test/cli/utils_test.go b/test/cli/utils_test.go index 17c7feb74..5ecf73198 100644 --- a/test/cli/utils_test.go +++ b/test/cli/utils_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "fmt" + "math" "os" "os/exec" "path" @@ -10,6 +11,7 @@ import ( "runtime" "strings" "testing" + "time" "github.com/anchore/stereoscope/pkg/imagetest" ) @@ -95,6 +97,20 @@ func runSyft(t testing.TB, env map[string]string, args ...string) (*exec.Cmd, st return cmd, stdout, stderr } +func runCosign(t testing.TB, env map[string]string, args ...string) (*exec.Cmd, string, string) { + cmd := getCosignCommand(t, args...) + if env == nil { + env = make(map[string]string) + } + + stdout, stderr := runCommand(cmd, env) + return cmd, stdout, stderr +} + +func getCosignCommand(t testing.TB, args ...string) *exec.Cmd { + return exec.Command(filepath.Join(repoRoot(t), ".tmp/cosign"), args...) +} + func runCommand(cmd *exec.Cmd, env map[string]string) (string, string) { if env != nil { cmd.Env = append(os.Environ(), envMapToSlice(env)...) @@ -154,3 +170,40 @@ func repoRoot(t testing.TB) string { } return absRepoRoot } + +func testRetryIntervals(done <-chan struct{}) <-chan time.Duration { + return exponentialBackoffDurations(250*time.Millisecond, 4*time.Second, 2, done) +} + +func exponentialBackoffDurations(minDuration, maxDuration time.Duration, step float64, done <-chan struct{}) <-chan time.Duration { + sleepDurations := make(chan time.Duration) + go func() { + defer close(sleepDurations) + retryLoop: + for attempt := 0; ; attempt++ { + duration := exponentialBackoffDuration(minDuration, maxDuration, step, attempt) + + select { + case sleepDurations <- duration: + break + case <-done: + break retryLoop + } + + if duration == maxDuration { + break + } + } + }() + return sleepDurations +} + +func exponentialBackoffDuration(minDuration, maxDuration time.Duration, step float64, attempt int) time.Duration { + duration := time.Duration(float64(minDuration) * math.Pow(step, float64(attempt))) + if duration < minDuration { + return minDuration + } else if duration > maxDuration { + return maxDuration + } + return duration +} diff --git a/test/integration/catalog_packages_test.go b/test/integration/catalog_packages_test.go index 2d79e1b09..7747a5536 100644 --- a/test/integration/catalog_packages_test.go +++ b/test/integration/catalog_packages_test.go @@ -1,6 +1,7 @@ package integration import ( + "github.com/stretchr/testify/require" "testing" "github.com/anchore/syft/syft/linux" @@ -22,7 +23,10 @@ func BenchmarkImagePackageCatalogers(b *testing.B) { var pc *pkg.Catalog for _, c := range cataloger.ImageCatalogers(cataloger.DefaultConfig()) { // in case of future alteration where state is persisted, assume no dependency is safe to reuse - theSource, cleanupSource, err := source.New("docker-archive:"+tarPath, nil, nil) + userInput := "docker-archive:" + tarPath + sourceInput, err := source.ParseInput(userInput, false) + require.NoError(b, err) + theSource, cleanupSource, err := source.New(*sourceInput, nil, nil) b.Cleanup(cleanupSource) if err != nil { b.Fatalf("unable to get source: %+v", err) diff --git a/test/integration/utils_test.go b/test/integration/utils_test.go index e9b8e74f6..3b8866786 100644 --- a/test/integration/utils_test.go +++ b/test/integration/utils_test.go @@ -1,6 +1,7 @@ package integration import ( + "github.com/stretchr/testify/require" "testing" "github.com/anchore/syft/syft/pkg/cataloger" @@ -15,12 +16,12 @@ import ( func catalogFixtureImage(t *testing.T, fixtureImageName string) (sbom.SBOM, *source.Source) { imagetest.GetFixtureImage(t, "docker-archive", fixtureImageName) tarPath := imagetest.GetFixtureImageTarPath(t, fixtureImageName) - - theSource, cleanupSource, err := source.New("docker-archive:"+tarPath, nil, nil) + userInput := "docker-archive:" + tarPath + sourceInput, err := source.ParseInput(userInput, false) + require.NoError(t, err) + theSource, cleanupSource, err := source.New(*sourceInput, nil, nil) t.Cleanup(cleanupSource) - if err != nil { - t.Fatalf("unable to get source: %+v", err) - } + require.NoError(t, err) // TODO: this would be better with functional options (after/during API refactor) c := cataloger.DefaultConfig() @@ -50,11 +51,12 @@ func catalogFixtureImage(t *testing.T, fixtureImageName string) (sbom.SBOM, *sou } func catalogDirectory(t *testing.T, dir string) (sbom.SBOM, *source.Source) { - theSource, cleanupSource, err := source.New("dir:"+dir, nil, nil) + userInput := "dir:" + dir + sourceInput, err := source.ParseInput(userInput, false) + require.NoError(t, err) + theSource, cleanupSource, err := source.New(*sourceInput, nil, nil) t.Cleanup(cleanupSource) - if err != nil { - t.Fatalf("unable to get source: %+v", err) - } + require.NoError(t, err) // TODO: this would be better with functional options (after/during API refactor) c := cataloger.DefaultConfig()