github.com/noqcks/syft@v0.0.0-20230920222752-a9e2c4e288e5/cmd/syft/cli/commands/attest.go (about)

     1  package commands
     2  
     3  import (
     4  	"fmt"
     5  	"os"
     6  	"os/exec"
     7  	"strings"
     8  
     9  	"github.com/spf13/cobra"
    10  	"github.com/wagoodman/go-partybus"
    11  	"github.com/wagoodman/go-progress"
    12  
    13  	"github.com/anchore/clio"
    14  	"github.com/anchore/stereoscope/pkg/image"
    15  	"github.com/anchore/syft/cmd/syft/cli/options"
    16  	"github.com/anchore/syft/internal"
    17  	"github.com/anchore/syft/internal/bus"
    18  	"github.com/anchore/syft/internal/file"
    19  	"github.com/anchore/syft/internal/log"
    20  	"github.com/anchore/syft/syft/event"
    21  	"github.com/anchore/syft/syft/event/monitor"
    22  	"github.com/anchore/syft/syft/formats"
    23  	"github.com/anchore/syft/syft/formats/github"
    24  	"github.com/anchore/syft/syft/formats/syftjson"
    25  	"github.com/anchore/syft/syft/formats/table"
    26  	"github.com/anchore/syft/syft/formats/template"
    27  	"github.com/anchore/syft/syft/formats/text"
    28  	"github.com/anchore/syft/syft/sbom"
    29  	"github.com/anchore/syft/syft/source"
    30  )
    31  
    32  const (
    33  	attestExample = `  {{.appName}} {{.command}} --output [FORMAT] alpine:latest defaults to using images from a Docker daemon. If Docker is not present, the image is pulled directly from the registry
    34  `
    35  	attestSchemeHelp = "\n  " + schemeHelpHeader + "\n" + imageSchemeHelp
    36  	attestHelp       = attestExample + attestSchemeHelp
    37  )
    38  
    39  type attestOptions struct {
    40  	options.Config       `yaml:",inline" mapstructure:",squash"`
    41  	options.SingleOutput `yaml:",inline" mapstructure:",squash"`
    42  	options.UpdateCheck  `yaml:",inline" mapstructure:",squash"`
    43  	options.Catalog      `yaml:",inline" mapstructure:",squash"`
    44  	options.Attest       `yaml:",inline" mapstructure:",squash"`
    45  }
    46  
    47  func Attest(app clio.Application) *cobra.Command {
    48  	id := app.ID()
    49  
    50  	var allowableOutputs []string
    51  	for _, f := range formats.AllIDs() {
    52  		switch f {
    53  		case table.ID, text.ID, github.ID, template.ID:
    54  			continue
    55  		}
    56  		allowableOutputs = append(allowableOutputs, f.String())
    57  	}
    58  
    59  	opts := &attestOptions{
    60  		UpdateCheck: options.DefaultUpdateCheck(),
    61  		SingleOutput: options.SingleOutput{
    62  			AllowableOptions: allowableOutputs,
    63  			Output:           syftjson.ID.String(),
    64  		},
    65  		Catalog: options.DefaultCatalog(),
    66  	}
    67  
    68  	return app.SetupCommand(&cobra.Command{
    69  		Use:   "attest --output [FORMAT] <IMAGE>",
    70  		Short: "Generate an SBOM as an attestation for the given [SOURCE] container image",
    71  		Long:  "Generate a packaged-based Software Bill Of Materials (SBOM) from a container image as the predicate of an in-toto attestation that will be uploaded to the image registry",
    72  		Example: internal.Tprintf(attestHelp, map[string]interface{}{
    73  			"appName": id.Name,
    74  			"command": "attest",
    75  		}),
    76  		Args:    validatePackagesArgs,
    77  		PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck),
    78  		RunE: func(cmd *cobra.Command, args []string) error {
    79  			return runAttest(id, opts, args[0])
    80  		},
    81  	}, opts)
    82  }
    83  
    84  //nolint:funlen
    85  func runAttest(id clio.Identification, opts *attestOptions, userInput string) error {
    86  	_, err := exec.LookPath("cosign")
    87  	if err != nil {
    88  		// when cosign is not installed the error will be rendered like so:
    89  		// 2023/06/30 08:31:52 error during command execution: 'syft attest' requires cosign to be installed: exec: "cosign": executable file not found in $PATH
    90  		return fmt.Errorf("'syft attest' requires cosign to be installed: %w", err)
    91  	}
    92  
    93  	s, err := buildSBOM(id, &opts.Catalog, userInput)
    94  	if err != nil {
    95  		return fmt.Errorf("unable to build SBOM: %w", err)
    96  	}
    97  
    98  	o := opts.Output
    99  
   100  	f, err := os.CreateTemp("", o)
   101  	if err != nil {
   102  		return fmt.Errorf("unable to create temp file: %w", err)
   103  	}
   104  	defer os.Remove(f.Name())
   105  
   106  	writer, err := opts.SBOMWriter(f.Name())
   107  	if err != nil {
   108  		return fmt.Errorf("unable to create SBOM writer: %w", err)
   109  	}
   110  
   111  	if err := writer.Write(*s); err != nil {
   112  		return fmt.Errorf("unable to write SBOM to temp file: %w", err)
   113  	}
   114  
   115  	// TODO: what other validation here besides binary name?
   116  	cmd := "cosign"
   117  	if !commandExists(cmd) {
   118  		return fmt.Errorf("unable to find cosign in PATH; make sure you have it installed")
   119  	}
   120  
   121  	// Select Cosign predicate type based on defined output type
   122  	// As orientation, check: https://github.com/sigstore/cosign/blob/main/pkg/cosign/attestation/attestation.go
   123  	var predicateType string
   124  	switch strings.ToLower(o) {
   125  	case "cyclonedx-json":
   126  		predicateType = "cyclonedx"
   127  	case "spdx-tag-value", "spdx-tv":
   128  		predicateType = "spdx"
   129  	case "spdx-json", "json":
   130  		predicateType = "spdxjson"
   131  	default:
   132  		predicateType = "custom"
   133  	}
   134  
   135  	args := []string{"attest", userInput, "--predicate", f.Name(), "--type", predicateType}
   136  	if opts.Attest.Key != "" {
   137  		args = append(args, "--key", opts.Attest.Key.String())
   138  	}
   139  
   140  	execCmd := exec.Command(cmd, args...)
   141  	execCmd.Env = os.Environ()
   142  	if opts.Attest.Key != "" {
   143  		execCmd.Env = append(execCmd.Env, fmt.Sprintf("COSIGN_PASSWORD=%s", opts.Attest.Password))
   144  	} else {
   145  		// no key provided, use cosign's keyless mode
   146  		execCmd.Env = append(execCmd.Env, "COSIGN_EXPERIMENTAL=1")
   147  	}
   148  
   149  	log.WithFields("cmd", strings.Join(execCmd.Args, " ")).Trace("creating attestation")
   150  
   151  	// bus adapter for ui to hook into stdout via an os pipe
   152  	r, w, err := os.Pipe()
   153  	if err != nil {
   154  		return fmt.Errorf("unable to create os pipe: %w", err)
   155  	}
   156  	defer w.Close()
   157  
   158  	mon := progress.NewManual(-1)
   159  
   160  	bus.Publish(
   161  		partybus.Event{
   162  			Type: event.AttestationStarted,
   163  			Source: monitor.GenericTask{
   164  				Title: monitor.Title{
   165  					Default:      "Create attestation",
   166  					WhileRunning: "Creating attestation",
   167  					OnSuccess:    "Created attestation",
   168  				},
   169  				Context: "cosign",
   170  			},
   171  			Value: &monitor.ShellProgress{
   172  				Reader:       r,
   173  				Progressable: mon,
   174  			},
   175  		},
   176  	)
   177  
   178  	execCmd.Stdout = w
   179  	execCmd.Stderr = w
   180  
   181  	// attest the SBOM
   182  	err = execCmd.Run()
   183  	if err != nil {
   184  		mon.SetError(err)
   185  		return fmt.Errorf("unable to attest SBOM: %w", err)
   186  	}
   187  
   188  	mon.SetCompleted()
   189  
   190  	return nil
   191  }
   192  
   193  func buildSBOM(id clio.Identification, opts *options.Catalog, userInput string) (*sbom.SBOM, error) {
   194  	cfg := source.DetectConfig{
   195  		DefaultImageSource: opts.DefaultImagePullSource,
   196  	}
   197  	detection, err := source.Detect(userInput, cfg)
   198  	if err != nil {
   199  		return nil, fmt.Errorf("could not deteremine source: %w", err)
   200  	}
   201  
   202  	if detection.IsContainerImage() {
   203  		return nil, fmt.Errorf("attestations are only supported for oci images at this time")
   204  	}
   205  
   206  	var platform *image.Platform
   207  
   208  	if opts.Platform != "" {
   209  		platform, err = image.NewPlatform(opts.Platform)
   210  		if err != nil {
   211  			return nil, fmt.Errorf("invalid platform: %w", err)
   212  		}
   213  	}
   214  
   215  	hashers, err := file.Hashers(opts.Source.File.Digests...)
   216  	if err != nil {
   217  		return nil, fmt.Errorf("invalid hash: %w", err)
   218  	}
   219  
   220  	src, err := detection.NewSource(
   221  		source.DetectionSourceConfig{
   222  			Alias: source.Alias{
   223  				Name:    opts.Source.Name,
   224  				Version: opts.Source.Version,
   225  			},
   226  			RegistryOptions: opts.Registry.ToOptions(),
   227  			Platform:        platform,
   228  			Exclude: source.ExcludeConfig{
   229  				Paths: opts.Exclusions,
   230  			},
   231  			DigestAlgorithms: hashers,
   232  			BasePath:         opts.BasePath,
   233  		},
   234  	)
   235  
   236  	if src != nil {
   237  		defer src.Close()
   238  	}
   239  	if err != nil {
   240  		return nil, fmt.Errorf("failed to construct source from user input %q: %w", userInput, err)
   241  	}
   242  
   243  	s, err := generateSBOM(id, src, opts)
   244  	if err != nil {
   245  		return nil, err
   246  	}
   247  
   248  	if s == nil {
   249  		return nil, fmt.Errorf("no SBOM produced for %q", userInput)
   250  	}
   251  
   252  	return s, nil
   253  }
   254  
   255  func commandExists(cmd string) bool {
   256  	_, err := exec.LookPath(cmd)
   257  	return err == nil
   258  }