github.com/kastenhq/syft@v0.0.0-20230821225854-0710af25cdbe/cmd/syft/cli/attest/attest.go (about)

     1  package attest
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"os"
     7  	"os/exec"
     8  	"strings"
     9  
    10  	"github.com/wagoodman/go-partybus"
    11  	"github.com/wagoodman/go-progress"
    12  	"golang.org/x/exp/slices"
    13  
    14  	"github.com/anchore/stereoscope"
    15  	"github.com/anchore/stereoscope/pkg/image"
    16  	"github.com/kastenhq/syft/cmd/syft/cli/eventloop"
    17  	"github.com/kastenhq/syft/cmd/syft/cli/options"
    18  	"github.com/kastenhq/syft/cmd/syft/cli/packages"
    19  	"github.com/kastenhq/syft/cmd/syft/internal/ui"
    20  	"github.com/kastenhq/syft/internal/bus"
    21  	"github.com/kastenhq/syft/internal/config"
    22  	"github.com/kastenhq/syft/internal/file"
    23  	"github.com/kastenhq/syft/internal/log"
    24  	"github.com/kastenhq/syft/syft"
    25  	"github.com/kastenhq/syft/syft/event"
    26  	"github.com/kastenhq/syft/syft/event/monitor"
    27  	"github.com/kastenhq/syft/syft/formats/syftjson"
    28  	"github.com/kastenhq/syft/syft/formats/table"
    29  	"github.com/kastenhq/syft/syft/sbom"
    30  	"github.com/kastenhq/syft/syft/source"
    31  )
    32  
    33  func Run(_ context.Context, app *config.Application, args []string) error {
    34  	err := ValidateOutputOptions(app)
    35  	if err != nil {
    36  		return err
    37  	}
    38  
    39  	// note: must be a container image
    40  	userInput := args[0]
    41  
    42  	_, err = exec.LookPath("cosign")
    43  	if err != nil {
    44  		// when cosign is not installed the error will be rendered like so:
    45  		// 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
    46  		return fmt.Errorf("'syft attest' requires cosign to be installed: %w", err)
    47  	}
    48  
    49  	eventBus := partybus.NewBus()
    50  	stereoscope.SetBus(eventBus)
    51  	syft.SetBus(eventBus)
    52  	subscription := eventBus.Subscribe()
    53  
    54  	return eventloop.EventLoop(
    55  		execWorker(app, userInput),
    56  		eventloop.SetupSignals(),
    57  		subscription,
    58  		stereoscope.Cleanup,
    59  		ui.Select(options.IsVerbose(app), app.Quiet)...,
    60  	)
    61  }
    62  
    63  func buildSBOM(app *config.Application, userInput string, errs chan error) (*sbom.SBOM, error) {
    64  	cfg := source.DetectConfig{
    65  		DefaultImageSource: app.DefaultImagePullSource,
    66  	}
    67  	detection, err := source.Detect(userInput, cfg)
    68  	if err != nil {
    69  		return nil, fmt.Errorf("could not deteremine source: %w", err)
    70  	}
    71  
    72  	if detection.IsContainerImage() {
    73  		return nil, fmt.Errorf("attestations are only supported for oci images at this time")
    74  	}
    75  
    76  	var platform *image.Platform
    77  
    78  	if app.Platform != "" {
    79  		platform, err = image.NewPlatform(app.Platform)
    80  		if err != nil {
    81  			return nil, fmt.Errorf("invalid platform: %w", err)
    82  		}
    83  	}
    84  
    85  	hashers, err := file.Hashers(app.Source.File.Digests...)
    86  	if err != nil {
    87  		return nil, fmt.Errorf("invalid hash: %w", err)
    88  	}
    89  
    90  	src, err := detection.NewSource(
    91  		source.DetectionSourceConfig{
    92  			Alias: source.Alias{
    93  				Name:    app.Source.Name,
    94  				Version: app.Source.Version,
    95  			},
    96  			RegistryOptions: app.Registry.ToOptions(),
    97  			Platform:        platform,
    98  			Exclude: source.ExcludeConfig{
    99  				Paths: app.Exclusions,
   100  			},
   101  			DigestAlgorithms: hashers,
   102  			BasePath:         app.BasePath,
   103  		},
   104  	)
   105  
   106  	if src != nil {
   107  		defer src.Close()
   108  	}
   109  	if err != nil {
   110  		return nil, fmt.Errorf("failed to construct source from user input %q: %w", userInput, err)
   111  	}
   112  
   113  	s, err := packages.GenerateSBOM(src, errs, app)
   114  	if err != nil {
   115  		return nil, err
   116  	}
   117  
   118  	if s == nil {
   119  		return nil, fmt.Errorf("no SBOM produced for %q", userInput)
   120  	}
   121  
   122  	return s, nil
   123  }
   124  
   125  //nolint:funlen
   126  func execWorker(app *config.Application, userInput string) <-chan error {
   127  	errs := make(chan error)
   128  	go func() {
   129  		defer close(errs)
   130  		defer bus.Exit()
   131  
   132  		s, err := buildSBOM(app, userInput, errs)
   133  		if err != nil {
   134  			errs <- fmt.Errorf("unable to build SBOM: %w", err)
   135  			return
   136  		}
   137  
   138  		// note: ValidateOutputOptions ensures that there is no more than one output type
   139  		o := app.Outputs[0]
   140  
   141  		f, err := os.CreateTemp("", o)
   142  		if err != nil {
   143  			errs <- fmt.Errorf("unable to create temp file: %w", err)
   144  			return
   145  		}
   146  		defer os.Remove(f.Name())
   147  
   148  		writer, err := options.MakeSBOMWriter(app.Outputs, f.Name(), app.OutputTemplatePath)
   149  		if err != nil {
   150  			errs <- fmt.Errorf("unable to create SBOM writer: %w", err)
   151  			return
   152  		}
   153  
   154  		if err := writer.Write(*s); err != nil {
   155  			errs <- fmt.Errorf("unable to write SBOM to temp file: %w", err)
   156  			return
   157  		}
   158  
   159  		// TODO: what other validation here besides binary name?
   160  		cmd := "cosign"
   161  		if !commandExists(cmd) {
   162  			errs <- fmt.Errorf("unable to find cosign in PATH; make sure you have it installed")
   163  			return
   164  		}
   165  
   166  		// Select Cosign predicate type based on defined output type
   167  		// As orientation, check: https://github.com/sigstore/cosign/blob/main/pkg/cosign/attestation/attestation.go
   168  		var predicateType string
   169  		switch strings.ToLower(o) {
   170  		case "cyclonedx-json":
   171  			predicateType = "cyclonedx"
   172  		case "spdx-tag-value", "spdx-tv":
   173  			predicateType = "spdx"
   174  		case "spdx-json", "json":
   175  			predicateType = "spdxjson"
   176  		default:
   177  			predicateType = "custom"
   178  		}
   179  
   180  		args := []string{"attest", userInput, "--predicate", f.Name(), "--type", predicateType}
   181  		if app.Attest.Key != "" {
   182  			args = append(args, "--key", app.Attest.Key)
   183  		}
   184  
   185  		execCmd := exec.Command(cmd, args...)
   186  		execCmd.Env = os.Environ()
   187  		if app.Attest.Key != "" {
   188  			execCmd.Env = append(execCmd.Env, fmt.Sprintf("COSIGN_PASSWORD=%s", app.Attest.Password))
   189  		} else {
   190  			// no key provided, use cosign's keyless mode
   191  			execCmd.Env = append(execCmd.Env, "COSIGN_EXPERIMENTAL=1")
   192  		}
   193  
   194  		log.WithFields("cmd", strings.Join(execCmd.Args, " ")).Trace("creating attestation")
   195  
   196  		// bus adapter for ui to hook into stdout via an os pipe
   197  		r, w, err := os.Pipe()
   198  		if err != nil {
   199  			errs <- fmt.Errorf("unable to create os pipe: %w", err)
   200  			return
   201  		}
   202  		defer w.Close()
   203  
   204  		mon := progress.NewManual(-1)
   205  
   206  		bus.Publish(
   207  			partybus.Event{
   208  				Type: event.AttestationStarted,
   209  				Source: monitor.GenericTask{
   210  					Title: monitor.Title{
   211  						Default:      "Create attestation",
   212  						WhileRunning: "Creating attestation",
   213  						OnSuccess:    "Created attestation",
   214  					},
   215  					Context: "cosign",
   216  				},
   217  				Value: &monitor.ShellProgress{
   218  					Reader:       r,
   219  					Progressable: mon,
   220  				},
   221  			},
   222  		)
   223  
   224  		execCmd.Stdout = w
   225  		execCmd.Stderr = w
   226  
   227  		// attest the SBOM
   228  		err = execCmd.Run()
   229  		if err != nil {
   230  			mon.SetError(err)
   231  			errs <- fmt.Errorf("unable to attest SBOM: %w", err)
   232  			return
   233  		}
   234  
   235  		mon.SetCompleted()
   236  	}()
   237  	return errs
   238  }
   239  
   240  func ValidateOutputOptions(app *config.Application) error {
   241  	err := packages.ValidateOutputOptions(app)
   242  	if err != nil {
   243  		return err
   244  	}
   245  
   246  	if len(app.Outputs) > 1 {
   247  		return fmt.Errorf("multiple SBOM format is not supported for attest at this time")
   248  	}
   249  
   250  	// cannot use table as default output format when using template output
   251  	if slices.Contains(app.Outputs, table.ID.String()) {
   252  		app.Outputs = []string{syftjson.ID.String()}
   253  	}
   254  
   255  	return nil
   256  }
   257  
   258  func commandExists(cmd string) bool {
   259  	_, err := exec.LookPath(cmd)
   260  	return err == nil
   261  }