diff --git a/policy/builtins.rego b/policy/builtins.rego index 62462d061..7cfe34c71 100644 --- a/policy/builtins.rego +++ b/policy/builtins.rego @@ -21,3 +21,8 @@ docker_github_builder_signature(sig, repo) if { sig.signer.runnerEnvironment == "github-hosted" count(sig.timestamps) > 0 } + +docker_github_builder_bundle(http, filename, repo) if { + sig := artifact_attestation(http, filename) + docker_github_builder_signature(sig, repo) +} diff --git a/policy/funcs.go b/policy/funcs.go index 1125bf41a..74e7a7528 100644 --- a/policy/funcs.go +++ b/policy/funcs.go @@ -17,9 +17,10 @@ import ( ) const ( - funcLoadJSON = "load_json" - funcVerifyGitSignature = "verify_git_signature" - funcPinImage = "pin_image" + funcLoadJSON = "load_json" + funcVerifyGitSignature = "verify_git_signature" + funcPinImage = "pin_image" + funcArtifactAttestation = "artifact_attestation" ) func (p *Policy) initBuiltinFuncs() { @@ -78,6 +79,88 @@ func (p *Policy) initBuiltinFuncs() { }) }, }) + + artifactAttestation := ®o.Function{ + Name: funcArtifactAttestation, + Decl: types.NewFunction( + types.Args( + types.A, + types.S, + ), + types.A, + ), + Memoize: false, + } + p.funcs = append(p.funcs, fun{ + decl: artifactAttestation, + impl: func(s *state) func(*rego.Rego) { + return rego.Function2(artifactAttestation, func(bctx rego.BuiltinContext, a1 *ast.Term, a2 *ast.Term) (*ast.Term, error) { + return p.builtinArtifactAttestationImpl(bctx, a1, a2, s) + }) + }, + }) +} + +func (p *Policy) builtinArtifactAttestationImpl(bctx rego.BuiltinContext, a1, a2 *ast.Term, s *state) (*ast.Term, error) { + inp := s.Input + if inp.HTTP == nil { + return nil, nil + } + + obja, ok := a1.Value.(ast.Object) + if !ok { + return nil, errors.Errorf("%s: expected object, got %T", funcArtifactAttestation, a1.Value) + } + + httpValue, err := ast.InterfaceToValue(inp.HTTP) + if err != nil { + return nil, errors.Wrapf(err, "%s: failed converting object to interface", funcArtifactAttestation) + } + + if obja.Compare(httpValue) != 0 { + return nil, errors.Errorf("%s: first argument is not the same as input http", funcArtifactAttestation) + } + + path, ok := a2.Value.(ast.String) + if !ok { + return nil, errors.Errorf("%s: expected string path, got %T", funcArtifactAttestation, a2.Value) + } + + if inp.HTTP.Checksum == "" { + s.addUnknown(funcArtifactAttestation) + return nil, nil + } + + dgst, err := digest.Parse(inp.HTTP.Checksum) + if err != nil { + return nil, errors.Wrapf(err, "%s: invalid checksum", funcArtifactAttestation) + } + + bundleBytes, err := p.readFile(string(path), 8*1024*1024) + if err != nil { + return nil, err + } + + if p.opt.VerifierProvider == nil { + return nil, errors.Errorf("%s: policy verifier is not configured", funcArtifactAttestation) + } + v, err := p.opt.VerifierProvider() + if err != nil { + return nil, errors.Wrapf(err, "%s: getting policy verifier", funcArtifactAttestation) + } + + siRaw, err := v.VerifyArtifact(bctx.Context, dgst, bundleBytes) + if err != nil { + return nil, nil + } + + si := toAttestationSignature(siRaw) + astVal, err := ast.InterfaceToValue(si) + if err != nil { + return nil, errors.Wrapf(err, "%s: failed converting verification result", funcArtifactAttestation) + } + + return ast.NewTerm(astVal), nil } func (p *Policy) builtinPinImageImpl(_ rego.BuiltinContext, a1, a2 *ast.Term, s *state) (*ast.Term, error) { diff --git a/policy/funcs_artifact_test.go b/policy/funcs_artifact_test.go new file mode 100644 index 000000000..b069f995c --- /dev/null +++ b/policy/funcs_artifact_test.go @@ -0,0 +1,129 @@ +package policy + +import ( + "context" + "errors" + "io/fs" + "testing" + "testing/fstest" + "time" + + policyverifier "github.com/moby/policy-helpers" + policytypes "github.com/moby/policy-helpers/types" + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/rego" + "github.com/opencontainers/go-digest" + "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" + "github.com/stretchr/testify/require" +) + +func TestBuiltinArtifactAttestationImpl(t *testing.T) { + t.Run("success", func(t *testing.T) { + const bundlePath = "bundle.sigstore" + bundleBytes := []byte("bundle-bytes") + dgst := digest.FromString("artifact-bytes") + st := &state{Input: Input{HTTP: &HTTP{Checksum: dgst.String()}}} + + expectedTS := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC) + expectedRaw := &policytypes.SignatureInfo{ + Kind: policytypes.KindDockerGithubBuilder, + SignatureType: policytypes.SignatureBundleV03, + Signer: &certificate.Summary{ + CertificateIssuer: "CN=sigstore-intermediate,O=sigstore.dev", + SubjectAlternativeName: "https://github.com/docker/buildx/.github/workflows/release.yml@refs/tags/v0.31.1", + Extensions: certificate.Extensions{ + Issuer: "https://token.actions.githubusercontent.com", + RunnerEnvironment: "github-hosted", + SourceRepositoryURI: "https://github.com/docker/buildx", + SourceRepositoryRef: "refs/tags/v0.31.1", + }, + }, + Timestamps: []policytypes.TimestampVerificationResult{{ + Type: "rekor", + URI: "https://rekor.sigstore.dev", + Timestamp: expectedTS, + }}, + } + + p := NewPolicy(Opt{ + FS: func() (fs.StatFS, func() error, error) { + return fstest.MapFS{ + bundlePath: &fstest.MapFile{Data: bundleBytes}, + }, func() error { return nil }, nil + }, + VerifierProvider: func() (PolicyVerifier, error) { + return &mockPolicyVerifier{ + verifyArtifact: func(_ context.Context, gotDigest digest.Digest, gotBundle []byte, _ ...policyverifier.ArtifactVerifyOpt) (*policytypes.SignatureInfo, error) { + require.Equal(t, dgst, gotDigest) + require.Equal(t, bundleBytes, gotBundle) + return expectedRaw, nil + }, + }, nil + }, + }) + + httpVal, err := ast.InterfaceToValue(st.Input.HTTP) + require.NoError(t, err) + + term, err := p.builtinArtifactAttestationImpl( + rego.BuiltinContext{Context: t.Context()}, + ast.NewTerm(httpVal), + ast.StringTerm(bundlePath), + st, + ) + require.NoError(t, err) + require.NotNil(t, term) + + expectedVal, err := ast.InterfaceToValue(toAttestationSignature(expectedRaw)) + require.NoError(t, err) + require.Equal(t, 0, term.Value.Compare(expectedVal)) + }) + + t.Run("verify failure returns undefined", func(t *testing.T) { + const bundlePath = "bundle.sigstore" + st := &state{Input: Input{HTTP: &HTTP{Checksum: digest.FromString("artifact-bytes").String()}}} + + p := NewPolicy(Opt{ + FS: func() (fs.StatFS, func() error, error) { + return fstest.MapFS{bundlePath: &fstest.MapFile{Data: []byte("bundle")}}, func() error { return nil }, nil + }, + VerifierProvider: func() (PolicyVerifier, error) { + return &mockPolicyVerifier{ + verifyArtifact: func(context.Context, digest.Digest, []byte, ...policyverifier.ArtifactVerifyOpt) (*policytypes.SignatureInfo, error) { + return nil, errors.New("verification failed") + }, + }, nil + }, + }) + + httpVal, err := ast.InterfaceToValue(st.Input.HTTP) + require.NoError(t, err) + + term, err := p.builtinArtifactAttestationImpl( + rego.BuiltinContext{Context: t.Context()}, + ast.NewTerm(httpVal), + ast.StringTerm(bundlePath), + st, + ) + require.NoError(t, err) + require.Nil(t, term) + }) + + t.Run("missing checksum adds unknown", func(t *testing.T) { + st := &state{Input: Input{HTTP: &HTTP{}}} + + p := NewPolicy(Opt{}) + httpVal, err := ast.InterfaceToValue(st.Input.HTTP) + require.NoError(t, err) + + term, err := p.builtinArtifactAttestationImpl( + rego.BuiltinContext{Context: t.Context()}, + ast.NewTerm(httpVal), + ast.StringTerm("bundle.sigstore"), + st, + ) + require.NoError(t, err) + require.Nil(t, term) + require.Contains(t, st.Unknowns, funcArtifactAttestation) + }) +} diff --git a/policy/signatures.go b/policy/signatures.go index c18b3ac65..01eed2156 100644 --- a/policy/signatures.go +++ b/policy/signatures.go @@ -22,6 +22,7 @@ import ( type PolicyVerifier interface { VerifyImage(context.Context, policyimage.ReferrersProvider, ocispecs.Descriptor, *ocispecs.Platform) (*policytypes.SignatureInfo, error) + VerifyArtifact(context.Context, digest.Digest, []byte, ...policyverifier.ArtifactVerifyOpt) (*policytypes.SignatureInfo, error) } type PolicyVerifierProvider func() (PolicyVerifier, error) @@ -124,6 +125,14 @@ func parseSignatures(ctx context.Context, getVerifier PolicyVerifierProvider, ac return nil, errors.Wrapf(err, "verifying image signatures") } + return []AttestationSignature{toAttestationSignature(siRaw)}, nil +} + +func toAttestationSignature(siRaw *policytypes.SignatureInfo) AttestationSignature { + if siRaw == nil { + return AttestationSignature{} + } + si := AttestationSignature{ raw: siRaw, Timestamps: siRaw.Timestamps, @@ -133,8 +142,6 @@ func parseSignatures(ctx context.Context, getVerifier PolicyVerifierProvider, ac SignatureKind: toSignatureKind(siRaw.Kind), } - // TODO: signature type after upstream update - if siRaw.Signer != nil { si.Signer = &SignerInfo{ CertificateIssuer: siRaw.Signer.CertificateIssuer, @@ -157,7 +164,7 @@ func parseSignatures(ctx context.Context, getVerifier PolicyVerifierProvider, ac } } - return []AttestationSignature{si}, nil + return si } type acProvider struct { diff --git a/policy/utils_test.go b/policy/utils_test.go index 6b9998930..01147b67b 100644 --- a/policy/utils_test.go +++ b/policy/utils_test.go @@ -64,10 +64,11 @@ func TestRuntimeUnknownInputRefs(t *testing.T) { st := &state{ Unknowns: map[string]struct{}{ - funcVerifyGitSignature: {}, + funcVerifyGitSignature: {}, + funcArtifactAttestation: {}, }, } - require.Equal(t, []string{"git.commit"}, runtimeUnknownInputRefs(st)) + require.Equal(t, []string{"git.commit", "http.checksum"}, runtimeUnknownInputRefs(st)) } func TestMissingInputRefsWithRuntimeUnknowns(t *testing.T) { diff --git a/policy/validate.go b/policy/validate.go index ef0ae71a3..3eb217830 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -288,6 +288,22 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy if err != nil { return nil, nil, err } + + rtUnk := runtimeUnknownInputRefs(st) + if len(rtUnk) > 0 { + next := &gwpb.ResolveSourceMetaRequest{ + Source: req.Source.Source, + Platform: req.Platform, + } + if err := AddUnknownsWithLogger(p.opt.Log, next, rtUnk); err != nil { + return nil, nil, err + } + if next.Image != nil || next.Git != nil || hasHTTPUnknowns(rtUnk) { + p.log(logrus.InfoLevel, "policy decision for source %s: resolve missing fields %+v", sourceName(req), summarizeUnknownsForLog(rtUnk)) + return nil, next, nil + } + } + if len(rs) == 0 { return nil, nil, errors.Errorf("policy returned zero result") } @@ -781,6 +797,9 @@ func runtimeUnknownInputRefs(st *state) []string { if _, ok := st.Unknowns[funcVerifyGitSignature]; ok { out = append(out, "git.commit") } + if _, ok := st.Unknowns[funcArtifactAttestation]; ok { + out = append(out, "http.checksum") + } return out } diff --git a/policy/validate_test.go b/policy/validate_test.go index b2b8040f6..49ac6f212 100644 --- a/policy/validate_test.go +++ b/policy/validate_test.go @@ -5,6 +5,7 @@ import ( "crypto/sha1" //nolint:gosec // used for git object checksums in tests "encoding/hex" "encoding/json" + "errors" "fmt" "testing" "time" @@ -13,6 +14,7 @@ import ( slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1" gwpb "github.com/moby/buildkit/frontend/gateway/pb" "github.com/moby/buildkit/solver/pb" + policyverifier "github.com/moby/policy-helpers" policyimage "github.com/moby/policy-helpers/image" policytypes "github.com/moby/policy-helpers/types" "github.com/opencontainers/go-digest" @@ -896,13 +898,24 @@ func gitObjectSHA1(objType string, raw []byte) string { } type mockPolicyVerifier struct { - verifyImage func(context.Context, policyimage.ReferrersProvider, ocispecs.Descriptor, *ocispecs.Platform) (*policytypes.SignatureInfo, error) + verifyImage func(context.Context, policyimage.ReferrersProvider, ocispecs.Descriptor, *ocispecs.Platform) (*policytypes.SignatureInfo, error) + verifyArtifact func(context.Context, digest.Digest, []byte, ...policyverifier.ArtifactVerifyOpt) (*policytypes.SignatureInfo, error) } func (m *mockPolicyVerifier) VerifyImage(ctx context.Context, provider policyimage.ReferrersProvider, desc ocispecs.Descriptor, platform *ocispecs.Platform) (*policytypes.SignatureInfo, error) { + if m.verifyImage == nil { + return nil, errors.New("unexpected VerifyImage call") + } return m.verifyImage(ctx, provider, desc, platform) } +func (m *mockPolicyVerifier) VerifyArtifact(ctx context.Context, dgst digest.Digest, bundle []byte, opts ...policyverifier.ArtifactVerifyOpt) (*policytypes.SignatureInfo, error) { + if m.verifyArtifact == nil { + return nil, errors.New("unexpected VerifyArtifact call") + } + return m.verifyArtifact(ctx, dgst, bundle, opts...) +} + func newTestAttestationChain(t *testing.T) *gwpb.AttestationChain { t.Helper()