github.com/lineaje-labs/syft@v0.98.1-0.20231227153149-9e393f60ff1b/cmd/syft/cli/commands/attest.go (about)

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