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