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()