-
Notifications
You must be signed in to change notification settings - Fork 588
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Signed-off-by: Christopher Phillips <christopher.phillips@anchore.com>
- Loading branch information
Showing
14 changed files
with
2,048 additions
and
97 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,61 @@ | ||
package cli | ||
|
||
import ( | ||
"log" | ||
|
||
"github.com/anchore/syft/cmd/syft/cli/attest" | ||
"github.com/anchore/syft/cmd/syft/cli/options" | ||
"github.com/anchore/syft/internal" | ||
"github.com/anchore/syft/internal/config" | ||
"github.com/spf13/cobra" | ||
"github.com/spf13/viper" | ||
) | ||
|
||
const ( | ||
attestExample = ` {{.appName}} {{.command}} --output [FORMAT] --key [KEY] alpine:latest | ||
Supports the following image sources: | ||
{{.appName}} {{.command}} --key [KEY] yourrepo/yourimage:tag defaults to using images from a Docker daemon. If Docker is not present, the image is pulled directly from the registry. | ||
{{.appName}} {{.command}} --key [KEY] path/to/a/file/or/dir only for OCI tar or OCI directory | ||
` | ||
attestSchemeHelp = "\n" + indent + schemeHelpHeader + "\n" + imageSchemeHelp | ||
|
||
attestHelp = attestExample + attestSchemeHelp | ||
) | ||
|
||
func Attest(v *viper.Viper, app *config.Application, ro *options.RootOptions) *cobra.Command { | ||
ao := options.AttestOptions{} | ||
cmd := &cobra.Command{ | ||
Use: "attest --output [FORMAT] --key [KEY] [SOURCE]", | ||
Short: "Generate a package SBOM as an attestation for the given [SOURCE] container image", | ||
Long: "Generate a packaged-based Software Bill Of Materials (SBOM) from a container image as the predicate of an in-toto attestation", | ||
Example: internal.Tprintf(attestHelp, map[string]interface{}{ | ||
"appName": internal.ApplicationName, | ||
"command": "attest", | ||
}), | ||
Args: helpArgs, | ||
SilenceUsage: true, | ||
SilenceErrors: true, | ||
RunE: func(cmd *cobra.Command, args []string) error { | ||
// this MUST be called first to make sure app config decodes | ||
// the viper object correctly | ||
if err := app.LoadAllValues(v, ro.Config); err != nil { | ||
return err | ||
} | ||
// configure logging for command | ||
newLogWrapper(app) | ||
|
||
if app.CheckForAppUpdate { | ||
checkForApplicationUpdate() | ||
} | ||
|
||
return attest.Run(cmd.Context(), app, args) | ||
}, | ||
} | ||
|
||
err := ao.AddFlags(cmd, v) | ||
if err != nil { | ||
log.Fatal(err) | ||
} | ||
|
||
return cmd | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,216 @@ | ||
package attest | ||
|
||
import ( | ||
"bytes" | ||
"context" | ||
"encoding/json" | ||
"fmt" | ||
"os" | ||
"strings" | ||
|
||
"github.com/anchore/stereoscope" | ||
"github.com/anchore/stereoscope/pkg/image" | ||
"github.com/anchore/syft/cmd/syft/cli/eventloop" | ||
"github.com/anchore/syft/cmd/syft/cli/options" | ||
"github.com/anchore/syft/cmd/syft/cli/packages" | ||
"github.com/anchore/syft/internal/bus" | ||
"github.com/anchore/syft/internal/config" | ||
"github.com/anchore/syft/internal/formats/cyclonedxjson" | ||
"github.com/anchore/syft/internal/formats/spdx22json" | ||
"github.com/anchore/syft/internal/formats/syftjson" | ||
"github.com/anchore/syft/internal/ui" | ||
"github.com/anchore/syft/syft" | ||
"github.com/anchore/syft/syft/event" | ||
"github.com/anchore/syft/syft/sbom" | ||
"github.com/anchore/syft/syft/source" | ||
"github.com/in-toto/in-toto-golang/in_toto" | ||
"github.com/pkg/errors" | ||
"github.com/sigstore/cosign/cmd/cosign/cli/sign" | ||
"github.com/sigstore/cosign/pkg/cosign/attestation" | ||
"github.com/sigstore/sigstore/pkg/signature/dsse" | ||
"github.com/wagoodman/go-partybus" | ||
|
||
signatureoptions "github.com/sigstore/sigstore/pkg/signature/options" | ||
) | ||
|
||
var ( | ||
allowedAttestFormats = []sbom.FormatID{ | ||
syftjson.ID, | ||
spdx22json.ID, | ||
cyclonedxjson.ID, | ||
} | ||
|
||
intotoJSONDsseType = `application/vnd.in-toto+json` | ||
) | ||
|
||
func Run(ctx context.Context, app *config.Application, args []string) error { | ||
// We cannot generate an attestation for more than one output | ||
if len(app.Outputs) > 1 { | ||
return fmt.Errorf("unable to generate attestation for more than one output") | ||
} | ||
|
||
// can only be an image for attestation or OCI DIR | ||
userInput := args[0] | ||
si, err := parseImageSource(userInput, app) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
format := syft.FormatByName(app.Outputs[0]) | ||
predicateType := formatPredicateType(format) | ||
if predicateType == "" { | ||
return fmt.Errorf("could not produce attestation predicate for given format: %q. Available formats: %+v", options.FormatAliases(format.ID()), options.FormatAliases(allowedAttestFormats...)) | ||
} | ||
|
||
passFunc, err := selectPassFunc(app.Attest.Key, app.Attest.Password) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
ko := sign.KeyOpts{ | ||
KeyRef: app.Attest.Key, | ||
PassFunc: passFunc, | ||
} | ||
|
||
sv, err := sign.SignerFromKeyOpts(ctx, "", "", ko) | ||
if err != nil { | ||
return err | ||
} | ||
defer sv.Close() | ||
|
||
eventBus := partybus.NewBus() | ||
stereoscope.SetBus(eventBus) | ||
syft.SetBus(eventBus) | ||
|
||
return eventloop.EventLoop( | ||
execWorker(app, *si, format, predicateType, sv), | ||
eventloop.SetupSignals(), | ||
eventBus.Subscribe(), | ||
stereoscope.Cleanup, | ||
ui.Select(options.IsVerbose(app), app.Quiet)..., | ||
) | ||
} | ||
|
||
func parseImageSource(userInput string, app *config.Application) (s *source.Input, err error) { | ||
si, err := source.ParseInput(userInput, app.Platform, false) | ||
if err != nil { | ||
return nil, fmt.Errorf("could not generate source input for attest command: %w", err) | ||
} | ||
|
||
switch si.Scheme { | ||
case source.ImageScheme, source.UnknownScheme: | ||
// at this point we know that it cannot be dir: or file: schemes; | ||
// we will assume that the unknown scheme could represent an image; | ||
si.Scheme = source.ImageScheme | ||
default: | ||
return nil, fmt.Errorf("attest command can only be used with image sources but discovered %q when given %q", si.Scheme, userInput) | ||
} | ||
|
||
// if the original detection was from the local daemon we want to short circuit | ||
// that and attempt to generate the image source from its current registry source instead | ||
switch si.ImageSource { | ||
case image.UnknownSource, image.OciRegistrySource: | ||
si.ImageSource = image.OciRegistrySource | ||
default: | ||
return nil, fmt.Errorf("attest command can only be used with image sources fetch directly from the registry, but discovered an image source of %q when given %q", si.ImageSource, userInput) | ||
} | ||
|
||
return si, nil | ||
} | ||
|
||
func execWorker(app *config.Application, sourceInput source.Input, format sbom.Format, predicateType string, sv *sign.SignerVerifier) <-chan error { | ||
errs := make(chan error) | ||
go func() { | ||
defer close(errs) | ||
|
||
src, cleanup, err := source.NewFromRegistry(sourceInput, app.Registry.ToOptions(), app.Exclusions) | ||
if cleanup != nil { | ||
defer cleanup() | ||
} | ||
if err != nil { | ||
errs <- fmt.Errorf("failed to construct source from user input %q: %w", sourceInput.UserInput, err) | ||
return | ||
} | ||
|
||
s, err := packages.GenerateSBOM(src, errs, app) | ||
if err != nil { | ||
errs <- err | ||
return | ||
} | ||
|
||
sbomBytes, err := syft.Encode(*s, format) | ||
if err != nil { | ||
errs <- err | ||
return | ||
} | ||
|
||
err = generateAttestation(sbomBytes, src, sv, predicateType) | ||
if err != nil { | ||
errs <- err | ||
return | ||
} | ||
}() | ||
return errs | ||
} | ||
|
||
func generateAttestation(predicate []byte, src *source.Source, sv *sign.SignerVerifier, predicateType string) error { | ||
switch len(src.Image.Metadata.RepoDigests) { | ||
case 0: | ||
return fmt.Errorf("cannot generate attestation since no repo digests were found; make sure you're passing an OCI registry source for the attest command") | ||
case 1: | ||
default: | ||
return fmt.Errorf("cannot generate attestation since multiple repo digests were found for the image: %+v", src.Image.Metadata.RepoDigests) | ||
} | ||
|
||
wrapped := dsse.WrapSigner(sv, intotoJSONDsseType) | ||
|
||
sh, err := attestation.GenerateStatement(attestation.GenerateOpts{ | ||
Predicate: bytes.NewBuffer(predicate), | ||
Type: predicateType, | ||
Digest: findValidDigest(src.Image.Metadata.RepoDigests), | ||
}) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
payload, err := json.Marshal(sh) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
signedPayload, err := wrapped.SignMessage(bytes.NewReader(payload), signatureoptions.WithContext(context.Background())) | ||
if err != nil { | ||
return errors.Wrap(err, "unable to sign SBOM") | ||
} | ||
|
||
bus.Publish(partybus.Event{ | ||
Type: event.Exit, | ||
Value: func() error { | ||
_, err := os.Stdout.Write(signedPayload) | ||
return err | ||
}, | ||
}) | ||
|
||
return nil | ||
} | ||
|
||
func findValidDigest(digests []string) string { | ||
// since we are only using the OCI repo provider for this source we are safe that this is only 1 value | ||
// see https://github.com/anchore/stereoscope/blob/25ebd49a842b5ac0a20c2e2b4b81335b64ad248c/pkg/image/oci/registry_provider.go#L57-L63 | ||
split := strings.Split(digests[0], "sha256:") | ||
return split[1] | ||
} | ||
|
||
func formatPredicateType(format sbom.Format) string { | ||
switch format.ID() { | ||
case spdx22json.ID: | ||
return in_toto.PredicateSPDX | ||
case cyclonedxjson.ID: | ||
// Tentative see https://github.com/in-toto/attestation/issues/82 | ||
return "https://cyclonedx.org/bom" | ||
case syftjson.ID: | ||
return "https://syft.dev/bom" | ||
default: | ||
return "" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
package attest | ||
|
||
import ( | ||
"errors" | ||
"fmt" | ||
"io" | ||
"os" | ||
"strings" | ||
|
||
"github.com/anchore/syft/internal" | ||
"github.com/anchore/syft/internal/log" | ||
"github.com/sigstore/cosign/pkg/cosign" | ||
) | ||
|
||
func selectPassFunc(keypath, password string) (cosign.PassFunc, error) { | ||
keyContents, err := os.ReadFile(keypath) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
var fn cosign.PassFunc = func(bool) (b []byte, err error) { return nil, nil } | ||
|
||
_, err = cosign.LoadPrivateKey(keyContents, nil) | ||
if err != nil { | ||
fn = func(bool) (b []byte, err error) { | ||
return fetchPassword(password) | ||
} | ||
} | ||
|
||
return fn, nil | ||
} | ||
|
||
func fetchPassword(password string) (b []byte, err error) { | ||
potentiallyPipedInput, err := internal.IsPipedInput() | ||
if err != nil { | ||
log.Warnf("unable to determine if there is piped input: %+v", err) | ||
} | ||
|
||
switch { | ||
case password != "": | ||
return []byte(password), nil | ||
case potentiallyPipedInput: | ||
// handle piped in passwords | ||
pwBytes, err := io.ReadAll(os.Stdin) | ||
if err != nil { | ||
return nil, fmt.Errorf("unable to get password from stdin: %w", err) | ||
} | ||
// be resilient to input that may have newline characters (in case someone is using echo without -n) | ||
cleanPw := strings.TrimRight(string(pwBytes), "\n") | ||
return []byte(cleanPw), nil | ||
case internal.IsTerminal(): | ||
return cosign.GetPassFromTerm(false) | ||
} | ||
|
||
return nil, errors.New("no method available to fetch password") | ||
} |
Oops, something went wrong.