diff --git a/cmd/syft/cli/attest.go b/cmd/syft/cli/attest.go index 21bfe6b93..1f12916d8 100644 --- a/cmd/syft/cli/attest.go +++ b/cmd/syft/cli/attest.go @@ -8,6 +8,7 @@ import ( "github.com/anchore/syft/cmd/syft/cli/options" "github.com/anchore/syft/internal" "github.com/anchore/syft/internal/config" + "github.com/sigstore/cosign/cmd/cosign/cli/sign" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -53,7 +54,19 @@ func Attest(v *viper.Viper, app *config.Application, ro *options.RootOptions) *c checkForApplicationUpdate() } - return attest.Run(cmd.Context(), app, args) + // build cosign key options for attestation + ko := sign.KeyOpts{ + KeyRef: app.Attest.KeyRef, + FulcioURL: app.Attest.FulcioURL, + IDToken: app.Attest.FulcioIdentityToken, + InsecureSkipFulcioVerify: app.Attest.InsecureSkipFulcioVerify, + RekorURL: app.Attest.RekorURL, + OIDCIssuer: app.Attest.OIDCIssuer, + OIDCClientID: app.Attest.OIDCClientID, + OIDCRedirectURL: app.Attest.OIDCRedirectURL, + } + + return attest.Run(cmd.Context(), app, ko, args) }, } diff --git a/cmd/syft/cli/attest/attest.go b/cmd/syft/cli/attest/attest.go index 62e712f4d..c0e202d1a 100644 --- a/cmd/syft/cli/attest/attest.go +++ b/cmd/syft/cli/attest/attest.go @@ -6,7 +6,8 @@ import ( "encoding/json" "fmt" "os" - "strings" + + "github.com/wagoodman/go-progress" "github.com/anchore/stereoscope" "github.com/anchore/stereoscope/pkg/image" @@ -18,15 +19,28 @@ import ( "github.com/anchore/syft/internal/formats/cyclonedxjson" "github.com/anchore/syft/internal/formats/spdx22json" "github.com/anchore/syft/internal/formats/syftjson" + "github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/ui" "github.com/anchore/syft/syft" "github.com/anchore/syft/syft/event" "github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/source" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/in-toto/in-toto-golang/in_toto" "github.com/pkg/errors" + "github.com/sigstore/cosign/cmd/cosign/cli/rekor" "github.com/sigstore/cosign/cmd/cosign/cli/sign" + "github.com/sigstore/cosign/pkg/cosign" "github.com/sigstore/cosign/pkg/cosign/attestation" + cbundle "github.com/sigstore/cosign/pkg/cosign/bundle" + "github.com/sigstore/cosign/pkg/oci/mutate" + ociremote "github.com/sigstore/cosign/pkg/oci/remote" + "github.com/sigstore/cosign/pkg/oci/static" + sigs "github.com/sigstore/cosign/pkg/signature" + "github.com/sigstore/cosign/pkg/types" + "github.com/sigstore/rekor/pkg/generated/client" + "github.com/sigstore/rekor/pkg/generated/models" "github.com/sigstore/sigstore/pkg/signature/dsse" "github.com/wagoodman/go-partybus" @@ -43,7 +57,7 @@ var ( intotoJSONDsseType = `application/vnd.in-toto+json` ) -func Run(ctx context.Context, app *config.Application, args []string) error { +func Run(ctx context.Context, app *config.Application, ko sign.KeyOpts, args []string) error { // We cannot generate an attestation for more than one output if len(app.Outputs) > 1 { return fmt.Errorf("unable to generate attestation for more than one output") @@ -59,17 +73,20 @@ func Run(ctx context.Context, app *config.Application, args []string) error { format := syft.FormatByName(app.Outputs[0]) predicateType := formatPredicateType(format) if predicateType == "" { - return fmt.Errorf("could not produce attestation predicate for given format: %q. Available formats: %+v", options.FormatAliases(format.ID()), options.FormatAliases(allowedAttestFormats...)) + return fmt.Errorf( + "could not produce attestation predicate for given format: %q. Available formats: %+v", + options.FormatAliases(format.ID()), + options.FormatAliases(allowedAttestFormats...), + ) } - passFunc, err := selectPassFunc(app.Attest.Key, app.Attest.Password) - if err != nil { - return err - } + if app.Attest.KeyRef != "" { + passFunc, err := selectPassFunc(app.Attest.KeyRef, app.Attest.Password) + if err != nil { + return err + } - ko := sign.KeyOpts{ - KeyRef: app.Attest.Key, - PassFunc: passFunc, + ko.PassFunc = passFunc } sv, err := sign.SignerFromKeyOpts(ctx, "", "", ko) @@ -144,7 +161,7 @@ func execWorker(app *config.Application, sourceInput source.Input, format sbom.F return } - err = generateAttestation(sbomBytes, src, sv, predicateType) + err = generateAttestation(app, sbomBytes, src, sv, predicateType) if err != nil { errs <- err return @@ -153,7 +170,7 @@ func execWorker(app *config.Application, sourceInput source.Input, format sbom.F return errs } -func generateAttestation(predicate []byte, src *source.Source, sv *sign.SignerVerifier, predicateType string) error { +func generateAttestation(app *config.Application, predicate []byte, src *source.Source, sv *sign.SignerVerifier, predicateType string) error { 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") @@ -163,11 +180,22 @@ func generateAttestation(predicate []byte, src *source.Source, sv *sign.SignerVe } wrapped := dsse.WrapSigner(sv, intotoJSONDsseType) + ref, err := name.ParseReference(src.Metadata.ImageMetadata.UserInput) + if err != nil { + return err + } + + digest, err := ociremote.ResolveDigest(ref) + if err != nil { + return err + } + + h, _ := v1.NewHash(digest.Identifier()) sh, err := attestation.GenerateStatement(attestation.GenerateOpts{ Predicate: bytes.NewBuffer(predicate), Type: predicateType, - Digest: findValidDigest(src.Image.Metadata.RepoDigests), + Digest: h.Hex, }) if err != nil { return err @@ -183,24 +211,107 @@ func generateAttestation(predicate []byte, src *source.Source, sv *sign.SignerVe return errors.Wrap(err, "unable to sign SBOM") } + // We want to give the option to not upload the generated attestation + // if passed or if the user is using local PKI + if app.Attest.NoUpload || app.Attest.KeyRef != "" { + bus.Publish(partybus.Event{ + Type: event.Exit, + Value: func() error { + _, err := os.Stdout.Write(signedPayload) + return err + }, + }) + return nil + } + + return uploadAttestation(app, signedPayload, digest, sv) +} + +func trackUploadAttestation() (*progress.Stage, *progress.Manual) { + stage := &progress.Stage{} + prog := &progress.Manual{} + + bus.Publish(partybus.Event{ + Type: event.UploadAttestation, + Value: progress.StagedProgressable(&struct { + progress.Stager + progress.Progressable + }{ + Stager: stage, + Progressable: prog, + }), + }) + + return stage, prog +} + +// uploads signed SBOM payload to Rekor transparency log along with key information; +// returns a bundle for attestation annotations +// rekor bundle includes a signed payload and rekor timestamp; +// the bundle is then wrapped onto an OCI signed entity and uploaded to +// the user's image's OCI registry repository as *.att +func uploadAttestation(app *config.Application, signedPayload []byte, digest name.Digest, sv *sign.SignerVerifier) error { + // add application/vnd.dsse.envelope.v1+json as media type for other applications to decode attestation + opts := []static.Option{static.WithLayerMediaType(types.DssePayloadType)} + if sv.Cert != nil { + opts = append(opts, static.WithCertChain(sv.Cert, sv.Chain)) + } + + stage, prog := trackUploadAttestation() + defer prog.SetCompleted() // just in case we return early + + prog.Total = 2 + stage.Current = "uploading signing information to transparency log" + + // uploads payload to Rekor transparency log along with key information; + // returns bundle for attesation annotations + // rekor bundle includes a signed payload and rekor timestamp; + // the bundle is then wrapped onto an OCI signed entity and uploaded to + // the user's image's OCI registry repository as *.att + bundle, err := uploadToTlog(context.TODO(), sv, app.Attest.RekorURL, func(r *client.Rekor, b []byte) (*models.LogEntryAnon, error) { + return cosign.TLogUploadInTotoAttestation(context.TODO(), r, signedPayload, b) + }) + if err != nil { + return err + } + + prog.N = 1 + stage.Current = "uploading attestation to OCI registry" + + // add bundle OCI attestation that is uploaded to + opts = append(opts, static.WithBundle(bundle)) + sig, err := static.NewAttestation(signedPayload, opts...) + if err != nil { + return err + } + + se, err := ociremote.SignedEntity(digest) + if err != nil { + return err + } + + newSE, err := mutate.AttachAttestationToEntity(se, sig) + if err != nil { + return err + } + + // Publish the attestations associated with this entity + err = ociremote.WriteAttestations(digest.Repository, newSE) + if err != nil { + return err + } + + prog.SetCompleted() + bus.Publish(partybus.Event{ Type: event.Exit, Value: func() error { - _, err := os.Stdout.Write(signedPayload) - return err + return nil }, }) - return nil } -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 formatPredicateType(format sbom.Format) string { switch format.ID() { case spdx22json.ID: @@ -214,3 +325,33 @@ func formatPredicateType(format sbom.Format) string { return "" } } + +type tlogUploadFn func(*client.Rekor, []byte) (*models.LogEntryAnon, error) + +func uploadToTlog(ctx context.Context, sv *sign.SignerVerifier, rekorURL string, upload tlogUploadFn) (*cbundle.RekorBundle, error) { + var rekorBytes []byte + // Upload the cert or the public key, depending on what we have + if sv.Cert != nil { + rekorBytes = sv.Cert + } else { + pemBytes, err := sigs.PublicKeyPem(sv, signatureoptions.WithContext(ctx)) + if err != nil { + return nil, err + } + rekorBytes = pemBytes + } + + rekorClient, err := rekor.NewClient(rekorURL) + if err != nil { + return nil, err + } + entry, err := upload(rekorClient, rekorBytes) + if err != nil { + return nil, err + } + + if entry.LogIndex != nil { + log.Debugf("transparency log entry created with index: %v", *entry.LogIndex) + } + return cbundle.EntryToBundle(entry), nil +} diff --git a/cmd/syft/cli/options/attest.go b/cmd/syft/cli/options/attest.go index b36d1cb3f..17f865264 100644 --- a/cmd/syft/cli/options/attest.go +++ b/cmd/syft/cli/options/attest.go @@ -6,17 +6,50 @@ import ( "github.com/spf13/viper" ) +const defaultKeyFileName = "cosign.key" + type AttestOptions struct { - Key string + Key string + Cert string + CertChain string + NoUpload bool + Force bool + Recursive bool + + Rekor RekorOptions + Fulcio FulcioOptions + OIDC OIDCOptions } var _ Interface = (*AttestOptions)(nil) func (o *AttestOptions) AddFlags(cmd *cobra.Command, v *viper.Viper) error { - cmd.PersistentFlags().StringVarP(&o.Key, "key", "", "cosign.key", + if err := o.Rekor.AddFlags(cmd, v); err != nil { + return err + } + if err := o.Fulcio.AddFlags(cmd, v); err != nil { + return err + } + if err := o.OIDC.AddFlags(cmd, v); err != nil { + return err + } + + cmd.Flags().StringVarP(&o.Key, "key", "", defaultKeyFileName, "path to the private key file to use for attestation") - return bindAttestationConfigOptions(cmd.PersistentFlags(), v) + cmd.Flags().StringVarP(&o.Cert, "cert", "", "", + "path to the x.509 certificate in PEM format to include in the OCI Signature") + + cmd.Flags().BoolVarP(&o.NoUpload, "no-upload", "", false, + "do not upload the generated attestation") + + cmd.Flags().BoolVarP(&o.Force, "force", "", false, + "skip warnings and confirmations") + + cmd.Flags().BoolVarP(&o.Recursive, "recursive", "", false, + "if a multi-arch image is specified, additionally sign each discrete image") + + return bindAttestationConfigOptions(cmd.Flags(), v) } func bindAttestationConfigOptions(flags *pflag.FlagSet, v *viper.Viper) error { @@ -24,5 +57,21 @@ func bindAttestationConfigOptions(flags *pflag.FlagSet, v *viper.Viper) error { return err } + if err := v.BindPFlag("attest.cert", flags.Lookup("cert")); err != nil { + return err + } + + if err := v.BindPFlag("attest.no-upload", flags.Lookup("no-upload")); err != nil { + return err + } + + if err := v.BindPFlag("attest.force", flags.Lookup("force")); err != nil { + return err + } + + if err := v.BindPFlag("attest.recursive", flags.Lookup("recursive")); err != nil { + return err + } + return nil } diff --git a/cmd/syft/cli/options/fulcio.go b/cmd/syft/cli/options/fulcio.go new file mode 100644 index 000000000..28a918910 --- /dev/null +++ b/cmd/syft/cli/options/fulcio.go @@ -0,0 +1,48 @@ +package options + +import ( + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +const defaultFulcioURL = "https://fulcio.sigstore.dev" + +// FulcioOptions is the wrapper for Fulcio related options. +type FulcioOptions struct { + URL string + IdentityToken string + InsecureSkipFulcioVerify bool +} + +var _ Interface = (*FulcioOptions)(nil) + +// AddFlags implements Interface +func (o *FulcioOptions) AddFlags(cmd *cobra.Command, v *viper.Viper) error { + // TODO: change this back to api.SigstorePublicServerURL after the v1 migration is complete. + cmd.Flags().StringVar(&o.URL, "fulcio-url", defaultFulcioURL, + "address of sigstore PKI server") + + cmd.Flags().StringVar(&o.IdentityToken, "identity-token", "", + "identity token to use for certificate from fulcio") + + cmd.Flags().BoolVar(&o.InsecureSkipFulcioVerify, "insecure-skip-verify", false, + "skip verifying fulcio certificat and the SCT (Signed Certificate Timestamp) (this should only be used for testing).") + return bindFulcioConfigOptions(cmd.Flags(), v) +} + +func bindFulcioConfigOptions(flags *pflag.FlagSet, v *viper.Viper) error { + if err := v.BindPFlag("attest.fulcio-url", flags.Lookup("fulcio-url")); err != nil { + return err + } + + if err := v.BindPFlag("attest.fulcio-identity-token", flags.Lookup("identity-token")); err != nil { + return err + } + + if err := v.BindPFlag("attest.insecure-skip-verify", flags.Lookup("insecure-skip-verify")); err != nil { + return err + } + + return nil +} diff --git a/cmd/syft/cli/options/oidc.go b/cmd/syft/cli/options/oidc.go new file mode 100644 index 000000000..ce1ea4817 --- /dev/null +++ b/cmd/syft/cli/options/oidc.go @@ -0,0 +1,48 @@ +package options + +import ( + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +const DefaultOIDCIssuerURL = "https://oauth2.sigstore.dev/auth" + +// OIDCOptions is the wrapper for OIDC related options. +type OIDCOptions struct { + Issuer string + ClientID string + RedirectURL string +} + +var _ Interface = (*OIDCOptions)(nil) + +// AddFlags implements Interface +func (o *OIDCOptions) AddFlags(cmd *cobra.Command, v *viper.Viper) error { + cmd.Flags().StringVar(&o.Issuer, "oidc-issuer", DefaultOIDCIssuerURL, + "OIDC provider to be used to issue ID token") + + cmd.Flags().StringVar(&o.ClientID, "oidc-client-id", "sigstore", + "OIDC client ID for application") + + cmd.Flags().StringVar(&o.RedirectURL, "oidc-redirect-url", "", + "OIDC redirect URL (Optional)") + + return bindOIDCConfigOptions(cmd.Flags(), v) +} + +func bindOIDCConfigOptions(flags *pflag.FlagSet, v *viper.Viper) error { + if err := v.BindPFlag("attest.oidc-issuer", flags.Lookup("oidc-issuer")); err != nil { + return err + } + + if err := v.BindPFlag("attest.oidc-client-id", flags.Lookup("oidc-client-id")); err != nil { + return err + } + + if err := v.BindPFlag("attest.oidc-redirect-url", flags.Lookup("oidc-redirect-url")); err != nil { + return err + } + + return nil +} diff --git a/cmd/syft/cli/options/rekor.go b/cmd/syft/cli/options/rekor.go new file mode 100644 index 000000000..668c06e4b --- /dev/null +++ b/cmd/syft/cli/options/rekor.go @@ -0,0 +1,32 @@ +package options + +import ( + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +const DefaultRekorURL = "https://rekor.sigstore.dev" + +// RekorOptions is the wrapper for Rekor related options. +type RekorOptions struct { + URL string +} + +var _ Interface = (*RekorOptions)(nil) + +// AddFlags implements Interface +func (o *RekorOptions) AddFlags(cmd *cobra.Command, v *viper.Viper) error { + cmd.Flags().StringVar(&o.URL, "rekor-url", DefaultRekorURL, + "address of rekor STL server") + return bindRekorConfigOptions(cmd.Flags(), v) +} + +func bindRekorConfigOptions(flags *pflag.FlagSet, v *viper.Viper) error { + // TODO: config re-design + if err := v.BindPFlag("attest.rekor-url", flags.Lookup("rekor-url")); err != nil { + return err + } + + return nil +} diff --git a/go.mod b/go.mod index 9272bdc88..aa985de46 100644 --- a/go.mod +++ b/go.mod @@ -58,6 +58,7 @@ require ( github.com/docker/docker v20.10.12+incompatible github.com/in-toto/in-toto-golang v0.3.4-0.20211211042327-af1f9fb822bf github.com/sigstore/cosign v1.7.2 + github.com/sigstore/rekor v0.4.1-0.20220114213500-23f583409af3 github.com/sigstore/sigstore v1.2.1-0.20220401110139-0e610e39782f ) @@ -209,7 +210,6 @@ require ( github.com/segmentio/ksuid v1.0.4 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/sigstore/fulcio v0.1.2-0.20220114150912-86a2036f9bc7 // indirect - github.com/sigstore/rekor v0.4.1-0.20220114213500-23f583409af3 // indirect github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect github.com/soheilhy/cmux v0.1.5 // indirect github.com/spiffe/go-spiffe/v2 v2.0.0 // indirect @@ -292,7 +292,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 // indirect + github.com/google/go-containerregistry v0.8.1-0.20220209165246-a44adc326839 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/internal/config/attest.go b/internal/config/attest.go index f658b944f..c59661e55 100644 --- a/internal/config/attest.go +++ b/internal/config/attest.go @@ -5,22 +5,35 @@ import ( "os" "github.com/mitchellh/go-homedir" + "github.com/sigstore/cosign/cmd/cosign/cli/options" "github.com/spf13/viper" ) +// IMPORTANT: do not show the password in any YAML/JSON output (sensitive information) type attest struct { - Key string `yaml:"key" json:"key" mapstructure:"key"` // same as --key, file path to the private key - // IMPORTANT: do not show the password in any YAML/JSON output (sensitive information) - Password string `yaml:"-" json:"-" mapstructure:"password"` // password for the private key + KeyRef string `yaml:"key" json:"key" mapstructure:"key"` // same as --key, file path to the private key + Cert string `yaml:"cert" json:"cert" mapstructure:"cert"` + NoUpload bool `yaml:"no_upload" json:"noUpload" mapstructure:"no_upload"` + Force bool `yaml:"force" json:"force" mapstructure:"force"` + Recursive bool `yaml:"recursive" json:"recursive" mapstructure:"recursive"` + Replace bool `yaml:"replace" json:"replace" mapstructure:"replace"` + Password string `yaml:"-" json:"-" mapstructure:"password"` // password for the private key + FulcioURL string `yaml:"fulcio_url" json:"fulcioUrl" mapstructure:"fulcio_url"` + FulcioIdentityToken string `yaml:"fulcio_identity_token" json:"fulcio_identity_token" mapstructure:"fulcio_identity_token"` + InsecureSkipFulcioVerify bool `yaml:"insecure_skip_verify" json:"insecure_skip_verify" mapstructure:"insecure_skip_verify"` + RekorURL string `yaml:"rekor_url" json:"rekorUrl" mapstructure:"rekor_url"` + OIDCIssuer string `yaml:"oidc_issuer" json:"oidcIssuer" mapstructure:"oidc_issuer"` + OIDCClientID string `yaml:"oidc_client_id" json:"oidcClientId" mapstructure:"oidc_client_id"` + OIDCRedirectURL string `yaml:"oidc_redirect_url" json:"OIDCRedirectURL" mapstructure:"oidc_redirect_url"` } func (cfg *attest) parseConfigValues() error { - if cfg.Key != "" { - expandedPath, err := homedir.Expand(cfg.Key) + if cfg.KeyRef != "" { + expandedPath, err := homedir.Expand(cfg.KeyRef) if err != nil { - return fmt.Errorf("unable to expand key path=%q: %w", cfg.Key, err) + return fmt.Errorf("unable to expand key path=%q: %w", cfg.KeyRef, err) } - cfg.Key = expandedPath + cfg.KeyRef = expandedPath } if cfg.Password == "" { @@ -34,5 +47,10 @@ func (cfg *attest) parseConfigValues() error { } func (cfg attest) loadDefaultValues(v *viper.Viper) { + v.SetDefault("attest.key", "") v.SetDefault("attest.password", "") + v.SetDefault("attest.fulcio_url", options.DefaultFulcioURL) + v.SetDefault("attest.rekor_url", options.DefaultRekorURL) + v.SetDefault("attest.oidc_issuer", options.DefaultOIDCIssuerURL) + v.SetDefault("attest.oidc_client_id", "sigstore") } diff --git a/internal/formats/spdx22tagvalue/test-fixtures/snapshot/TestSPDXJSONSPDXIDs.golden b/internal/formats/spdx22tagvalue/test-fixtures/snapshot/TestSPDXJSONSPDXIDs.golden index eb2488f31..6e83d7d8e 100644 --- a/internal/formats/spdx22tagvalue/test-fixtures/snapshot/TestSPDXJSONSPDXIDs.golden +++ b/internal/formats/spdx22tagvalue/test-fixtures/snapshot/TestSPDXJSONSPDXIDs.golden @@ -2,11 +2,11 @@ SPDXVersion: SPDX-2.2 DataLicense: CC0-1.0 SPDXID: SPDXRef-DOCUMENT DocumentName: . -DocumentNamespace: https://anchore.com/syft/dir/644e30b7-5a31-4d0b-a903-b96c757921c2 +DocumentNamespace: https://anchore.com/syft/dir/8fbb3714-785d-4e3e-95cf-44a258bc65b0 LicenseListVersion: 3.16 Creator: Organization: Anchore, Inc Creator: Tool: syft-[not provided] -Created: 2022-04-19T15:10:19Z +Created: 2022-05-02T15:27:05Z ##### Package: @at-sign diff --git a/syft/event/event.go b/syft/event/event.go index c5ff81d31..8adcdd297 100644 --- a/syft/event/event.go +++ b/syft/event/event.go @@ -31,4 +31,7 @@ const ( // ImportStarted is a partybus event that occurs when an SBOM upload process has begun ImportStarted partybus.EventType = "syft-import-started-event" + + // UploadAttestation is a partybus event that occurs when syft uploads an attestation to an OCI registry (+ any transparency log) + UploadAttestation partybus.EventType = "syft-upload-attestation" ) diff --git a/syft/event/parsers/parsers.go b/syft/event/parsers/parsers.go index 6abcd28ca..f384044cd 100644 --- a/syft/event/parsers/parsers.go +++ b/syft/event/parsers/parsers.go @@ -151,3 +151,16 @@ func ParseImportStarted(e partybus.Event) (string, progress.StagedProgressable, return host, prog, nil } + +func ParseUploadAttestation(e partybus.Event) (progress.StagedProgressable, error) { + if err := checkEventType(e.Type, event.UploadAttestation); err != nil { + return nil, err + } + + prog, ok := e.Value.(progress.StagedProgressable) + if !ok { + return nil, newPayloadErr(e.Type, "Value", e.Value) + } + + return prog, nil +} diff --git a/test/cli/attest_cmd_test.go b/test/cli/attest_cmd_test.go index fa385a176..43a1d0404 100644 --- a/test/cli/attest_cmd_test.go +++ b/test/cli/attest_cmd_test.go @@ -26,7 +26,7 @@ func TestAttestCmd(t *testing.T) { }, { name: "can encode syft.json as the predicate given a password", - args: []string{"attest", "-o", "json", img}, + args: []string{"attest", "-o", "json", "--key", "cosign.key", img}, assertions: []traitAssertion{ assertSuccessfulReturnCode, }, @@ -34,7 +34,7 @@ func TestAttestCmd(t *testing.T) { }, { name: "can encode syft.json as the predicate given a blank password", - args: []string{"attest", "-o", "json", img}, + args: []string{"attest", "-o", "json", "--key", "cosign.key", img}, assertions: []traitAssertion{ assertSuccessfulReturnCode, }, diff --git a/test/cli/cosign_test.go b/test/cli/cosign_test.go index 823180991..283d36b6e 100644 --- a/test/cli/cosign_test.go +++ b/test/cli/cosign_test.go @@ -59,9 +59,11 @@ func TestCosignWorkflow(t *testing.T) { "attest", "-o", "json", + "--key", + "cosign.key", img, }, - // cosign attach attestation --attestation image_latest_sbom_attestation.json caphill4/attest:latest + // cosign attach attestation cosignAttachArgs: []string{ "attach", "attestation", @@ -69,7 +71,7 @@ func TestCosignWorkflow(t *testing.T) { attestationFile, img, }, - // cosign verify-attestation -key cosign.pub caphill4/attest:latest + // cosign verify-attestation cosignVerifyArgs: []string{ "verify-attestation", "-key", diff --git a/test/cli/test-fixtures/attestation/Dockerfile.ctfe_init b/test/cli/test-fixtures/attestation/Dockerfile.ctfe_init new file mode 100644 index 000000000..5b131e03b --- /dev/null +++ b/test/cli/test-fixtures/attestation/Dockerfile.ctfe_init @@ -0,0 +1,10 @@ +FROM golang:1.17.8@sha256:c7c94588b6445f5254fbc34df941afa10de04706deb330e62831740c9f0f2030 AS builder + +WORKDIR /root/ + +RUN go install github.com/google/trillian/cmd/createtree@v1.3.10 +ADD ./config/logid.sh /root/ +ADD ./config/ctfe /root/ctfe +RUN chmod +x /root/logid.sh + +CMD /root/logid.sh diff --git a/test/cli/test-fixtures/attestation/config/config.json b/test/cli/test-fixtures/attestation/config/config.json new file mode 100644 index 000000000..92ef25362 --- /dev/null +++ b/test/cli/test-fixtures/attestation/config/config.json @@ -0,0 +1,9 @@ +{ + "OIDCIssuers": { + "http://dex-idp:8888/auth": { + "IssuerURL": "http://dex-idp:8888/auth", + "ClientID": "fulcio", + "Type": "email" + } + } +} diff --git a/test/cli/test-fixtures/attestation/config/ctfe/ct_server.cfg b/test/cli/test-fixtures/attestation/config/ctfe/ct_server.cfg new file mode 100644 index 000000000..7ef4ebae0 --- /dev/null +++ b/test/cli/test-fixtures/attestation/config/ctfe/ct_server.cfg @@ -0,0 +1,11 @@ +config { + log_id: %LOGID% + prefix: "test" + roots_pem_file: "/etc/config/root.pem" + private_key: { + [type.googleapis.com/keyspb.PEMKeyFile] { + path: "/etc/config/privkey.pem" + password: "foobar" + } + } +} diff --git a/test/cli/test-fixtures/attestation/config/ctfe/privkey.pem b/test/cli/test-fixtures/attestation/config/ctfe/privkey.pem new file mode 100644 index 000000000..64b1950a5 --- /dev/null +++ b/test/cli/test-fixtures/attestation/config/ctfe/privkey.pem @@ -0,0 +1,8 @@ +-----BEGIN EC PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: DES-CBC,05BAAA9143C46320 + +AttbquLclNy7ZEnlDFpReZvV2PZKuv89YMWqDvGGtnBVw+3eXYIa54Xli1CyXEPn +qNGvibjIxj+Q19+VhA3n42SE2fHyULHKPZHebSL5qcVvZTqmbtAe/dZNH1SiGG2f +bWauIw0oeHhXW5i9isxrLggPMRmPA65Ii3W7gyWFmjE= +-----END EC PRIVATE KEY----- diff --git a/test/cli/test-fixtures/attestation/config/ctfe/pubkey.pem b/test/cli/test-fixtures/attestation/config/ctfe/pubkey.pem new file mode 100644 index 000000000..8a3e7b56c --- /dev/null +++ b/test/cli/test-fixtures/attestation/config/ctfe/pubkey.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbbQiLx6GKy6ivhc11wJGbQjc2VX/ +mnuk5d670MTXR3p+LIAcxd5MhqIHpLmyYJ5mDKLEoZ/pC0nPuje3JueBcA== +-----END PUBLIC KEY----- diff --git a/test/cli/test-fixtures/attestation/config/dex/docker-compose-config.yaml b/test/cli/test-fixtures/attestation/config/dex/docker-compose-config.yaml new file mode 100644 index 000000000..e04402909 --- /dev/null +++ b/test/cli/test-fixtures/attestation/config/dex/docker-compose-config.yaml @@ -0,0 +1,48 @@ +# +# Copyright 2021 The Sigstore Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +issuer: http://dex-idp:8888/auth + +storage: + type: memory + +web: + http: dex-idp:8888 + +frontend: + issuer: Fulcio in Docker Compose + +expiry: + signingKeys: "24h" + idTokens: "1m" + authRequests: "24h" + +oauth2: + responseTypes: [ "code" ] + alwaysShowLoginScreen: false + skipApprovalScreen: true + +connectors: +- type: mockCallback + id: approved + name: AlwaysApprovesOIDCProvider + +staticClients: + - id: fulcio + public: true + name: 'Fulcio in Docker Compose' + +# Dex's issuer URL + "/callback" +redirectURI: http://dex-idp:8888/auth/callback diff --git a/test/cli/test-fixtures/attestation/config/logid.sh b/test/cli/test-fixtures/attestation/config/logid.sh new file mode 100644 index 000000000..1e54f40f4 --- /dev/null +++ b/test/cli/test-fixtures/attestation/config/logid.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# +# Copyright 2021 The Sigstore Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +function get_log_id() { + curl -s --retry-connrefused --retry 10 http://trillian-log-server:8095/metrics |grep "^quota_acquired_tokens{spec=\"trees"|head -1|awk ' { print $1 } '|sed -e 's/[^0-9]*//g' > /tmp/logid +} + +function get_ephemeral_ca() { + curl -s --retry-connrefused --retry 10 http://fulcio-server:5555/api/v1/rootCert > /etc/config/root.pem +} + +function create_log () { + /go/bin/createtree -admin_server trillian-log-server:8096 > /tmp/logid + echo -n "Created log ID " && cat /tmp/logid +} + +function update_config() { + cat /root/ctfe/ct_server.cfg | sed -e "s/%LOGID%/"`cat /tmp/logid`"/g" > /etc/config/ct_server.cfg + cp /root/ctfe/*.pem /etc/config/ +} + +# check to see if log id exists; if so, use that +echo -n "Checking for existing configuration..." +echo "Checking for preexisting logs..." +get_log_id +# else create one +if ! [[ -s /tmp/logid ]]; then + echo "No log found; let's create one..." + create_log +fi +echo "Updating config with current log" +update_config + +configid=`cat /etc/config/ct_server.cfg|grep log_id|awk ' { print $2 } '` +echo "Exisiting configuration uses log ID $configid, exiting" + +echo "Grabing fulcio root pem file" +get_ephemeral_ca + +echo "Finished ct_server configuration" diff --git a/test/cli/test-fixtures/attestation/docker-compose-config.yaml b/test/cli/test-fixtures/attestation/docker-compose-config.yaml new file mode 100644 index 000000000..f0e01b566 --- /dev/null +++ b/test/cli/test-fixtures/attestation/docker-compose-config.yaml @@ -0,0 +1,48 @@ +# +# Copyright 2021 The Sigstore Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +issuer: http://dex-idp:8888/auth + +storage: + type: memory + +web: + http: 0.0.0.0:8888 + +frontend: + issuer: Fulcio in Docker Compose + +expiry: + signingKeys: "24h" + idTokens: "1m" + authRequests: "24h" + +oauth2: + responseTypes: [ "code" ] + alwaysShowLoginScreen: true + skipApprovalScreen: true + +connectors: +- type: mockCallback + id: https://any.valid.url/ + name: AlwaysApprovesOIDCProvider + +staticClients: + - id: fulcio + public: true + name: 'Fulcio in Docker Compose' + +# Dex's issuer URL + "/callback" +redirectURI: http://dex-idp:8888/auth/callback diff --git a/test/cli/test-fixtures/attestation/docker-compose.yaml b/test/cli/test-fixtures/attestation/docker-compose.yaml new file mode 100644 index 000000000..fa2b8da4d --- /dev/null +++ b/test/cli/test-fixtures/attestation/docker-compose.yaml @@ -0,0 +1,116 @@ +version: '3.2' +services: + fulcio-server: + image: fulcio_fulcio-server + command: [ + "fulcio-server", + "serve", + "--host=0.0.0.0", + "--port=5555", + "--ca=ephemeralca", + "--ct-log-url=http://ct_server:6962/test", + ] + restart: always # keep the server running + ports: + - "5555:5555" + volumes: + - ./config/config.json:/etc/fulcio-config/config.json:z + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5555/ping"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + depends_on: + - dex-idp + mysql: + image: gcr.io/trillian-opensource-ci/db_server:3c8193ebb2d7fedb44d18e9c810d0d2e4dbb7e4d + environment: + - MYSQL_ROOT_PASSWORD=password + - MYSQL_DATABASE=test + - MYSQL_USER=test + - MYSQL_PASSWORD=password + restart: always # keep the MySQL server running + healthcheck: + test: ["CMD", "/etc/init.d/mysql", "status"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + ctfe_init: + build: + context: . + dockerfile: Dockerfile.ctfe_init + depends_on: + - trillian-log-server + - fulcio-server + volumes: + - ctfeConfig:/etc/config/:rw + ct_server: + image: gcr.io/trillian-opensource-ci/ctfe + volumes: + - ctfeConfig:/etc/config/:rw + command: [ + "--log_config" ,"/etc/config/ct_server.cfg", + "--log_rpc_server", "trillian-log-server:8096", + "--http_endpoint", "0.0.0.0:6962", + "--alsologtostderr", + ] + restart: always # retry while ctfe_init is running + depends_on: + - trillian-log-server + - trillian-log-signer + - ctfe_init + ports: + - "6962:6962" + dex-idp: + image: dexidp/dex:v2.30.0 + user: root + command: [ + "dex", + "serve", + "/etc/config/docker-compose-config.yaml", + ] + restart: always # keep the server running + ports: + - "8888:8888" + volumes: + - ./config/dex:/etc/config/:ro + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8888/auth/healthz"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + trillian-log-server: + image: gcr.io/trillian-opensource-ci/log_server + command: [ + "--storage_system=mysql", + "--mysql_uri=test:password@tcp(mysql:3306)/test", + "--rpc_endpoint=0.0.0.0:8096", + "--http_endpoint=0.0.0.0:8095", + "--alsologtostderr", + ] + restart: always # retry while mysql is starting up + ports: + - "8096:8096" + - "8095:8095" + depends_on: + - mysql + trillian-log-signer: + image: gcr.io/trillian-opensource-ci/log_signer + command: [ + "--storage_system=mysql", + "--mysql_uri=test:password@tcp(mysql:3306)/test", + "--rpc_endpoint=0.0.0.0:8095", + "--http_endpoint=0.0.0.0:8097", + "--force_master", + "--alsologtostderr", + ] + restart: always # retry while mysql is starting up + ports: + - "8097:8097" + depends_on: + - mysql +volumes: + ctfeConfig: {} diff --git a/ui/event_handlers.go b/ui/event_handlers.go index ba543a366..0c4298441 100644 --- a/ui/event_handlers.go +++ b/ui/event_handlers.go @@ -226,6 +226,48 @@ func FetchImageHandler(ctx context.Context, fr *frame.Frame, event partybus.Even return err } +func UploadAttestationHandler(ctx context.Context, fr *frame.Frame, event partybus.Event, wg *sync.WaitGroup) error { + prog, err := syftEventParsers.ParseUploadAttestation(event) + if err != nil { + return fmt.Errorf("bad %s event: %w", event.Type, err) + } + + line, err := fr.Append() + if err != nil { + return err + } + wg.Add(1) + + formatter, spinner := startProcess() + stream := progress.Stream(ctx, prog, interval) + title := tileFormat.Sprint("Uploading attestation") + + formatFn := func(p progress.Progress) { + progStr, err := formatter.Format(p) + spin := color.Magenta.Sprint(spinner.Next()) + if err != nil { + _, _ = io.WriteString(line, fmt.Sprintf("Error: %+v", err)) + } else { + auxInfo := auxInfoFormat.Sprintf("[%s]", prog.Stage()) + _, _ = io.WriteString(line, fmt.Sprintf(statusTitleTemplate+"%s %s", spin, title, progStr, auxInfo)) + } + } + + go func() { + defer wg.Done() + + formatFn(progress.Progress{}) + for p := range stream { + formatFn(p) + } + + spin := color.Green.Sprint(completedStatus) + title = tileFormat.Sprint("Uploaded attestation") + _, _ = io.WriteString(line, fmt.Sprintf(statusTitleTemplate, spin, title)) + }() + return err +} + // ReadImageHandler periodically writes a the image read/parse/build-tree status in the form of a progress bar. func ReadImageHandler(ctx context.Context, fr *frame.Frame, event partybus.Event, wg *sync.WaitGroup) error { _, prog, err := stereoEventParsers.ParseReadImage(event) @@ -355,8 +397,8 @@ func SecretsCatalogerStartedHandler(ctx context.Context, fr *frame.Frame, event return err } +//nolint:dupl // FileMetadataCatalogerStartedHandler shows the intermittent secrets searching progress. -// nolint:dupl func FileMetadataCatalogerStartedHandler(ctx context.Context, fr *frame.Frame, event partybus.Event, wg *sync.WaitGroup) error { prog, err := syftEventParsers.ParseFileMetadataCatalogingStarted(event) if err != nil { diff --git a/ui/handler.go b/ui/handler.go index bd11733eb..bf9eb81d3 100644 --- a/ui/handler.go +++ b/ui/handler.go @@ -27,7 +27,16 @@ func NewHandler() *Handler { // RespondsTo indicates if the handler is capable of handling the given event. func (r *Handler) RespondsTo(event partybus.Event) bool { switch event.Type { - case stereoscopeEvent.PullDockerImage, stereoscopeEvent.ReadImage, stereoscopeEvent.FetchImage, syftEvent.PackageCatalogerStarted, syftEvent.SecretsCatalogerStarted, syftEvent.FileDigestsCatalogerStarted, syftEvent.FileMetadataCatalogerStarted, syftEvent.FileIndexingStarted, syftEvent.ImportStarted: + case stereoscopeEvent.PullDockerImage, + stereoscopeEvent.ReadImage, + stereoscopeEvent.FetchImage, + syftEvent.UploadAttestation, + syftEvent.PackageCatalogerStarted, + syftEvent.SecretsCatalogerStarted, + syftEvent.FileDigestsCatalogerStarted, + syftEvent.FileMetadataCatalogerStarted, + syftEvent.FileIndexingStarted, + syftEvent.ImportStarted: return true default: return false @@ -46,6 +55,9 @@ func (r *Handler) Handle(ctx context.Context, fr *frame.Frame, event partybus.Ev case stereoscopeEvent.FetchImage: return FetchImageHandler(ctx, fr, event, wg) + case syftEvent.UploadAttestation: + return UploadAttestationHandler(ctx, fr, event, wg) + case syftEvent.PackageCatalogerStarted: return PackageCatalogerStartedHandler(ctx, fr, event, wg)