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