mirror of
https://github.com/anchore/syft
synced 2024-11-12 23:27:20 +00:00
Fix the attest
command (#2337)
* fix attest command Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * add notification on how to access the attestation Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix integration test Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> --------- Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
parent
ebeb768f59
commit
4712246897
10 changed files with 509 additions and 166 deletions
|
@ -2,6 +2,7 @@ package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -11,15 +12,14 @@ import (
|
||||||
"github.com/wagoodman/go-progress"
|
"github.com/wagoodman/go-progress"
|
||||||
|
|
||||||
"github.com/anchore/clio"
|
"github.com/anchore/clio"
|
||||||
"github.com/anchore/stereoscope/pkg/image"
|
|
||||||
"github.com/anchore/syft/cmd/syft/cli/options"
|
"github.com/anchore/syft/cmd/syft/cli/options"
|
||||||
"github.com/anchore/syft/cmd/syft/internal/ui"
|
"github.com/anchore/syft/cmd/syft/internal/ui"
|
||||||
"github.com/anchore/syft/internal"
|
"github.com/anchore/syft/internal"
|
||||||
"github.com/anchore/syft/internal/bus"
|
"github.com/anchore/syft/internal/bus"
|
||||||
"github.com/anchore/syft/internal/file"
|
|
||||||
"github.com/anchore/syft/internal/log"
|
"github.com/anchore/syft/internal/log"
|
||||||
"github.com/anchore/syft/syft/event"
|
"github.com/anchore/syft/syft/event"
|
||||||
"github.com/anchore/syft/syft/event/monitor"
|
"github.com/anchore/syft/syft/event/monitor"
|
||||||
|
"github.com/anchore/syft/syft/format"
|
||||||
"github.com/anchore/syft/syft/format/cyclonedxjson"
|
"github.com/anchore/syft/syft/format/cyclonedxjson"
|
||||||
"github.com/anchore/syft/syft/format/spdxjson"
|
"github.com/anchore/syft/syft/format/spdxjson"
|
||||||
"github.com/anchore/syft/syft/format/spdxtagvalue"
|
"github.com/anchore/syft/syft/format/spdxtagvalue"
|
||||||
|
@ -33,6 +33,7 @@ const (
|
||||||
`
|
`
|
||||||
attestSchemeHelp = "\n " + schemeHelpHeader + "\n" + imageSchemeHelp
|
attestSchemeHelp = "\n " + schemeHelpHeader + "\n" + imageSchemeHelp
|
||||||
attestHelp = attestExample + attestSchemeHelp
|
attestHelp = attestExample + attestSchemeHelp
|
||||||
|
cosignBinName = "cosign"
|
||||||
)
|
)
|
||||||
|
|
||||||
type attestOptions struct {
|
type attestOptions struct {
|
||||||
|
@ -46,24 +47,7 @@ type attestOptions struct {
|
||||||
func Attest(app clio.Application) *cobra.Command {
|
func Attest(app clio.Application) *cobra.Command {
|
||||||
id := app.ID()
|
id := app.ID()
|
||||||
|
|
||||||
opts := &attestOptions{
|
opts := defaultAttestOptions()
|
||||||
UpdateCheck: options.DefaultUpdateCheck(),
|
|
||||||
Output: options.Output{
|
|
||||||
AllowMultipleOutputs: false,
|
|
||||||
AllowableOptions: []string{
|
|
||||||
string(syftjson.ID),
|
|
||||||
string(cyclonedxjson.ID),
|
|
||||||
string(spdxjson.ID),
|
|
||||||
string(spdxtagvalue.ID),
|
|
||||||
},
|
|
||||||
Outputs: []string{syftjson.ID.String()},
|
|
||||||
OutputFile: options.OutputFile{ // nolint:staticcheck
|
|
||||||
Enabled: false, // explicitly not allowed
|
|
||||||
},
|
|
||||||
Format: options.DefaultFormat(),
|
|
||||||
},
|
|
||||||
Catalog: options.DefaultCatalog(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// template format explicitly not allowed
|
// template format explicitly not allowed
|
||||||
opts.Format.Template.Enabled = false
|
opts.Format.Template.Enabled = false
|
||||||
|
@ -82,83 +66,96 @@ func Attest(app clio.Application) *cobra.Command {
|
||||||
restoreStdout := ui.CaptureStdoutToTraceLog()
|
restoreStdout := ui.CaptureStdoutToTraceLog()
|
||||||
defer restoreStdout()
|
defer restoreStdout()
|
||||||
|
|
||||||
return runAttest(id, opts, args[0])
|
return runAttest(id, &opts, args[0])
|
||||||
},
|
},
|
||||||
}, opts)
|
}, &opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultAttestOptions() attestOptions {
|
||||||
|
return attestOptions{
|
||||||
|
Output: defaultAttestOutputOptions(),
|
||||||
|
UpdateCheck: options.DefaultUpdateCheck(),
|
||||||
|
Catalog: options.DefaultCatalog(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultAttestOutputOptions() options.Output {
|
||||||
|
return options.Output{
|
||||||
|
AllowMultipleOutputs: false,
|
||||||
|
AllowToFile: false,
|
||||||
|
AllowableOptions: []string{
|
||||||
|
string(syftjson.ID),
|
||||||
|
string(cyclonedxjson.ID),
|
||||||
|
string(spdxjson.ID),
|
||||||
|
string(spdxtagvalue.ID),
|
||||||
|
},
|
||||||
|
Outputs: []string{syftjson.ID.String()},
|
||||||
|
OutputFile: options.OutputFile{ // nolint:staticcheck
|
||||||
|
Enabled: false, // explicitly not allowed
|
||||||
|
},
|
||||||
|
Format: options.DefaultFormat(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:funlen
|
//nolint:funlen
|
||||||
func runAttest(id clio.Identification, opts *attestOptions, userInput string) error {
|
func runAttest(id clio.Identification, opts *attestOptions, userInput string) error {
|
||||||
_, err := exec.LookPath("cosign")
|
// TODO: what other validation here besides binary name?
|
||||||
if err != nil {
|
if !commandExists(cosignBinName) {
|
||||||
// when cosign is not installed the error will be rendered like so:
|
return fmt.Errorf("'syft attest' requires cosign to be installed, however it does not appear to be on PATH")
|
||||||
// 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
|
|
||||||
return fmt.Errorf("'syft attest' requires cosign to be installed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s, err := buildSBOM(id, &opts.Catalog, userInput)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to build SBOM: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// this is the file that will contain the SBOM being attested
|
||||||
f, err := os.CreateTemp("", "syft-attest-")
|
f, err := os.CreateTemp("", "syft-attest-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to create temp file: %w", err)
|
return fmt.Errorf("unable to create temp file: %w", err)
|
||||||
}
|
}
|
||||||
defer os.Remove(f.Name())
|
defer os.Remove(f.Name())
|
||||||
|
|
||||||
writer, err := opts.SBOMWriter()
|
s, err := generateSBOMForAttestation(id, &opts.Catalog, userInput)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to create SBOM writer: %w", err)
|
return fmt.Errorf("unable to build SBOM: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := writer.Write(*s); err != nil {
|
if err = writeSBOMToFormattedFile(s, f, opts); err != nil {
|
||||||
return fmt.Errorf("unable to write SBOM to temp file: %w", err)
|
return fmt.Errorf("unable to write SBOM to file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: what other validation here besides binary name?
|
if err = createAttestation(f.Name(), opts, userInput); err != nil {
|
||||||
cmd := "cosign"
|
return err
|
||||||
if !commandExists(cmd) {
|
|
||||||
return fmt.Errorf("unable to find cosign in PATH; make sure you have it installed")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
outputNames := opts.OutputNameSet()
|
bus.Notify("Attestation has been created, please check your registry for the output or use the cosign command:")
|
||||||
var outputName string
|
bus.Notify(fmt.Sprintf("cosign download attestation %s", userInput))
|
||||||
switch outputNames.Size() {
|
return nil
|
||||||
case 0:
|
}
|
||||||
return fmt.Errorf("no output format specified")
|
|
||||||
case 1:
|
func writeSBOMToFormattedFile(s *sbom.SBOM, sbomFile io.Writer, opts *attestOptions) error {
|
||||||
outputName = outputNames.List()[0]
|
if sbomFile == nil {
|
||||||
default:
|
return fmt.Errorf("no output file provided")
|
||||||
return fmt.Errorf("multiple output formats specified: %s", strings.Join(outputNames.List(), ", "))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select Cosign predicate type based on defined output type
|
encs, err := opts.Format.Encoders()
|
||||||
// As orientation, check: https://github.com/sigstore/cosign/blob/main/pkg/cosign/attestation/attestation.go
|
if err != nil {
|
||||||
var predicateType string
|
return fmt.Errorf("unable to create encoders: %w", err)
|
||||||
switch strings.ToLower(outputName) {
|
|
||||||
case "cyclonedx-json":
|
|
||||||
predicateType = "cyclonedx"
|
|
||||||
case "spdx-tag-value", "spdx-tv":
|
|
||||||
predicateType = "spdx"
|
|
||||||
case "spdx-json", "json":
|
|
||||||
predicateType = "spdxjson"
|
|
||||||
default:
|
|
||||||
predicateType = "custom"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
args := []string{"attest", userInput, "--predicate", f.Name(), "--type", predicateType}
|
encoders := format.NewEncoderCollection(encs...)
|
||||||
if opts.Attest.Key != "" {
|
encoder := encoders.GetByString(opts.Outputs[0])
|
||||||
args = append(args, "--key", opts.Attest.Key.String())
|
if encoder == nil {
|
||||||
|
return fmt.Errorf("unable to find encoder for %q", opts.Outputs[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
execCmd := exec.Command(cmd, args...)
|
if err = encoder.Encode(sbomFile, *s); err != nil {
|
||||||
execCmd.Env = os.Environ()
|
return fmt.Errorf("unable to encode SBOM: %w", err)
|
||||||
if opts.Attest.Key != "" {
|
}
|
||||||
execCmd.Env = append(execCmd.Env, fmt.Sprintf("COSIGN_PASSWORD=%s", opts.Attest.Password))
|
|
||||||
} else {
|
return nil
|
||||||
// no key provided, use cosign's keyless mode
|
}
|
||||||
execCmd.Env = append(execCmd.Env, "COSIGN_EXPERIMENTAL=1")
|
|
||||||
|
func createAttestation(sbomFilepath string, opts *attestOptions, userInput string) error {
|
||||||
|
execCmd, err := attestCommand(sbomFilepath, opts, userInput)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to craft attest command: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.WithFields("cmd", strings.Join(execCmd.Args, " ")).Trace("creating attestation")
|
log.WithFields("cmd", strings.Join(execCmd.Args, " ")).Trace("creating attestation")
|
||||||
|
@ -201,59 +198,67 @@ func runAttest(id clio.Identification, opts *attestOptions, userInput string) er
|
||||||
}
|
}
|
||||||
|
|
||||||
mon.SetCompleted()
|
mon.SetCompleted()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSBOM(id clio.Identification, opts *options.Catalog, userInput string) (*sbom.SBOM, error) {
|
func attestCommand(sbomFilepath string, opts *attestOptions, userInput string) (*exec.Cmd, error) {
|
||||||
cfg := source.DetectConfig{
|
outputNames := opts.OutputNameSet()
|
||||||
DefaultImageSource: opts.DefaultImagePullSource,
|
var outputName string
|
||||||
|
switch outputNames.Size() {
|
||||||
|
case 0:
|
||||||
|
return nil, fmt.Errorf("no output format specified")
|
||||||
|
case 1:
|
||||||
|
outputName = outputNames.List()[0]
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("multiple output formats specified: %s", strings.Join(outputNames.List(), ", "))
|
||||||
}
|
}
|
||||||
detection, err := source.Detect(userInput, cfg)
|
|
||||||
|
args := []string{"attest", userInput, "--predicate", sbomFilepath, "--type", predicateType(outputName), "-y"}
|
||||||
|
if opts.Attest.Key != "" {
|
||||||
|
args = append(args, "--key", opts.Attest.Key.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
execCmd := exec.Command(cosignBinName, args...)
|
||||||
|
execCmd.Env = os.Environ()
|
||||||
|
if opts.Attest.Key != "" {
|
||||||
|
execCmd.Env = append(execCmd.Env, fmt.Sprintf("COSIGN_PASSWORD=%s", opts.Attest.Password))
|
||||||
|
} else {
|
||||||
|
// no key provided, use cosign's keyless mode
|
||||||
|
execCmd.Env = append(execCmd.Env, "COSIGN_EXPERIMENTAL=1")
|
||||||
|
}
|
||||||
|
|
||||||
|
return execCmd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func predicateType(outputName string) string {
|
||||||
|
// Select Cosign predicate type based on defined output type
|
||||||
|
// As orientation, check: https://github.com/sigstore/cosign/blob/main/pkg/cosign/attestation/attestation.go
|
||||||
|
switch strings.ToLower(outputName) {
|
||||||
|
case "cyclonedx-json":
|
||||||
|
return "cyclonedx"
|
||||||
|
case "spdx-tag-value", "spdx-tv":
|
||||||
|
return "spdx"
|
||||||
|
case "spdx-json", "json":
|
||||||
|
return "spdxjson"
|
||||||
|
default:
|
||||||
|
return "custom"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSBOMForAttestation(id clio.Identification, opts *options.Catalog, userInput string) (*sbom.SBOM, error) {
|
||||||
|
src, err := getSource(opts, userInput, onlyContainerImages)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not deteremine source: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if detection.IsContainerImage() {
|
defer func() {
|
||||||
return nil, fmt.Errorf("attestations are only supported for oci images at this time")
|
if src != nil {
|
||||||
}
|
if err := src.Close(); err != nil {
|
||||||
|
log.Tracef("unable to close source: %+v", err)
|
||||||
var platform *image.Platform
|
}
|
||||||
|
|
||||||
if opts.Platform != "" {
|
|
||||||
platform, err = image.NewPlatform(opts.Platform)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid platform: %w", err)
|
|
||||||
}
|
}
|
||||||
}
|
}()
|
||||||
|
|
||||||
hashers, err := file.Hashers(opts.Source.File.Digests...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid hash: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
src, err := detection.NewSource(
|
|
||||||
source.DetectionSourceConfig{
|
|
||||||
Alias: source.Alias{
|
|
||||||
Name: opts.Source.Name,
|
|
||||||
Version: opts.Source.Version,
|
|
||||||
},
|
|
||||||
RegistryOptions: opts.Registry.ToOptions(),
|
|
||||||
Platform: platform,
|
|
||||||
Exclude: source.ExcludeConfig{
|
|
||||||
Paths: opts.Exclusions,
|
|
||||||
},
|
|
||||||
DigestAlgorithms: hashers,
|
|
||||||
BasePath: opts.BasePath,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if src != nil {
|
|
||||||
defer src.Close()
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to construct source from user input %q: %w", userInput, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s, err := generateSBOM(id, src, opts)
|
s, err := generateSBOM(id, src, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -267,6 +272,13 @@ func buildSBOM(id clio.Identification, opts *options.Catalog, userInput string)
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func onlyContainerImages(d *source.Detection) error {
|
||||||
|
if !d.IsContainerImage() {
|
||||||
|
return fmt.Errorf("attestations are only supported for oci images at this time")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func commandExists(cmd string) bool {
|
func commandExists(cmd string) bool {
|
||||||
_, err := exec.LookPath(cmd)
|
_, err := exec.LookPath(cmd)
|
||||||
return err == nil
|
return err == nil
|
||||||
|
|
268
cmd/syft/cli/commands/attest_test.go
Normal file
268
cmd/syft/cli/commands/attest_test.go
Normal file
|
@ -0,0 +1,268 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/scylladb/go-set/strset"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/anchore/clio"
|
||||||
|
"github.com/anchore/syft/cmd/syft/cli/options"
|
||||||
|
"github.com/anchore/syft/syft/sbom"
|
||||||
|
"github.com/anchore/syft/syft/source"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_writeSBOMToFormattedFile(t *testing.T) {
|
||||||
|
type args struct {
|
||||||
|
s *sbom.SBOM
|
||||||
|
opts *attestOptions
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args args
|
||||||
|
wantSbomFile string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "go case",
|
||||||
|
args: args{
|
||||||
|
opts: &attestOptions{
|
||||||
|
Output: func() options.Output {
|
||||||
|
def := defaultAttestOutputOptions()
|
||||||
|
def.Outputs = []string{"syft-json"}
|
||||||
|
return def
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
s: &sbom.SBOM{
|
||||||
|
Artifacts: sbom.Artifacts{},
|
||||||
|
Relationships: nil,
|
||||||
|
Source: source.Description{
|
||||||
|
ID: "source-id",
|
||||||
|
Name: "source-name",
|
||||||
|
Version: "source-version",
|
||||||
|
},
|
||||||
|
Descriptor: sbom.Descriptor{
|
||||||
|
Name: "syft-test",
|
||||||
|
Version: "non-version",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantSbomFile: `{
|
||||||
|
"artifacts": [],
|
||||||
|
"artifactRelationships": [],
|
||||||
|
"source": {
|
||||||
|
"id": "source-id",
|
||||||
|
"name": "source-name",
|
||||||
|
"version": "source-version",
|
||||||
|
"type": "",
|
||||||
|
"metadata": null
|
||||||
|
},
|
||||||
|
"distro": {},
|
||||||
|
"descriptor": {
|
||||||
|
"name": "syft-test",
|
||||||
|
"version": "non-version"
|
||||||
|
},
|
||||||
|
"schema": {}
|
||||||
|
}`,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
sbomFile := &bytes.Buffer{}
|
||||||
|
|
||||||
|
err := writeSBOMToFormattedFile(tt.args.s, sbomFile, tt.args.opts)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("writeSBOMToFormattedFile() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// redact the schema block
|
||||||
|
re := regexp.MustCompile(`(?s)"schema":\W*\{.*?},?`)
|
||||||
|
subject := re.ReplaceAllString(sbomFile.String(), `"schema":{}`)
|
||||||
|
|
||||||
|
assert.JSONEq(t, tt.wantSbomFile, subject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_attestCommand(t *testing.T) {
|
||||||
|
cmdPrefix := cosignBinName
|
||||||
|
lp, err := exec.LookPath(cosignBinName)
|
||||||
|
if err == nil {
|
||||||
|
cmdPrefix = lp
|
||||||
|
}
|
||||||
|
|
||||||
|
fullCmd := func(args string) string {
|
||||||
|
return fmt.Sprintf("%s %s", cmdPrefix, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
type args struct {
|
||||||
|
sbomFilepath string
|
||||||
|
opts attestOptions
|
||||||
|
userInput string
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args args
|
||||||
|
wantCmd string
|
||||||
|
wantEnvVars map[string]string
|
||||||
|
notEnvVars []string
|
||||||
|
wantErr require.ErrorAssertionFunc
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "with key and password",
|
||||||
|
args: args{
|
||||||
|
userInput: "myimage",
|
||||||
|
sbomFilepath: "/tmp/sbom-filepath.json",
|
||||||
|
opts: func() attestOptions {
|
||||||
|
def := defaultAttestOptions()
|
||||||
|
def.Outputs = []string{"syft-json"}
|
||||||
|
def.Attest.Key = "key"
|
||||||
|
def.Attest.Password = "password"
|
||||||
|
return def
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
wantCmd: fullCmd("attest myimage --predicate /tmp/sbom-filepath.json --type custom -y --key key"),
|
||||||
|
wantEnvVars: map[string]string{
|
||||||
|
"COSIGN_PASSWORD": "password",
|
||||||
|
},
|
||||||
|
notEnvVars: []string{
|
||||||
|
"COSIGN_EXPERIMENTAL", // only for keyless
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "keyless",
|
||||||
|
args: args{
|
||||||
|
userInput: "myimage",
|
||||||
|
sbomFilepath: "/tmp/sbom-filepath.json",
|
||||||
|
opts: func() attestOptions {
|
||||||
|
def := defaultAttestOptions()
|
||||||
|
def.Outputs = []string{"syft-json"}
|
||||||
|
return def
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
wantCmd: fullCmd("attest myimage --predicate /tmp/sbom-filepath.json --type custom -y"),
|
||||||
|
wantEnvVars: map[string]string{
|
||||||
|
"COSIGN_EXPERIMENTAL": "1",
|
||||||
|
},
|
||||||
|
notEnvVars: []string{
|
||||||
|
"COSIGN_PASSWORD",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.wantErr == nil {
|
||||||
|
tt.wantErr = require.NoError
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := attestCommand(tt.args.sbomFilepath, &tt.args.opts, tt.args.userInput)
|
||||||
|
tt.wantErr(t, err)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotNil(t, got)
|
||||||
|
assert.Equal(t, tt.wantCmd, got.String())
|
||||||
|
|
||||||
|
gotEnv := strset.New(got.Env...)
|
||||||
|
|
||||||
|
for k, v := range tt.wantEnvVars {
|
||||||
|
assert.True(t, gotEnv.Has(fmt.Sprintf("%s=%s", k, v)))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, k := range tt.notEnvVars {
|
||||||
|
for _, env := range got.Env {
|
||||||
|
fields := strings.Split(env, "=")
|
||||||
|
if fields[0] == k {
|
||||||
|
t.Errorf("attestCommand() unexpected environment variable %s", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_predicateType(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "cyclonedx-json",
|
||||||
|
want: "cyclonedx",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "spdx-tag-value",
|
||||||
|
want: "spdx",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "spdx-tv",
|
||||||
|
want: "spdx",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "spdx-json",
|
||||||
|
want: "spdxjson",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "json",
|
||||||
|
want: "spdxjson",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "syft-json",
|
||||||
|
want: "custom",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equalf(t, tt.want, predicateType(tt.name), "predicateType(%v)", tt.name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_buildSBOMForAttestation(t *testing.T) {
|
||||||
|
// note: this test is only meant to test that the filter function is wired
|
||||||
|
// and not the correctness of the function in depth
|
||||||
|
type args struct {
|
||||||
|
id clio.Identification
|
||||||
|
opts *options.Catalog
|
||||||
|
userInput string
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args args
|
||||||
|
want *sbom.SBOM
|
||||||
|
wantErr require.ErrorAssertionFunc
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "do not allow directory scans",
|
||||||
|
args: args{
|
||||||
|
opts: func() *options.Catalog {
|
||||||
|
def := defaultAttestOptions()
|
||||||
|
return &def.Catalog
|
||||||
|
}(),
|
||||||
|
userInput: "dir:/tmp/something",
|
||||||
|
},
|
||||||
|
wantErr: require.Error,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.wantErr == nil {
|
||||||
|
tt.wantErr = require.NoError
|
||||||
|
}
|
||||||
|
_, err := generateSBOMForAttestation(tt.args.id, tt.args.opts, tt.args.userInput)
|
||||||
|
tt.wantErr(t, err)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -117,51 +117,10 @@ func runPackages(id clio.Identification, opts *packagesOptions, userInput string
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
detection, err := source.Detect(
|
src, err := getSource(&opts.Catalog, userInput)
|
||||||
userInput,
|
|
||||||
source.DetectConfig{
|
|
||||||
DefaultImageSource: opts.DefaultImagePullSource,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not deteremine source: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var platform *image.Platform
|
|
||||||
|
|
||||||
if opts.Platform != "" {
|
|
||||||
platform, err = image.NewPlatform(opts.Platform)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid platform: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hashers, err := file.Hashers(opts.Source.File.Digests...)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid hash: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
src, err := detection.NewSource(
|
|
||||||
source.DetectionSourceConfig{
|
|
||||||
Alias: source.Alias{
|
|
||||||
Name: opts.Source.Name,
|
|
||||||
Version: opts.Source.Version,
|
|
||||||
},
|
|
||||||
RegistryOptions: opts.Registry.ToOptions(),
|
|
||||||
Platform: platform,
|
|
||||||
Exclude: source.ExcludeConfig{
|
|
||||||
Paths: opts.Exclusions,
|
|
||||||
},
|
|
||||||
DigestAlgorithms: hashers,
|
|
||||||
BasePath: opts.BasePath,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if userInput == "power-user" {
|
return err
|
||||||
bus.Notify("Note: the 'power-user' command has been removed.")
|
|
||||||
}
|
|
||||||
return fmt.Errorf("failed to construct source from user input %q: %w", userInput, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
|
@ -188,6 +147,63 @@ func runPackages(id clio.Identification, opts *packagesOptions, userInput string
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getSource(opts *options.Catalog, userInput string, filters ...func(*source.Detection) error) (source.Source, error) {
|
||||||
|
detection, err := source.Detect(
|
||||||
|
userInput,
|
||||||
|
source.DetectConfig{
|
||||||
|
DefaultImageSource: opts.DefaultImagePullSource,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not deteremine source: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, filter := range filters {
|
||||||
|
if err := filter(detection); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var platform *image.Platform
|
||||||
|
|
||||||
|
if opts.Platform != "" {
|
||||||
|
platform, err = image.NewPlatform(opts.Platform)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid platform: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hashers, err := file.Hashers(opts.Source.File.Digests...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid hash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := detection.NewSource(
|
||||||
|
source.DetectionSourceConfig{
|
||||||
|
Alias: source.Alias{
|
||||||
|
Name: opts.Source.Name,
|
||||||
|
Version: opts.Source.Version,
|
||||||
|
},
|
||||||
|
RegistryOptions: opts.Registry.ToOptions(),
|
||||||
|
Platform: platform,
|
||||||
|
Exclude: source.ExcludeConfig{
|
||||||
|
Paths: opts.Exclusions,
|
||||||
|
},
|
||||||
|
DigestAlgorithms: hashers,
|
||||||
|
BasePath: opts.BasePath,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if userInput == "power-user" {
|
||||||
|
bus.Notify("Note: the 'power-user' command has been removed.")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to construct source from user input %q: %w", userInput, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return src, nil
|
||||||
|
}
|
||||||
|
|
||||||
func generateSBOM(id clio.Identification, src source.Source, opts *options.Catalog) (*sbom.SBOM, error) {
|
func generateSBOM(id clio.Identification, src source.Source, opts *options.Catalog) (*sbom.SBOM, error) {
|
||||||
tasks, err := eventloop.Tasks(opts)
|
tasks, err := eventloop.Tasks(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -32,8 +32,12 @@ func (o FormatCyclonedxJSON) formatEncoders() ([]sbom.FormatEncoder, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o FormatCyclonedxJSON) buildConfig(version string) cyclonedxjson.EncoderConfig {
|
func (o FormatCyclonedxJSON) buildConfig(version string) cyclonedxjson.EncoderConfig {
|
||||||
|
var pretty bool
|
||||||
|
if o.Pretty != nil {
|
||||||
|
pretty = *o.Pretty
|
||||||
|
}
|
||||||
return cyclonedxjson.EncoderConfig{
|
return cyclonedxjson.EncoderConfig{
|
||||||
Version: version,
|
Version: version,
|
||||||
Pretty: *o.Pretty,
|
Pretty: pretty,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,8 +32,12 @@ func (o FormatCyclonedxXML) formatEncoders() ([]sbom.FormatEncoder, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o FormatCyclonedxXML) buildConfig(version string) cyclonedxxml.EncoderConfig {
|
func (o FormatCyclonedxXML) buildConfig(version string) cyclonedxxml.EncoderConfig {
|
||||||
|
var pretty bool
|
||||||
|
if o.Pretty != nil {
|
||||||
|
pretty = *o.Pretty
|
||||||
|
}
|
||||||
return cyclonedxxml.EncoderConfig{
|
return cyclonedxxml.EncoderConfig{
|
||||||
Version: version,
|
Version: version,
|
||||||
Pretty: *o.Pretty,
|
Pretty: pretty,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,8 +32,12 @@ func (o FormatSPDXJSON) formatEncoders() ([]sbom.FormatEncoder, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o FormatSPDXJSON) buildConfig(v string) spdxjson.EncoderConfig {
|
func (o FormatSPDXJSON) buildConfig(v string) spdxjson.EncoderConfig {
|
||||||
|
var pretty bool
|
||||||
|
if o.Pretty != nil {
|
||||||
|
pretty = *o.Pretty
|
||||||
|
}
|
||||||
return spdxjson.EncoderConfig{
|
return spdxjson.EncoderConfig{
|
||||||
Version: v,
|
Version: v,
|
||||||
Pretty: *o.Pretty,
|
Pretty: pretty,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,8 +22,12 @@ func (o FormatSyftJSON) formatEncoders() ([]sbom.FormatEncoder, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o FormatSyftJSON) buildConfig() syftjson.EncoderConfig {
|
func (o FormatSyftJSON) buildConfig() syftjson.EncoderConfig {
|
||||||
|
var pretty bool
|
||||||
|
if o.Pretty != nil {
|
||||||
|
pretty = *o.Pretty
|
||||||
|
}
|
||||||
return syftjson.EncoderConfig{
|
return syftjson.EncoderConfig{
|
||||||
Legacy: o.Legacy,
|
Legacy: o.Legacy,
|
||||||
Pretty: *o.Pretty,
|
Pretty: pretty,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,7 @@ var _ interface {
|
||||||
type Output struct {
|
type Output struct {
|
||||||
AllowableOptions []string `yaml:"-" json:"-" mapstructure:"-"`
|
AllowableOptions []string `yaml:"-" json:"-" mapstructure:"-"`
|
||||||
AllowMultipleOutputs bool `yaml:"-" json:"-" mapstructure:"-"`
|
AllowMultipleOutputs bool `yaml:"-" json:"-" mapstructure:"-"`
|
||||||
|
AllowToFile bool `yaml:"-" json:"-" mapstructure:"-"`
|
||||||
Outputs []string `yaml:"output" json:"output" mapstructure:"output"` // -o, the format to use for output
|
Outputs []string `yaml:"output" json:"output" mapstructure:"output"` // -o, the format to use for output
|
||||||
OutputFile `yaml:",inline" json:"" mapstructure:",squash"`
|
OutputFile `yaml:",inline" json:"" mapstructure:",squash"`
|
||||||
Format `yaml:"format" json:"format" mapstructure:"format"`
|
Format `yaml:"format" json:"format" mapstructure:"format"`
|
||||||
|
@ -38,6 +39,7 @@ type Output struct {
|
||||||
func DefaultOutput() Output {
|
func DefaultOutput() Output {
|
||||||
return Output{
|
return Output{
|
||||||
AllowMultipleOutputs: true,
|
AllowMultipleOutputs: true,
|
||||||
|
AllowToFile: true,
|
||||||
Outputs: []string{string(table.ID)},
|
Outputs: []string{string(table.ID)},
|
||||||
OutputFile: OutputFile{
|
OutputFile: OutputFile{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
@ -86,6 +88,14 @@ func (o Output) SBOMWriter() (sbom.Writer, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !o.AllowToFile {
|
||||||
|
for _, opt := range o.Outputs {
|
||||||
|
if strings.Contains(opt, "=") {
|
||||||
|
return nil, fmt.Errorf("file output is not allowed ('-o format=path' should be '-o format')")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return makeSBOMWriter(o.Outputs, o.File, encoders)
|
return makeSBOMWriter(o.Outputs, o.File, encoders)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -179,3 +179,25 @@ func Test_EncoderCollection_ByString_IDOnly_Defaults(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_OutputHonorsAllowFile(t *testing.T) {
|
||||||
|
o := DefaultOutput()
|
||||||
|
|
||||||
|
t.Run("file is not allowed", func(t *testing.T) {
|
||||||
|
o.AllowToFile = false
|
||||||
|
o.Outputs = []string{"table=/tmp/somefile"}
|
||||||
|
|
||||||
|
w, err := o.SBOMWriter()
|
||||||
|
assert.Nil(t, w)
|
||||||
|
assert.ErrorContains(t, err, "file output is not allowed")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("file is allowed", func(t *testing.T) {
|
||||||
|
o.AllowToFile = true
|
||||||
|
o.Outputs = []string{"table=/tmp/somefile"}
|
||||||
|
|
||||||
|
w, err := o.SBOMWriter()
|
||||||
|
assert.NotNil(t, w)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -78,10 +78,9 @@ func TestConvertCmd(t *testing.T) {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
opts := &commands.ConvertOptions{
|
opts := &commands.ConvertOptions{
|
||||||
Output: options.Output{
|
Output: options.DefaultOutput(),
|
||||||
Outputs: []string{fmt.Sprintf("%s=%s", test.format.ID().String(), formatFile.Name())},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
opts.Outputs = []string{fmt.Sprintf("%s=%s", test.format.ID().String(), formatFile.Name())}
|
||||||
require.NoError(t, opts.PostLoad())
|
require.NoError(t, opts.PostLoad())
|
||||||
|
|
||||||
// stdout reduction of test noise
|
// stdout reduction of test noise
|
||||||
|
|
Loading…
Reference in a new issue