github.com/anchore/syft@v1.38.2/cmd/syft/internal/commands/attest.go (about)

     1  package commands
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"io"
     7  	"os"
     8  	"os/exec"
     9  	"strings"
    10  
    11  	"github.com/spf13/cobra"
    12  	"github.com/wagoodman/go-partybus"
    13  	"github.com/wagoodman/go-progress"
    14  
    15  	"github.com/anchore/clio"
    16  	"github.com/anchore/stereoscope"
    17  	"github.com/anchore/syft/cmd/syft/internal/options"
    18  	"github.com/anchore/syft/cmd/syft/internal/ui"
    19  	"github.com/anchore/syft/internal"
    20  	"github.com/anchore/syft/internal/bus"
    21  	"github.com/anchore/syft/internal/log"
    22  	"github.com/anchore/syft/syft/event"
    23  	"github.com/anchore/syft/syft/event/monitor"
    24  	"github.com/anchore/syft/syft/format"
    25  	"github.com/anchore/syft/syft/format/cyclonedxjson"
    26  	"github.com/anchore/syft/syft/format/spdxjson"
    27  	"github.com/anchore/syft/syft/format/spdxtagvalue"
    28  	"github.com/anchore/syft/syft/format/syftjson"
    29  	"github.com/anchore/syft/syft/sbom"
    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  	cosignBinName    = "cosign"
    38  )
    39  
    40  type attestOptions struct {
    41  	options.Config      `yaml:",inline" mapstructure:",squash"`
    42  	options.Output      `yaml:",inline" mapstructure:",squash"`
    43  	options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
    44  	options.Catalog     `yaml:",inline" mapstructure:",squash"`
    45  	Attest              options.Attest `yaml:"attest" mapstructure:"attest"`
    46  	Cache               options.Cache  `json:"-" yaml:"cache" mapstructure:"cache"`
    47  }
    48  
    49  func Attest(app clio.Application) *cobra.Command {
    50  	id := app.ID()
    51  
    52  	opts := defaultAttestOptions()
    53  
    54  	// template format explicitly not allowed
    55  	opts.Template.Enabled = false
    56  
    57  	return app.SetupCommand(&cobra.Command{
    58  		Use:   "attest --output [FORMAT] <IMAGE>",
    59  		Short: "Generate an SBOM as an attestation for the given [SOURCE] container image",
    60  		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",
    61  		Example: internal.Tprintf(attestHelp, map[string]interface{}{
    62  			"appName": id.Name,
    63  			"command": "attest",
    64  		}),
    65  		Args:    validateScanArgs,
    66  		PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck),
    67  		RunE: func(cmd *cobra.Command, args []string) error {
    68  			restoreStdout := ui.CaptureStdoutToTraceLog()
    69  			defer restoreStdout()
    70  
    71  			return runAttest(cmd.Context(), id, &opts, args[0])
    72  		},
    73  	}, &opts)
    74  }
    75  
    76  func defaultAttestOptions() attestOptions {
    77  	return attestOptions{
    78  		Output:      defaultAttestOutputOptions(),
    79  		UpdateCheck: options.DefaultUpdateCheck(),
    80  		Catalog:     options.DefaultCatalog(),
    81  		Cache:       options.DefaultCache(),
    82  	}
    83  }
    84  
    85  func defaultAttestOutputOptions() options.Output {
    86  	return options.Output{
    87  		AllowMultipleOutputs: false,
    88  		AllowToFile:          false,
    89  		AllowableOptions: []string{
    90  			string(syftjson.ID),
    91  			string(cyclonedxjson.ID),
    92  			string(spdxjson.ID),
    93  			string(spdxtagvalue.ID),
    94  		},
    95  		Outputs: []string{syftjson.ID.String()},
    96  		OutputFile: options.OutputFile{ //nolint:staticcheck
    97  			Enabled: false, // explicitly not allowed
    98  		},
    99  		Format: options.DefaultFormat(),
   100  	}
   101  }
   102  
   103  func runAttest(ctx context.Context, id clio.Identification, opts *attestOptions, userInput string) error {
   104  	// TODO: what other validation here besides binary name?
   105  	if !commandExists(cosignBinName) {
   106  		return fmt.Errorf("'syft attest' requires cosign to be installed, however it does not appear to be on PATH")
   107  	}
   108  
   109  	// this is the file that will contain the SBOM being attested
   110  	f, err := os.CreateTemp("", "syft-attest-")
   111  	if err != nil {
   112  		return fmt.Errorf("unable to create temp file: %w", err)
   113  	}
   114  	defer os.Remove(f.Name())
   115  
   116  	s, err := generateSBOMForAttestation(ctx, id, &opts.Catalog, userInput)
   117  	if err != nil {
   118  		return fmt.Errorf("unable to build SBOM: %w", err)
   119  	}
   120  
   121  	if err = writeSBOMToFormattedFile(s, f, opts); err != nil {
   122  		return fmt.Errorf("unable to write SBOM to file: %w", err)
   123  	}
   124  
   125  	if err = createAttestation(f.Name(), opts, userInput); err != nil {
   126  		return err
   127  	}
   128  
   129  	bus.Notify("Attestation has been created, please check your registry for the output or use the cosign command:")
   130  	bus.Notify(fmt.Sprintf("cosign download attestation %s", userInput))
   131  	return nil
   132  }
   133  
   134  func writeSBOMToFormattedFile(s *sbom.SBOM, sbomFile io.Writer, opts *attestOptions) error {
   135  	if sbomFile == nil {
   136  		return fmt.Errorf("no output file provided")
   137  	}
   138  
   139  	encs, err := opts.Encoders()
   140  	if err != nil {
   141  		return fmt.Errorf("unable to create encoders: %w", err)
   142  	}
   143  
   144  	encoders := format.NewEncoderCollection(encs...)
   145  	encoder := encoders.GetByString(opts.Outputs[0])
   146  	if encoder == nil {
   147  		return fmt.Errorf("unable to find encoder for %q", opts.Outputs[0])
   148  	}
   149  
   150  	if err = encoder.Encode(sbomFile, *s); err != nil {
   151  		return fmt.Errorf("unable to encode SBOM: %w", err)
   152  	}
   153  
   154  	return nil
   155  }
   156  
   157  func createAttestation(sbomFilepath string, opts *attestOptions, userInput string) error {
   158  	execCmd, err := attestCommand(sbomFilepath, opts, userInput)
   159  	if err != nil {
   160  		return fmt.Errorf("unable to craft attest command: %w", err)
   161  	}
   162  
   163  	log.WithFields("cmd", strings.Join(execCmd.Args, " ")).Trace("creating attestation")
   164  
   165  	// bus adapter for ui to hook into stdout via an os pipe
   166  	r, w, err := os.Pipe()
   167  	if err != nil {
   168  		return fmt.Errorf("unable to create os pipe: %w", err)
   169  	}
   170  	defer w.Close()
   171  
   172  	mon := progress.NewManual(-1)
   173  
   174  	bus.Publish(
   175  		partybus.Event{
   176  			Type: event.AttestationStarted,
   177  			Source: monitor.GenericTask{
   178  				Title: monitor.Title{
   179  					Default:      "Create attestation",
   180  					WhileRunning: "Creating attestation",
   181  					OnSuccess:    "Created attestation",
   182  				},
   183  				Context: "cosign",
   184  			},
   185  			Value: &monitor.ShellProgress{
   186  				Reader:       r,
   187  				Progressable: mon,
   188  			},
   189  		},
   190  	)
   191  
   192  	execCmd.Stdout = w
   193  	execCmd.Stderr = w
   194  
   195  	// attest the SBOM
   196  	err = execCmd.Run()
   197  	if err != nil {
   198  		mon.SetError(err)
   199  		return fmt.Errorf("unable to attest SBOM: %w", err)
   200  	}
   201  
   202  	mon.SetCompleted()
   203  	return nil
   204  }
   205  
   206  func attestCommand(sbomFilepath string, opts *attestOptions, userInput string) (*exec.Cmd, error) {
   207  	outputNames := opts.OutputNameSet()
   208  	var outputName string
   209  	switch outputNames.Size() {
   210  	case 0:
   211  		return nil, fmt.Errorf("no output format specified")
   212  	case 1:
   213  		outputName = outputNames.List()[0]
   214  	default:
   215  		return nil, fmt.Errorf("multiple output formats specified: %s", strings.Join(outputNames.List(), ", "))
   216  	}
   217  
   218  	args := []string{"attest", userInput, "--predicate", sbomFilepath, "--type", predicateType(outputName), "-y"}
   219  	if opts.Attest.Key != "" {
   220  		args = append(args, "--key", opts.Attest.Key.String())
   221  	}
   222  
   223  	execCmd := exec.Command(cosignBinName, args...)
   224  	execCmd.Env = os.Environ()
   225  	if opts.Attest.Key != "" {
   226  		execCmd.Env = append(execCmd.Env, fmt.Sprintf("COSIGN_PASSWORD=%s", opts.Attest.Password))
   227  	} else {
   228  		// no key provided, use cosign's keyless mode
   229  		execCmd.Env = append(execCmd.Env, "COSIGN_EXPERIMENTAL=1")
   230  	}
   231  
   232  	return execCmd, nil
   233  }
   234  
   235  func predicateType(outputName string) string {
   236  	// select the Cosign predicate type based on defined output type
   237  	// As orientation, check: https://github.com/sigstore/cosign/blob/main/pkg/cosign/attestation/attestation.go
   238  	switch strings.ToLower(outputName) {
   239  	case "cyclonedx-json":
   240  		return "cyclonedx"
   241  	case "spdx-tag-value", "spdx-tv":
   242  		return "spdx"
   243  	case "spdx-json", "json":
   244  		return "spdxjson"
   245  	default:
   246  		return "custom"
   247  	}
   248  }
   249  
   250  func generateSBOMForAttestation(ctx context.Context, id clio.Identification, opts *options.Catalog, userInput string) (*sbom.SBOM, error) {
   251  	if len(opts.From) > 1 || (len(opts.From) == 1 && opts.From[0] != stereoscope.RegistryTag) {
   252  		return nil, fmt.Errorf("attest requires use of an OCI registry directly, one or more of the specified sources is unsupported: %v", opts.From)
   253  	}
   254  
   255  	src, err := getSource(ctx, opts, userInput, stereoscope.RegistryTag)
   256  	if err != nil {
   257  		return nil, err
   258  	}
   259  
   260  	defer func() {
   261  		if src != nil {
   262  			if err := src.Close(); err != nil {
   263  				log.Tracef("unable to close source: %+v", err)
   264  			}
   265  		}
   266  	}()
   267  
   268  	s, err := generateSBOM(ctx, id, src, opts)
   269  	if err != nil {
   270  		return nil, err
   271  	}
   272  
   273  	if s == nil {
   274  		return nil, fmt.Errorf("no SBOM produced for %q", userInput)
   275  	}
   276  
   277  	return s, nil
   278  }
   279  
   280  func commandExists(cmd string) bool {
   281  	_, err := exec.LookPath(cmd)
   282  	return err == nil
   283  }