From 4880756a0f5066b5a15034f2a030fcf96433c03b Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Mon, 23 Feb 2026 08:52:47 -0800 Subject: [PATCH 1/4] policy: simplify recursive material resolution Unify root/material unknown resolution with recursive Input traversal. Signed-off-by: Tonis Tiigi --- .golangci.yml | 3 + commands/policy/eval.go | 273 ++++++----- commands/policy/eval_test.go | 126 ++++- commands/policy/test.go | 4 +- go.mod | 1 + policy/input.go | 88 ++++ policy/input_unknowns.go | 47 ++ policy/input_unknowns_test.go | 34 ++ policy/materials.go | 116 +++++ policy/provenance.go | 75 ++- policy/resolve.go | 152 ++++++ policy/resolve_test.go | 68 +++ policy/tester.go | 2 +- policy/types.go | 7 + policy/utils_test.go | 9 +- policy/validate.go | 262 ++++++----- policy/validate_test.go | 4 +- util/sourcemeta/convert.go | 102 ++++ .../moby/buildkit/util/purl/image.go | 132 ++++++ .../package-url/packageurl-go/.gitignore | 14 + .../package-url/packageurl-go/.golangci.yaml | 17 + .../package-url/packageurl-go/LICENSE | 18 + .../package-url/packageurl-go/Makefile | 8 + .../package-url/packageurl-go/README.md | 74 +++ .../package-url/packageurl-go/VERSION | 1 + .../package-url/packageurl-go/packageurl.go | 438 ++++++++++++++++++ vendor/modules.txt | 4 + 27 files changed, 1830 insertions(+), 249 deletions(-) create mode 100644 policy/input.go create mode 100644 policy/input_unknowns.go create mode 100644 policy/input_unknowns_test.go create mode 100644 policy/materials.go create mode 100644 policy/resolve.go create mode 100644 policy/resolve_test.go create mode 100644 util/sourcemeta/convert.go create mode 100644 vendor/github.com/moby/buildkit/util/purl/image.go create mode 100644 vendor/github.com/package-url/packageurl-go/.gitignore create mode 100644 vendor/github.com/package-url/packageurl-go/.golangci.yaml create mode 100644 vendor/github.com/package-url/packageurl-go/LICENSE create mode 100644 vendor/github.com/package-url/packageurl-go/Makefile create mode 100644 vendor/github.com/package-url/packageurl-go/README.md create mode 100644 vendor/github.com/package-url/packageurl-go/VERSION create mode 100644 vendor/github.com/package-url/packageurl-go/packageurl.go diff --git a/.golangci.yml b/.golangci.yml index fc62c31484cd..7a6204468a88 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -109,6 +109,9 @@ linters: - linters: - revive text: if-return + - linters: + - staticcheck + text: "SA1019: .* is deprecated: .*in-toto/attestation" paths: - .*\.pb\.go$ diff --git a/commands/policy/eval.go b/commands/policy/eval.go index 7c77b33e2039..7a17734c65fb 100644 --- a/commands/policy/eval.go +++ b/commands/policy/eval.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io/fs" + "maps" "os" "path/filepath" "slices" @@ -17,7 +18,6 @@ import ( "github.com/docker/buildx/util/confutil" "github.com/docker/buildx/util/sourcemeta" "github.com/docker/cli/cli/command" - "github.com/moby/buildkit/client/llb/sourceresolver" "github.com/moby/buildkit/frontend/dockerui" gwpb "github.com/moby/buildkit/frontend/gateway/pb" "github.com/moby/buildkit/solver/pb" @@ -27,7 +27,6 @@ import ( "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/spf13/cobra" - "google.golang.org/protobuf/types/known/timestamppb" ) type evalOpts struct { @@ -111,48 +110,50 @@ func runEval(ctx context.Context, dockerCli command.Cli, source string, opts eva srcReq := &gwpb.ResolveSourceMetaResponse{ Source: src, } + input, err := policy.SourceToInput(ctx, verifier, srcReq, &p, nil) + if err != nil { + return err + } maxAttempts := 5 - var unknowns []string var lastUnknowns []string var trimmedUnknowns []string - var input policy.Input - var doneInvalidCheck bool var invalidFields []string for { maxAttempts-- if maxAttempts <= 0 { return errors.New("maximum attempts reached for resolving source metadata") } - input, unknowns, err = policy.SourceToInput(ctx, verifier, srcReq, &p) - if err != nil { - return err + unknowns := input.Unknowns() + trimmedUnknowns = make([]string, 0, len(unknowns)) + for _, u := range unknowns { + trimmedUnknowns = append(trimmedUnknowns, strings.TrimPrefix(u, "input.")) } - trimmedUnknowns = trimInputPrefixSlice(unknowns) if lastUnknowns != nil && slices.Equal(trimmedUnknowns, lastUnknowns) { break } lastUnknowns = slices.Clone(trimmedUnknowns) - toReload := []string{} - for _, f := range opts.fields { - if slices.Contains(trimmedUnknowns, f) { - toReload = append(toReload, f) - } else if !doneInvalidCheck { - invalidFields = append(invalidFields, f) - } - } - doneInvalidCheck = true + toReload, invalid := selectReloadFields(opts.fields, trimmedUnknowns) + invalidFields = invalid if len(toReload) > 0 { - req := &gwpb.ResolveSourceMetaRequest{} - if err := policy.AddUnknowns(req, toReload); err != nil { - return err - } - opt := sourceResolverOpt(req, &p) - resp, err := metaResolver.ResolveSourceMetadata(ctx, src, opt) + retry, next, err := policy.ResolveInputUnknowns(ctx, &input, srcReq.Source, toReload, platform, &p, metaResolver, verifier, nil) if err != nil { return err } - srcReq = buildSourceMetaResponse(resp) - continue + if next != nil { + resp, err := metaResolver.ResolveSourceMetadata(ctx, next.Source, sourcemeta.ToResolverOpt(next, &p)) + if err != nil { + return err + } + srcReq = sourcemeta.ToGatewayMetaResponse(resp) + input, err = policy.SourceToInput(ctx, verifier, srcReq, &p, nil) + if err != nil { + return err + } + continue + } + if retry { + continue + } } break } @@ -160,11 +161,15 @@ func runEval(ctx context.Context, dockerCli command.Cli, source string, opts eva if len(invalidFields) > 0 { logrus.Warnf("invalid fields: %v", strings.Join(invalidFields, ", ")) } - if len(trimmedUnknowns) > 0 { - logrus.Infof("unresolved fields: %v", strings.Join(trimmedUnknowns, ", ")) + reportedUnknowns := summarizeEvalUnknowns(trimmedUnknowns, opts.fields) + if len(reportedUnknowns) > 0 { + logrus.Infof("unresolved fields: %v", strings.Join(reportedUnknowns, ", ")) } - dt, err := json.MarshalIndent(input, "", " ") + printInput := input + sanitizePrintInput(&printInput) + + dt, err := json.MarshalIndent(printInput, "", " ") if err != nil { return errors.Wrap(err, "failed to marshal policy input") } @@ -198,6 +203,9 @@ func runEval(ctx context.Context, dockerCli command.Cli, source string, opts eva env := policy.Env{ Filename: filepath.Base(policyName), } + policyLog := func(_ logrus.Level, msg string) { + logrus.Debug(msg) + } policyEval := policy.NewPolicy(policy.Opt{ Files: []policy.File{ @@ -207,9 +215,11 @@ func runEval(ctx context.Context, dockerCli command.Cli, source string, opts eva }, }, Env: env, + Log: policyLog, FS: fsProvider, VerifierProvider: verifier, DefaultPlatform: &p, + SourceResolver: metaResolver, }) srcReq := &gwpb.ResolveSourceMetaResponse{ @@ -233,114 +243,153 @@ func runEval(ctx context.Context, dockerCli command.Cli, source string, opts eva return evalDecisionError(decision) } - opt := sourceResolverOpt(next, &p) - resp, err := metaResolver.ResolveSourceMetadata(ctx, src, opt) + opt := sourcemeta.ToResolverOpt(next, &p) + target := src + if next.Source != nil { + target = next.Source + } + resp, err := metaResolver.ResolveSourceMetadata(ctx, target, opt) if err != nil { return err } - srcReq = buildSourceMetaResponse(resp) + srcReq = sourcemeta.ToGatewayMetaResponse(resp) } } -func toGatewayDescriptor(desc ocispecs.Descriptor) *gwpb.Descriptor { - return &gwpb.Descriptor{ - MediaType: desc.MediaType, - Digest: desc.Digest.String(), - Size: desc.Size, - Annotations: desc.Annotations, - } -} - -func toGatewayAttestationChain(chain *sourceresolver.AttestationChain) *gwpb.AttestationChain { - if chain == nil { - return nil - } - signatures := make([]string, 0, len(chain.SignatureManifests)) - for _, dgst := range chain.SignatureManifests { - signatures = append(signatures, dgst.String()) +func selectReloadFields(fields []string, unknowns []string) ([]string, []string) { + if len(fields) == 0 { + return nil, nil } - blobs := make(map[string]*gwpb.Blob, len(chain.Blobs)) - for dgst, blob := range chain.Blobs { - blobs[dgst.String()] = &gwpb.Blob{ - Descriptor_: toGatewayDescriptor(blob.Descriptor), - Data: blob.Data, + reload := map[string]struct{}{} + var invalid []string + for _, field := range fields { + if prereq, ok := materialFieldPrerequisites(field); ok { + added := false + for _, p := range prereq { + if slices.Contains(unknowns, p) { + reload[p] = struct{}{} + added = true + } + } + if slices.Contains(unknowns, field) { + reload[field] = struct{}{} + added = true + } else if ancestor := findUnknownAncestor(field, unknowns); ancestor != "" { + reload[ancestor] = struct{}{} + added = true + } + if !added { + invalid = append(invalid, field) + } + continue } + if slices.Contains(unknowns, field) { + reload[field] = struct{}{} + continue + } + invalid = append(invalid, field) } - return &gwpb.AttestationChain{ - Root: chain.Root.String(), - ImageManifest: chain.ImageManifest.String(), - AttestationManifest: chain.AttestationManifest.String(), - SignatureManifests: signatures, - Blobs: blobs, - } + return slices.Collect(maps.Keys(reload)), invalid } -func sourceResolverOpt(req *gwpb.ResolveSourceMetaRequest, platform *ocispecs.Platform) sourceresolver.Opt { - opt := sourceresolver.Opt{ - LogName: req.LogName, - SourcePolicies: req.SourcePolicies, - } - if req.Image != nil { - opt.ImageOpt = &sourceresolver.ResolveImageOpt{ - NoConfig: req.Image.NoConfig, - AttestationChain: req.Image.AttestationChain, - ResolveAttestations: slices.Clone(req.Image.ResolveAttestations), - Platform: platform, - ResolveMode: req.ResolveMode, +func findUnknownAncestor(field string, unknowns []string) string { + var best string + for _, unknown := range unknowns { + if field == unknown { + return unknown } - } - if req.Git != nil { - opt.GitOpt = &sourceresolver.ResolveGitOpt{ - ReturnObject: req.Git.ReturnObject, + if strings.HasPrefix(field, unknown+".") { + if len(unknown) > len(best) { + best = unknown + } + continue + } + if strings.HasPrefix(field, unknown+"[") { + if len(unknown) > len(best) { + best = unknown + } } } - return opt + return best +} + +func materialFieldPrerequisites(field string) ([]string, bool) { + const seg = ".image.provenance.materials[" + if !strings.HasPrefix(field, seg[1:]) { + return nil, false + } + provenancePath := strings.TrimSuffix(seg, ".materials[") + out := map[string]struct{}{strings.TrimPrefix(provenancePath, "."): {}} + collectMaterialPrerequisites(field, seg, provenancePath, 0, out) + keys := slices.Collect(maps.Keys(out)) + slices.Sort(keys) + return keys, true +} + +func collectMaterialPrerequisites(field, seg, provenancePath string, start int, out map[string]struct{}) { + i := strings.Index(field[start:], seg) + if i < 0 { + return + } + i += start + out[field[:i]+provenancePath] = struct{}{} + collectMaterialPrerequisites(field, seg, provenancePath, i+len(seg), out) } -func buildSourceMetaResponse(resp *sourceresolver.MetaResponse) *gwpb.ResolveSourceMetaResponse { - out := &gwpb.ResolveSourceMetaResponse{ - Source: resp.Op, +func summarizeEvalUnknowns(unknowns, requested []string) []string { + if len(unknowns) == 0 { + return nil } - if resp.Image != nil { - chain := toGatewayAttestationChain(resp.Image.AttestationChain) - out.Image = &gwpb.ResolveSourceImageResponse{ - Digest: resp.Image.Digest.String(), - Config: resp.Image.Config, - AttestationChain: chain, + if len(requested) > 0 { + out := map[string]struct{}{} + for _, field := range requested { + if slices.Contains(unknowns, field) { + out[field] = struct{}{} + continue + } + if ancestor := findUnknownAncestor(field, unknowns); ancestor != "" { + out[ancestor] = struct{}{} + } } + keys := slices.Collect(maps.Keys(out)) + slices.Sort(keys) + return keys } - if resp.Git != nil { - out.Git = &gwpb.ResolveSourceGitResponse{ - Checksum: resp.Git.Checksum, - Ref: resp.Git.Ref, - CommitChecksum: resp.Git.CommitChecksum, - CommitObject: resp.Git.CommitObject, - TagObject: resp.Git.TagObject, - } + + out := map[string]struct{}{} + for _, u := range unknowns { + out[summarizeUnknownField(u)] = struct{}{} } - if resp.HTTP != nil { - var lastModified *timestamppb.Timestamp - if resp.HTTP.LastModified != nil { - lastModified = timestamppb.New(*resp.HTTP.LastModified) - } - out.HTTP = &gwpb.ResolveSourceHTTPResponse{ - Checksum: resp.HTTP.Digest.String(), - Filename: resp.HTTP.Filename, - LastModified: lastModified, - } + keys := slices.Collect(maps.Keys(out)) + slices.Sort(keys) + return keys +} + +func summarizeUnknownField(field string) string { + if base, _, ok := strings.Cut(field, ".materials["); ok { + return base + ".materials" + } + if strings.HasPrefix(field, "materials[") { + return "materials" + } + parts := strings.Split(field, ".") + if len(parts) > 1 { + return strings.Join(parts[:2], ".") } - return out + return field } -func trimInputPrefixSlice(fields []string) []string { - if len(fields) == 0 { - return fields +func sanitizePrintInput(inp *policy.Input) { + if inp == nil { + return } - out := make([]string, 0, len(fields)) - for _, field := range fields { - out = append(out, strings.TrimPrefix(field, "input.")) + inp.Env.Depth = 0 + if inp.Image == nil || inp.Image.Provenance == nil || len(inp.Image.Provenance.Materials) == 0 { + return + } + for i := range inp.Image.Provenance.Materials { + sanitizePrintInput(&inp.Image.Provenance.Materials[i]) } - return out } func evalDecisionError(decision *policysession.DecisionResponse) error { diff --git a/commands/policy/eval_test.go b/commands/policy/eval_test.go index be8264fe0002..75df88b32e33 100644 --- a/commands/policy/eval_test.go +++ b/commands/policy/eval_test.go @@ -3,6 +3,8 @@ package policy import ( "testing" + policytypes "github.com/docker/buildx/policy" + "github.com/docker/buildx/util/sourcemeta" gwpb "github.com/moby/buildkit/frontend/gateway/pb" ocispecs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/require" @@ -18,10 +20,132 @@ func TestSourceResolverOptIncludesResolveAttestations(t *testing.T) { } platform := &ocispecs.Platform{OS: "linux", Architecture: "amd64"} - opt := sourceResolverOpt(req, platform) + opt := sourcemeta.ToResolverOpt(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) } + +func TestSanitizePrintInputClearsDepthRecursively(t *testing.T) { + inp := policytypes.Input{ + Env: policytypes.Env{Depth: 7, Filename: "Dockerfile"}, + Image: &policytypes.Image{ + Provenance: &policytypes.ImageProvenance{ + Materials: []policytypes.Input{ + { + Env: policytypes.Env{Depth: 3, Target: "app"}, + Image: &policytypes.Image{ + Provenance: &policytypes.ImageProvenance{ + Materials: []policytypes.Input{ + {Env: policytypes.Env{Depth: 2}}, + }, + }, + }, + }, + }, + }, + }, + } + + sanitizePrintInput(&inp) + + require.Zero(t, inp.Env.Depth) + require.Equal(t, "Dockerfile", inp.Env.Filename) + require.Zero(t, inp.Image.Provenance.Materials[0].Env.Depth) + require.Equal(t, "app", inp.Image.Provenance.Materials[0].Env.Target) + require.Zero(t, inp.Image.Provenance.Materials[0].Image.Provenance.Materials[0].Env.Depth) +} + +func TestSelectReloadFields(t *testing.T) { + unknowns := []string{ + "image.provenance", + "image.provenance.materials[0].image.hasProvenance", + "git.tag", + } + + t.Run("exact match", func(t *testing.T) { + reload, invalid := selectReloadFields([]string{"git.tag"}, unknowns) + require.Equal(t, []string{"git.tag"}, reload) + require.Nil(t, invalid) + }) + + t.Run("ancestor mapping", func(t *testing.T) { + reload, invalid := selectReloadFields([]string{"image.provenance.materials[0].image.labels"}, unknowns) + require.ElementsMatch(t, []string{"image.provenance"}, reload) + require.Nil(t, invalid) + }) + + t.Run("dedupe mapped reloads", func(t *testing.T) { + reload, invalid := selectReloadFields([]string{ + "image.provenance.materials[0].image.labels", + "image.provenance.materials[0].image.user", + }, unknowns) + require.ElementsMatch(t, []string{ + "image.provenance", + }, reload) + require.Nil(t, invalid) + }) + + t.Run("invalid fields reported", func(t *testing.T) { + reload, invalid := selectReloadFields([]string{"image.labels", "foo.bar"}, unknowns) + require.Empty(t, reload) + require.Equal(t, []string{"image.labels", "foo.bar"}, invalid) + }) + + t.Run("mix exact mapped invalid", func(t *testing.T) { + reload, invalid := selectReloadFields([]string{ + "git.tag", + "image.provenance.materials[0].image.env", + "no.such.field", + }, unknowns) + require.ElementsMatch(t, []string{"git.tag", "image.provenance"}, reload) + require.Equal(t, []string{"no.such.field"}, invalid) + }) + + t.Run("nested material prerequisites", func(t *testing.T) { + reload, invalid := selectReloadFields([]string{ + "image.provenance.materials[0].image.provenance.materials[1].image.labels", + }, unknowns) + require.ElementsMatch(t, []string{ + "image.provenance", + }, reload) + require.Nil(t, invalid) + }) + + t.Run("material field after provenance loaded", func(t *testing.T) { + reload, invalid := selectReloadFields([]string{ + "image.provenance.materials[0].image.hasProvenance", + }, []string{ + "image.provenance.materials[0].image.hasProvenance", + }) + require.ElementsMatch(t, []string{ + "image.provenance.materials[0].image.hasProvenance", + }, reload) + require.Nil(t, invalid) + }) +} + +func TestMaterialFieldPrerequisites(t *testing.T) { + t.Run("non material field", func(t *testing.T) { + prereq, ok := materialFieldPrerequisites("image.provenance") + require.False(t, ok) + require.Nil(t, prereq) + }) + + t.Run("single level material field", func(t *testing.T) { + prereq, ok := materialFieldPrerequisites("image.provenance.materials[0].image.labels") + require.True(t, ok) + require.Equal(t, []string{"image.provenance"}, prereq) + }) + + t.Run("nested material field", func(t *testing.T) { + prereq, ok := materialFieldPrerequisites("image.provenance.materials[0].image.provenance.materials[1].image.labels") + require.True(t, ok) + require.Equal(t, []string{ + "image.provenance", + "image.provenance.materials[0].image.provenance", + }, prereq) + }) +} diff --git a/commands/policy/test.go b/commands/policy/test.go index f1d964de6514..455e4fe18afc 100644 --- a/commands/policy/test.go +++ b/commands/policy/test.go @@ -158,12 +158,12 @@ func (r *policyTestOptionsProvider) Resolve(ctx context.Context, source *pb.Sour if err := r.init(ctx); err != nil { return nil, err } - opt := sourceResolverOpt(req, r.platform) + opt := sourcemeta.ToResolverOpt(req, r.platform) resp, err := r.metaResolver.ResolveSourceMetadata(ctx, source, opt) if err != nil { return nil, err } - return buildSourceMetaResponse(resp), nil + return sourcemeta.ToGatewayMetaResponse(resp), nil } func (r *policyTestOptionsProvider) init(ctx context.Context) error { diff --git a/go.mod b/go.mod index 5913dd02f8c7..00d3af23543b 100644 --- a/go.mod +++ b/go.mod @@ -183,6 +183,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/oklog/ulid v1.3.1 // indirect + github.com/package-url/packageurl-go v0.1.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect diff --git a/policy/input.go b/policy/input.go new file mode 100644 index 000000000000..bc53ed58b58a --- /dev/null +++ b/policy/input.go @@ -0,0 +1,88 @@ +package policy + +import ( + "context" + + "github.com/containerd/platforms" + gwpb "github.com/moby/buildkit/frontend/gateway/pb" + "github.com/moby/buildkit/solver/pb" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +const maxMaterialDepth = 24 + +func SourceToInput(ctx context.Context, verifier PolicyVerifierProvider, src *gwpb.ResolveSourceMetaResponse, platform *ocispecs.Platform, logf func(logrus.Level, string)) (Input, error) { + seen := map[string]struct{}{} + return sourceToInputRecursive(ctx, verifier, src, platform, 0, seen, logf) +} + +func sourceToInputRecursive(ctx context.Context, verifier PolicyVerifierProvider, src *gwpb.ResolveSourceMetaResponse, platform *ocispecs.Platform, depth int, seen map[string]struct{}, logf func(logrus.Level, string)) (Input, error) { + if depth > maxMaterialDepth { + return Input{}, errors.Errorf("provenance materials depth exceeds limit %d", maxMaterialDepth) + } + if src == nil || src.Source == nil { + return Input{}, errors.New("source metadata response is required") + } + + inp, unknowns, err := sourceToInput(ctx, verifier, src, platform, logf) + if err != nil { + return Input{}, err + } + inp.setUnknowns(unknowns) + inp.Env.Depth = depth + + if inp.Image == nil || inp.Image.Provenance == nil || len(inp.Image.Provenance.materialsRaw) == 0 { + return inp, nil + } + + key := sourceUniqueIdentifier(src.Source, platform) + if _, ok := seen[key]; ok { + return Input{}, nil + } + seen[key] = struct{}{} + defer delete(seen, key) + + materials := make([]Input, 0, len(inp.Image.Provenance.materialsRaw)) + for _, m := range inp.Image.Provenance.materialsRaw { + matSrc, matPlatform, err := parseSLSAMaterial(m) + if err != nil { + materials = append(materials, Input{}) + continue + } + if matSrc == nil { + materials = append(materials, Input{}) + continue + } + matResp := &gwpb.ResolveSourceMetaResponse{Source: matSrc} + child, err := sourceToInputRecursive(ctx, verifier, matResp, firstNonNilPlatform(matPlatform, platform), depth+1, seen, logf) + if err != nil { + return Input{}, errors.Wrapf(err, "failed to build material input for %q", m.URI) + } + materials = append(materials, child) + } + inp.Image.Provenance.Materials = materials + return inp, nil +} + +// sourceUniqueIdentifier is used only for recursive cycle detection safety. +func sourceUniqueIdentifier(src *pb.SourceOp, platform *ocispecs.Platform) string { + if src == nil { + return "" + } + key := src.Identifier + if platform != nil { + key += "|" + platforms.Format(*platform) + } + return key +} + +func firstNonNilPlatform(values ...*ocispecs.Platform) *ocispecs.Platform { + for _, v := range values { + if v != nil { + return v + } + } + return nil +} diff --git a/policy/input_unknowns.go b/policy/input_unknowns.go new file mode 100644 index 000000000000..1fbb6aa7bb05 --- /dev/null +++ b/policy/input_unknowns.go @@ -0,0 +1,47 @@ +package policy + +import ( + "fmt" + "strings" +) + +func (inp *Input) setUnknowns(unknowns []string) { + if inp == nil { + return + } + if len(unknowns) == 0 { + inp.unknowns = nil + return + } + out := make([]string, 0, len(unknowns)) + for _, u := range unknowns { + v := strings.TrimPrefix(u, "input.") + if v == "" { + continue + } + out = append(out, v) + } + inp.unknowns = out +} + +func (inp Input) Unknowns() []string { + var refs []string + collectInputUnknowns(inp, "input", &refs) + return refs +} + +func collectInputUnknowns(inp Input, prefix string, refs *[]string) { + for _, u := range inp.unknowns { + if u == "" { + continue + } + *refs = append(*refs, prefix+"."+u) + } + if inp.Image == nil || inp.Image.Provenance == nil { + return + } + for i := range inp.Image.Provenance.Materials { + childPrefix := fmt.Sprintf("%s.image.provenance.materials[%d]", prefix, i) + collectInputUnknowns(inp.Image.Provenance.Materials[i], childPrefix, refs) + } +} diff --git a/policy/input_unknowns_test.go b/policy/input_unknowns_test.go new file mode 100644 index 000000000000..ec58e7091cff --- /dev/null +++ b/policy/input_unknowns_test.go @@ -0,0 +1,34 @@ +package policy + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestInputUnknownRefsNestedMaterials(t *testing.T) { + inp := Input{ + Image: &Image{ + Provenance: &ImageProvenance{ + Materials: []Input{ + { + Image: &Image{ + Provenance: &ImageProvenance{ + Materials: []Input{{}}, + }, + }, + }, + }, + }, + }, + } + inp.setUnknowns([]string{"input.image.provenance"}) + inp.Image.Provenance.Materials[0].setUnknowns([]string{"input.image.hasProvenance"}) + inp.Image.Provenance.Materials[0].Image.Provenance.Materials[0].setUnknowns([]string{"input.git.commit"}) + + require.Equal(t, []string{ + "input.image.provenance", + "input.image.provenance.materials[0].image.hasProvenance", + "input.image.provenance.materials[0].image.provenance.materials[0].git.commit", + }, inp.Unknowns()) +} diff --git a/policy/materials.go b/policy/materials.go new file mode 100644 index 000000000000..b705efa1db92 --- /dev/null +++ b/policy/materials.go @@ -0,0 +1,116 @@ +package policy + +import ( + "path" + "strconv" + "strings" + + "github.com/distribution/reference" + "github.com/docker/buildx/util/urlutil" + slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1" + "github.com/moby/buildkit/solver/pb" + "github.com/moby/buildkit/util/gitutil" + "github.com/moby/buildkit/util/purl" + "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" +) + +func isMaterialKey(key string) (idx int, rest string, ok bool) { + const prefix = "image.provenance.materials[" + if !strings.HasPrefix(key, prefix) { + return 0, "", false + } + rest = strings.TrimPrefix(key, prefix) + end := strings.IndexByte(rest, ']') + if end < 0 { + return 0, "", false + } + n, err := strconv.Atoi(rest[:end]) + if err != nil { + return 0, "", false + } + rest = strings.TrimPrefix(rest[end+1:], ".") + return n, rest, true +} + +func parseSLSAMaterial(m slsa1.ResourceDescriptor) (*pb.SourceOp, *ocispecs.Platform, error) { + uri := m.URI + dgst := m.Digest + if strings.HasPrefix(uri, "pkg:docker/") { + return dockerMaterialSource(uri, dgst) + } + + if gu, err := gitutil.ParseURL(uri); err == nil { + if strings.HasSuffix(strings.ToLower(gu.Path), ".git") || gu.Scheme != gitutil.HTTPProtocol && gu.Scheme != gitutil.HTTPSProtocol { + return gitMaterialSource(uri) + } + } + + if urlutil.IsHTTPURL(uri) { + return &pb.SourceOp{Identifier: uri}, nil, nil + } + + return nil, nil, errors.Errorf("unsupported material URI %q", uri) +} + +func dockerMaterialSource(uri string, dgst map[string]string) (*pb.SourceOp, *ocispecs.Platform, error) { + refStr, platform, err := purl.PURLToRef(uri) + if err != nil { + return nil, nil, err + } + + named, err := reference.ParseNormalizedNamed(refStr) + if err != nil { + return nil, nil, errors.Wrapf(err, "invalid docker reference %q from %q", refStr, uri) + } + if checksum := strings.TrimSpace(dgst["sha256"]); checksum != "" { + dgstRef, err := digest.Parse(checksum) + if err != nil { + dgstRef, err = digest.Parse("sha256:" + checksum) + } + if err != nil { + return nil, nil, errors.Wrapf(err, "invalid material digest %q for %q", checksum, uri) + } + if canonical, ok := named.(reference.Canonical); ok { + if canonical.Digest() != dgstRef { + return nil, nil, errors.Errorf("material digest mismatch for %q: ref has %s but provenance has %s", uri, canonical.Digest(), dgstRef) + } + } else { + named, err = reference.WithDigest(named, dgstRef) + if err != nil { + return nil, nil, errors.Wrapf(err, "failed to add digest %q to %q", dgstRef, refStr) + } + } + } + + return &pb.SourceOp{Identifier: "docker-image://" + named.String()}, platform, nil +} + +func gitMaterialSource(uri string) (*pb.SourceOp, *ocispecs.Platform, error) { + gu, err := gitutil.ParseURL(uri) + if err != nil { + return nil, nil, err + } + + switch gu.Scheme { + case gitutil.HTTPSProtocol, gitutil.HTTPProtocol, gitutil.SSHProtocol, gitutil.GitProtocol: + default: + return nil, nil, errors.Errorf("unsupported git material URI %q", uri) + } + + id := gu.Host + path.Join("/", gu.Path) + if gu.Opts != nil && (gu.Opts.Ref != "" || gu.Opts.Subdir != "") { + id += "#" + gu.Opts.Ref + if gu.Opts.Subdir != "" { + id += ":" + gu.Opts.Subdir + } + } + + return &pb.SourceOp{ + Identifier: "git://" + id, + Attrs: map[string]string{ + pb.AttrFullRemoteURL: uri, + }, + }, nil, nil +} diff --git a/policy/provenance.go b/policy/provenance.go index 5827864283c7..dc830feebf18 100644 --- a/policy/provenance.go +++ b/policy/provenance.go @@ -2,6 +2,8 @@ package policy import ( "encoding/json" + "fmt" + "maps" "slices" "strings" "time" @@ -10,6 +12,7 @@ import ( 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" + "github.com/sirupsen/logrus" ) const predicateTypeAnnotation = "in-toto.io/predicate-type" @@ -24,12 +27,11 @@ type inTotoStatement struct { Predicate json.RawMessage `json:"predicate"` } -func parseProvenance(ac *gwpb.AttestationChain) (*ImageProvenance, error) { +func parseProvenance(ac *gwpb.AttestationChain, logf func(logrus.Level, string)) (*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 @@ -41,7 +43,7 @@ func parseProvenance(ac *gwpb.AttestationChain) (*ImageProvenance, error) { if !slices.Contains(resolveProvenanceAttestations, pt) { continue } - prv, err := parseProvenanceBlob(b.Data, pt) + prv, err := parseProvenanceBlob(b.Data, pt, logf) if err != nil { return nil, err } @@ -53,25 +55,30 @@ func parseProvenance(ac *gwpb.AttestationChain) (*ImageProvenance, error) { return nil, nil } -func parseProvenanceBlob(dt []byte, pt string) (*ImageProvenance, error) { +func parseProvenanceBlob(dt []byte, pt string, logf func(logrus.Level, string)) (*ImageProvenance, error) { var stmt inTotoStatement - if err := json.Unmarshal(dt, &stmt); err != nil || len(stmt.Predicate) == 0 { + if err := json.Unmarshal(dt, &stmt); err != nil { return nil, nil } - if stmt.PredicateType != "" && stmt.PredicateType != pt { + if len(stmt.Predicate) == 0 { return nil, nil } - switch pt { + predicateType := stmt.PredicateType + if predicateType == "" { + predicateType = pt + } + switch predicateType { case slsa1.PredicateSLSAProvenance: - return parseSLSA1Provenance(stmt.Predicate) + return parseSLSA1Provenance(stmt.Predicate, logf) case slsa02.PredicateSLSAProvenance: - return parseSLSA02Provenance(stmt.Predicate) + return parseSLSA02Provenance(stmt.Predicate, logf) + default: + return nil, nil } - return nil, nil } -func parseSLSA1Provenance(dt []byte) (*ImageProvenance, error) { +func parseSLSA1Provenance(dt []byte, logf func(logrus.Level, string)) (*ImageProvenance, error) { var pred provenancetypes.ProvenancePredicateSLSA1 if err := json.Unmarshal(dt, &pred); err != nil { return nil, nil @@ -92,6 +99,7 @@ func parseSLSA1Provenance(dt []byte) (*ImageProvenance, error) { BuildArgs: extractBuildArgs(pred.BuildDefinition.ExternalParameters.Request.Args), RawArgs: pred.BuildDefinition.ExternalParameters.Request.Args, } + prv.materialsRaw = rawMaterialsFromSLSA1(pred.BuildDefinition.ResolvedDependencies, logf) if md := pred.RunDetails.Metadata; md != nil { prv.InvocationID = md.InvocationID @@ -108,7 +116,7 @@ func parseSLSA1Provenance(dt []byte) (*ImageProvenance, error) { return prv, nil } -func parseSLSA02Provenance(dt []byte) (*ImageProvenance, error) { +func parseSLSA02Provenance(dt []byte, logf func(logrus.Level, string)) (*ImageProvenance, error) { var pred provenancetypes.ProvenancePredicateSLSA02 if err := json.Unmarshal(dt, &pred); err != nil { return nil, nil @@ -129,6 +137,7 @@ func parseSLSA02Provenance(dt []byte) (*ImageProvenance, error) { BuildArgs: extractBuildArgs(pred.Invocation.Parameters.Args), RawArgs: pred.Invocation.Parameters.Args, } + prv.materialsRaw = rawMaterialsFromSLSA02(pred.Materials, logf) if md := pred.Metadata; md != nil { prv.InvocationID = md.BuildInvocationID @@ -146,6 +155,48 @@ func parseSLSA02Provenance(dt []byte) (*ImageProvenance, error) { return prv, nil } +func rawMaterialsFromSLSA1(materials []slsa1.ResourceDescriptor, logf func(logrus.Level, string)) []slsa1.ResourceDescriptor { + if len(materials) == 0 { + return nil + } + out := make([]slsa1.ResourceDescriptor, 0, len(materials)) + for _, m := range materials { + rd := slsa1.ResourceDescriptor{ + URI: m.URI, + Digest: maps.Clone(m.Digest), + } + if _, _, err := parseSLSAMaterial(rd); err != nil { + if logf != nil { + logf(logrus.WarnLevel, fmt.Sprintf("skipping unsupported provenance material %q: %v", m.URI, err)) + } + continue + } + out = append(out, rd) + } + return out +} + +func rawMaterialsFromSLSA02(materials []slsa02.ProvenanceMaterial, logf func(logrus.Level, string)) []slsa1.ResourceDescriptor { + if len(materials) == 0 { + return nil + } + out := make([]slsa1.ResourceDescriptor, 0, len(materials)) + for _, m := range materials { + rd := slsa1.ResourceDescriptor{ + URI: m.URI, + Digest: maps.Clone(m.Digest), + } + if _, _, err := parseSLSAMaterial(rd); err != nil { + if logf != nil { + logf(logrus.WarnLevel, fmt.Sprintf("skipping unsupported provenance material %q: %v", m.URI, err)) + } + continue + } + out = append(out, rd) + } + return out +} + func boolPtr(v bool) *bool { return &v } diff --git a/policy/resolve.go b/policy/resolve.go new file mode 100644 index 000000000000..84aa8c102714 --- /dev/null +++ b/policy/resolve.go @@ -0,0 +1,152 @@ +package policy + +import ( + "context" + "slices" + "strings" + + "github.com/docker/buildx/util/sourcemeta" + "github.com/moby/buildkit/client/llb/sourceresolver" + gwpb "github.com/moby/buildkit/frontend/gateway/pb" + "github.com/moby/buildkit/solver/pb" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +type SourceMetadataResolver interface { + ResolveSourceMetadata(context.Context, *pb.SourceOp, sourceresolver.Opt) (*sourceresolver.MetaResponse, error) +} + +func ResolveInputUnknowns(ctx context.Context, input *Input, rootSource *pb.SourceOp, unknowns []string, rootPlatform *pb.Platform, defaultPlatform *ocispecs.Platform, resolver SourceMetadataResolver, verifier PolicyVerifierProvider, logf func(logrus.Level, string)) (bool, *gwpb.ResolveSourceMetaRequest, error) { + if input == nil || len(unknowns) == 0 { + return false, nil, nil + } + return resolveNodeUnknowns(ctx, input, rootSource, defaultPlatform, normalizeNodeUnknowns(unknowns), rootPlatform, defaultPlatform, resolver, verifier, logf, nil) +} + +func resolveNodeUnknowns(ctx context.Context, node *Input, source *pb.SourceOp, nodePlatform *ocispecs.Platform, unknowns []string, rootPlatform *pb.Platform, defaultPlatform *ocispecs.Platform, resolver SourceMetadataResolver, verifier PolicyVerifierProvider, logf func(logrus.Level, string), setNode func(Input) error) (bool, *gwpb.ResolveSourceMetaRequest, error) { + directUnknowns, childUnknowns := splitNodeUnknowns(unknowns) + if len(directUnknowns) > 0 { + req, err := sourceResolveRequest(source, nodePlatform, setNode == nil, directUnknowns, rootPlatform, logf) + if err != nil { + return false, nil, err + } + if req != nil { + if setNode == nil { + return false, req, nil + } + if resolver == nil { + return false, nil, errors.Errorf("material metadata resolution requires source resolver") + } + resp, err := resolveSourceMetaWithResolver(ctx, resolver, req, defaultPlatform) + if err != nil { + return false, nil, errors.Wrap(err, "failed to resolve source metadata for material") + } + nextInput, err := SourceToInput(ctx, verifier, resp, nodePlatform, logf) + if err != nil { + return false, nil, errors.Wrap(err, "failed to rebuild material input") + } + if err := setNode(nextInput); err != nil { + return false, nil, err + } + return true, nil, nil + } + } + + for idx, childNodeUnknowns := range childUnknowns { + if node == nil || node.Image == nil || node.Image.Provenance == nil { + continue + } + if idx < 0 || idx >= len(node.Image.Provenance.Materials) || idx >= len(node.Image.Provenance.materialsRaw) { + continue + } + raw := node.Image.Provenance.materialsRaw[idx] + childSource, childNodePlatform, err := parseSLSAMaterial(raw) + if err != nil { + continue + } + childPlatform := firstNonNilPlatform(childNodePlatform, nodePlatform) + retry, next, err := resolveNodeUnknowns(ctx, &node.Image.Provenance.Materials[idx], childSource, childPlatform, childNodeUnknowns, rootPlatform, defaultPlatform, resolver, verifier, logf, func(next Input) error { + node.Image.Provenance.Materials[idx] = next + return nil + }) + if err != nil { + return false, nil, err + } + if retry || next != nil { + return retry, next, nil + } + } + return false, nil, nil +} + +func splitNodeUnknowns(unknowns []string) ([]string, map[int][]string) { + child := map[int][]string{} + var direct []string + for _, u := range unknowns { + if u == "" { + continue + } + if idx, rest, ok := isMaterialKey(u); ok { + if rest == "" { + continue + } + if !slices.Contains(child[idx], rest) { + child[idx] = append(child[idx], rest) + } + continue + } + if !slices.Contains(direct, u) { + direct = append(direct, u) + } + } + return direct, child +} + +func normalizeNodeUnknowns(unknowns []string) []string { + out := make([]string, 0, len(unknowns)) + for _, u := range unknowns { + v := strings.TrimPrefix(u, "input.") + if v == "" { + continue + } + if !slices.Contains(out, v) { + out = append(out, v) + } + } + return out +} + +func sourceResolveRequest(source *pb.SourceOp, nodePlatform *ocispecs.Platform, rootNode bool, fields []string, rootPlatform *pb.Platform, logf func(logrus.Level, string)) (*gwpb.ResolveSourceMetaRequest, error) { + if source == nil { + return nil, nil + } + req := &gwpb.ResolveSourceMetaRequest{Source: source} + if nodePlatform != nil { + req.Platform = &pb.Platform{OS: nodePlatform.OS, Architecture: nodePlatform.Architecture, Variant: nodePlatform.Variant} + } else if rootNode { + req.Platform = rootPlatform + } + if err := AddUnknownsWithLogger(logf, req, fields); err != nil { + return nil, err + } + if req.Image == nil && req.Git == nil && !hasHTTPUnknowns(fields) { + return nil, nil + } + return req, nil +} + +func resolveSourceMetaWithResolver(ctx context.Context, resolver SourceMetadataResolver, req *gwpb.ResolveSourceMetaRequest, defaultPlatform *ocispecs.Platform) (*gwpb.ResolveSourceMetaResponse, error) { + if resolver == nil { + return nil, errors.New("source resolver is not configured") + } + if req == nil || req.Source == nil { + return nil, errors.New("source metadata request is missing source") + } + resp, err := resolver.ResolveSourceMetadata(ctx, req.Source, sourcemeta.ToResolverOpt(req, defaultPlatform)) + if err != nil { + return nil, err + } + return sourcemeta.ToGatewayMetaResponse(resp), nil +} diff --git a/policy/resolve_test.go b/policy/resolve_test.go new file mode 100644 index 000000000000..59e94b133695 --- /dev/null +++ b/policy/resolve_test.go @@ -0,0 +1,68 @@ +package policy + +import ( + "context" + "testing" + + slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1" + "github.com/moby/buildkit/client/llb/sourceresolver" + "github.com/moby/buildkit/solver/pb" + "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" +) + +type stubSourceResolver struct { + resolveFn func(context.Context, *pb.SourceOp, sourceresolver.Opt) (*sourceresolver.MetaResponse, error) +} + +func (s stubSourceResolver) ResolveSourceMetadata(ctx context.Context, op *pb.SourceOp, opt sourceresolver.Opt) (*sourceresolver.MetaResponse, error) { + return s.resolveFn(ctx, op, opt) +} + +func TestResolveInputUnknownsResolvesMaterialField(t *testing.T) { + inp := Input{ + Image: &Image{ + Provenance: &ImageProvenance{ + Materials: []Input{ + {Image: &Image{Ref: "docker.io/library/alpine:3.20"}}, + }, + materialsRaw: []slsa1.ResourceDescriptor{ + {URI: "pkg:docker/library/alpine@3.20?platform=linux/amd64"}, + }, + }, + }, + } + inp.Image.Provenance.Materials[0].setUnknowns([]string{"input.image.hasProvenance"}) + + resolver := stubSourceResolver{ + resolveFn: func(_ context.Context, op *pb.SourceOp, _ sourceresolver.Opt) (*sourceresolver.MetaResponse, error) { + require.Equal(t, "docker-image://docker.io/library/alpine:3.20", op.Identifier) + return &sourceresolver.MetaResponse{ + Op: op, + Image: &sourceresolver.ResolveImageResponse{ + Digest: digest.Digest("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + AttestationChain: &sourceresolver.AttestationChain{ + AttestationManifest: digest.Digest("sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + }, + }, + }, nil + }, + } + + retry, next, err := ResolveInputUnknowns( + context.Background(), + &inp, + &pb.SourceOp{Identifier: "docker-image://docker.io/library/busybox:latest"}, + []string{"image.provenance.materials[0].image.hasProvenance"}, + &pb.Platform{OS: "linux", Architecture: "amd64"}, + &ocispecs.Platform{OS: "linux", Architecture: "amd64"}, + resolver, + nil, + nil, + ) + require.NoError(t, err) + require.True(t, retry) + require.Nil(t, next) + require.True(t, inp.Image.Provenance.Materials[0].Image.HasProvenance) +} diff --git a/policy/tester.go b/policy/tester.go index f6fd14e8613f..5cc193e62fe1 100644 --- a/policy/tester.go +++ b/policy/tester.go @@ -454,7 +454,7 @@ func resolveTestInput(ctx context.Context, files []File, resolver *TestOptionsPr return nil, false, err } if next == nil { - inp, _, err := SourceToInputWithLogger(ctx, resolver.VerifierProvider, srcReq, platform, nil) + inp, _, err := sourceToInput(ctx, resolver.VerifierProvider, srcReq, platform, nil) if err != nil { return nil, false, err } diff --git a/policy/types.go b/policy/types.go index aa18c6a76f67..fe1d8664d050 100644 --- a/policy/types.go +++ b/policy/types.go @@ -3,6 +3,7 @@ package policy import ( "time" + slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1" "github.com/moby/buildkit/util/gitutil/gitobject" policytypes "github.com/moby/policy-helpers/types" ) @@ -13,6 +14,8 @@ type Input struct { Image *Image `json:"image,omitempty"` HTTP *HTTP `json:"http,omitempty"` Git *Git `json:"git,omitempty"` + + unknowns []string `json:"-"` } type Decision struct { @@ -25,6 +28,7 @@ type Env struct { Labels map[string]string `json:"labels,omitempty"` Filename string `json:"filename,omitempty"` Target string `json:"target,omitempty"` + Depth int `json:"depth"` } type HTTP struct { @@ -149,6 +153,9 @@ type ImageProvenance struct { Hermetic *bool `json:"hermetic,omitempty"` Completeness *ImageProvenanceCompleteness `json:"completeness,omitempty"` + Materials []Input `json:"materials,omitempty"` + + materialsRaw []slsa1.ResourceDescriptor `json:"-"` } type ImageProvenanceConfigSource struct { diff --git a/policy/utils_test.go b/policy/utils_test.go index 47c0e1098f95..37a692e5aa3f 100644 --- a/policy/utils_test.go +++ b/policy/utils_test.go @@ -29,6 +29,8 @@ func TestTrimKey(t *testing.T) { {"git.tag[0]", "git.tag"}, {"input.git.tag.author", "git.tag"}, {"input.git.tag[0]", "git.tag"}, + {"input.image.provenance.materials[0].image.hasProvenance", "image.provenance.materials[0].image.hasProvenance"}, + {"image.provenance.materials[0].image.labels", "image.provenance.materials[0].image.labels"}, {"a.b.c", "a.b"}, } @@ -46,16 +48,17 @@ func TestCollectUnknowns(t *testing.T) { p if { input.git.tag[0].author == "a" input.image.signatures[_].signer.certificateIssuer != "" + input.image.provenance.materials[0].image.hasProvenance data.foo.bar == 1 } `) require.NoError(t, err) all := collectUnknowns([]*ast.Module{mod}, nil) - require.ElementsMatch(t, []string{"git.tag", "image.signatures"}, all) + require.ElementsMatch(t, []string{"git.tag", "image.signatures", "image.provenance.materials[0].image.hasProvenance"}, all) - filtered := collectUnknowns([]*ast.Module{mod}, []string{"input.image.signatures"}) - require.Equal(t, []string{"image.signatures"}, filtered) + filtered := collectUnknowns([]*ast.Module{mod}, []string{"input.image.signatures", "input.image.provenance.materials[0].image.hasProvenance"}) + require.ElementsMatch(t, []string{"image.signatures", "image.provenance.materials[0].image.hasProvenance"}, filtered) } func TestRuntimeUnknownInputRefs(t *testing.T) { diff --git a/policy/validate.go b/policy/validate.go index 1bee904a46c0..643502458a17 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -39,6 +39,8 @@ type Policy struct { denyIdentifiers map[string]struct{} } +const maxResolveIterations = 10 + type state struct { Input Input Unknowns map[string]struct{} @@ -131,7 +133,7 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy if req.Source == nil || req.Source.Source == nil { return nil, nil, errors.Errorf("no source info in request") } - src := req.Source + var platform *ocispecs.Platform if req.Platform != nil { pl, err := platformFromReq(req) @@ -143,17 +145,15 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy platform = p.opt.DefaultPlatform } - inp, unknowns, err := SourceToInputWithLogger(ctx, p.opt.VerifierProvider, src, platform, p.opt.Log) + inp, err := SourceToInput(ctx, p.opt.VerifierProvider, req.Source, platform, p.opt.Log) if err != nil { - return nil, nil, errors.Wrapf(err, "failed to convert source to policy input") + return nil, nil, errors.Wrap(err, "failed to build policy input") } - inp.Env = p.opt.Env caps := &ast.Capabilities{ Builtins: builtins(), Features: slices.Clone(ast.Features), } - comp := ast.NewCompiler().WithCapabilities(caps).WithKeepModules(true) if p.opt.Log != nil { comp = comp.WithEnablePrintStatements(true) @@ -170,7 +170,6 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy var root fs.StatFS var closeFS func() error - defer func() { if closeFS != nil { closeFS() @@ -210,7 +209,6 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy if err != nil { return nil, errors.Wrapf(err, "failed to parse imported policy file %s for module %s", fn, k) } - // rewrite package to be less strict pkgParts := strings.Split(pkgPath, ".") ref := ast.Ref{mod.Package.Path[0]} for _, p := range pkgParts { @@ -224,155 +222,171 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy return out, nil }) - opts := []func(*rego.Rego){ + baseOpts := []func(*rego.Rego){ rego.SetRegoVersion(ast.RegoV1), rego.Query("data.docker.decision"), - rego.Input(inp), rego.SkipPartialNamespace(true), rego.Compiler(comp), + rego.Module(builtinPolicyModuleFilename, builtinPolicyModule), } if p.opt.Log != nil { - opts = append(opts, + baseOpts = append(baseOpts, rego.EnablePrintStatements(true), rego.PrintHook(p), ) } - st := &state{ - Input: inp, - } - for _, f := range p.funcs { - opts = append(opts, f.impl(st)) - } - - opts = append(opts, rego.Module(builtinPolicyModuleFilename, builtinPolicyModule)) for _, file := range p.opt.Files { - opts = append(opts, rego.Module(file.Filename, string(file.Data))) - } - dt, err := json.MarshalIndent(inp, "", " ") - if err != nil { - return nil, nil, errors.Wrapf(err, "failed to marshal policy input") + baseOpts = append(baseOpts, rego.Module(file.Filename, string(file.Data))) } + p.log(logrus.InfoLevel, "checking policy for source %s", sourceName(req)) - p.log(logrus.DebugLevel, "policy input: %s", dt) - if len(unknowns) > 0 { - p.log(logrus.DebugLevel, "unknowns for policy evaluation: %+v", unknowns) - opts = append(opts, rego.Unknowns(unknowns)) - } - r := rego.New(opts...) + for range maxResolveIterations { + runInput := inp + applyEnvWithDepth(&runInput, p.opt.Env, 0) + + runOpts := append([]func(*rego.Rego){}, baseOpts...) + runOpts = append(runOpts, rego.Input(runInput)) + + st := &state{Input: runInput} + for _, f := range p.funcs { + runOpts = append(runOpts, f.impl(st)) + } - if len(unknowns) > 0 { - pq, err := r.Partial(ctx) + dt, err := json.MarshalIndent(runInput, "", " ") if err != nil { - return nil, nil, err + return nil, nil, errors.Wrapf(err, "failed to marshal policy input") + } + p.log(logrus.DebugLevel, "policy input: %s", dt) + + unknowns := inp.Unknowns() + if len(unknowns) > 0 { + p.log(logrus.DebugLevel, "unknowns for policy evaluation: %+v", summarizeUnknownsForLog(unknowns)) + runOpts = append(runOpts, rego.Unknowns(unknowns)) } - unk := collectUnknowns(pq.Support, unknowns) - unk = append(unk, runtimeUnknownInputRefs(st)...) + r := rego.New(runOpts...) - if len(unk) > 0 { - next := &gwpb.ResolveSourceMetaRequest{ - Source: req.Source.Source, - Platform: req.Platform, + if len(unknowns) > 0 { + pq, err := r.Partial(ctx) + if err != nil { + return nil, nil, err } - if err := AddUnknownsWithLogger(p.opt.Log, next, unk); err != nil { + unk := collectUnknowns(pq.Support, unknowns) + unk = append(unk, runtimeUnknownInputRefs(st)...) + + retry, next, err := p.resolveUnknowns(ctx, &inp, req, platform, unk) + if err != nil { return nil, nil, err } - if next.Image != nil || next.Git != nil || hasHTTPUnknowns(unk) { - p.log(logrus.InfoLevel, "policy decision for source %s: resolve missing fields %+v", sourceName(req), summarizeUnknownsForLog(unk)) + if next != nil { return nil, next, nil } + if retry { + continue + } } - } - - st.ImagePins = nil - - rs, err := r.Eval(ctx) - if err != nil { - return nil, nil, err - } - rtUnk := runtimeUnknownInputRefs(st) - if len(rtUnk) > 0 { - next := &gwpb.ResolveSourceMetaRequest{ - Source: req.Source.Source, - Platform: req.Platform, + st.ImagePins = nil + rs, err := r.Eval(ctx) + if err != nil { + return nil, nil, err } - if err := AddUnknownsWithLogger(p.opt.Log, next, rtUnk); err != nil { + + retry, next, err := p.resolveUnknowns(ctx, &inp, req, platform, runtimeUnknownInputRefs(st)) + if 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)) + if next != nil { return nil, next, nil } - } + if retry { + continue + } - if len(rs) == 0 { - return nil, nil, errors.Errorf("policy returned zero result") - } - rsz := rs[0] - if len(rsz.Expressions) == 0 { - return nil, nil, errors.Errorf("policy returned zero expressions") - } - v := rsz.Expressions[0].Value - vt, ok := v.(map[string]any) - if !ok { - return nil, nil, errors.Errorf("unexpected policy return type: %T %s", vt, rsz.Expressions[0].Text) - } + if len(rs) == 0 { + return nil, nil, errors.Errorf("policy returned zero result") + } + rsz := rs[0] + if len(rsz.Expressions) == 0 { + return nil, nil, errors.Errorf("policy returned zero expressions") + } + v := rsz.Expressions[0].Value + vt, ok := v.(map[string]any) + if !ok { + return nil, nil, errors.Errorf("unexpected policy return type: %T %s", vt, rsz.Expressions[0].Text) + } - resp := &policysession.DecisionResponse{ - Action: moby_buildkit_v1_sourcepolicy.PolicyAction_DENY, - } - p.log(logrus.DebugLevel, "policy response: %+v", vt) + resp := &policysession.DecisionResponse{ + Action: moby_buildkit_v1_sourcepolicy.PolicyAction_DENY, + } + p.log(logrus.DebugLevel, "policy response: %+v", vt) - if v, ok := vt["allow"]; ok { - if vv, ok := v.(bool); !ok { - return nil, nil, errors.Errorf("invalid allowed property type %T, expecting bool", v) - } else if vv { - resp.Action = moby_buildkit_v1_sourcepolicy.PolicyAction_ALLOW + if v, ok := vt["allow"]; ok { + if vv, ok := v.(bool); !ok { + return nil, nil, errors.Errorf("invalid allowed property type %T, expecting bool", v) + } else if vv { + resp.Action = moby_buildkit_v1_sourcepolicy.PolicyAction_ALLOW + } } - } - if v, ok := vt["deny_msg"]; ok { - if vv, ok := v.([]any); ok { - for _, m := range vv { - if m, ok := m.(string); ok { - resp.DenyMessages = append(resp.DenyMessages, &policysession.DenyMessage{ - Message: m, - }) + if v, ok := vt["deny_msg"]; ok { + if vv, ok := v.([]any); ok { + for _, m := range vv { + if m, ok := m.(string); ok { + resp.DenyMessages = append(resp.DenyMessages, &policysession.DenyMessage{ + Message: m, + }) + } } } } - } - if resp.Action == moby_buildkit_v1_sourcepolicy.PolicyAction_ALLOW { - if len(st.ImagePins) > 1 { - return nil, nil, errors.Errorf("multiple image pins set to %s: %v", sourceName(req), st.ImagePins) - } - if len(st.ImagePins) == 1 { - newSrc, err := addPinToImage(src.Source, slices.Collect(maps.Keys(st.ImagePins))[0]) - if err != nil { - return nil, nil, errors.Wrapf(err, "failed to add image pin to source") + if resp.Action == moby_buildkit_v1_sourcepolicy.PolicyAction_ALLOW { + if len(st.ImagePins) > 1 { + return nil, nil, errors.Errorf("multiple image pins set to %s: %v", sourceName(req), st.ImagePins) } - p.log(logrus.InfoLevel, "policy decision for source %s: convert to %s", sourceName(req), newSrc.Identifier) + if len(st.ImagePins) == 1 { + newSrc, err := addPinToImage(req.Source.Source, slices.Collect(maps.Keys(st.ImagePins))[0]) + if err != nil { + return nil, nil, errors.Wrapf(err, "failed to add image pin to source") + } + p.log(logrus.InfoLevel, "policy decision for source %s: convert to %s", sourceName(req), newSrc.Identifier) - return &policysession.DecisionResponse{ - Action: moby_buildkit_v1_sourcepolicy.PolicyAction_CONVERT, - Update: newSrc, - }, nil, nil + return &policysession.DecisionResponse{ + Action: moby_buildkit_v1_sourcepolicy.PolicyAction_CONVERT, + Update: newSrc, + }, nil, nil + } } - } - p.log(logrus.InfoLevel, "policy decision for source %s: %s", sourceName(req), resp.Action) - for _, dm := range resp.DenyMessages { - p.log(logrus.InfoLevel, " - %s", dm.Message) - } - if resp.Action == moby_buildkit_v1_sourcepolicy.PolicyAction_DENY { - p.recordDenyIdentifier(req) + p.log(logrus.InfoLevel, "policy decision for source %s: %s", sourceName(req), resp.Action) + for _, dm := range resp.DenyMessages { + p.log(logrus.InfoLevel, " - %s", dm.Message) + } + if resp.Action == moby_buildkit_v1_sourcepolicy.PolicyAction_DENY { + p.recordDenyIdentifier(req) + } + return resp, nil, nil } - return resp, nil, nil + return nil, nil, errors.Errorf("maximum attempts reached for resolving policy metadata") } +func (p *Policy) resolveUnknowns(ctx context.Context, input *Input, req *policysession.CheckPolicyRequest, defaultPlatform *ocispecs.Platform, unk []string) (bool, *gwpb.ResolveSourceMetaRequest, error) { + var resolver SourceMetadataResolver + if p.opt.SourceResolver != nil { + resolver = p.opt.SourceResolver + } + retry, next, err := ResolveInputUnknowns(ctx, input, req.Source.Source, unk, req.Platform, defaultPlatform, resolver, p.opt.VerifierProvider, p.opt.Log) + if err != nil { + return false, nil, err + } + if next != nil { + p.log(logrus.InfoLevel, "policy decision for source %s: resolve missing fields %+v", sourceName(req), summarizeUnknownsForLog(unk)) + return false, next, nil + } + return retry, nil, nil +} func platformFromReq(req *policysession.CheckPolicyRequest) (*ocispecs.Platform, error) { if req.Platform != nil { platformStr := req.Platform.OS + "/" + req.Platform.Architecture @@ -404,11 +418,7 @@ func (p *Policy) Print(ctx print.Context, msg string) error { return nil } -func SourceToInput(ctx context.Context, getVerifier PolicyVerifierProvider, src *gwpb.ResolveSourceMetaResponse, platform *ocispecs.Platform) (Input, []string, error) { - return SourceToInputWithLogger(ctx, getVerifier, src, platform, nil) -} - -func SourceToInputWithLogger(ctx context.Context, getVerifier PolicyVerifierProvider, src *gwpb.ResolveSourceMetaResponse, platform *ocispecs.Platform, logf func(logrus.Level, string)) (Input, []string, error) { +func sourceToInput(ctx context.Context, getVerifier PolicyVerifierProvider, src *gwpb.ResolveSourceMetaResponse, platform *ocispecs.Platform, logf func(logrus.Level, string)) (Input, []string, error) { var inp Input var unknowns []string @@ -625,7 +635,9 @@ func SourceToInputWithLogger(ctx context.Context, getVerifier PolicyVerifierProv if err := json.Unmarshal(cfg, &img); err != nil { return inp, nil, errors.Wrapf(err, "failed to unmarshal image config") } - inp.Image.CreatedTime = img.Created.Format(time.RFC3339) + if img.Created != nil { + inp.Image.CreatedTime = img.Created.Format(time.RFC3339) + } inp.Image.Labels = img.Config.Labels inp.Image.Env = img.Config.Env inp.Image.User = img.Config.User @@ -639,7 +651,7 @@ func SourceToInputWithLogger(ctx context.Context, getVerifier PolicyVerifierProv } if ac := src.Image.AttestationChain; ac != nil { - if prv, err := parseProvenance(ac); err != nil { + if prv, err := parseProvenance(ac, logf); err != nil { if logf != nil { logf(logrus.DebugLevel, fmt.Sprintf("failed to parse image provenance: %v", err)) } @@ -681,6 +693,20 @@ func withPrefix(arr []string, prefix string) []string { return out } +func applyEnvWithDepth(inp *Input, env Env, depth int) { + if inp == nil { + return + } + inp.Env = env + inp.Env.Depth = depth + if inp.Image == nil || inp.Image.Provenance == nil || len(inp.Image.Provenance.Materials) == 0 { + return + } + for i := range inp.Image.Provenance.Materials { + applyEnvWithDepth(&inp.Image.Provenance.Materials[i], env, depth+1) + } +} + func AddUnknowns(req *gwpb.ResolveSourceMetaRequest, unk []string) error { return AddUnknownsWithLogger(nil, req, unk) } @@ -811,6 +837,7 @@ func summarizeUnknownsForLog(unk []string) []string { out := make([]string, 0, len(unk)) seen := map[string]struct{}{} for _, u := range unk { + u = strings.TrimPrefix(u, "input.") if strings.HasPrefix(u, "image.signatures") { u = "image.signatures" } @@ -849,6 +876,9 @@ func hasHTTPUnknowns(unk []string) bool { func trimKey(s string) string { s = strings.TrimPrefix(s, "input.") + if strings.HasPrefix(s, "image.provenance.materials[") { + return s + } const ( dot = '.' diff --git a/policy/validate_test.go b/policy/validate_test.go index 49ac6f212c57..efb95918b711 100644 --- a/policy/validate_test.go +++ b/policy/validate_test.go @@ -23,7 +23,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestSourceToInputWithLogger(t *testing.T) { +func TestSourceToInputSingleSource(t *testing.T) { tm := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) tests := []struct { @@ -866,7 +866,7 @@ func TestSourceToInputWithLogger(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - inp, unknowns, err := SourceToInputWithLogger(t.Context(), tc.verifier, tc.src, tc.platform, nil) + inp, unknowns, err := sourceToInput(t.Context(), tc.verifier, tc.src, tc.platform, nil) if tc.assert != nil { tc.assert(t, inp, unknowns, err) return diff --git a/util/sourcemeta/convert.go b/util/sourcemeta/convert.go new file mode 100644 index 000000000000..1bc16d8a8c82 --- /dev/null +++ b/util/sourcemeta/convert.go @@ -0,0 +1,102 @@ +package sourcemeta + +import ( + "github.com/moby/buildkit/client/llb/sourceresolver" + gwpb "github.com/moby/buildkit/frontend/gateway/pb" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func ToResolverOpt(req *gwpb.ResolveSourceMetaRequest, defaultPlatform *ocispecs.Platform) sourceresolver.Opt { + platform := defaultPlatform + if req != nil && req.Platform != nil { + platform = &ocispecs.Platform{ + Architecture: req.Platform.Architecture, + OS: req.Platform.OS, + Variant: req.Platform.Variant, + } + } + opt := sourceresolver.Opt{} + if req != nil { + opt.LogName = req.LogName + opt.SourcePolicies = req.SourcePolicies + } + if req != nil && req.Image != nil { + opt.ImageOpt = &sourceresolver.ResolveImageOpt{ + NoConfig: req.Image.NoConfig, + AttestationChain: req.Image.AttestationChain, + ResolveAttestations: append([]string(nil), req.Image.ResolveAttestations...), + ResolveMode: req.ResolveMode, + Platform: platform, + } + } + if req != nil && req.Git != nil { + opt.GitOpt = &sourceresolver.ResolveGitOpt{ReturnObject: req.Git.ReturnObject} + } + return opt +} + +func ToGatewayMetaResponse(resp *sourceresolver.MetaResponse) *gwpb.ResolveSourceMetaResponse { + out := &gwpb.ResolveSourceMetaResponse{Source: resp.Op} + if resp.Image != nil { + out.Image = &gwpb.ResolveSourceImageResponse{ + Digest: resp.Image.Digest.String(), + Config: resp.Image.Config, + AttestationChain: toGatewayAttestationChain(resp.Image.AttestationChain), + } + } + if resp.Git != nil { + out.Git = &gwpb.ResolveSourceGitResponse{ + Checksum: resp.Git.Checksum, + Ref: resp.Git.Ref, + CommitChecksum: resp.Git.CommitChecksum, + CommitObject: resp.Git.CommitObject, + TagObject: resp.Git.TagObject, + } + } + if resp.HTTP != nil { + var lastModified *timestamppb.Timestamp + if resp.HTTP.LastModified != nil { + lastModified = timestamppb.New(*resp.HTTP.LastModified) + } + out.HTTP = &gwpb.ResolveSourceHTTPResponse{ + Checksum: resp.HTTP.Digest.String(), + Filename: resp.HTTP.Filename, + LastModified: lastModified, + } + } + return out +} + +func toGatewayDescriptor(desc ocispecs.Descriptor) *gwpb.Descriptor { + return &gwpb.Descriptor{ + MediaType: desc.MediaType, + Digest: desc.Digest.String(), + Size: desc.Size, + Annotations: desc.Annotations, + } +} + +func toGatewayAttestationChain(chain *sourceresolver.AttestationChain) *gwpb.AttestationChain { + if chain == nil { + return nil + } + signatures := make([]string, 0, len(chain.SignatureManifests)) + for _, dgst := range chain.SignatureManifests { + signatures = append(signatures, dgst.String()) + } + blobs := make(map[string]*gwpb.Blob, len(chain.Blobs)) + for dgst, blob := range chain.Blobs { + blobs[dgst.String()] = &gwpb.Blob{ + Descriptor_: toGatewayDescriptor(blob.Descriptor), + Data: blob.Data, + } + } + return &gwpb.AttestationChain{ + Root: chain.Root.String(), + ImageManifest: chain.ImageManifest.String(), + AttestationManifest: chain.AttestationManifest.String(), + SignatureManifests: signatures, + Blobs: blobs, + } +} diff --git a/vendor/github.com/moby/buildkit/util/purl/image.go b/vendor/github.com/moby/buildkit/util/purl/image.go new file mode 100644 index 000000000000..b41acfaa5c6f --- /dev/null +++ b/vendor/github.com/moby/buildkit/util/purl/image.go @@ -0,0 +1,132 @@ +package purl + +import ( + "strings" + + "github.com/containerd/platforms" + "github.com/distribution/reference" + digest "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + packageurl "github.com/package-url/packageurl-go" + "github.com/pkg/errors" +) + +// RefToPURL converts an image reference with optional platform constraint to a package URL. +// Image references are defined in https://github.com/distribution/distribution/blob/v2.8.1/reference/reference.go#L1 +// Package URLs are defined in https://github.com/package-url/purl-spec +func RefToPURL(purlType string, ref string, platform *ocispecs.Platform) (string, error) { + named, err := reference.ParseNormalizedNamed(ref) + if err != nil { + return "", errors.Wrapf(err, "failed to parse ref %q", ref) + } + var qualifiers []packageurl.Qualifier + + if canonical, ok := named.(reference.Canonical); ok { + qualifiers = append(qualifiers, packageurl.Qualifier{ + Key: "digest", + Value: canonical.Digest().String(), + }) + } else { + named = reference.TagNameOnly(named) + } + + version := "" + if tagged, ok := named.(reference.Tagged); ok { + version = tagged.Tag() + } + + name := reference.FamiliarName(named) + + ns := "" + parts := strings.Split(name, "/") + if len(parts) > 1 { + ns = strings.Join(parts[:len(parts)-1], "/") + } + name = parts[len(parts)-1] + + if platform != nil { + p := platforms.Normalize(*platform) + qualifiers = append(qualifiers, packageurl.Qualifier{ + Key: "platform", + Value: platforms.Format(p), + }) + } + + p := packageurl.NewPackageURL(purlType, ns, name, version, qualifiers, "") + return p.ToString(), nil +} + +// PURLToRef converts a package URL to an image reference and platform. +func PURLToRef(purl string) (string, *ocispecs.Platform, error) { + p, err := packageurl.FromString(purl) + if err != nil { + return "", nil, err + } + if p.Type != "docker" { + return "", nil, errors.Errorf("invalid package type %q, expecting docker", p.Type) + } + ref := p.Name + if p.Namespace != "" { + ref = p.Namespace + "/" + ref + } + dgstVersion := "" + if p.Version != "" { + dgst, err := digest.Parse(p.Version) + if err == nil { + ref = ref + "@" + dgst.String() + dgstVersion = dgst.String() + } else { + ref += ":" + p.Version + } + } + var platform *ocispecs.Platform + for _, q := range p.Qualifiers { + if q.Key == "platform" { + p, err := platforms.Parse(q.Value) + if err != nil { + return "", nil, err + } + + // OS-version and OS-features are not included when serializing a + // platform as a string, however, containerd platforms.Parse appends + // missing information (including os-version) based on the host's + // platform. + // + // Given that this information is not obtained from the package-URL, + // we're resetting this information. Ideally, we'd do the same for + // "OS" and "architecture" (when not included in the URL). + // + // See: + // - https://github.com/containerd/containerd/commit/cfb30a31a8507e4417d42d38c9a99b04fc8af8a9 (https://github.com/containerd/containerd/pull/8778) + // - https://github.com/moby/buildkit/pull/4315#discussion_r1355141241 + p.OSVersion = "" + p.OSFeatures = nil + platform = &p + } + if q.Key == "digest" { + if dgstVersion != "" { + if dgstVersion != q.Value { + return "", nil, errors.Errorf("digest %q does not match version %q", q.Value, dgstVersion) + } + continue + } + dgst, err := digest.Parse(q.Value) + if err != nil { + return "", nil, err + } + ref = ref + "@" + dgst.String() + dgstVersion = dgst.String() + } + } + + if dgstVersion == "" && p.Version == "" { + ref += ":latest" + } + + named, err := reference.ParseNormalizedNamed(ref) + if err != nil { + return "", nil, errors.Wrapf(err, "invalid image url %q", purl) + } + + return named.String(), platform, nil +} diff --git a/vendor/github.com/package-url/packageurl-go/.gitignore b/vendor/github.com/package-url/packageurl-go/.gitignore new file mode 100644 index 000000000000..a1338d68517e --- /dev/null +++ b/vendor/github.com/package-url/packageurl-go/.gitignore @@ -0,0 +1,14 @@ +# Binaries for programs and plugins +*.exe +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 +.glide/ diff --git a/vendor/github.com/package-url/packageurl-go/.golangci.yaml b/vendor/github.com/package-url/packageurl-go/.golangci.yaml new file mode 100644 index 000000000000..73a5741c9270 --- /dev/null +++ b/vendor/github.com/package-url/packageurl-go/.golangci.yaml @@ -0,0 +1,17 @@ +# individual linter configs go here +linters-settings: + +# default linters are enabled `golangci-lint help linters` +linters: + disable-all: true + enable: + - deadcode + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - structcheck + - typecheck + - unused + - varcheck \ No newline at end of file diff --git a/vendor/github.com/package-url/packageurl-go/LICENSE b/vendor/github.com/package-url/packageurl-go/LICENSE new file mode 100644 index 000000000000..0b5633b5de5b --- /dev/null +++ b/vendor/github.com/package-url/packageurl-go/LICENSE @@ -0,0 +1,18 @@ +Copyright (c) the purl authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/github.com/package-url/packageurl-go/Makefile b/vendor/github.com/package-url/packageurl-go/Makefile new file mode 100644 index 000000000000..f799baaeb050 --- /dev/null +++ b/vendor/github.com/package-url/packageurl-go/Makefile @@ -0,0 +1,8 @@ +.PHONY: test clean lint + +test: + go test -v -cover ./... + +lint: + go get -u golang.org/x/lint/golint + golint -set_exit_status diff --git a/vendor/github.com/package-url/packageurl-go/README.md b/vendor/github.com/package-url/packageurl-go/README.md new file mode 100644 index 000000000000..b7fd200e79db --- /dev/null +++ b/vendor/github.com/package-url/packageurl-go/README.md @@ -0,0 +1,74 @@ +# packageurl-go + +[![build](https://github.com/package-url/packageurl-go/workflows/test/badge.svg)](https://github.com/package-url/packageurl-go/actions?query=workflow%3Atest) [![Coverage Status](https://coveralls.io/repos/github/package-url/packageurl-go/badge.svg)](https://coveralls.io/github/package-url/packageurl-go) [![PkgGoDev](https://pkg.go.dev/badge/github.com/package-url/packageurl-go)](https://pkg.go.dev/github.com/package-url/packageurl-go) [![Go Report Card](https://goreportcard.com/badge/github.com/package-url/packageurl-go)](https://goreportcard.com/report/github.com/package-url/packageurl-go) + +Go implementation of the package url spec. + + +## Install +``` +go get -u github.com/package-url/packageurl-go +``` + +## Versioning + +The versions will follow the spec. So if the spec is released at ``1.0``. Then all versions in the ``1.x.y`` will follow the ``1.x`` spec. + + +## Usage + +### Create from parts +```go +package main + +import ( + "fmt" + + "github.com/package-url/packageurl-go" +) + +func main() { + instance := packageurl.NewPackageURL("test", "ok", "name", "version", nil, "") + fmt.Printf("%s", instance.ToString()) +} +``` + +### Parse from string +```go +package main + +import ( + "fmt" + + "github.com/package-url/packageurl-go" +) + +func main() { + instance, err := packageurl.FromString("test:ok/name@version") + if err != nil { + panic(err) + } + fmt.Printf("%#v", instance) +} + +``` + + +## Test +Testing using the normal ``go test`` command. Using ``make test`` will pull the test fixtures shared between all package-url projects and then execute the tests. + +``` +$ make test +go test -v -cover ./... +=== RUN TestFromStringExamples +--- PASS: TestFromStringExamples (0.00s) +=== RUN TestToStringExamples +--- PASS: TestToStringExamples (0.00s) +=== RUN TestStringer +--- PASS: TestStringer (0.00s) +=== RUN TestQualifiersMapConversion +--- PASS: TestQualifiersMapConversion (0.00s) +PASS +coverage: 90.7% of statements +ok github.com/package-url/packageurl-go 0.004s coverage: 90.7% of statements +``` diff --git a/vendor/github.com/package-url/packageurl-go/VERSION b/vendor/github.com/package-url/packageurl-go/VERSION new file mode 100644 index 000000000000..77d6f4ca2371 --- /dev/null +++ b/vendor/github.com/package-url/packageurl-go/VERSION @@ -0,0 +1 @@ +0.0.0 diff --git a/vendor/github.com/package-url/packageurl-go/packageurl.go b/vendor/github.com/package-url/packageurl-go/packageurl.go new file mode 100644 index 000000000000..771ddc3727a6 --- /dev/null +++ b/vendor/github.com/package-url/packageurl-go/packageurl.go @@ -0,0 +1,438 @@ +/* +Copyright (c) the purl authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +// Package packageurl implements the package-url spec +package packageurl + +import ( + "errors" + "fmt" + "net/url" + "regexp" + "sort" + "strings" +) + +var ( + // QualifierKeyPattern describes a valid qualifier key: + // + // - The key must be composed only of ASCII letters and numbers, '.', + // '-' and '_' (period, dash and underscore). + // - A key cannot start with a number. + QualifierKeyPattern = regexp.MustCompile(`^[A-Za-z\.\-_][0-9A-Za-z\.\-_]*$`) +) + +// These are the known purl types as defined in the spec. Some of these require +// special treatment during parsing. +// https://github.com/package-url/purl-spec#known-purl-types +var ( + // TypeBitbucket is a pkg:bitbucket purl. + TypeBitbucket = "bitbucket" + // TypeCocoapods is a pkg:cocoapods purl. + TypeCocoapods = "cocoapods" + // TypeCargo is a pkg:cargo purl. + TypeCargo = "cargo" + // TypeComposer is a pkg:composer purl. + TypeComposer = "composer" + // TypeConan is a pkg:conan purl. + TypeConan = "conan" + // TypeConda is a pkg:conda purl. + TypeConda = "conda" + // TypeCran is a pkg:cran purl. + TypeCran = "cran" + // TypeDebian is a pkg:deb purl. + TypeDebian = "deb" + // TypeDocker is a pkg:docker purl. + TypeDocker = "docker" + // TypeGem is a pkg:gem purl. + TypeGem = "gem" + // TypeGeneric is a pkg:generic purl. + TypeGeneric = "generic" + // TypeGithub is a pkg:github purl. + TypeGithub = "github" + // TypeGolang is a pkg:golang purl. + TypeGolang = "golang" + // TypeHackage is a pkg:hackage purl. + TypeHackage = "hackage" + // TypeHex is a pkg:hex purl. + TypeHex = "hex" + // TypeMaven is a pkg:maven purl. + TypeMaven = "maven" + // TypeNPM is a pkg:npm purl. + TypeNPM = "npm" + // TypeNuget is a pkg:nuget purl. + TypeNuget = "nuget" + // TypeOCI is a pkg:oci purl + TypeOCI = "oci" + // TypePyPi is a pkg:pypi purl. + TypePyPi = "pypi" + // TypeRPM is a pkg:rpm purl. + TypeRPM = "rpm" + // TypeSwift is pkg:swift purl + TypeSwift = "swift" + // TypeHuggingface is pkg:huggingface purl. + TypeHuggingface = "huggingface" + // TypeMLflow is pkg:mlflow purl. + TypeMLFlow = "mlflow" +) + +// Qualifier represents a single key=value qualifier in the package url +type Qualifier struct { + Key string + Value string +} + +func (q Qualifier) String() string { + // A value must be a percent-encoded string + return fmt.Sprintf("%s=%s", q.Key, url.PathEscape(q.Value)) +} + +// Qualifiers is a slice of key=value pairs, with order preserved as it appears +// in the package URL. +type Qualifiers []Qualifier + +// QualifiersFromMap constructs a Qualifiers slice from a string map. To get a +// deterministic qualifier order (despite maps not providing any iteration order +// guarantees) the returned Qualifiers are sorted in increasing order of key. +func QualifiersFromMap(mm map[string]string) Qualifiers { + q := Qualifiers{} + + for k, v := range mm { + q = append(q, Qualifier{Key: k, Value: v}) + } + + // sort for deterministic qualifier order + sort.Slice(q, func(i int, j int) bool { return q[i].Key < q[j].Key }) + + return q +} + +// Map converts a Qualifiers struct to a string map. +func (qq Qualifiers) Map() map[string]string { + m := make(map[string]string) + + for i := 0; i < len(qq); i++ { + k := qq[i].Key + v := qq[i].Value + m[k] = v + } + + return m +} + +func (qq Qualifiers) String() string { + var kvPairs []string + for _, q := range qq { + kvPairs = append(kvPairs, q.String()) + } + return strings.Join(kvPairs, "&") +} + +// PackageURL is the struct representation of the parts that make a package url +type PackageURL struct { + Type string + Namespace string + Name string + Version string + Qualifiers Qualifiers + Subpath string +} + +// NewPackageURL creates a new PackageURL struct instance based on input +func NewPackageURL(purlType, namespace, name, version string, + qualifiers Qualifiers, subpath string) *PackageURL { + + return &PackageURL{ + Type: purlType, + Namespace: namespace, + Name: name, + Version: version, + Qualifiers: qualifiers, + Subpath: subpath, + } +} + +// ToString returns the human-readable instance of the PackageURL structure. +// This is the literal purl as defined by the spec. +func (p *PackageURL) ToString() string { + // Start with the type and a colon + purl := fmt.Sprintf("pkg:%s/", p.Type) + // Add namespaces if provided + if p.Namespace != "" { + var ns []string + for _, item := range strings.Split(p.Namespace, "/") { + ns = append(ns, url.QueryEscape(item)) + } + purl = purl + strings.Join(ns, "/") + "/" + } + // The name is always required and must be a percent-encoded string + // Use url.QueryEscape instead of PathEscape, as it handles @ signs + purl = purl + url.QueryEscape(p.Name) + // If a version is provided, add it after the at symbol + if p.Version != "" { + // A name must be a percent-encoded string + purl = purl + "@" + url.PathEscape(p.Version) + } + + // Iterate over qualifiers and make groups of key=value + var qualifiers []string + for _, q := range p.Qualifiers { + qualifiers = append(qualifiers, q.String()) + } + // If there are one or more key=value pairs, append on the package url + if len(qualifiers) != 0 { + purl = purl + "?" + strings.Join(qualifiers, "&") + } + // Add a subpath if available + if p.Subpath != "" { + purl = purl + "#" + p.Subpath + } + return purl +} + +func (p PackageURL) String() string { + return p.ToString() +} + +// FromString parses a valid package url string into a PackageURL structure +func FromString(purl string) (PackageURL, error) { + initialIndex := strings.Index(purl, "#") + // Start with purl being stored in the remainder + remainder := purl + substring := "" + if initialIndex != -1 { + initialSplit := strings.SplitN(purl, "#", 2) + remainder = initialSplit[0] + rightSide := initialSplit[1] + rightSide = strings.TrimLeft(rightSide, "/") + rightSide = strings.TrimRight(rightSide, "/") + var rightSides []string + + for _, item := range strings.Split(rightSide, "/") { + item = strings.Replace(item, ".", "", -1) + item = strings.Replace(item, "..", "", -1) + if item != "" { + i, err := url.PathUnescape(item) + if err != nil { + return PackageURL{}, fmt.Errorf("failed to unescape path: %s", err) + } + rightSides = append(rightSides, i) + } + } + substring = strings.Join(rightSides, "/") + } + qualifiers := Qualifiers{} + index := strings.LastIndex(remainder, "?") + // If we don't have anything to split then return an empty result + if index != -1 { + qualifier := remainder[index+1:] + for _, item := range strings.Split(qualifier, "&") { + kv := strings.Split(item, "=") + key := strings.ToLower(kv[0]) + key, err := url.PathUnescape(key) + if err != nil { + return PackageURL{}, fmt.Errorf("failed to unescape qualifier key: %s", err) + } + if !validQualifierKey(key) { + return PackageURL{}, fmt.Errorf("invalid qualifier key: '%s'", key) + } + // TODO + // - If the `key` is `checksums`, split the `value` on ',' to create + // a list of `checksums` + if kv[1] == "" { + continue + } + value, err := url.PathUnescape(kv[1]) + if err != nil { + return PackageURL{}, fmt.Errorf("failed to unescape qualifier value: %s", err) + } + qualifiers = append(qualifiers, Qualifier{key, value}) + } + remainder = remainder[:index] + } + + nextSplit := strings.SplitN(remainder, ":", 2) + if len(nextSplit) != 2 || nextSplit[0] != "pkg" { + return PackageURL{}, errors.New("scheme is missing") + } + // leading slashes after pkg: are to be ignored (pkg://maven is + // equivalent to pkg:maven) + remainder = strings.TrimLeft(nextSplit[1], "/") + + nextSplit = strings.SplitN(remainder, "/", 2) + if len(nextSplit) != 2 { + return PackageURL{}, errors.New("type is missing") + } + // purl type is case-insensitive, canonical form is lower-case + purlType := strings.ToLower(nextSplit[0]) + remainder = nextSplit[1] + + index = strings.LastIndex(remainder, "/") + name := typeAdjustName(purlType, remainder[index+1:], qualifiers) + version := "" + + atIndex := strings.Index(name, "@") + if atIndex != -1 { + v, err := url.PathUnescape(name[atIndex+1:]) + if err != nil { + return PackageURL{}, fmt.Errorf("failed to unescape purl version: %s", err) + } + version = typeAdjustVersion(purlType, v) + + unecapeName, err := url.PathUnescape(name[:atIndex]) + if err != nil { + return PackageURL{}, fmt.Errorf("failed to unescape purl name: %s", err) + } + name = unecapeName + } + var namespaces []string + + if index != -1 { + remainder = remainder[:index] + + for _, item := range strings.Split(remainder, "/") { + if item != "" { + unescaped, err := url.PathUnescape(item) + if err != nil { + return PackageURL{}, fmt.Errorf("failed to unescape path: %s", err) + } + namespaces = append(namespaces, unescaped) + } + } + } + namespace := strings.Join(namespaces, "/") + namespace = typeAdjustNamespace(purlType, namespace) + + // Fail if name is empty at this point + if name == "" { + return PackageURL{}, errors.New("name is required") + } + + err := validCustomRules(purlType, name, namespace, version, qualifiers) + if err != nil { + return PackageURL{}, err + } + + return PackageURL{ + Type: purlType, + Namespace: namespace, + Name: name, + Version: version, + Qualifiers: qualifiers, + Subpath: substring, + }, nil +} + +// Make any purl type-specific adjustments to the parsed namespace. +// See https://github.com/package-url/purl-spec#known-purl-types +func typeAdjustNamespace(purlType, ns string) string { + switch purlType { + case TypeBitbucket, TypeDebian, TypeGithub, TypeGolang, TypeNPM, TypeRPM, TypeComposer: + return strings.ToLower(ns) + } + return ns +} + +// Make any purl type-specific adjustments to the parsed name. +// See https://github.com/package-url/purl-spec#known-purl-types +func typeAdjustName(purlType, name string, qualifiers Qualifiers) string { + quals := qualifiers.Map() + switch purlType { + case TypeBitbucket, TypeDebian, TypeGithub, TypeGolang, TypeNPM, TypeComposer: + return strings.ToLower(name) + case TypePyPi: + return strings.ToLower(strings.ReplaceAll(name, "_", "-")) + case TypeMLFlow: + return adjustMlflowName(name, quals) + } + return name +} + +// Make any purl type-specific adjustments to the parsed version. +// See https://github.com/package-url/purl-spec#known-purl-types +func typeAdjustVersion(purlType, version string) string { + switch purlType { + case TypeHuggingface: + return strings.ToLower(version) + } + return version +} + +// https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#mlflow +func adjustMlflowName(name string, qualifiers map[string]string) string { + if repo, ok := qualifiers["repository_url"]; ok { + if strings.Contains(repo, "azureml") { + // Azure ML is case-sensitive and must be kept as-is + return name + } else if strings.Contains(repo, "databricks") { + // Databricks is case-insensitive and must be lowercased + return strings.ToLower(name) + } else { + // Unknown repository type, keep as-is + return name + } + } else { + // No repository qualifier given, keep as-is + return name + } +} + +// validQualifierKey validates a qualifierKey against our QualifierKeyPattern. +func validQualifierKey(key string) bool { + return QualifierKeyPattern.MatchString(key) +} + +// validCustomRules evaluates additional rules for each package url type, as specified in the package-url specification. +// On success, it returns nil. On failure, a descriptive error will be returned. +func validCustomRules(purlType, name, ns, version string, qualifiers Qualifiers) error { + q := qualifiers.Map() + switch purlType { + case TypeConan: + if ns != "" { + if val, ok := q["channel"]; ok { + if val == "" { + return errors.New("the qualifier channel must be not empty if namespace is present") + } + } else { + return errors.New("channel qualifier does not exist") + } + } else { + if val, ok := q["channel"]; ok { + if val != "" { + return errors.New("namespace is required if channel is non empty") + } + } + } + case TypeSwift: + if ns == "" { + return errors.New("namespace is required") + } + if version == "" { + return errors.New("version is required") + } + case TypeCran: + if version == "" { + return errors.New("version is required") + } + } + return nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 13eef94bf22f..92a6fa6107b8 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -730,6 +730,7 @@ github.com/moby/buildkit/util/pgpsign github.com/moby/buildkit/util/progress github.com/moby/buildkit/util/progress/progressui github.com/moby/buildkit/util/progress/progresswriter +github.com/moby/buildkit/util/purl github.com/moby/buildkit/util/resolver/config github.com/moby/buildkit/util/resolver/limited github.com/moby/buildkit/util/resolver/retryhandler @@ -930,6 +931,9 @@ github.com/opencontainers/go-digest ## explicit; go 1.18 github.com/opencontainers/image-spec/specs-go github.com/opencontainers/image-spec/specs-go/v1 +# github.com/package-url/packageurl-go v0.1.1 +## explicit; go 1.17 +github.com/package-url/packageurl-go # github.com/pelletier/go-toml/v2 v2.2.4 ## explicit; go 1.21.0 github.com/pelletier/go-toml/v2 From 88cba2c3675506aeeb2521548cadc61e369878f0 Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Wed, 25 Feb 2026 14:02:12 -0800 Subject: [PATCH 2/4] policy: avoid stale invalid field warnings in eval Track fields reloaded during eval --print resolution loops and filter final invalid-field warnings against that set. Signed-off-by: Tonis Tiigi --- commands/policy/eval.go | 19 +++++++++++++++++++ commands/policy/eval_test.go | 13 +++++++++++++ 2 files changed, 32 insertions(+) diff --git a/commands/policy/eval.go b/commands/policy/eval.go index 7a17734c65fb..9cd484cdddba 100644 --- a/commands/policy/eval.go +++ b/commands/policy/eval.go @@ -118,6 +118,7 @@ func runEval(ctx context.Context, dockerCli command.Cli, source string, opts eva var lastUnknowns []string var trimmedUnknowns []string var invalidFields []string + reloadedFields := map[string]struct{}{} for { maxAttempts-- if maxAttempts <= 0 { @@ -133,6 +134,9 @@ func runEval(ctx context.Context, dockerCli command.Cli, source string, opts eva } lastUnknowns = slices.Clone(trimmedUnknowns) toReload, invalid := selectReloadFields(opts.fields, trimmedUnknowns) + for _, f := range toReload { + reloadedFields[f] = struct{}{} + } invalidFields = invalid if len(toReload) > 0 { retry, next, err := policy.ResolveInputUnknowns(ctx, &input, srcReq.Source, toReload, platform, &p, metaResolver, verifier, nil) @@ -157,6 +161,7 @@ func runEval(ctx context.Context, dockerCli command.Cli, source string, opts eva } break } + invalidFields = filterInvalidFields(invalidFields, reloadedFields) if len(invalidFields) > 0 { logrus.Warnf("invalid fields: %v", strings.Join(invalidFields, ", ")) @@ -292,6 +297,20 @@ func selectReloadFields(fields []string, unknowns []string) ([]string, []string) return slices.Collect(maps.Keys(reload)), invalid } +func filterInvalidFields(invalid []string, reloadedFields map[string]struct{}) []string { + if len(invalid) == 0 { + return nil + } + out := make([]string, 0, len(invalid)) + for _, field := range invalid { + if _, ok := reloadedFields[field]; ok { + continue + } + out = append(out, field) + } + return out +} + func findUnknownAncestor(field string, unknowns []string) string { var best string for _, unknown := range unknowns { diff --git a/commands/policy/eval_test.go b/commands/policy/eval_test.go index 75df88b32e33..77522a5b3e6d 100644 --- a/commands/policy/eval_test.go +++ b/commands/policy/eval_test.go @@ -127,6 +127,19 @@ func TestSelectReloadFields(t *testing.T) { }) } +func TestFilterInvalidFields(t *testing.T) { + out := filterInvalidFields([]string{ + "git.tag", + "image.checksum", + }, map[string]struct{}{ + "git.tag": {}, + }) + require.Equal(t, []string{"image.checksum"}, out) + + out = filterInvalidFields([]string{"foo.bar"}, nil) + require.Equal(t, []string{"foo.bar"}, out) +} + func TestMaterialFieldPrerequisites(t *testing.T) { t.Run("non material field", func(t *testing.T) { prereq, ok := materialFieldPrerequisites("image.provenance") From 73ea669465f5ea00a8de86c3b463667ce6d5f361 Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Thu, 26 Feb 2026 17:06:30 -0800 Subject: [PATCH 3/4] vendor: update buildkit to ecde33610015 Signed-off-by: Tonis Tiigi --- go.mod | 2 +- go.sum | 4 ++-- .../frontend/gateway/grpcclient/client.go | 23 ++++++++++++++++--- .../moby/buildkit/frontend/gateway/pb/caps.go | 20 ++++++++++++++++ vendor/modules.txt | 2 +- 5 files changed, 44 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 00d3af23543b..085f00e26d38 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( github.com/hashicorp/hcl/v2 v2.24.0 github.com/in-toto/in-toto-golang v0.10.0 github.com/mitchellh/hashstructure/v2 v2.0.2 - github.com/moby/buildkit v0.28.0-rc1 + github.com/moby/buildkit v0.28.0-rc1.0.20260226174804-ecde33610015 github.com/moby/go-archive v0.2.0 github.com/moby/moby/api v1.53.0 github.com/moby/moby/client v0.2.2 diff --git a/go.sum b/go.sum index 0316acea72ed..5628f8d6da3d 100644 --- a/go.sum +++ b/go.sum @@ -422,8 +422,8 @@ github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4 github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/moby/buildkit v0.28.0-rc1 h1:DmO/S9uWXSDUPIgNiYHiqiPNcRAGxAaXYz2zhCRy+FA= -github.com/moby/buildkit v0.28.0-rc1/go.mod h1:OvWR1wd21skG+T2wKP3J3dZO+C+oEbIXoyWQTl4dX2A= +github.com/moby/buildkit v0.28.0-rc1.0.20260226174804-ecde33610015 h1:4Mz55WW5Y28MzqBJXlSCtHi4KzY9Sjd1MMRmNH831lQ= +github.com/moby/buildkit v0.28.0-rc1.0.20260226174804-ecde33610015/go.mod h1:OvWR1wd21skG+T2wKP3J3dZO+C+oEbIXoyWQTl4dX2A= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= diff --git a/vendor/github.com/moby/buildkit/frontend/gateway/grpcclient/client.go b/vendor/github.com/moby/buildkit/frontend/gateway/grpcclient/client.go index c19344a99b9e..5e262db94ef5 100644 --- a/vendor/github.com/moby/buildkit/frontend/gateway/grpcclient/client.go +++ b/vendor/github.com/moby/buildkit/frontend/gateway/grpcclient/client.go @@ -472,14 +472,20 @@ func (c *grpcClient) Solve(ctx context.Context, creq client.SolveRequest) (res * } func (c *grpcClient) ResolveSourceMetadata(ctx context.Context, op *opspb.SourceOp, opt sourceresolver.Opt) (*sourceresolver.MetaResponse, error) { + requiresImageAttestationResolve := opt.ImageOpt != nil && (opt.ImageOpt.AttestationChain || len(opt.ImageOpt.ResolveAttestations) > 0) + requiresHTTPChecksumRequest := opt.HTTPOpt != nil && opt.HTTPOpt.ChecksumReq != nil + if c.caps.Supports(pb.CapSourceMetaResolver) != nil { + if requiresImageAttestationResolve { + return nil, errors.New("image attestation resolution requires source metadata resolver support") + } var ref string if v, ok := strings.CutPrefix(op.Identifier, "docker-image://"); ok { ref = v } else if v, ok := strings.CutPrefix(op.Identifier, "oci-layout://"); ok { ref = v } else { - if opt.HTTPOpt != nil && opt.HTTPOpt.ChecksumReq != nil { + if requiresHTTPChecksumRequest { return nil, errors.New("http checksum request requires source metadata resolver support") } return &sourceresolver.MetaResponse{Op: op}, nil @@ -503,6 +509,17 @@ func (c *grpcClient) ResolveSourceMetadata(ctx context.Context, op *opspb.Source }, nil } + if requiresImageAttestationResolve { + if err := c.caps.Supports(pb.CapSourceMetaResolverImageAttestations); err != nil { + return nil, errors.Wrap(err, "image attestation resolution requires additional source metadata resolver support") + } + } + if requiresHTTPChecksumRequest { + if err := c.caps.Supports(pb.CapSourceMetaResolverHTTPChecksumRequest); err != nil { + return nil, errors.Wrap(err, "http checksum request requires additional source metadata resolver support") + } + } + var platform *ocispecs.Platform if imgOpt := opt.ImageOpt; imgOpt != nil && imgOpt.Platform != nil { platform = imgOpt.Platform @@ -544,7 +561,7 @@ func (c *grpcClient) ResolveSourceMetadata(ctx context.Context, op *opspb.Source ReturnObject: opt.GitOpt.ReturnObject, } } - if opt.HTTPOpt != nil && opt.HTTPOpt.ChecksumReq != nil { + if requiresHTTPChecksumRequest { algo, err := toPBHTTPChecksumAlgo(opt.HTTPOpt.ChecksumReq.Algo) if err != nil { return nil, err @@ -598,7 +615,7 @@ func (c *grpcClient) ResolveSourceMetadata(ctx context.Context, op *opspb.Source } } } - if opt.HTTPOpt != nil && opt.HTTPOpt.ChecksumReq != nil { + if requiresHTTPChecksumRequest { if resp.HTTP == nil || resp.HTTP.ChecksumResponse == nil { return nil, errors.New("http checksum request was sent but response did not include checksum response") } diff --git a/vendor/github.com/moby/buildkit/frontend/gateway/pb/caps.go b/vendor/github.com/moby/buildkit/frontend/gateway/pb/caps.go index 4c8843ae1d23..a8e45fca9f3c 100644 --- a/vendor/github.com/moby/buildkit/frontend/gateway/pb/caps.go +++ b/vendor/github.com/moby/buildkit/frontend/gateway/pb/caps.go @@ -76,6 +76,12 @@ const ( // CapSourceMetaResolver is the capability to indicates support for ResolveSourceMetadata // function in gateway API CapSourceMetaResolver apicaps.CapID = "source.metaresolver" + // CapSourceMetaResolverImageAttestations is the capability to indicate support + // for attestation resolution fields in ResolveSourceMeta image requests. + CapSourceMetaResolverImageAttestations apicaps.CapID = "source.metaresolver.image.attestations" + // CapSourceMetaResolverHTTPChecksumRequest is the capability to indicate support + // for custom checksum request fields in ResolveSourceMeta http requests. + CapSourceMetaResolverHTTPChecksumRequest apicaps.CapID = "source.metaresolver.http.checksumrequest" ) func init() { @@ -253,4 +259,18 @@ func init() { Enabled: true, Status: apicaps.CapStatusExperimental, }) + + Caps.Init(apicaps.Cap{ + ID: CapSourceMetaResolverImageAttestations, + Name: "source meta resolver image attestations", + Enabled: true, + Status: apicaps.CapStatusExperimental, + }) + + Caps.Init(apicaps.Cap{ + ID: CapSourceMetaResolverHTTPChecksumRequest, + Name: "source meta resolver http checksum request", + Enabled: true, + Status: apicaps.CapStatusExperimental, + }) } diff --git a/vendor/modules.txt b/vendor/modules.txt index 92a6fa6107b8..6b1c8b7b7414 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -652,7 +652,7 @@ github.com/mitchellh/go-wordwrap # github.com/mitchellh/hashstructure/v2 v2.0.2 ## explicit; go 1.14 github.com/mitchellh/hashstructure/v2 -# github.com/moby/buildkit v0.28.0-rc1 +# github.com/moby/buildkit v0.28.0-rc1.0.20260226174804-ecde33610015 ## explicit; go 1.25.5 github.com/moby/buildkit/api/services/control github.com/moby/buildkit/api/types From 1c4a436a0d04a9502c9540179dff36101264706e Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Thu, 26 Feb 2026 17:12:18 -0800 Subject: [PATCH 4/4] policy: match parent unknown keys in collectUnknowns Allow child refs from partial evaluation to match allowed parent keys on path boundaries and return canonical unknowns for metadata resolution. Add tests for parent-child matching and boundary safety cases. Signed-off-by: Tonis Tiigi --- policy/utils_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ policy/validate.go | 31 +++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/policy/utils_test.go b/policy/utils_test.go index 37a692e5aa3f..3f7ffb2509d6 100644 --- a/policy/utils_test.go +++ b/policy/utils_test.go @@ -31,6 +31,9 @@ func TestTrimKey(t *testing.T) { {"input.git.tag[0]", "git.tag"}, {"input.image.provenance.materials[0].image.hasProvenance", "image.provenance.materials[0].image.hasProvenance"}, {"image.provenance.materials[0].image.labels", "image.provenance.materials[0].image.labels"}, + {"input.image.provenance.materials[0].image.provenance.predicateType", "image.provenance.materials[0].image.provenance.predicateType"}, + {"input.image.provenance.materials[0].image.signatures[0].signer.certificateIssuer", "image.provenance.materials[0].image.signatures[0].signer.certificateIssuer"}, + {"input.image.provenance.materials[10].image.hasProvenance", "image.provenance.materials[10].image.hasProvenance"}, {"a.b.c", "a.b"}, } @@ -61,6 +64,47 @@ func TestCollectUnknowns(t *testing.T) { require.ElementsMatch(t, []string{"image.signatures", "image.provenance.materials[0].image.hasProvenance"}, filtered) } +func TestCollectUnknownsParentAllowedMatchesChildRef(t *testing.T) { + mod, err := ast.ParseModule("x.rego", ` + package x + p if { + input.image.provenance.materials[0].image.provenance.predicateType != "" + input.image.provenance.materials[0].image.signatures[0].signer.certificateIssuer != "" + input.image.provenance.materials[0].git.tag.name != "" + input.foo.bar != "" + input.image.provenance.materials[10].image.hasProvenance + } + `) + require.NoError(t, err) + + filtered := collectUnknowns([]*ast.Module{mod}, []string{ + "input.image.provenance.materials[0].image.provenance", + "input.image.provenance.materials[0].image.signatures", + "input.image.provenance.materials[0].git.tag", + "input.foo.b", + "input.image.provenance.materials[1].image", + }) + + require.ElementsMatch(t, []string{ + "image.provenance.materials[0].image.provenance", + "image.provenance.materials[0].image.signatures", + "image.provenance.materials[0].git.tag", + }, filtered) +} + +func TestMatchAllowedOrParentBoundary(t *testing.T) { + allowed := map[string]struct{}{ + "foo.b": {}, + "image.provenance.materials[1].image": {}, + } + + _, ok := matchAllowedOrParent("foo.bar", allowed) + require.False(t, ok) + + _, ok = matchAllowedOrParent("image.provenance.materials[10].image.hasProvenance", allowed) + require.False(t, ok) +} + func TestRuntimeUnknownInputRefs(t *testing.T) { require.Nil(t, runtimeUnknownInputRefs(nil)) require.Nil(t, runtimeUnknownInputRefs(&state{})) diff --git a/policy/validate.go b/policy/validate.go index 643502458a17..69599cb59d5f 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -808,15 +808,42 @@ func collectUnknowns(mods []*ast.Module, allowed []string) []string { } filtered := make([]string, 0, len(out)) + filteredSeen := map[string]struct{}{} for _, k := range out { - if _, ok := valid[k]; ok { - filtered = append(filtered, k) + matched, ok := matchAllowedOrParent(k, valid) + if !ok { + continue + } + if _, exists := filteredSeen[matched]; exists { + continue } + filteredSeen[matched] = struct{}{} + filtered = append(filtered, matched) } return filtered } +func matchAllowedOrParent(key string, allowed map[string]struct{}) (string, bool) { + if _, ok := allowed[key]; ok { + return key, true + } + // Find the nearest parent on a component boundary. + for i := len(key) - 1; i >= 0; i-- { + switch key[i] { + case '.', '[': + if i == 0 { + continue + } + candidate := key[:i] + if _, ok := allowed[candidate]; ok { + return candidate, true + } + } + } + return "", false +} + func runtimeUnknownInputRefs(st *state) []string { if st == nil || len(st.Unknowns) == 0 { return nil