From 8d605dcd87d1552aff26a110d0482d58eeffdc50 Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Tue, 10 Feb 2026 18:47:11 -0800 Subject: [PATCH] policy: add image.provenance input type Signed-off-by: Tonis Tiigi --- commands/policy/eval.go | 18 ++- commands/policy/eval_test.go | 27 ++++ commands/policy/test.go | 2 +- policy/add_unknowns_test.go | 41 ++++++ policy/provenance.go | 175 +++++++++++++++++++++++++ policy/types.go | 32 +++++ policy/validate.go | 36 +++++- policy/validate_test.go | 240 ++++++++++++++++++++++++++++++++++- tests/policy_eval.go | 152 ++++++++++++++++++++++ 9 files changed, 708 insertions(+), 15 deletions(-) create mode 100644 commands/policy/eval_test.go create mode 100644 policy/provenance.go diff --git a/commands/policy/eval.go b/commands/policy/eval.go index 2d516215c762..364dd0f157b9 100644 --- a/commands/policy/eval.go +++ b/commands/policy/eval.go @@ -158,7 +158,7 @@ func runEval(ctx context.Context, dockerCli command.Cli, source string, opts eva if err != nil { return err } - srcReq = buildSourceMetaResponse(resp, req) + srcReq = buildSourceMetaResponse(resp) continue } break @@ -249,7 +249,7 @@ func runEval(ctx context.Context, dockerCli command.Cli, source string, opts eva if err != nil { return err } - srcReq = buildSourceMetaResponse(resp, next) + srcReq = buildSourceMetaResponse(resp) } } @@ -293,10 +293,11 @@ func sourceResolverOpt(req *gwpb.ResolveSourceMetaRequest, platform *ocispecs.Pl } if req.Image != nil { opt.ImageOpt = &sourceresolver.ResolveImageOpt{ - NoConfig: req.Image.NoConfig, - AttestationChain: req.Image.AttestationChain, - Platform: platform, - ResolveMode: req.ResolveMode, + NoConfig: req.Image.NoConfig, + AttestationChain: req.Image.AttestationChain, + ResolveAttestations: slices.Clone(req.Image.ResolveAttestations), + Platform: platform, + ResolveMode: req.ResolveMode, } } if req.Git != nil { @@ -307,15 +308,12 @@ func sourceResolverOpt(req *gwpb.ResolveSourceMetaRequest, platform *ocispecs.Pl return opt } -func buildSourceMetaResponse(resp *sourceresolver.MetaResponse, req *gwpb.ResolveSourceMetaRequest) *gwpb.ResolveSourceMetaResponse { +func buildSourceMetaResponse(resp *sourceresolver.MetaResponse) *gwpb.ResolveSourceMetaResponse { out := &gwpb.ResolveSourceMetaResponse{ Source: resp.Op, } if resp.Image != nil { chain := toGatewayAttestationChain(resp.Image.AttestationChain) - if chain == nil && req != nil && req.Image != nil && req.Image.AttestationChain { - chain = &gwpb.AttestationChain{} - } out.Image = &gwpb.ResolveSourceImageResponse{ Digest: resp.Image.Digest.String(), Config: resp.Image.Config, diff --git a/commands/policy/eval_test.go b/commands/policy/eval_test.go new file mode 100644 index 000000000000..be8264fe0002 --- /dev/null +++ b/commands/policy/eval_test.go @@ -0,0 +1,27 @@ +package policy + +import ( + "testing" + + gwpb "github.com/moby/buildkit/frontend/gateway/pb" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" +) + +func TestSourceResolverOptIncludesResolveAttestations(t *testing.T) { + req := &gwpb.ResolveSourceMetaRequest{ + ResolveMode: "default", + Image: &gwpb.ResolveSourceImageRequest{ + NoConfig: true, + ResolveAttestations: []string{"https://slsa.dev/provenance/v0.2"}, + }, + } + platform := &ocispecs.Platform{OS: "linux", Architecture: "amd64"} + + opt := sourceResolverOpt(req, platform) + require.NotNil(t, opt.ImageOpt) + require.True(t, opt.ImageOpt.NoConfig) + require.Equal(t, []string{"https://slsa.dev/provenance/v0.2"}, opt.ImageOpt.ResolveAttestations) + require.Equal(t, "default", opt.ImageOpt.ResolveMode) + require.Equal(t, platform, opt.ImageOpt.Platform) +} diff --git a/commands/policy/test.go b/commands/policy/test.go index ec956b50d1b5..35eae0570bd7 100644 --- a/commands/policy/test.go +++ b/commands/policy/test.go @@ -167,7 +167,7 @@ func (r *policyTestResolver) Resolve(ctx context.Context, source *pb.SourceOp, r if err != nil { return nil, err } - return buildSourceMetaResponse(resp, req), nil + return buildSourceMetaResponse(resp), nil } func (r *policyTestResolver) init(ctx context.Context) error { diff --git a/policy/add_unknowns_test.go b/policy/add_unknowns_test.go index 2db947336056..de6683c09199 100644 --- a/policy/add_unknowns_test.go +++ b/policy/add_unknowns_test.go @@ -46,6 +46,47 @@ func TestAddUnknowns(t *testing.T) { }, }, }, + { + name: "image-provenance-enables-resolve-attestations", + unknowns: []string{"image.provenance"}, + initial: &gwpb.ResolveSourceMetaRequest{}, + expected: &gwpb.ResolveSourceMetaRequest{ + Image: &gwpb.ResolveSourceImageRequest{ + NoConfig: true, + AttestationChain: true, + ResolveAttestations: resolveProvenanceAttestations, + }, + }, + }, + { + name: "nested-image-provenance-enables-resolve-attestations", + unknowns: []string{"image.provenance.buildType"}, + initial: &gwpb.ResolveSourceMetaRequest{}, + expected: &gwpb.ResolveSourceMetaRequest{ + Image: &gwpb.ResolveSourceImageRequest{ + NoConfig: true, + AttestationChain: true, + ResolveAttestations: resolveProvenanceAttestations, + }, + }, + }, + { + name: "image-provenance-on-existing-image-request-preserves-fields", + unknowns: []string{"image.provenance.builderID"}, + initial: &gwpb.ResolveSourceMetaRequest{ + Image: &gwpb.ResolveSourceImageRequest{ + NoConfig: false, + AttestationChain: true, + }, + }, + expected: &gwpb.ResolveSourceMetaRequest{ + Image: &gwpb.ResolveSourceImageRequest{ + NoConfig: false, + AttestationChain: true, + ResolveAttestations: resolveProvenanceAttestations, + }, + }, + }, { name: "image-attestation-on-existing-image-request", unknowns: []string{"image.hasProvenance"}, diff --git a/policy/provenance.go b/policy/provenance.go new file mode 100644 index 000000000000..5827864283c7 --- /dev/null +++ b/policy/provenance.go @@ -0,0 +1,175 @@ +package policy + +import ( + "encoding/json" + "slices" + "strings" + "time" + + slsa02 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2" + slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1" + gwpb "github.com/moby/buildkit/frontend/gateway/pb" + provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types" +) + +const predicateTypeAnnotation = "in-toto.io/predicate-type" + +var resolveProvenanceAttestations = []string{ + slsa02.PredicateSLSAProvenance, + slsa1.PredicateSLSAProvenance, +} + +type inTotoStatement struct { + PredicateType string `json:"predicateType"` + Predicate json.RawMessage `json:"predicate"` +} + +func parseProvenance(ac *gwpb.AttestationChain) (*ImageProvenance, error) { + if ac == nil || len(ac.Blobs) == 0 { + return nil, nil + } + + // Prefer blobs that explicitly declare a provenance predicate type. + for _, b := range ac.Blobs { + if b == nil || b.Descriptor_ == nil || len(b.Data) == 0 { + continue + } + pt := b.Descriptor_.Annotations[predicateTypeAnnotation] + if pt == "" { + continue + } + if !slices.Contains(resolveProvenanceAttestations, pt) { + continue + } + prv, err := parseProvenanceBlob(b.Data, pt) + if err != nil { + return nil, err + } + if prv != nil { + return prv, nil + } + } + + return nil, nil +} + +func parseProvenanceBlob(dt []byte, pt string) (*ImageProvenance, error) { + var stmt inTotoStatement + if err := json.Unmarshal(dt, &stmt); err != nil || len(stmt.Predicate) == 0 { + return nil, nil + } + if stmt.PredicateType != "" && stmt.PredicateType != pt { + return nil, nil + } + + switch pt { + case slsa1.PredicateSLSAProvenance: + return parseSLSA1Provenance(stmt.Predicate) + case slsa02.PredicateSLSAProvenance: + return parseSLSA02Provenance(stmt.Predicate) + } + return nil, nil +} + +func parseSLSA1Provenance(dt []byte) (*ImageProvenance, error) { + var pred provenancetypes.ProvenancePredicateSLSA1 + if err := json.Unmarshal(dt, &pred); err != nil { + return nil, nil + } + if pred.BuildDefinition.BuildType == "" && pred.RunDetails.Builder.ID == "" { + return nil, nil + } + prv := &ImageProvenance{ + PredicateType: slsa1.PredicateSLSAProvenance, + BuildType: pred.BuildDefinition.BuildType, + BuilderID: pred.RunDetails.Builder.ID, + ConfigSource: &ImageProvenanceConfigSource{ + URI: pred.BuildDefinition.ExternalParameters.ConfigSource.URI, + Digest: pred.BuildDefinition.ExternalParameters.ConfigSource.Digest, + Path: pred.BuildDefinition.ExternalParameters.ConfigSource.Path, + }, + Frontend: pred.BuildDefinition.ExternalParameters.Request.Frontend, + BuildArgs: extractBuildArgs(pred.BuildDefinition.ExternalParameters.Request.Args), + RawArgs: pred.BuildDefinition.ExternalParameters.Request.Args, + } + + if md := pred.RunDetails.Metadata; md != nil { + prv.InvocationID = md.InvocationID + prv.StartedOn = formatProvenanceTime(md.StartedOn) + prv.FinishedOn = formatProvenanceTime(md.FinishedOn) + prv.Reproducible = boolPtr(md.Reproducible) + prv.Hermetic = boolPtr(md.Hermetic) + prv.Completeness = &ImageProvenanceCompleteness{ + Parameters: boolPtr(md.Completeness.Request), + Materials: boolPtr(md.Completeness.ResolvedDependencies), + } + } + + return prv, nil +} + +func parseSLSA02Provenance(dt []byte) (*ImageProvenance, error) { + var pred provenancetypes.ProvenancePredicateSLSA02 + if err := json.Unmarshal(dt, &pred); err != nil { + return nil, nil + } + if pred.BuildType == "" && pred.Builder.ID == "" { + return nil, nil + } + prv := &ImageProvenance{ + PredicateType: slsa02.PredicateSLSAProvenance, + BuildType: pred.BuildType, + BuilderID: pred.Builder.ID, + ConfigSource: &ImageProvenanceConfigSource{ + URI: pred.Invocation.ConfigSource.URI, + Digest: pred.Invocation.ConfigSource.Digest, + Path: pred.Invocation.ConfigSource.EntryPoint, + }, + Frontend: pred.Invocation.Parameters.Frontend, + BuildArgs: extractBuildArgs(pred.Invocation.Parameters.Args), + RawArgs: pred.Invocation.Parameters.Args, + } + + if md := pred.Metadata; md != nil { + prv.InvocationID = md.BuildInvocationID + prv.StartedOn = formatProvenanceTime(md.BuildStartedOn) + prv.FinishedOn = formatProvenanceTime(md.BuildFinishedOn) + prv.Reproducible = boolPtr(md.Reproducible) + prv.Hermetic = boolPtr(md.Hermetic) + prv.Completeness = &ImageProvenanceCompleteness{ + Parameters: boolPtr(md.Completeness.Parameters), + Environment: boolPtr(md.Completeness.Environment), + Materials: boolPtr(md.Completeness.Materials), + } + } + + return prv, nil +} + +func boolPtr(v bool) *bool { + return &v +} + +func formatProvenanceTime(t *time.Time) string { + if t == nil { + return "" + } + return t.UTC().Format(time.RFC3339) +} + +func extractBuildArgs(args map[string]string) map[string]string { + if len(args) == 0 { + return nil + } + const prefix = "build-arg:" + out := make(map[string]string) + for k, v := range args { + if name, ok := strings.CutPrefix(k, prefix); ok && name != "" { + out[name] = v + } + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/policy/types.go b/policy/types.go index 2c28d416fc80..aa18c6a76f67 100644 --- a/policy/types.go +++ b/policy/types.go @@ -128,9 +128,41 @@ type Image struct { WorkingDir string `json:"workingDir,omitempty"` HasProvenance bool `json:"hasProvenance,omitempty"` + Provenance *ImageProvenance `json:"provenance,omitempty"` Signatures []AttestationSignature `json:"signatures,omitempty"` } +type ImageProvenance struct { + PredicateType string `json:"predicateType,omitempty"` + BuildType string `json:"buildType,omitempty"` + BuilderID string `json:"builderID,omitempty"` + + InvocationID string `json:"invocationID,omitempty"` + StartedOn string `json:"startedOn,omitempty"` + FinishedOn string `json:"finishedOn,omitempty"` + + ConfigSource *ImageProvenanceConfigSource `json:"configSource,omitempty"` + Frontend string `json:"frontend,omitempty"` + BuildArgs map[string]string `json:"buildArgs,omitempty"` + RawArgs map[string]string `json:"rawArgs,omitempty"` + Reproducible *bool `json:"reproducible,omitempty"` + Hermetic *bool `json:"hermetic,omitempty"` + + Completeness *ImageProvenanceCompleteness `json:"completeness,omitempty"` +} + +type ImageProvenanceConfigSource struct { + URI string `json:"uri,omitempty"` + Digest map[string]string `json:"digest,omitempty"` + Path string `json:"path,omitempty"` +} + +type ImageProvenanceCompleteness struct { + Parameters *bool `json:"parameters,omitempty"` + Environment *bool `json:"environment,omitempty"` + Materials *bool `json:"materials,omitempty"` +} + type AttestationSignature struct { SignatureKind SignatureKind `json:"kind,omitempty"` SignatureType SignatureType `json:"type,omitempty"` diff --git a/policy/validate.go b/policy/validate.go index 3cd5b89192c8..91fd5611212b 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -555,7 +555,7 @@ func SourceToInputWithLogger(ctx context.Context, getVerifier PolicyVerifierProv unknowns = append(unknowns, "input.image.checksum") } unknowns = append(unknowns, withPrefix(configFields, "input.image.")...) - unknowns = append(unknowns, "input.image.hasProvenance", "input.image.signatures") + unknowns = append(unknowns, "input.image.hasProvenance", "input.image.provenance", "input.image.signatures") } else { inp.Image.Checksum = src.Image.Digest if cfg := src.Image.Config; cfg != nil { @@ -577,7 +577,14 @@ func SourceToInputWithLogger(ctx context.Context, getVerifier PolicyVerifierProv } if ac := src.Image.AttestationChain; ac != nil { - inp.Image.HasProvenance = ac.AttestationManifest != "" + if prv, err := parseProvenance(ac); err != nil { + if logf != nil { + logf(logrus.DebugLevel, fmt.Sprintf("failed to parse image provenance: %v", err)) + } + } else { + inp.Image.Provenance = prv + } + inp.Image.HasProvenance = ac.AttestationManifest != "" || inp.Image.Provenance != nil if getVerifier != nil { signatures, err := parseSignatures(ctx, getVerifier, ac, platform) if err != nil { @@ -589,7 +596,7 @@ func SourceToInputWithLogger(ctx context.Context, getVerifier PolicyVerifierProv } } } else { - unknowns = append(unknowns, "input.image.hasProvenance", "input.image.signatures") + unknowns = append(unknowns, "input.image.hasProvenance", "input.image.provenance", "input.image.signatures") } } case "local": @@ -635,6 +642,17 @@ func AddUnknownsWithLogger(logf func(logrus.Level, string), req *gwpb.ResolveSou logf(logrus.DebugLevel, fmt.Sprintf("collected unknowns: %+v", unk2)) } for _, u := range unk2 { + if u == "image.provenance" || strings.HasPrefix(u, "image.provenance.") { + if req.Image == nil { + req.Image = &gwpb.ResolveSourceImageRequest{ + NoConfig: true, + } + } + req.Image.AttestationChain = true + req.Image.ResolveAttestations = appendUnique(req.Image.ResolveAttestations, resolveProvenanceAttestations...) + continue + } + switch u { case "image.checksum", "image.labels", "image.user", "image.volumes", "image.workingDir", "image.env": if req.Image == nil { @@ -729,6 +747,9 @@ func summarizeUnknownsForLog(unk []string) []string { if strings.HasPrefix(u, "image.signatures") { u = "image.signatures" } + if strings.HasPrefix(u, "image.provenance") { + u = "image.provenance" + } if u == "image" { continue } @@ -741,6 +762,15 @@ func summarizeUnknownsForLog(unk []string) []string { return out } +func appendUnique(dst []string, values ...string) []string { + for _, v := range values { + if !slices.Contains(dst, v) { + dst = append(dst, v) + } + } + return dst +} + func hasHTTPUnknowns(unk []string) bool { for _, u := range unk { if strings.HasPrefix(u, "http.") { diff --git a/policy/validate_test.go b/policy/validate_test.go index 6930e2fe98b5..b2b8040f6186 100644 --- a/policy/validate_test.go +++ b/policy/validate_test.go @@ -9,6 +9,8 @@ import ( "testing" "time" + slsa02 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2" + 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" policyimage "github.com/moby/policy-helpers/image" @@ -177,6 +179,7 @@ func TestSourceToInputWithLogger(t *testing.T) { "input.image.workingDir", "input.image.env", "input.image.hasProvenance", + "input.image.provenance", "input.image.signatures", }, }, @@ -208,6 +211,7 @@ func TestSourceToInputWithLogger(t *testing.T) { "input.image.workingDir", "input.image.env", "input.image.hasProvenance", + "input.image.provenance", "input.image.signatures", }, }, @@ -343,6 +347,113 @@ func TestSourceToInputWithLogger(t *testing.T) { require.Equal(t, "docker/buildx", sig.Signer.SourceRepositoryIdentifier) }, }, + { + name: "image-attestation-chain-loads-provenance-fields-v0.2", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "docker-image://alpine:latest", + }, + Image: &gwpb.ResolveSourceImageResponse{ + Digest: "sha256:efefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefef", + AttestationChain: newTestAttestationChainWithProvenance(t), + }, + }, + platform: &ocispecs.Platform{OS: "linux", Architecture: "amd64"}, + assert: func(t *testing.T, inp Input, unknowns []string, err error) { + t.Helper() + require.NoError(t, err) + require.Equal(t, []string{ + "input.image.labels", + "input.image.user", + "input.image.volumes", + "input.image.workingDir", + "input.image.env", + }, unknowns) + require.NotNil(t, inp.Image) + require.True(t, inp.Image.HasProvenance) + require.NotNil(t, inp.Image.Provenance) + require.Equal(t, slsa02.PredicateSLSAProvenance, inp.Image.Provenance.PredicateType) + require.Equal(t, "https://example.com/build-type", inp.Image.Provenance.BuildType) + require.Equal(t, "https://example.com/builder-id", inp.Image.Provenance.BuilderID) + require.Equal(t, "inv-v02", inp.Image.Provenance.InvocationID) + require.Equal(t, "2024-01-02T03:04:05Z", inp.Image.Provenance.StartedOn) + require.Equal(t, "2024-01-02T03:05:05Z", inp.Image.Provenance.FinishedOn) + require.Equal(t, "gateway.v0", inp.Image.Provenance.Frontend) + require.Equal(t, map[string]string{"BUILDKIT_CONTEXT_KEEP_GIT_DIR": "1"}, inp.Image.Provenance.BuildArgs) + require.Equal(t, map[string]string{ + "build-arg:BUILDKIT_CONTEXT_KEEP_GIT_DIR": "1", + "cmdline": "docker/dockerfile-upstream:master", + }, inp.Image.Provenance.RawArgs) + require.NotNil(t, inp.Image.Provenance.ConfigSource) + require.Equal(t, "https://github.com/moby/buildkit.git#refs/tags/v0.21.0", inp.Image.Provenance.ConfigSource.URI) + require.Equal(t, "Dockerfile", inp.Image.Provenance.ConfigSource.Path) + require.Equal(t, map[string]string{"sha1": "52b004d2afe20c5c80967cc1784e718b52d69dae"}, inp.Image.Provenance.ConfigSource.Digest) + require.NotNil(t, inp.Image.Provenance.Completeness) + require.NotNil(t, inp.Image.Provenance.Completeness.Parameters) + require.True(t, *inp.Image.Provenance.Completeness.Parameters) + require.NotNil(t, inp.Image.Provenance.Completeness.Environment) + require.True(t, *inp.Image.Provenance.Completeness.Environment) + require.NotNil(t, inp.Image.Provenance.Completeness.Materials) + require.False(t, *inp.Image.Provenance.Completeness.Materials) + require.NotNil(t, inp.Image.Provenance.Reproducible) + require.True(t, *inp.Image.Provenance.Reproducible) + require.NotNil(t, inp.Image.Provenance.Hermetic) + require.True(t, *inp.Image.Provenance.Hermetic) + }, + }, + { + name: "image-attestation-chain-loads-provenance-fields-v1", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "docker-image://alpine:latest", + }, + Image: &gwpb.ResolveSourceImageResponse{ + Digest: "sha256:f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0", + AttestationChain: newTestAttestationChainWithProvenanceV1(t), + }, + }, + platform: &ocispecs.Platform{OS: "linux", Architecture: "amd64"}, + assert: func(t *testing.T, inp Input, unknowns []string, err error) { + t.Helper() + require.NoError(t, err) + require.Equal(t, []string{ + "input.image.labels", + "input.image.user", + "input.image.volumes", + "input.image.workingDir", + "input.image.env", + }, unknowns) + require.NotNil(t, inp.Image) + require.True(t, inp.Image.HasProvenance) + require.NotNil(t, inp.Image.Provenance) + require.Equal(t, slsa1.PredicateSLSAProvenance, inp.Image.Provenance.PredicateType) + require.Equal(t, "https://example.com/build-type-v1", inp.Image.Provenance.BuildType) + require.Equal(t, "https://example.com/builder-id-v1", inp.Image.Provenance.BuilderID) + require.Equal(t, "inv-v1", inp.Image.Provenance.InvocationID) + require.Equal(t, "2024-02-03T04:05:06Z", inp.Image.Provenance.StartedOn) + require.Equal(t, "2024-02-03T04:06:06Z", inp.Image.Provenance.FinishedOn) + require.Equal(t, "gateway.v0", inp.Image.Provenance.Frontend) + require.Equal(t, map[string]string{"BUILDKIT_CONTEXT_KEEP_GIT_DIR": "1"}, inp.Image.Provenance.BuildArgs) + require.Equal(t, map[string]string{ + "build-arg:BUILDKIT_CONTEXT_KEEP_GIT_DIR": "1", + "source": "docker/dockerfile-upstream:master", + }, inp.Image.Provenance.RawArgs) + require.NotNil(t, inp.Image.Provenance.ConfigSource) + require.Equal(t, "https://github.com/moby/buildkit.git#refs/heads/master", inp.Image.Provenance.ConfigSource.URI) + require.Equal(t, "Dockerfile", inp.Image.Provenance.ConfigSource.Path) + require.Equal(t, map[string]string{"sha1": "9836771d0c5b21cbc7f0c38b81be39c42fc46b7b"}, inp.Image.Provenance.ConfigSource.Digest) + require.NotNil(t, inp.Image.Provenance.Completeness) + require.NotNil(t, inp.Image.Provenance.Completeness.Parameters) + require.True(t, *inp.Image.Provenance.Completeness.Parameters) + require.Nil(t, inp.Image.Provenance.Completeness.Environment) + require.NotNil(t, inp.Image.Provenance.Completeness.Materials) + require.False(t, *inp.Image.Provenance.Completeness.Materials) + require.NotNil(t, inp.Image.Provenance.Reproducible) + require.True(t, *inp.Image.Provenance.Reproducible) + require.NotNil(t, inp.Image.Provenance.Hermetic) + require.True(t, *inp.Image.Provenance.Hermetic) + }, + }, { name: "image-attestation-chain-without-manifest-keeps-has-provenance-false", src: &gwpb.ResolveSourceMetaResponse{ @@ -428,7 +539,7 @@ func TestSourceToInputWithLogger(t *testing.T) { WorkingDir: "/work", }, }, - expUnk: []string{"input.image.hasProvenance", "input.image.signatures"}, + expUnk: []string{"input.image.hasProvenance", "input.image.provenance", "input.image.signatures"}, }, { name: "git-source-missing-full-remote-url-attr", @@ -855,6 +966,133 @@ func newTestAttestationChain(t *testing.T) *gwpb.AttestationChain { } } +func newTestAttestationChainWithProvenance(t *testing.T) *gwpb.AttestationChain { + t.Helper() + + ac := newTestAttestationChain(t) + provenancePredicate := map[string]any{ + "builder": map[string]any{ + "id": "https://example.com/builder-id", + }, + "buildType": "https://example.com/build-type", + "invocation": map[string]any{ + "configSource": map[string]any{ + "digest": map[string]any{ + "sha1": "52b004d2afe20c5c80967cc1784e718b52d69dae", + }, + "entryPoint": "Dockerfile", + "uri": "https://github.com/moby/buildkit.git#refs/tags/v0.21.0", + }, + "parameters": map[string]any{ + "frontend": "gateway.v0", + "args": map[string]any{ + "build-arg:BUILDKIT_CONTEXT_KEEP_GIT_DIR": "1", + "cmdline": "docker/dockerfile-upstream:master", + }, + }, + "environment": map[string]any{ + "platform": "linux/amd64", + }, + }, + "metadata": map[string]any{ + "buildInvocationID": "inv-v02", + "buildStartedOn": "2024-01-02T03:04:05Z", + "buildFinishedOn": "2024-01-02T03:05:05Z", + "completeness": map[string]any{ + "parameters": true, + "environment": true, + "materials": false, + }, + "reproducible": true, + "https://mobyproject.org/buildkit@v1#hermetic": true, + }, + } + provenanceBytes := mustMarshalJSON(t, map[string]any{ + "_type": "https://in-toto.io/Statement/v0.1", + "predicateType": slsa02.PredicateSLSAProvenance, + "predicate": provenancePredicate, + }) + provenanceDigest := digest.FromBytes(provenanceBytes) + + ac.Blobs[provenanceDigest.String()] = &gwpb.Blob{ + Descriptor_: &gwpb.Descriptor{ + MediaType: "application/vnd.in-toto+json", + Digest: provenanceDigest.String(), + Size: int64(len(provenanceBytes)), + Annotations: map[string]string{ + predicateTypeAnnotation: slsa02.PredicateSLSAProvenance, + }, + }, + Data: provenanceBytes, + } + + return ac +} + +func newTestAttestationChainWithProvenanceV1(t *testing.T) *gwpb.AttestationChain { + t.Helper() + + ac := newTestAttestationChain(t) + provenancePredicate := map[string]any{ + "buildDefinition": map[string]any{ + "buildType": "https://example.com/build-type-v1", + "externalParameters": map[string]any{ + "configSource": map[string]any{ + "digest": map[string]any{ + "sha1": "9836771d0c5b21cbc7f0c38b81be39c42fc46b7b", + }, + "path": "Dockerfile", + "uri": "https://github.com/moby/buildkit.git#refs/heads/master", + }, + "request": map[string]any{ + "frontend": "gateway.v0", + "args": map[string]any{ + "build-arg:BUILDKIT_CONTEXT_KEEP_GIT_DIR": "1", + "source": "docker/dockerfile-upstream:master", + }, + }, + }, + "internalParameters": map[string]any{}, + }, + "runDetails": map[string]any{ + "builder": map[string]any{ + "id": "https://example.com/builder-id-v1", + }, + "metadata": map[string]any{ + "invocationID": "inv-v1", + "startedOn": "2024-02-03T04:05:06Z", + "finishedOn": "2024-02-03T04:06:06Z", + "buildkit_completeness": map[string]any{ + "request": true, + "resolvedDependencies": false, + }, + "buildkit_reproducible": true, + "buildkit_hermetic": true, + }, + }, + } + provenanceBytes := mustMarshalJSON(t, map[string]any{ + "_type": "https://in-toto.io/Statement/v0.1", + "predicateType": slsa1.PredicateSLSAProvenance, + "predicate": provenancePredicate, + }) + provenanceDigest := digest.FromBytes(provenanceBytes) + + ac.Blobs[provenanceDigest.String()] = &gwpb.Blob{ + Descriptor_: &gwpb.Descriptor{ + MediaType: "application/vnd.in-toto+json", + Digest: provenanceDigest.String(), + Size: int64(len(provenanceBytes)), + Annotations: map[string]string{ + predicateTypeAnnotation: slsa1.PredicateSLSAProvenance, + }, + }, + Data: provenanceBytes, + } + + return ac +} + func mustMarshalJSON(t *testing.T, v any) []byte { t.Helper() dt, err := json.Marshal(v) diff --git a/tests/policy_eval.go b/tests/policy_eval.go index 1fbff5f7286a..cfd97348a386 100644 --- a/tests/policy_eval.go +++ b/tests/policy_eval.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" urlpkg "net/url" + "strings" "testing" "github.com/containerd/continuity/fs/fstest" @@ -23,6 +24,8 @@ var policyEvalTests = []func(t *testing.T, sb integration.Sandbox){ testPolicyEvalPrint, testPolicyEvalFields, testPolicyEvalLabel, + testPolicyEvalProvenance, + testPolicyEvalProvenanceValidation, testPolicyEvalHTTP, } @@ -220,6 +223,155 @@ decision := {"allow": allow} require.NoError(t, err, string(out)) } +func testPolicyEvalProvenance(t *testing.T, sb integration.Sandbox) { + // TODO: update after v0.28+ to test with non-master BuildKit + if buildkitTag() != "master" { + t.Skip("policy eval provenance integration requires TEST_BUILDKIT_TAG=master") + } + if !isDockerContainerWorker(sb) { + t.Skip("policy eval provenance integration requires docker-container worker") + } + // Base policy input support. + skipNoCompatBuildKit(t, sb, ">= 0.26.0-0", "policy input requires BuildKit v0.26.0+") + + imageRef := createPolicyEvalProvenanceImage(t, sb) + + cmd := buildxCmd(sb, withArgs( + "policy", + "eval", + "--print", + "--fields", + "image.provenance", + "docker-image://"+imageRef, + )) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + expectResolveAttestations := true + t.Logf("buildkit image=%s worker=%s expectResolveAttestations=%v", buildkitImage, sb.Name(), expectResolveAttestations) + if err != nil && strings.Contains(stderr.String(), "maximum attempts reached for resolving policy metadata") { + if expectResolveAttestations { + require.NoError(t, err, stderr.String()) + } + t.Skip("policy eval provenance requires BuildKit ResolveAttestations support (currently master-only)") + } + require.NoError(t, err, stderr.String()) + + var input policy.Input + err = json.Unmarshal(out, &input) + require.NoError(t, err, string(out)) + require.NotNil(t, input.Image) + require.True(t, input.Image.HasProvenance, "expected source image to report provenance") + if input.Image.Provenance == nil { + if expectResolveAttestations { + require.NotNil(t, input.Image.Provenance, "expected image provenance to be resolved with master BuildKit") + } + t.Skip("policy eval provenance requires BuildKit ResolveAttestations support (currently master-only)") + } + require.Equal(t, "https://slsa.dev/provenance/v1", input.Image.Provenance.PredicateType) + require.Equal(t, "dockerfile.v0", input.Image.Provenance.Frontend) +} + +func testPolicyEvalProvenanceValidation(t *testing.T, sb integration.Sandbox) { + // TODO: update after v0.28+ is released to test with non-master BuildKit + if buildkitTag() != "master" { + t.Skip("policy eval provenance integration requires TEST_BUILDKIT_TAG=master") + } + if !isDockerContainerWorker(sb) { + t.Skip("policy eval provenance integration requires docker-container worker") + } + // Base policy input support. + skipNoCompatBuildKit(t, sb, ">= 0.26.0-0", "policy input requires BuildKit v0.26.0+") + + imageRef := createPolicyEvalProvenanceImage(t, sb) + + allowPolicyDir := tmpdir( + t, + fstest.CreateFile("policy.rego", []byte(` +package docker + +default allow = false + +allow if { + input.image.hasProvenance + input.image.provenance.predicateType == "https://slsa.dev/provenance/v1" + input.image.provenance.frontend == "dockerfile.v0" +} + +decision := {"allow": allow} +`), 0600), + ) + + allowCmd := buildxCmd(sb, withDir(allowPolicyDir), withArgs( + "policy", + "eval", + "--filename", + "policy", + "docker-image://"+imageRef, + )) + allowOut, err := allowCmd.CombinedOutput() + if err != nil && strings.Contains(string(allowOut), "maximum attempts reached for resolving policy metadata") { + t.Skip("policy eval provenance requires BuildKit ResolveAttestations support (currently master-only)") + } + require.NoError(t, err, string(allowOut)) + + denyPolicyDir := tmpdir( + t, + fstest.CreateFile("policy.rego", []byte(` +package docker + +default allow = false + +allow if input.image.provenance.frontend == "not-a-real-frontend" + +decision := {"allow": allow} +`), 0600), + ) + + denyCmd := buildxCmd(sb, withDir(denyPolicyDir), withArgs( + "policy", + "eval", + "--filename", + "policy", + "docker-image://"+imageRef, + )) + denyOut, err := denyCmd.CombinedOutput() + if err != nil && strings.Contains(string(denyOut), "maximum attempts reached for resolving policy metadata") { + t.Skip("policy eval provenance requires BuildKit ResolveAttestations support (currently master-only)") + } + require.Error(t, err, string(denyOut)) + require.Contains(t, string(denyOut), "policy denied") +} + +func createPolicyEvalProvenanceImage(t *testing.T, sb integration.Sandbox) string { + registry, err := sb.NewRegistry() + if errors.Is(err, integration.ErrRequirements) { + t.Skip(err.Error()) + } + require.NoError(t, err) + + imageRef := registry + "/buildx/policy-eval-provenance:" + identity.NewID() + dir := tmpdir( + t, + fstest.CreateFile("Dockerfile", []byte(` +FROM busybox:latest +RUN echo provenance > /proof +`), 0600), + ) + + buildCmd := buildxCmd(sb, withDir(dir), withArgs( + "build", + "--progress=plain", + "--provenance=mode=max,version=v1", + "--output=type=image,name="+imageRef+",push=true", + dir, + )) + buildOut, err := buildCmd.CombinedOutput() + require.NoError(t, err, string(buildOut)) + + return imageRef +} + func testPolicyEvalHTTP(t *testing.T, sb integration.Sandbox) { resp := &httpserver.Response{Content: []byte("policy-eval-http")} server := httpserver.NewTestServer(map[string]*httpserver.Response{