feat: verify provenance for bcr modules produced by trusted reusable workflows (#840)

@fweikert these are the changes I think might be needed to get this to
work (it's somewhat hacky, I'm not sure I've fully covered what's
needed).

@ramonpetgrave64 is this kinda what's needed?

This now adds the `verify-github-attestation` sub command. Use this
instead of `verify-artifact`.

---------

Signed-off-by: Appu Goundan <appu@google.com>
Signed-off-by: Appu <appu@google.com>
Co-authored-by: Ramon Petgrave <32398091+ramonpetgrave64@users.noreply.github.com>
This commit is contained in:
Appu
2025-04-10 14:09:09 -04:00
committed by GitHub
parent b53bd9415b
commit a481a1974e
19 changed files with 401 additions and 0 deletions

View File

@@ -35,6 +35,7 @@ For more information on SLSA, visit https://slsa.dev`,
} }
c.AddCommand(version.Version()) c.AddCommand(version.Version())
c.AddCommand(verifyArtifactCmd()) c.AddCommand(verifyArtifactCmd())
c.AddCommand(verifyGithubAttestation())
c.AddCommand(verifyImageCmd()) c.AddCommand(verifyImageCmd())
c.AddCommand(verifyNpmPackageCmd()) c.AddCommand(verifyNpmPackageCmd())
c.AddCommand(verifyVSACmd()) c.AddCommand(verifyVSACmd())

View File

@@ -1510,6 +1510,75 @@ func Test_runVerifyGHAContainerBased(t *testing.T) {
} }
} }
func Test_runVerifyGithubAttestation(t *testing.T) {
t.Parallel()
os.Setenv("SLSA_VERIFIER_EXPERIMENTAL", "1")
bcrReleaserBuilderID := "https://github.com/bazel-contrib/.github/.github/workflows/release_ruleset.yaml"
bcrPublisherBuilderID := "https://github.com/bazel-contrib/publish-to-bcr/.github/workflows/publish.yaml"
tests := []struct {
name string
artifact string
source string
builderID string
err error
}{
{
name: "module.bazel using publishing builder",
artifact: "MODULE.bazel",
source: "github.com/aspect-build/rules_lint",
builderID: bcrPublisherBuilderID,
},
{
name: "source archive using release builder",
artifact: "rules_lint-v1.3.1.tar.gz",
source: "github.com/aspect-build/rules_lint",
builderID: bcrReleaserBuilderID,
},
{
name: "module.bazel wrong signer",
artifact: "MODULE-wrong-signer.bazel",
source: "github.com/aspect-build/rules_lint",
builderID: bcrPublisherBuilderID,
err: serrors.ErrorUntrustedReusableWorkflow,
},
{
name: "module.bazel no builder id",
artifact: "MODULE.bazel",
source: "github.com/aspect-build/rules_lint",
err: serrors.ErrorUntrustedReusableWorkflow,
},
{
name: "source archive no builder id",
artifact: "rules_lint-v1.3.1.tar.gz",
source: "github.com/aspect-build/rules_lint",
err: serrors.ErrorUntrustedReusableWorkflow,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
artifactPath := filepath.Clean(filepath.Join(TEST_DIR, "bcr", tt.artifact))
// we treat these single entry *.intoto.jsonl bundles as single attestations
attestationPath := fmt.Sprintf("%s.intoto.jsonl", artifactPath)
cmd := verify.VerifyGithubAttestationCommand{
AttestationPath: attestationPath,
BuilderID: &tt.builderID,
SourceURI: tt.source,
}
_, err := cmd.Exec(context.Background(), artifactPath)
if !errCmp(tt.err, err) {
t.Errorf("unexpected error (-want +got):\n%s", cmp.Diff(err, tt.err, cmpopts.EquateErrors()))
}
})
}
}
func Test_runVerifyNpmPackage(t *testing.T) { func Test_runVerifyNpmPackage(t *testing.T) {
// We cannot use t.Setenv due to parallelized tests. // We cannot use t.Setenv due to parallelized tests.
os.Setenv("SLSA_VERIFIER_EXPERIMENTAL", "1") os.Setenv("SLSA_VERIFIER_EXPERIMENTAL", "1")
@@ -2063,3 +2132,15 @@ func Test_runVerifyVSA(t *testing.T) {
func pointerTo[K any](object K) *K { func pointerTo[K any](object K) *K {
return &object return &object
} }
func unwrapFull(t *testing.T, err error) error {
for err != nil {
t.Logf("%v", err)
unwrapped := errors.Unwrap(err)
if unwrapped == nil {
return err
}
err = unwrapped
}
return nil
}

View File

@@ -0,0 +1,34 @@
"Bazel dependencies"
module(
name = "aspect_rules_lint",
version = "1.3.1",
compatibility_level = 1,
)
bazel_dep(name = "aspect_bazel_lib", version = "2.7.7")
# Needed in the root because we use js_lib_helpers in our aspect impl
# Minimum version needs 'chore: bump bazel-lib to 2.0 by @alexeagle in #1311'
# to allow users on bazel-lib 2.0
bazel_dep(name = "aspect_rules_js", version = "1.40.0")
bazel_dep(name = "bazel_features", version = "1.0.0")
bazel_dep(name = "bazel_skylib", version = "1.4.2")
bazel_dep(name = "platforms", version = "0.0.7")
bazel_dep(name = "rules_multirun", version = "0.9.0")
bazel_dep(name = "rules_multitool", version = "0.4.0")
bazel_dep(name = "rules_diff", version = "1.0.0")
# Needed in the root because we dereference ProtoInfo in our aspect impl
bazel_dep(name = "rules_proto", version = "6.0.0")
# Needed in the root because we dereference the toolchain in our aspect impl
bazel_dep(name = "rules_buf", version = "0.1.1")
bazel_dep(name = "toolchains_protoc", version = "0.2.1")
multitool = use_extension("@rules_multitool//multitool:extension.bzl", "multitool")
multitool.hub(lockfile = "//format:multitool.lock.json")
multitool.hub(lockfile = "//lint:multitool.lock.json")
use_repo(multitool, "multitool")
bazel_dep(name = "stardoc", version = "0.7.0", dev_dependency = True, repo_name = "io_bazel_stardoc")

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,34 @@
"Bazel dependencies"
module(
name = "aspect_rules_lint",
version = "1.3.1",
compatibility_level = 1,
)
bazel_dep(name = "aspect_bazel_lib", version = "2.7.7")
# Needed in the root because we use js_lib_helpers in our aspect impl
# Minimum version needs 'chore: bump bazel-lib to 2.0 by @alexeagle in #1311'
# to allow users on bazel-lib 2.0
bazel_dep(name = "aspect_rules_js", version = "1.40.0")
bazel_dep(name = "bazel_features", version = "1.0.0")
bazel_dep(name = "bazel_skylib", version = "1.4.2")
bazel_dep(name = "platforms", version = "0.0.7")
bazel_dep(name = "rules_multirun", version = "0.9.0")
bazel_dep(name = "rules_multitool", version = "0.4.0")
bazel_dep(name = "rules_diff", version = "1.0.0")
# Needed in the root because we dereference ProtoInfo in our aspect impl
bazel_dep(name = "rules_proto", version = "6.0.0")
# Needed in the root because we dereference the toolchain in our aspect impl
bazel_dep(name = "rules_buf", version = "0.1.1")
bazel_dep(name = "toolchains_protoc", version = "0.2.1")
multitool = use_extension("@rules_multitool//multitool:extension.bzl", "multitool")
multitool.hub(lockfile = "//format:multitool.lock.json")
multitool.hub(lockfile = "//lint:multitool.lock.json")
use_repo(multitool, "multitool")
bazel_dep(name = "stardoc", version = "0.7.0", dev_dependency = True, repo_name = "io_bazel_stardoc")

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -185,6 +185,38 @@ func verifyNpmPackageCmd() *cobra.Command {
return cmd return cmd
} }
func verifyGithubAttestation() *cobra.Command {
o := &verify.VerifyGithubAttestationOptions{}
cmd := &cobra.Command{
Use: "verify-github-attestation [flags] module-file",
Args: func(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
return errors.New("expects a single path to an module file")
}
return nil
},
Short: "Verifies SLSA provenance for a github attestation [experimental]",
Run: func(cmd *cobra.Command, args []string) {
v := verify.VerifyGithubAttestationCommand{
AttestationPath: o.AttestationPath,
SourceURI: o.SourceURI,
PrintAttestation: o.PrintAttestation,
BuilderID: &o.BuilderID,
}
if _, err := v.Exec(cmd.Context(), args[0]); err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", FAILURE, err)
os.Exit(1)
} else {
fmt.Fprintf(os.Stderr, "%s\n", SUCCESS)
}
},
}
o.AddFlags(cmd)
return cmd
}
func verifyVSACmd() *cobra.Command { func verifyVSACmd() *cobra.Command {
o := &verify.VerifyVSAOptions{} o := &verify.VerifyVSAOptions{}

View File

@@ -127,6 +127,36 @@ func (o *VerifyNpmOptions) AddFlags(cmd *cobra.Command) {
cmd.MarkFlagsMutuallyExclusive("source-versioned-tag", "source-tag") cmd.MarkFlagsMutuallyExclusive("source-versioned-tag", "source-tag")
} }
// VerifyGithubAttestationOptions is the top-level options for the `verify-github-attestation` command.
type VerifyGithubAttestationOptions struct {
SourceURI string
BuilderID string
AttestationPath string
PrintAttestation bool
}
var _ Interface = (*VerifyGithubAttestationOptions)(nil)
// AddFlags implements Interface.
func (o *VerifyGithubAttestationOptions) AddFlags(cmd *cobra.Command) {
cmd.Flags().StringVar(&o.BuilderID, "builder-id", "", "the unique builder ID who created the provenance")
/* Source options */
cmd.Flags().StringVar(&o.SourceURI, "source-uri", "",
"expected source repository that should have produced the binary, e.g. github.com/some/repo")
/* Other options */
cmd.Flags().StringVar(&o.AttestationPath, "attestation-path", "",
"path to an attestation file")
cmd.Flags().BoolVar(&o.PrintAttestation, "print-attestation", false,
"[optional] print the verified attestation to stdout")
cmd.MarkFlagRequired("source-uri")
cmd.MarkFlagRequired("attestation-path")
cmd.MarkFlagRequired("builder-id")
}
// VerifyVSAOptions is the top-level options for the `verifyVSA` command. // VerifyVSAOptions is the top-level options for the `verifyVSA` command.
type VerifyVSAOptions struct { type VerifyVSAOptions struct {
SubjectDigests []string SubjectDigests []string

View File

@@ -0,0 +1,78 @@
// Copyright 2025 SLSA Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package verify
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"os"
"github.com/slsa-framework/slsa-verifier/v2/options"
"github.com/slsa-framework/slsa-verifier/v2/verifiers"
"github.com/slsa-framework/slsa-verifier/v2/verifiers/utils"
)
type VerifyGithubAttestationCommand struct {
AttestationPath string
BuilderID *string
SourceURI string
BuildWorkflowInputs map[string]string
PrintAttestation bool
}
func (c *VerifyGithubAttestationCommand) Exec(ctx context.Context, artifact string) (*utils.TrustedBuilderID, error) {
if !options.ExperimentalEnabled() {
err := errors.New("feature support is only provided in SLSA_VERIFIER_EXPERIMENTAL mode")
fmt.Fprintf(os.Stderr, "Verifying github attestation: FAILED: %v\n\n", err)
return nil, err
}
artifactHash, err := computeFileHash(artifact, sha256.New())
if err != nil {
fmt.Fprintf(os.Stderr, "Verifying artifact %s: FAILED: %v\n\n", artifact, err)
return nil, err
}
provenanceOpts := &options.ProvenanceOpts{
ExpectedSourceURI: c.SourceURI,
ExpectedDigest: artifactHash,
ExpectedWorkflowInputs: c.BuildWorkflowInputs,
}
builderOpts := &options.BuilderOpts{
ExpectedID: c.BuilderID,
}
attestation, err := os.ReadFile(c.AttestationPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Verifying artifact %s: FAILED: %v\n\n", artifact, err)
return nil, err
}
verifiedAttestation, outBuilderID, err := verifiers.VerifyGithubAttestation(ctx, attestation, provenanceOpts, builderOpts)
if err != nil {
fmt.Fprintf(os.Stderr, "Verifying artifact %s: FAILED: %v\n\n", artifact, err)
return nil, err
}
if c.PrintAttestation {
fmt.Fprintf(os.Stdout, "%s\n", string(verifiedAttestation))
}
fmt.Fprintf(os.Stderr, "Verifying artifact %s: PASSED\n\n", artifact)
return outBuilderID, nil
}

View File

@@ -29,6 +29,13 @@ type SLSAVerifier interface {
builderOpts *options.BuilderOpts, builderOpts *options.BuilderOpts,
) ([]byte, *utils.TrustedBuilderID, error) ) ([]byte, *utils.TrustedBuilderID, error)
// VerifyGithubAttestation verifies provenance for a Github Attestations.
VerifyGithubAttestation(ctx context.Context,
attestation []byte,
provenanceOpts *options.ProvenanceOpts,
builderOpts *options.BuilderOpts,
) ([]byte, *utils.TrustedBuilderID, error)
VerifyNpmPackage(ctx context.Context, VerifyNpmPackage(ctx context.Context,
attestations []byte, tarballHash string, attestations []byte, tarballHash string,
provenanceOpts *options.ProvenanceOpts, provenanceOpts *options.ProvenanceOpts,

View File

@@ -39,6 +39,15 @@ func (v *GCBVerifier) VerifyArtifact(ctx context.Context,
return nil, nil, serrors.ErrorNotSupported return nil, nil, serrors.ErrorNotSupported
} }
// VerifyGithubAttestation verifies provenance for a Github Attestations.
func (v *GCBVerifier) VerifyGithubAttestation(ctx context.Context,
attestation []byte,
provenanceOpts *options.ProvenanceOpts,
builderOpts *options.BuilderOpts,
) ([]byte, *utils.TrustedBuilderID, error) {
return nil, nil, serrors.ErrorNotSupported
}
// VerifyNpmPackage verifies an npm package tarball. // VerifyNpmPackage verifies an npm package tarball.
func (v *GCBVerifier) VerifyNpmPackage(ctx context.Context, func (v *GCBVerifier) VerifyNpmPackage(ctx context.Context,
attestations []byte, tarballHash string, attestations []byte, tarballHash string,

View File

@@ -23,4 +23,9 @@ var (
GenericDelegatorBuilderID = trustedBuilderRepository + "/.github/workflows/delegator_generic_slsa3.yml" GenericDelegatorBuilderID = trustedBuilderRepository + "/.github/workflows/delegator_generic_slsa3.yml"
// GenericLowPermsDelegatorBuilderID is the SLSA builder ID for the BYOB Generic Low-Permissions Delegated Builder. // GenericLowPermsDelegatorBuilderID is the SLSA builder ID for the BYOB Generic Low-Permissions Delegated Builder.
GenericLowPermsDelegatorBuilderID = trustedBuilderRepository + "/.github/workflows/delegator_lowperms-generic_slsa3.yml" GenericLowPermsDelegatorBuilderID = trustedBuilderRepository + "/.github/workflows/delegator_lowperms-generic_slsa3.yml"
// BCRReleaserBuilderID is the bcr reusable workflow that generates github attestations for a ruleset release.
BCRReleaserBuilderID = "https://github.com/bazel-contrib/.github/.github/workflows/release_ruleset.yaml"
// BCRPublisherBuilderID is the bcr reusable workflow that generates github attestations for BCR repository metadata.
BCRPublisherBuilderID = "https://github.com/bazel-contrib/publish-to-bcr/.github/workflows/publish.yaml"
) )

View File

@@ -24,6 +24,8 @@ var (
// NpmCLIGithubActionsBuildTypeV1 is the buildType for provenance by the npm cli from GitHub Actions. // NpmCLIGithubActionsBuildTypeV1 is the buildType for provenance by the npm cli from GitHub Actions.
NpmCLIGithubActionsBuildTypeV1 = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1" NpmCLIGithubActionsBuildTypeV1 = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1"
GithubActionsBuildTypeV1 = "https://actions.github.io/buildtypes/workflow/v1"
) )
// Legacy buildTypes. // Legacy buildTypes.

View File

@@ -0,0 +1,36 @@
package v1
import (
"fmt"
serrors "github.com/slsa-framework/slsa-verifier/v2/errors"
)
// GithubAttestBuildType is the build type for the github attest based builder.
var GithubAttestBuildType = "https://actions.github.io/buildtypes/workflow/v1"
// GithubAttestProvenance is provenance generated by an action using github's attest action.
type GithubAttestProvenance struct {
*provenanceV1
}
func (p *GithubAttestProvenance) TriggerURI() (string, error) {
externalParams, err := p.getExternalParameters()
if err != nil {
return "", err
}
workflow, ok := externalParams["workflow"].(map[string]interface{})
if !ok {
return "", fmt.Errorf("%w: %s", serrors.ErrorInvalidFormat, "workflow parameters")
}
repository, ok := workflow["repository"].(string)
if !ok {
return "", fmt.Errorf("%w: %s", serrors.ErrorInvalidFormat, "workflow parameters: repository")
}
ref, ok := workflow["ref"].(string)
if !ok {
return "", fmt.Errorf("%w: %s", serrors.ErrorInvalidFormat, "workflow parameters: ref")
}
uri := fmt.Sprintf("git+%s@%s", repository, ref)
return uri, nil
}

View File

@@ -50,12 +50,22 @@ func newNpmCLIGithubActions(a *Attestation) iface.Provenance {
} }
} }
func newGithubAttest(a *Attestation) iface.Provenance {
return &GithubAttestProvenance{
provenanceV1: &provenanceV1{
prov: a,
},
}
}
// buildTypeMap is a map of builder IDs to supported buildTypes. // buildTypeMap is a map of builder IDs to supported buildTypes.
var buildTypeMap = map[string]map[string]provFunc{ var buildTypeMap = map[string]map[string]provFunc{
common.GenericDelegatorBuilderID: {common.BYOBBuildTypeV0: newBYOB}, common.GenericDelegatorBuilderID: {common.BYOBBuildTypeV0: newBYOB},
common.GenericLowPermsDelegatorBuilderID: {common.BYOBBuildTypeV0: newBYOB}, common.GenericLowPermsDelegatorBuilderID: {common.BYOBBuildTypeV0: newBYOB},
common.ContainerBasedBuilderID: {common.ContainerBasedBuildTypeV01Draft: newContainerBased}, common.ContainerBasedBuilderID: {common.ContainerBasedBuildTypeV01Draft: newContainerBased},
common.NpmCLIHostedBuilderID: {common.NpmCLIGithubActionsBuildTypeV1: newNpmCLIGithubActions}, common.NpmCLIHostedBuilderID: {common.NpmCLIGithubActionsBuildTypeV1: newNpmCLIGithubActions},
common.BCRReleaserBuilderID: {common.GithubActionsBuildTypeV1: newGithubAttest},
common.BCRPublisherBuilderID: {common.GithubActionsBuildTypeV1: newGithubAttest},
} }
// New returns a new Provenance object based on the payload. // New returns a new Provenance object based on the payload.

View File

@@ -242,6 +242,31 @@ func (v *GHAVerifier) VerifyArtifact(ctx context.Context,
utils.MergeMaps(defaultArtifactTrustedReusableWorkflows, defaultBYOBReusableWorkflows)) utils.MergeMaps(defaultArtifactTrustedReusableWorkflows, defaultBYOBReusableWorkflows))
} }
// VerifyGithubAttestation verifies provenance for a Github Attestations.
func (v *GHAVerifier) VerifyGithubAttestation(ctx context.Context,
attestation []byte,
provenanceOpts *options.ProvenanceOpts,
builderOpts *options.BuilderOpts,
) ([]byte, *utils.TrustedBuilderID, error) {
if !IsSigstoreBundle(attestation) {
return nil, nil, errors.New("github attestations must be signed by Sigstore")
}
trustedRoot, err := utils.GetSigstoreTrustedRoot()
if err != nil {
return nil, nil, err
}
/* Verify signature on the intoto attestation. */
signedAtt, err := VerifyProvenanceBundle(ctx, attestation, trustedRoot)
if err != nil {
return nil, nil, err
}
return verifyEnvAndCert(signedAtt.Envelope, signedAtt.SigningCert,
provenanceOpts, builderOpts, map[string]bool{})
}
// VerifyImage verifies provenance for an OCI image. // VerifyImage verifies provenance for an OCI image.
func (v *GHAVerifier) VerifyImage(ctx context.Context, func (v *GHAVerifier) VerifyImage(ctx context.Context,
provenance []byte, artifactImage string, provenanceOpts *options.ProvenanceOpts, provenance []byte, artifactImage string, provenanceOpts *options.ProvenanceOpts,

View File

@@ -62,6 +62,20 @@ func VerifyArtifact(ctx context.Context,
provenanceOpts, builderOpts) provenanceOpts, builderOpts)
} }
func VerifyGithubAttestation(ctx context.Context,
provenance []byte,
provenanceOpts *options.ProvenanceOpts,
builderOpts *options.BuilderOpts,
) ([]byte, *utils.TrustedBuilderID, error) {
verifier, err := getVerifier(builderOpts)
if err != nil {
return nil, nil, err
}
return verifier.VerifyGithubAttestation(ctx, provenance,
provenanceOpts, builderOpts)
}
func VerifyNpmPackage(ctx context.Context, func VerifyNpmPackage(ctx context.Context,
attestations []byte, tarballHash string, attestations []byte, tarballHash string,
provenanceOpts *options.ProvenanceOpts, provenanceOpts *options.ProvenanceOpts,