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 }