Skip to content

Commit

Permalink
restore attest command
Browse files Browse the repository at this point in the history
Signed-off-by: Christopher Phillips <christopher.phillips@anchore.com>
  • Loading branch information
spiffcs committed Apr 20, 2022
1 parent bcef904 commit e191e02
Show file tree
Hide file tree
Showing 14 changed files with 2,035 additions and 94 deletions.
60 changes: 60 additions & 0 deletions cmd/syft/cli/attest.go
@@ -1 +1,61 @@
package cli

import (
"log"

"github.com/anchore/syft/cmd/syft/cli/attest"
"github.com/anchore/syft/cmd/syft/cli/options"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/config"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)

const (
attestExample = ` {{.appName}} {{.command}} --output [FORMAT] --key [KEY] alpine:latest
Supports the following image sources:
{{.appName}} {{.command}} --key [KEY] yourrepo/yourimage:tag defaults to using images from a Docker daemon. If Docker is not present, the image is pulled directly from the registry.
{{.appName}} {{.command}} --key [KEY] path/to/a/file/or/dir only for OCI tar or OCI directory
`
attestSchemeHelp = "\n" + indent + schemeHelpHeader + "\n" + imageSchemeHelp

attestHelp = attestExample + attestSchemeHelp
)

func Attest(v *viper.Viper, app *config.Application, ro *options.RootOptions) *cobra.Command {
ao := options.AttestOptions{}
cmd := &cobra.Command{
Use: "attest --output [FORMAT] --key [KEY] [SOURCE]",
Short: "Generate a package SBOM as an attestation for the given [SOURCE] container image",
Long: "Generate a packaged-based Software Bill Of Materials (SBOM) from a container image as the predicate of an in-toto attestation",
Example: internal.Tprintf(attestHelp, map[string]interface{}{
"appName": internal.ApplicationName,
"command": "attest",
}),
Args: helpArgs,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
// this MUST be called first to make sure app config decodes
// the viper object correctly
if err := app.LoadAllValues(v, ro.Config); err != nil {
return err
}
// configure logging for command
newLogWrapper(app)

if app.CheckForAppUpdate {
checkForApplicationUpdate()
}

return attest.Run(cmd.Context(), app, args)
},
}

err := ao.AddFlags(cmd, v)
if err != nil {
log.Fatal(err)
}

return cmd
}
214 changes: 214 additions & 0 deletions cmd/syft/cli/attest/attest.go
@@ -0,0 +1,214 @@
package attest

import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"strings"

"github.com/anchore/stereoscope"
"github.com/anchore/stereoscope/pkg/image"
"github.com/anchore/syft/cmd/syft/cli/eventloop"
"github.com/anchore/syft/cmd/syft/cli/options"
"github.com/anchore/syft/cmd/syft/cli/packages"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/config"
"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/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/in-toto/in-toto-golang/in_toto"
"github.com/pkg/errors"
"github.com/sigstore/cosign/cmd/cosign/cli/sign"
"github.com/sigstore/cosign/pkg/cosign/attestation"
"github.com/sigstore/sigstore/pkg/signature/dsse"
"github.com/wagoodman/go-partybus"

signatureoptions "github.com/sigstore/sigstore/pkg/signature/options"
)

var allowedAttestFormats = []sbom.FormatID{
syftjson.ID,
spdx22json.ID,
cyclonedxjson.ID,
}

var intotoJSONDsseType = `application/vnd.in-toto+json`

func Run(ctx context.Context, app *config.Application, 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")
}

// can only be an image for attestation or OCI DIR
userInput := args[0]
si, err := sourceFromInput(userInput, app)
if err != nil {
return err
}

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...))
}

passFunc, err := selectPassFunc(app.Attest.Key, app.Attest.Password)
if err != nil {
return err
}

ko := sign.KeyOpts{
KeyRef: app.Attest.Key,
PassFunc: passFunc,
}

sv, err := sign.SignerFromKeyOpts(ctx, "", "", ko)
if err != nil {
return err
}
defer sv.Close()

eventBus := partybus.NewBus()
stereoscope.SetBus(eventBus)
syft.SetBus(eventBus)

return eventloop.EventLoop(
execWorker(app, *si, format, predicateType, sv),
eventloop.SetupSignals(),
eventBus.Subscribe(),
stereoscope.Cleanup,
ui.Select(options.IsVerbose(app), app.Quiet)...,
)
}

func sourceFromInput(userInput string, app *config.Application) (s *source.Input, err error) {
si, err := source.ParseInput(userInput, app.Platform, false)
if err != nil {
return nil, fmt.Errorf("could not generate source input for attest command: %w", err)
}

switch si.Scheme {
case source.ImageScheme, source.UnknownScheme:
// at this point we know that it cannot be dir: or file: schemes;
// we will assume that the unknown scheme could represent an image;
si.Scheme = source.ImageScheme
default:
return nil, 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 the local daemon we want to short circuit
// that and attempt to generate the image source from its current registry source instead
switch si.ImageSource {
case image.UnknownSource, image.OciRegistrySource:
si.ImageSource = image.OciRegistrySource
default:
return nil, 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)
}

return si, nil
}

func execWorker(app *config.Application, sourceInput source.Input, format sbom.Format, predicateType string, sv *sign.SignerVerifier) <-chan error {
errs := make(chan error)
go func() {
defer close(errs)

src, cleanup, err := source.NewFromRegistry(sourceInput, app.Registry.ToOptions(), app.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 := packages.GenerateSBOM(src, errs, app)
if err != nil {
errs <- err
return
}

sbomBytes, err := syft.Encode(*s, format)
if err != nil {
errs <- err
return
}

err = generateAttestation(sbomBytes, src, sv, predicateType)
if err != nil {
errs <- err
return
}
}()
return errs
}

func generateAttestation(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")
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)

sh, err := attestation.GenerateStatement(attestation.GenerateOpts{
Predicate: bytes.NewBuffer(predicate),
Type: predicateType,
Digest: findValidDigest(src.Image.Metadata.RepoDigests),
})
if err != nil {
return err
}

payload, err := json.Marshal(sh)
if err != nil {
return err
}

signedPayload, err := wrapped.SignMessage(bytes.NewReader(payload), signatureoptions.WithContext(context.Background()))
if err != nil {
return errors.Wrap(err, "unable to sign SBOM")
}

bus.Publish(partybus.Event{
Type: event.Exit,
Value: func() error {
_, err := os.Stdout.Write(signedPayload)
return err
},
})

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:
return in_toto.PredicateSPDX
case cyclonedxjson.ID:
// Tentative see https://github.com/in-toto/attestation/issues/82
return "https://cyclonedx.org/bom"
case syftjson.ID:
return "https://syft.dev/bom"
default:
return ""
}
}
56 changes: 56 additions & 0 deletions cmd/syft/cli/attest/password.go
@@ -0,0 +1,56 @@
package attest

import (
"errors"
"fmt"
"io"
"os"
"strings"

"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/log"
"github.com/sigstore/cosign/pkg/cosign"
)

func selectPassFunc(keypath, password string) (cosign.PassFunc, error) {
keyContents, err := os.ReadFile(keypath)
if err != nil {
return nil, err
}

var fn cosign.PassFunc = func(bool) (b []byte, err error) { return nil, nil }

_, err = cosign.LoadPrivateKey(keyContents, nil)
if err != nil {
fn = func(bool) (b []byte, err error) {
return fetchPassword(password)
}
}

return fn, nil
}

func fetchPassword(password string) (b []byte, err error) {
potentiallyPipedInput, err := internal.IsPipedInput()
if err != nil {
log.Warnf("unable to determine if there is piped input: %+v", err)
}

switch {
case password != "":
return []byte(password), nil
case potentiallyPipedInput:
// handle piped in passwords
pwBytes, err := io.ReadAll(os.Stdin)
if err != nil {
return nil, fmt.Errorf("unable to get password from stdin: %w", err)
}
// be resilient to input that may have newline characters (in case someone is using echo without -n)
cleanPw := strings.TrimRight(string(pwBytes), "\n")
return []byte(cleanPw), nil
case internal.IsTerminal():
return cosign.GetPassFromTerm(false)
}

return nil, errors.New("no method available to fetch password")
}

0 comments on commit e191e02

Please sign in to comment.