github.com/nextlinux/gosbom@v0.81.1-0.20230627115839-1ff50c281391/cmd/gosbom/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/nextlinux/gosbom/cmd/gosbom/cli/eventloop"
    11  	"github.com/nextlinux/gosbom/cmd/gosbom/cli/options"
    12  	"github.com/nextlinux/gosbom/cmd/gosbom/cli/packages"
    13  	"github.com/nextlinux/gosbom/gosbom"
    14  	"github.com/nextlinux/gosbom/gosbom/event"
    15  	"github.com/nextlinux/gosbom/gosbom/event/monitor"
    16  	"github.com/nextlinux/gosbom/gosbom/formats/gosbomjson"
    17  	"github.com/nextlinux/gosbom/gosbom/formats/table"
    18  	"github.com/nextlinux/gosbom/gosbom/sbom"
    19  	"github.com/nextlinux/gosbom/gosbom/source"
    20  	"github.com/nextlinux/gosbom/internal/bus"
    21  	"github.com/nextlinux/gosbom/internal/config"
    22  	"github.com/nextlinux/gosbom/internal/log"
    23  	"github.com/nextlinux/gosbom/internal/ui"
    24  	"github.com/wagoodman/go-partybus"
    25  	"github.com/wagoodman/go-progress"
    26  	"golang.org/x/exp/slices"
    27  
    28  	"github.com/anchore/stereoscope"
    29  )
    30  
    31  func Run(_ context.Context, app *config.Application, args []string) error {
    32  	err := ValidateOutputOptions(app)
    33  	if err != nil {
    34  		return err
    35  	}
    36  
    37  	// could be an image or a directory, with or without a scheme
    38  	// TODO: validate that source is image
    39  	userInput := args[0]
    40  	si, err := source.ParseInputWithNameVersion(userInput, app.Platform, app.SourceName, app.SourceVersion, app.DefaultImagePullSource)
    41  	if err != nil {
    42  		return fmt.Errorf("could not generate source input for packages command: %w", err)
    43  	}
    44  
    45  	if si.Scheme != source.ImageScheme {
    46  		return fmt.Errorf("attestations are only supported for oci images at this time")
    47  	}
    48  
    49  	eventBus := partybus.NewBus()
    50  	stereoscope.SetBus(eventBus)
    51  	gosbom.SetBus(eventBus)
    52  	subscription := eventBus.Subscribe()
    53  
    54  	return eventloop.EventLoop(
    55  		execWorker(app, *si),
    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, si source.Input, errs chan error) (*sbom.SBOM, error) {
    64  	src, cleanup, err := source.New(si, app.Registry.ToOptions(), app.Exclusions)
    65  	if cleanup != nil {
    66  		defer cleanup()
    67  	}
    68  	if err != nil {
    69  		return nil, fmt.Errorf("failed to construct source from user input %q: %w", si.UserInput, err)
    70  	}
    71  
    72  	s, err := packages.GenerateSBOM(src, errs, app)
    73  	if err != nil {
    74  		return nil, err
    75  	}
    76  
    77  	if s == nil {
    78  		return nil, fmt.Errorf("no SBOM produced for %q", si.UserInput)
    79  	}
    80  
    81  	return s, nil
    82  }
    83  
    84  //nolint:funlen
    85  func execWorker(app *config.Application, si source.Input) <-chan error {
    86  	errs := make(chan error)
    87  	go func() {
    88  		defer close(errs)
    89  		defer bus.Publish(partybus.Event{Type: event.Exit})
    90  
    91  		s, err := buildSBOM(app, si, errs)
    92  		if err != nil {
    93  			errs <- fmt.Errorf("unable to build SBOM: %w", err)
    94  			return
    95  		}
    96  
    97  		// note: ValidateOutputOptions ensures that there is no more than one output type
    98  		o := app.Outputs[0]
    99  
   100  		f, err := os.CreateTemp("", o)
   101  		if err != nil {
   102  			errs <- fmt.Errorf("unable to create temp file: %w", err)
   103  			return
   104  		}
   105  		defer os.Remove(f.Name())
   106  
   107  		writer, err := options.MakeSBOMWriter(app.Outputs, f.Name(), app.OutputTemplatePath)
   108  		if err != nil {
   109  			errs <- fmt.Errorf("unable to create SBOM writer: %w", err)
   110  			return
   111  		}
   112  
   113  		if err := writer.Write(*s); err != nil {
   114  			errs <- fmt.Errorf("unable to write SBOM to temp file: %w", err)
   115  			return
   116  		}
   117  
   118  		// TODO: what other validation here besides binary name?
   119  		cmd := "cosign"
   120  		if !commandExists(cmd) {
   121  			errs <- fmt.Errorf("unable to find cosign in PATH; make sure you have it installed")
   122  			return
   123  		}
   124  
   125  		// Select Cosign predicate type based on defined output type
   126  		// As orientation, check: https://github.com/sigstore/cosign/blob/main/pkg/cosign/attestation/attestation.go
   127  		var predicateType string
   128  		switch strings.ToLower(o) {
   129  		case "cyclonedx-json":
   130  			predicateType = "cyclonedx"
   131  		case "spdx-tag-value", "spdx-tv":
   132  			predicateType = "spdx"
   133  		case "spdx-json", "json":
   134  			predicateType = "spdxjson"
   135  		default:
   136  			predicateType = "custom"
   137  		}
   138  
   139  		args := []string{"attest", si.UserInput, "--predicate", f.Name(), "--type", predicateType}
   140  		if app.Attest.Key != "" {
   141  			args = append(args, "--key", app.Attest.Key)
   142  		}
   143  
   144  		execCmd := exec.Command(cmd, args...)
   145  		execCmd.Env = os.Environ()
   146  		if app.Attest.Key != "" {
   147  			execCmd.Env = append(execCmd.Env, fmt.Sprintf("COSIGN_PASSWORD=%s", app.Attest.Password))
   148  		} else {
   149  			// no key provided, use cosign's keyless mode
   150  			execCmd.Env = append(execCmd.Env, "COSIGN_EXPERIMENTAL=1")
   151  		}
   152  
   153  		log.WithFields("cmd", strings.Join(execCmd.Args, " ")).Trace("creating attestation")
   154  
   155  		// bus adapter for ui to hook into stdout via an os pipe
   156  		r, w, err := os.Pipe()
   157  		if err != nil {
   158  			errs <- fmt.Errorf("unable to create os pipe: %w", err)
   159  			return
   160  		}
   161  		defer w.Close()
   162  
   163  		mon := progress.NewManual(-1)
   164  
   165  		bus.Publish(
   166  			partybus.Event{
   167  				Type: event.AttestationStarted,
   168  				Source: monitor.GenericTask{
   169  					Title: monitor.Title{
   170  						Default:      "Create attestation",
   171  						WhileRunning: "Creating attestation",
   172  						OnSuccess:    "Created attestation",
   173  					},
   174  					Context: "cosign",
   175  				},
   176  				Value: &monitor.ShellProgress{
   177  					Reader: r,
   178  					Manual: mon,
   179  				},
   180  			},
   181  		)
   182  
   183  		execCmd.Stdout = w
   184  		execCmd.Stderr = w
   185  
   186  		// attest the SBOM
   187  		err = execCmd.Run()
   188  		if err != nil {
   189  			mon.SetError(err)
   190  			errs <- fmt.Errorf("unable to attest SBOM: %w", err)
   191  			return
   192  		}
   193  
   194  		mon.SetCompleted()
   195  	}()
   196  	return errs
   197  }
   198  
   199  func ValidateOutputOptions(app *config.Application) error {
   200  	err := packages.ValidateOutputOptions(app)
   201  	if err != nil {
   202  		return err
   203  	}
   204  
   205  	if len(app.Outputs) > 1 {
   206  		return fmt.Errorf("multiple SBOM format is not supported for attest at this time")
   207  	}
   208  
   209  	// cannot use table as default output format when using template output
   210  	if slices.Contains(app.Outputs, table.ID.String()) {
   211  		app.Outputs = []string{gosbomjson.ID.String()}
   212  	}
   213  
   214  	return nil
   215  }
   216  
   217  func commandExists(cmd string) bool {
   218  	_, err := exec.LookPath(cmd)
   219  	return err == nil
   220  }