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 }