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 <christopher.phillips@anchore.com>
Co-authored-by: Alex Goodman <alex.goodman@anchore.com>
This commit is contained in:
Christopher Angelo Phillips 2022-03-01 23:16:42 -05:00 committed by GitHub
parent edac8c7bf7
commit afc0c1acd9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 459 additions and 79 deletions

View File

@ -25,7 +25,7 @@ COVERAGE_THRESHOLD := 62
# CI cache busting values; change these if you want CI to not use previous stored cache # CI cache busting values; change these if you want CI to not use previous stored cache
INTEGRATION_CACHE_BUSTER="894d8ca" INTEGRATION_CACHE_BUSTER="894d8ca"
CLI_CACHE_BUSTER="894d8ca" CLI_CACHE_BUSTER="e5cdfd8"
BOOTSTRAP_CACHE="c7afb99ad" BOOTSTRAP_CACHE="c7afb99ad"
## Build variables ## Build variables

View File

@ -19,7 +19,6 @@ import (
"github.com/anchore/syft/syft/event" "github.com/anchore/syft/syft/event"
"github.com/anchore/syft/syft/format" "github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft/source" "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/in-toto/in-toto-golang/in_toto"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/pkg/profile" "github.com/pkg/profile"
@ -27,7 +26,6 @@ import (
"github.com/sigstore/cosign/pkg/cosign" "github.com/sigstore/cosign/pkg/cosign"
"github.com/sigstore/cosign/pkg/cosign/attestation" "github.com/sigstore/cosign/pkg/cosign/attestation"
"github.com/sigstore/sigstore/pkg/signature/dsse" "github.com/sigstore/sigstore/pkg/signature/dsse"
"github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag" "github.com/spf13/pflag"
"github.com/spf13/viper" "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 { func attestExec(ctx context.Context, _ *cobra.Command, args []string) error {
// can only be an image for attestation or OCI DIR // can only be an image for attestation or OCI DIR
userInput := args[0] userInput := args[0]
fs := afero.NewOsFs() si, err := source.ParseInput(userInput, false)
parsedScheme, _, _, err := source.DetectScheme(fs, image.DetectSource, userInput)
if err != nil { if err != nil {
return err return fmt.Errorf("could not generate source input for attest command: %q", err)
} }
if parsedScheme != source.ImageScheme { switch si.Scheme {
return fmt.Errorf("attest command can only be used with image sources but discovered %q when given %q", parsedScheme, userInput) 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 { if len(appConfig.Output) > 1 {
@ -162,7 +172,7 @@ func attestExec(ctx context.Context, _ *cobra.Command, args []string) error {
defer sv.Close() defer sv.Close()
return eventLoop( return eventLoop(
attestationExecWorker(userInput, output, predicateType, sv), attestationExecWorker(*si, output, predicateType, sv),
setupSignals(), setupSignals(),
eventSubscription, eventSubscription,
stereoscope.Cleanup, 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) errs := make(chan error)
go func() { go func() {
defer close(errs) 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 { if err != nil {
errs <- err errs <- err
return 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 { func generateAttestation(predicate []byte, src *source.Source, sv *sign.SignerVerifier, predicateType string) error {
h, err := v1.NewHash(src.Image.Metadata.ManifestDigest) switch len(src.Image.Metadata.RepoDigests) {
if err != nil { case 0:
return errors.Wrap(err, "could not hash manifest digest for image") 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) 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{ sh, err := attestation.GenerateStatement(attestation.GenerateOpts{
Predicate: bytes.NewBuffer(predicate), Predicate: bytes.NewBuffer(predicate),
Type: predicateType, Type: predicateType,
Digest: h.Hex, Digest: findValidDigest(src.Image.Metadata.RepoDigests),
}) })
if err != nil { if err != nil {
return err return err

View File

@ -228,9 +228,13 @@ func packagesExec(_ *cobra.Command, args []string) error {
// could be an image or a directory, with or without a scheme // could be an image or a directory, with or without a scheme
userInput := args[0] 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( return eventLoop(
packagesExecWorker(userInput, writer), packagesExecWorker(*si, writer),
setupSignals(), setupSignals(),
eventSubscription, eventSubscription,
stereoscope.Cleanup, stereoscope.Cleanup,
@ -249,18 +253,10 @@ func isVerbose() (result bool) {
return appConfig.CliOptions.Verbosity > 0 || isPipedInput 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() tasks, err := tasks()
if err != nil { if err != nil {
return nil, nil, err return 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()
} }
s := sbom.SBOM{ s := sbom.SBOM{
@ -274,7 +270,7 @@ func generateSBOM(userInput string, errs chan error) (*sbom.SBOM, *source.Source
buildRelationships(&s, src, tasks, errs) 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) { 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...)...) 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) errs := make(chan error)
go func() { go func() {
defer close(errs) 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 { if err != nil {
errs <- err errs <- err
return return

View File

@ -115,7 +115,13 @@ func powerUserExecWorker(userInput string, writer sbom.Writer) <-chan error {
return 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 { if err != nil {
errs <- err errs <- err
return return

4
go.mod
View File

@ -13,7 +13,7 @@ require (
github.com/anchore/go-testutils v0.0.0-20200925183923-d5f45b0d3c04 github.com/anchore/go-testutils v0.0.0-20200925183923-d5f45b0d3c04
github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b
github.com/anchore/packageurl-go v0.0.0-20210922164639-b3fa992ebd29 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/antihax/optional v1.0.0
github.com/bmatcuk/doublestar/v4 v4.0.2 github.com/bmatcuk/doublestar/v4 v4.0.2
github.com/docker/docker v20.10.12+incompatible github.com/docker/docker v20.10.12+incompatible
@ -285,7 +285,7 @@ require (
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.2 // indirect github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.4 // 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/errwrap v1.1.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0 // indirect github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0 // indirect

4
go.sum
View File

@ -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/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 h1:K9LfnxwhqvihqU0+MF325FNy7fsKV9EGaUxdfR4gnWk=
github.com/anchore/packageurl-go v0.0.0-20210922164639-b3fa992ebd29/go.mod h1:Oc1UkGaJwY6ND6vtAqPSlYrptKRJngHwkwB6W7l1uP0= 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-20220301220648-8aa8a4a0bf50 h1:+Fe67xv6NRLdZ5V9X2kw959XsKQCUx5/6RL/wQZfs44=
github.com/anchore/stereoscope v0.0.0-20220217141419-c6f02aed9ed2/go.mod h1:QpDHHV2h1NNfu7klzU75XC8RvSlaPK6HHgi0dy8A6sk= 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/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.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=

View File

@ -55,7 +55,6 @@ func DetectScheme(fs afero.Fs, imageDetector sourceDetector, userInput string) (
} }
// next: let's try more generic sources (dir, file, etc.) // next: let's try more generic sources (dir, file, etc.)
location, err := homedir.Expand(userInput) location, err := homedir.Expand(userInput)
if err != nil { if err != nil {
return UnknownScheme, image.UnknownSource, "", fmt.Errorf("unable to expand potential directory path: %w", err) 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() { if fileMeta.IsDir() {
return DirectoryScheme, source, location, nil return DirectoryScheme, source, location, nil
} }
return FileScheme, source, location, nil return FileScheme, source, location, nil
} }

View File

@ -33,28 +33,80 @@ type Source struct {
Exclusions []string 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 // ParseInput generates a source Input that can be used as an argument to generate a new source
func New(userInput string, registryOptions *image.RegistryOptions, exclusions []string) (*Source, func(), error) { // from specific providers including a registry.
func ParseInput(userInput string, detectAvailableImageSources bool) (*Input, error) {
fs := afero.NewOsFs() fs := afero.NewOsFs()
parsedScheme, imageSource, location, err := DetectScheme(fs, image.DetectSource, userInput) scheme, source, location, err := DetectScheme(fs, image.DetectSource, userInput)
if err != nil { 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() {} cleanupFn := func() {}
switch parsedScheme { switch in.Scheme {
case FileScheme: case FileScheme:
source, cleanupFn, err = generateFileSource(fs, location) source, cleanupFn, err = generateFileSource(fs, in.Location)
case DirectoryScheme: case DirectoryScheme:
source, cleanupFn, err = generateDirectorySource(fs, location) source, cleanupFn, err = generateDirectorySource(fs, in.Location)
case ImageScheme: case ImageScheme:
source, cleanupFn, err = generateImageSource(userInput, location, imageSource, registryOptions) source, cleanupFn, err = generateImageSource(in, registryOptions)
default: 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 { if err == nil {
@ -64,15 +116,15 @@ func New(userInput string, registryOptions *image.RegistryOptions, exclusions []
return source, cleanupFn, err return source, cleanupFn, err
} }
func generateImageSource(userInput, location string, imageSource image.Source, registryOptions *image.RegistryOptions) (*Source, func(), error) { func generateImageSource(in Input, registryOptions *image.RegistryOptions) (*Source, func(), error) {
img, cleanup, err := getImageWithRetryStrategy(userInput, location, imageSource, registryOptions) img, cleanup, err := getImageWithRetryStrategy(in, registryOptions)
if err != nil || img == nil { 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 { 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 return &s, cleanup, nil
@ -87,7 +139,7 @@ func parseScheme(userInput string) string {
return parts[0] 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() ctx := context.TODO()
var opts []stereoscope.Option var opts []stereoscope.Option
@ -95,13 +147,13 @@ func getImageWithRetryStrategy(userInput, location string, imageSource image.Sou
opts = append(opts, stereoscope.WithRegistryOptions(*registryOptions)) 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 { if err == nil {
// Success on the first try! // Success on the first try!
return img, stereoscope.Cleanup, nil return img, stereoscope.Cleanup, nil
} }
scheme := parseScheme(userInput) scheme := parseScheme(in.UserInput)
if !(scheme == "docker" || scheme == "registry") { if !(scheme == "docker" || scheme == "registry") {
// Image retrieval failed, and we shouldn't retry it. It's most likely that the // 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 // 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"+ "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", " without scheme parsing because image retrieval using scheme parsing was unsuccessful: %v",
scheme, scheme,
userInput, in.UserInput,
err, err,
) )
// We need to determine the image source again, such that this determination // We need to determine the image source again, such that this determination
// doesn't take scheme parsing into account. // doesn't take scheme parsing into account.
imageSource = image.DetermineImagePullSource(userInput) if in.autoDetectAvailableImageSources {
img, err = stereoscope.GetImageFromSource(ctx, userInput, imageSource, opts...) in.ImageSource = image.DetermineDefaultImagePullSource(in.UserInput)
}
img, err = stereoscope.GetImageFromSource(ctx, in.UserInput, in.ImageSource, opts...)
if err != nil { if err != nil {
return nil, nil, err 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) { func generateDirectorySource(fs afero.Fs, location string) (*Source, func(), error) {
fileMeta, err := fs.Stat(location) fileMeta, err := fs.Stat(location)
if err != nil { 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() { 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) s, err := NewFromDirectory(location)
if err != nil { 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 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) { func generateFileSource(fs afero.Fs, location string) (*Source, func(), error) {
fileMeta, err := fs.Stat(location) fileMeta, err := fs.Stat(location)
if err != nil { 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() { 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) s, cleanupFn := NewFromFile(location)

View File

@ -22,6 +22,30 @@ import (
"github.com/anchore/stereoscope/pkg/image" "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) { func TestNewFromImageFails(t *testing.T) {
t.Run("no image given", func(t *testing.T) { t.Run("no image given", func(t *testing.T) {
_, err := NewFromImage(nil, "") _, err := NewFromImage(nil, "")
@ -428,7 +452,9 @@ func TestDirectoryExclusions(t *testing.T) {
registryOpts := &image.RegistryOptions{} registryOpts := &image.RegistryOptions{}
for _, test := range testCases { for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) { 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() defer fn()
if test.err { if test.err {
@ -520,7 +546,9 @@ func TestImageExclusions(t *testing.T) {
for _, test := range testCases { for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) { t.Run(test.desc, func(t *testing.T) {
archiveLocation := imagetest.PrepareFixtureImage(t, "docker-archive", test.input) 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() defer fn()
if err != nil { if err != nil {

View File

@ -6,7 +6,7 @@ import (
) )
func TestAttestCmd(t *testing.T) { func TestAttestCmd(t *testing.T) {
coverageImage := "docker-archive:" + getFixtureImage(t, "image-pkg-coverage") img := "registry:busybox:latest"
tests := []struct { tests := []struct {
name string name string
args []string args []string
@ -26,19 +26,17 @@ func TestAttestCmd(t *testing.T) {
}, },
{ {
name: "can encode syft.json as the predicate given a password", 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{ assertions: []traitAssertion{
assertSuccessfulReturnCode, assertSuccessfulReturnCode,
// assertVerifyAttestation(coverageImage), Follow up on this assertion with verify blog or ephemperal registry
}, },
pw: "test", pw: "test",
}, },
{ {
name: "can encode syft.json as the predicate given a blank password", 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{ assertions: []traitAssertion{
assertSuccessfulReturnCode, assertSuccessfulReturnCode,
// assertVerifyAttestation(coverageImage), Follow up on this assertion with verify blog or ephemperal registry
}, },
pw: "", pw: "",
}, },

175
test/cli/cosign_test.go Normal file
View File

@ -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, " "))
}
}

View File

@ -0,0 +1 @@
**/*

View File

@ -0,0 +1 @@
FROM alpine:latest

View File

@ -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

View File

@ -3,6 +3,7 @@ package cli
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"math"
"os" "os"
"os/exec" "os/exec"
"path" "path"
@ -10,6 +11,7 @@ import (
"runtime" "runtime"
"strings" "strings"
"testing" "testing"
"time"
"github.com/anchore/stereoscope/pkg/imagetest" "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 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) { func runCommand(cmd *exec.Cmd, env map[string]string) (string, string) {
if env != nil { if env != nil {
cmd.Env = append(os.Environ(), envMapToSlice(env)...) cmd.Env = append(os.Environ(), envMapToSlice(env)...)
@ -154,3 +170,40 @@ func repoRoot(t testing.TB) string {
} }
return absRepoRoot 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
}

View File

@ -1,6 +1,7 @@
package integration package integration
import ( import (
"github.com/stretchr/testify/require"
"testing" "testing"
"github.com/anchore/syft/syft/linux" "github.com/anchore/syft/syft/linux"
@ -22,7 +23,10 @@ func BenchmarkImagePackageCatalogers(b *testing.B) {
var pc *pkg.Catalog var pc *pkg.Catalog
for _, c := range cataloger.ImageCatalogers(cataloger.DefaultConfig()) { for _, c := range cataloger.ImageCatalogers(cataloger.DefaultConfig()) {
// in case of future alteration where state is persisted, assume no dependency is safe to reuse // 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) b.Cleanup(cleanupSource)
if err != nil { if err != nil {
b.Fatalf("unable to get source: %+v", err) b.Fatalf("unable to get source: %+v", err)

View File

@ -1,6 +1,7 @@
package integration package integration
import ( import (
"github.com/stretchr/testify/require"
"testing" "testing"
"github.com/anchore/syft/syft/pkg/cataloger" "github.com/anchore/syft/syft/pkg/cataloger"
@ -15,12 +16,12 @@ import (
func catalogFixtureImage(t *testing.T, fixtureImageName string) (sbom.SBOM, *source.Source) { func catalogFixtureImage(t *testing.T, fixtureImageName string) (sbom.SBOM, *source.Source) {
imagetest.GetFixtureImage(t, "docker-archive", fixtureImageName) imagetest.GetFixtureImage(t, "docker-archive", fixtureImageName)
tarPath := imagetest.GetFixtureImageTarPath(t, fixtureImageName) tarPath := imagetest.GetFixtureImageTarPath(t, fixtureImageName)
userInput := "docker-archive:" + tarPath
theSource, cleanupSource, err := source.New("docker-archive:"+tarPath, nil, nil) sourceInput, err := source.ParseInput(userInput, false)
require.NoError(t, err)
theSource, cleanupSource, err := source.New(*sourceInput, nil, nil)
t.Cleanup(cleanupSource) t.Cleanup(cleanupSource)
if err != nil { require.NoError(t, err)
t.Fatalf("unable to get source: %+v", err)
}
// TODO: this would be better with functional options (after/during API refactor) // TODO: this would be better with functional options (after/during API refactor)
c := cataloger.DefaultConfig() 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) { 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) t.Cleanup(cleanupSource)
if err != nil { require.NoError(t, err)
t.Fatalf("unable to get source: %+v", err)
}
// TODO: this would be better with functional options (after/during API refactor) // TODO: this would be better with functional options (after/during API refactor)
c := cataloger.DefaultConfig() c := cataloger.DefaultConfig()