From 5ad09ce3ebc125e50b037a01c5fce4bc25c35c0f Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Wed, 7 Jan 2026 20:18:19 -0800 Subject: [PATCH] commands: implement policy eval command Signed-off-by: Tonis Tiigi --- build/opt.go | 4 +- commands/policy/eval.go | 377 +++++++++++- commands/policy/gateway_client.go | 80 +++ commands/policy/root.go | 9 +- commands/root.go | 2 +- docs/reference/buildx.md | 1 + docs/reference/buildx_build.md | 75 +-- docs/reference/buildx_dap_build.md | 75 +-- docs/reference/buildx_debug_build.md | 75 +-- docs/reference/buildx_policy.md | 24 + docs/reference/buildx_policy_eval.md | 18 + docs/reference/buildx_policy_json-schema.md | 15 + docs/reference/buildx_policy_test.md | 15 + policy/multipolicy_test.go | 2 +- policy/signatures.go | 46 +- policy/validate.go | 642 ++++++++++---------- 16 files changed, 1003 insertions(+), 457 deletions(-) create mode 100644 commands/policy/gateway_client.go create mode 100644 docs/reference/buildx_policy.md create mode 100644 docs/reference/buildx_policy_eval.md create mode 100644 docs/reference/buildx_policy_json-schema.md create mode 100644 docs/reference/buildx_policy_test.md diff --git a/build/opt.go b/build/opt.go index efaec8bd6..ddf9d2911 100644 --- a/build/opt.go +++ b/build/opt.go @@ -355,8 +355,8 @@ func toSolveOpt(ctx context.Context, node builder.Node, multiDriver bool, opt *O Log: func(msg string) { log.Printf("[policy] %s", msg) }, - FS: opt.Inputs.policy.FS, - Config: cfg, + FS: opt.Inputs.policy.FS, + VerifierProvider: policy.SignatureVerifier(cfg), }) cbs = append(cbs, p.CheckPolicy) if popt.Strict { diff --git a/commands/policy/eval.go b/commands/policy/eval.go index 0b54ede5b..f56e8938c 100644 --- a/commands/policy/eval.go +++ b/commands/policy/eval.go @@ -1,47 +1,392 @@ package policy import ( + "context" + "encoding/json" + "fmt" + "io/fs" "os" + "path/filepath" + "slices" "strings" + "github.com/distribution/reference" + "github.com/docker/buildx/builder" + "github.com/docker/buildx/policy" + "github.com/docker/buildx/util/confutil" + "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" + spb "github.com/moby/buildkit/sourcepolicy/pb" + "github.com/moby/buildkit/sourcepolicy/policysession" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" + "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/timestamppb" ) -func evalCmd() *cobra.Command { - var filename string - var printOutput bool +type evalOpts struct { + filename string + printOutput bool + fields []string + builder *string +} + +func evalCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command { + var opts evalOpts cmd := &cobra.Command{ - Use: "eval [source]", + Use: "eval source", Short: "Evaluate policy for a source", - Args: cobra.MaximumNArgs(1), + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runEval(args, filename, printOutput) + opts.builder = rootOpts.Builder + return runEval(cmd.Context(), dockerCli, args[0], opts) }, } - cmd.Flags().StringVar(&filename, "filename", "", "Policy filename to evaluate") - cmd.Flags().BoolVar(&printOutput, "print", false, "Print policy output") - + cmd.Flags().StringVar(&opts.filename, "filename", "Dockerfile", "Policy filename to evaluate") + cmd.Flags().BoolVar(&opts.printOutput, "print", false, "Print policy output") + cmd.Flags().StringSliceVar(&opts.fields, "fields", nil, "Fields to evaluate") return cmd } -func runEval(args []string, filename string, printOutput bool) error { - if len(args) > 0 { - if _, err := parseSource(args[0]); err != nil { +func runEval(ctx context.Context, dockerCli command.Cli, source string, opts evalOpts) error { + src, err := parseSource(source) + if err != nil { + return err + } + + bopts := []builder.Option{} + if opts.builder != nil { + bopts = append(bopts, builder.WithName(*opts.builder)) + } + + b, err := builder.New(dockerCli, bopts...) + if err != nil { + return err + } + + nodes, err := b.LoadNodes(ctx) + if err != nil { + return err + } + + c, err := nodes[0].Driver.Client(ctx) + if err != nil { + return err + } + + workers, err := c.ListWorkers(ctx) + if err != nil { + return err + } + + if len(workers) == 0 { + return errors.New("no workers available in the builder") + } + + defaultPlatform := workers[0].Platforms[0] + p := ocispecs.Platform{ + Architecture: defaultPlatform.Architecture, + OS: defaultPlatform.OS, + Variant: defaultPlatform.Variant, + } + openClient, release, err := gatewayClientFactory(c) + if err != nil { + return err + } + defer release() + + platform := &pb.Platform{ + Architecture: p.Architecture, + OS: p.OS, + Variant: p.Variant, + } + verifier := policy.SignatureVerifier(confutil.NewConfig(dockerCli)) + + if opts.printOutput { + srcReq := &gwpb.ResolveSourceMetaResponse{ + Source: src, + } + 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 + } + 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 + if len(toReload) > 0 { + req := &gwpb.ResolveSourceMetaRequest{} + if err := policy.AddUnknowns(req, toReload); err != nil { + return err + } + gwClient, err := openClient(ctx) + if err != nil { + return err + } + + opt := sourceResolverOpt(req, &p) + resp, err := gwClient.ResolveSourceMetadata(ctx, src, opt) + if err != nil { + return err + } + srcReq = buildSourceMetaResponse(resp, req) + continue + } + break + } + + if len(invalidFields) > 0 { + logrus.Warnf("invalid fields: %v", strings.Join(invalidFields, ", ")) + } + if len(trimmedUnknowns) > 0 { + logrus.Infof("unresolved fields: %v", strings.Join(trimmedUnknowns, ", ")) + } + + dt, err := json.MarshalIndent(input, "", " ") + if err != nil { + return errors.Wrap(err, "failed to marshal policy input") + } + _, _ = fmt.Fprintln(os.Stdout, string(dt)) + return nil + } + + if opts.filename == "" { + return errors.New("filename is required") + } + policyName := opts.filename + policyFile := policyName + ".rego" + policyData, err := os.ReadFile(policyFile) + if err != nil { + return errors.Wrapf(err, "failed to read policy file %s", policyFile) + } + fsProvider := func() (fs.StatFS, func() error, error) { + root, err := os.OpenRoot(".") + if err != nil { + return nil, nil, errors.Wrapf(err, "failed to open root for policy file %s", policyFile) + } + baseFS := root.FS() + statFS, ok := baseFS.(fs.StatFS) + if !ok { + _ = root.Close() + return nil, nil, errors.Errorf("invalid root FS type %T", baseFS) + } + return statFS, root.Close, nil + } + + env := policy.Env{ + Filename: filepath.Base(policyName), + } + + policyEval := policy.NewPolicy(policy.Opt{ + Files: []policy.File{ + { + Filename: filepath.Base(policyFile), + Data: policyData, + }, + }, + Env: env, + FS: fsProvider, + VerifierProvider: verifier, + }) + + srcReq := &gwpb.ResolveSourceMetaResponse{ + Source: src, + } + maxAttempts := 5 + for { + maxAttempts-- + if maxAttempts <= 0 { + return errors.New("maximum attempts reached for resolving policy metadata") + } + + decision, next, err := policyEval.CheckPolicy(ctx, &policysession.CheckPolicyRequest{ + Platform: platform, + Source: srcReq, + }) + if err != nil { return err } + if next == nil { + return evalDecisionError(decision) + } + + gwClient, err := openClient(ctx) + if err != nil { + return err + } + opt := sourceResolverOpt(next, &p) + resp, err := gwClient.ResolveSourceMetadata(ctx, src, opt) + if err != nil { + return err + } + srcReq = buildSourceMetaResponse(resp, next) + } +} + +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, + } +} + +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, + Platform: platform, + ResolveMode: req.ResolveMode, + } + } + if req.Git != nil { + opt.GitOpt = &sourceresolver.ResolveGitOpt{ + ReturnObject: req.Git.ReturnObject, + } + } + return opt +} + +func buildSourceMetaResponse(resp *sourceresolver.MetaResponse, req *gwpb.ResolveSourceMetaRequest) *gwpb.ResolveSourceMetaResponse { + out := &gwpb.ResolveSourceMetaResponse{ + Source: resp.Op, + } + if resp.Image != nil { + chain := toGatewayAttestationChain(resp.Image.AttestationChain) + if chain == nil && req != nil && req.Image != nil && req.Image.AttestationChain { + chain = &gwpb.AttestationChain{} + } + out.Image = &gwpb.ResolveSourceImageResponse{ + Digest: resp.Image.Digest.String(), + Config: resp.Image.Config, + AttestationChain: chain, + } + } + 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 trimInputPrefixSlice(fields []string) []string { + if len(fields) == 0 { + return fields + } + out := make([]string, 0, len(fields)) + for _, field := range fields { + out = append(out, strings.TrimPrefix(field, "input.")) + } + return out +} + +func evalDecisionError(decision *policysession.DecisionResponse) error { + if decision == nil { + return errors.New("policy returned no decision") + } + switch decision.Action { + case spb.PolicyAction_ALLOW, spb.PolicyAction_CONVERT: + return nil + case spb.PolicyAction_DENY: + if len(decision.DenyMessages) == 0 { + return errors.New("policy denied") + } + msgs := make([]string, 0, len(decision.DenyMessages)) + for _, msg := range decision.DenyMessages { + if msg != nil && msg.Message != "" { + msgs = append(msgs, msg.Message) + } + } + if len(msgs) == 0 { + return errors.New("policy denied") + } + return errors.Errorf("policy denied: %s", strings.Join(msgs, "; ")) + default: + return errors.Errorf("unknown policy action %s", decision.Action) } - _ = filename - _ = printOutput - return errors.New("not implemented") } func parseSource(input string) (*pb.SourceOp, error) { if strings.HasPrefix(input, "docker-image://") { - return &pb.SourceOp{Identifier: input}, nil + refstr := strings.TrimPrefix(input, "docker-image://") + ref, err := reference.ParseNormalizedNamed(refstr) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse image source reference") + } + return &pb.SourceOp{Identifier: "docker-image://" + ref.String()}, nil } if strings.HasPrefix(input, "git://") { _, ok, err := dockerui.DetectGitContext(input, nil) diff --git a/commands/policy/gateway_client.go b/commands/policy/gateway_client.go new file mode 100644 index 000000000..711d02855 --- /dev/null +++ b/commands/policy/gateway_client.go @@ -0,0 +1,80 @@ +package policy + +import ( + "context" + "errors" + "sync" + "sync/atomic" + + "github.com/moby/buildkit/client" + gwclient "github.com/moby/buildkit/frontend/gateway/client" +) + +type gatewayClientOpener func(context.Context) (gwclient.Client, error) + +func gatewayClientFactory(c *client.Client) (gatewayClientOpener, func() error, error) { + var ( + once sync.Once + releaseOnce sync.Once + started atomic.Bool + ready = make(chan gwclient.Client, 1) + done = make(chan error, 1) + openErr error + releaseErr error + gwClient gwclient.Client + cancel context.CancelCauseFunc + ) + + open := func(ctx context.Context) (gwclient.Client, error) { + once.Do(func() { + started.Store(true) + buildCtx, cancelFn := context.WithCancelCause(ctx) + cancel = cancelFn + + go func() { + _, err := c.Build(buildCtx, client.SolveOpt{Internal: true}, "buildx", func(ctx context.Context, c gwclient.Client) (*gwclient.Result, error) { + ready <- c + <-buildCtx.Done() + return nil, context.Cause(buildCtx) + }, nil) + done <- err + }() + + select { + case gwClient = <-ready: + case err := <-done: + if err == nil { + err = errors.New("gateway build finished without a client") + } + openErr = err + case <-ctx.Done(): + openErr = context.Cause(ctx) + cancelFn(openErr) + } + }) + + if openErr != nil { + return nil, openErr + } + return gwClient, nil + } + + release := func() error { + releaseOnce.Do(func() { + if !started.Load() { + return + } + if cancel != nil { + cancel(context.Canceled) + } + err := <-done + if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return + } + releaseErr = err + }) + return releaseErr + } + + return open, release, nil +} diff --git a/commands/policy/root.go b/commands/policy/root.go index 2f8744e08..e88eae4f1 100644 --- a/commands/policy/root.go +++ b/commands/policy/root.go @@ -1,11 +1,16 @@ package policy import ( + "github.com/docker/cli/cli/command" "github.com/spf13/cobra" ) +type RootOptions struct { + Builder *string +} + // RootCmd creates the policy command tree. -func RootCmd(rootcmd *cobra.Command) *cobra.Command { +func RootCmd(rootcmd *cobra.Command, dockerCli command.Cli, rootOpts RootOptions) *cobra.Command { cmd := &cobra.Command{ Use: "policy", Short: "Commands for working with build policies", @@ -13,7 +18,7 @@ func RootCmd(rootcmd *cobra.Command) *cobra.Command { cmd.AddCommand( jsonSchemaCmd(), - evalCmd(), + evalCmd(dockerCli, rootOpts), testCmd(), ) diff --git a/commands/root.go b/commands/root.go index ae6c5801f..1c36eb408 100644 --- a/commands/root.go +++ b/commands/root.go @@ -121,7 +121,7 @@ func addCommands(cmd *cobra.Command, opts *rootOptions, dockerCli command.Cli) { installCmd(dockerCli), uninstallCmd(dockerCli), versionCmd(dockerCli), - policycmd.RootCmd(cmd), + policycmd.RootCmd(cmd, dockerCli, policycmd.RootOptions{Builder: &opts.builder}), pruneCmd(dockerCli, opts), duCmd(dockerCli, opts), imagetoolscmd.RootCmd(cmd, dockerCli, imagetoolscmd.RootOptions{Builder: &opts.builder}), diff --git a/docs/reference/buildx.md b/docs/reference/buildx.md index 39d2e7e66..a4f02ee30 100644 --- a/docs/reference/buildx.md +++ b/docs/reference/buildx.md @@ -22,6 +22,7 @@ Extended build capabilities with BuildKit | [`imagetools`](buildx_imagetools.md) | Commands to work on images in registry | | [`inspect`](buildx_inspect.md) | Inspect current builder instance | | [`ls`](buildx_ls.md) | List builder instances | +| [`policy`](buildx_policy.md) | Commands for working with build policies | | [`prune`](buildx_prune.md) | Remove build cache | | [`rm`](buildx_rm.md) | Remove one or more builder instances | | [`stop`](buildx_stop.md) | Stop builder instance | diff --git a/docs/reference/buildx_build.md b/docs/reference/buildx_build.md index 611731203..4d85a6a47 100644 --- a/docs/reference/buildx_build.md +++ b/docs/reference/buildx_build.md @@ -13,43 +13,44 @@ Start a build ### Options -| Name | Type | Default | Description | -|:----------------------------------------|:--------------|:----------|:----------------------------------------------------------------------------------------------------------------------| -| [`--add-host`](#add-host) | `stringSlice` | | Add a custom host-to-IP mapping (format: `host:ip`) | -| [`--allow`](#allow) | `stringArray` | | Allow extra privileged entitlement (e.g., `network.host`, `security.insecure`, `device`) | -| [`--annotation`](#annotation) | `stringArray` | | Add annotation to the image | -| [`--attest`](#attest) | `stringArray` | | Attestation parameters (format: `type=sbom,generator=image`) | -| [`--build-arg`](#build-arg) | `stringArray` | | Set build-time variables | -| [`--build-context`](#build-context) | `stringArray` | | Additional build contexts (e.g., name=path) | -| [`--builder`](#builder) | `string` | | Override the configured builder instance | -| [`--cache-from`](#cache-from) | `stringArray` | | External cache sources (e.g., `user/app:cache`, `type=local,src=path/to/dir`) | -| [`--cache-to`](#cache-to) | `stringArray` | | Cache export destinations (e.g., `user/app:cache`, `type=local,dest=path/to/dir`) | -| [`--call`](#call) | `string` | `build` | Set method for evaluating build (`check`, `outline`, `targets`) | -| [`--cgroup-parent`](#cgroup-parent) | `string` | | Set the parent cgroup for the `RUN` instructions during build | -| [`--check`](#check) | `bool` | | Shorthand for `--call=check` | -| `-D`, `--debug` | `bool` | | Enable debug logging | -| [`-f`](#file), [`--file`](#file) | `string` | | Name of the Dockerfile (default: `PATH/Dockerfile`) | -| `--iidfile` | `string` | | Write the image ID to a file | -| `--label` | `stringArray` | | Set metadata for an image | -| [`--load`](#load) | `bool` | | Shorthand for `--output=type=docker` | -| [`--metadata-file`](#metadata-file) | `string` | | Write build result metadata to a file | -| [`--network`](#network) | `string` | `default` | Set the networking mode for the `RUN` instructions during build | -| `--no-cache` | `bool` | | Do not use cache when building the image | -| [`--no-cache-filter`](#no-cache-filter) | `stringArray` | | Do not cache specified stages | -| [`-o`](#output), [`--output`](#output) | `stringArray` | | Output destination (format: `type=local,dest=path`) | -| [`--platform`](#platform) | `stringArray` | | Set target platform for build | -| [`--progress`](#progress) | `string` | `auto` | Set type of progress output (`auto`, `none`, `plain`, `quiet`, `rawjson`, `tty`). Use plain to show container output | -| [`--provenance`](#provenance) | `string` | | Shorthand for `--attest=type=provenance` | -| `--pull` | `bool` | | Always attempt to pull all referenced images | -| [`--push`](#push) | `bool` | | Shorthand for `--output=type=registry,unpack=false` | -| `-q`, `--quiet` | `bool` | | Suppress the build output and print image ID on success | -| [`--sbom`](#sbom) | `string` | | Shorthand for `--attest=type=sbom` | -| [`--secret`](#secret) | `stringArray` | | Secret to expose to the build (format: `id=mysecret[,src=/local/secret]`) | -| [`--shm-size`](#shm-size) | `bytes` | `0` | Shared memory size for build containers | -| [`--ssh`](#ssh) | `stringArray` | | SSH agent socket or keys to expose to the build (format: `default\|[=\|[,]]`) | -| [`-t`](#tag), [`--tag`](#tag) | `stringArray` | | Image identifier (format: `[registry/]repository[:tag]`) | -| [`--target`](#target) | `string` | | Set the target build stage to build | -| [`--ulimit`](#ulimit) | `ulimit` | | Ulimit options | +| Name | Type | Default | Description | +|:----------------------------------------|:--------------|:----------|:-------------------------------------------------------------------------------------------------------------------------------------------------| +| [`--add-host`](#add-host) | `stringSlice` | | Add a custom host-to-IP mapping (format: `host:ip`) | +| [`--allow`](#allow) | `stringArray` | | Allow extra privileged entitlement (e.g., `network.host`, `security.insecure`, `device`) | +| [`--annotation`](#annotation) | `stringArray` | | Add annotation to the image | +| [`--attest`](#attest) | `stringArray` | | Attestation parameters (format: `type=sbom,generator=image`) | +| [`--build-arg`](#build-arg) | `stringArray` | | Set build-time variables | +| [`--build-context`](#build-context) | `stringArray` | | Additional build contexts (e.g., name=path) | +| [`--builder`](#builder) | `string` | | Override the configured builder instance | +| [`--cache-from`](#cache-from) | `stringArray` | | External cache sources (e.g., `user/app:cache`, `type=local,src=path/to/dir`) | +| [`--cache-to`](#cache-to) | `stringArray` | | Cache export destinations (e.g., `user/app:cache`, `type=local,dest=path/to/dir`) | +| [`--call`](#call) | `string` | `build` | Set method for evaluating build (`check`, `outline`, `targets`) | +| [`--cgroup-parent`](#cgroup-parent) | `string` | | Set the parent cgroup for the `RUN` instructions during build | +| [`--check`](#check) | `bool` | | Shorthand for `--call=check` | +| `-D`, `--debug` | `bool` | | Enable debug logging | +| [`-f`](#file), [`--file`](#file) | `string` | | Name of the Dockerfile (default: `PATH/Dockerfile`) | +| `--iidfile` | `string` | | Write the image ID to a file | +| `--label` | `stringArray` | | Set metadata for an image | +| [`--load`](#load) | `bool` | | Shorthand for `--output=type=docker` | +| [`--metadata-file`](#metadata-file) | `string` | | Write build result metadata to a file | +| [`--network`](#network) | `string` | `default` | Set the networking mode for the `RUN` instructions during build | +| `--no-cache` | `bool` | | Do not use cache when building the image | +| [`--no-cache-filter`](#no-cache-filter) | `stringArray` | | Do not cache specified stages | +| [`-o`](#output), [`--output`](#output) | `stringArray` | | Output destination (format: `type=local,dest=path`) | +| [`--platform`](#platform) | `stringArray` | | Set target platform for build | +| `--policy` | `stringArray` | | Policy configuration (format: `filename=path[,filename=path][,reset=true\|false][,disabled=true\|false][,strict=true\|false][,log-level=level]`) | +| [`--progress`](#progress) | `string` | `auto` | Set type of progress output (`auto`, `none`, `plain`, `quiet`, `rawjson`, `tty`). Use plain to show container output | +| [`--provenance`](#provenance) | `string` | | Shorthand for `--attest=type=provenance` | +| `--pull` | `bool` | | Always attempt to pull all referenced images | +| [`--push`](#push) | `bool` | | Shorthand for `--output=type=registry,unpack=false` | +| `-q`, `--quiet` | `bool` | | Suppress the build output and print image ID on success | +| [`--sbom`](#sbom) | `string` | | Shorthand for `--attest=type=sbom` | +| [`--secret`](#secret) | `stringArray` | | Secret to expose to the build (format: `id=mysecret[,src=/local/secret]`) | +| [`--shm-size`](#shm-size) | `bytes` | `0` | Shared memory size for build containers | +| [`--ssh`](#ssh) | `stringArray` | | SSH agent socket or keys to expose to the build (format: `default\|[=\|[,]]`) | +| [`-t`](#tag), [`--tag`](#tag) | `stringArray` | | Image identifier (format: `[registry/]repository[:tag]`) | +| [`--target`](#target) | `string` | | Set the target build stage to build | +| [`--ulimit`](#ulimit) | `ulimit` | | Ulimit options | diff --git a/docs/reference/buildx_dap_build.md b/docs/reference/buildx_dap_build.md index 99821aefb..60da585f8 100644 --- a/docs/reference/buildx_dap_build.md +++ b/docs/reference/buildx_dap_build.md @@ -5,43 +5,44 @@ Start a build ### Options -| Name | Type | Default | Description | -|:--------------------|:--------------|:----------|:----------------------------------------------------------------------------------------------------------------------| -| `--add-host` | `stringSlice` | | Add a custom host-to-IP mapping (format: `host:ip`) | -| `--allow` | `stringArray` | | Allow extra privileged entitlement (e.g., `network.host`, `security.insecure`, `device`) | -| `--annotation` | `stringArray` | | Add annotation to the image | -| `--attest` | `stringArray` | | Attestation parameters (format: `type=sbom,generator=image`) | -| `--build-arg` | `stringArray` | | Set build-time variables | -| `--build-context` | `stringArray` | | Additional build contexts (e.g., name=path) | -| `--builder` | `string` | | Override the configured builder instance | -| `--cache-from` | `stringArray` | | External cache sources (e.g., `user/app:cache`, `type=local,src=path/to/dir`) | -| `--cache-to` | `stringArray` | | Cache export destinations (e.g., `user/app:cache`, `type=local,dest=path/to/dir`) | -| `--call` | `string` | `build` | Set method for evaluating build (`check`, `outline`, `targets`) | -| `--cgroup-parent` | `string` | | Set the parent cgroup for the `RUN` instructions during build | -| `--check` | `bool` | | Shorthand for `--call=check` | -| `-D`, `--debug` | `bool` | | Enable debug logging | -| `-f`, `--file` | `string` | | Name of the Dockerfile (default: `PATH/Dockerfile`) | -| `--iidfile` | `string` | | Write the image ID to a file | -| `--label` | `stringArray` | | Set metadata for an image | -| `--load` | `bool` | | Shorthand for `--output=type=docker` | -| `--metadata-file` | `string` | | Write build result metadata to a file | -| `--network` | `string` | `default` | Set the networking mode for the `RUN` instructions during build | -| `--no-cache` | `bool` | | Do not use cache when building the image | -| `--no-cache-filter` | `stringArray` | | Do not cache specified stages | -| `-o`, `--output` | `stringArray` | | Output destination (format: `type=local,dest=path`) | -| `--platform` | `stringArray` | | Set target platform for build | -| `--progress` | `string` | `auto` | Set type of progress output (`auto`, `none`, `plain`, `quiet`, `rawjson`, `tty`). Use plain to show container output | -| `--provenance` | `string` | | Shorthand for `--attest=type=provenance` | -| `--pull` | `bool` | | Always attempt to pull all referenced images | -| `--push` | `bool` | | Shorthand for `--output=type=registry,unpack=false` | -| `-q`, `--quiet` | `bool` | | Suppress the build output and print image ID on success | -| `--sbom` | `string` | | Shorthand for `--attest=type=sbom` | -| `--secret` | `stringArray` | | Secret to expose to the build (format: `id=mysecret[,src=/local/secret]`) | -| `--shm-size` | `bytes` | `0` | Shared memory size for build containers | -| `--ssh` | `stringArray` | | SSH agent socket or keys to expose to the build (format: `default\|[=\|[,]]`) | -| `-t`, `--tag` | `stringArray` | | Image identifier (format: `[registry/]repository[:tag]`) | -| `--target` | `string` | | Set the target build stage to build | -| `--ulimit` | `ulimit` | | Ulimit options | +| Name | Type | Default | Description | +|:--------------------|:--------------|:----------|:-------------------------------------------------------------------------------------------------------------------------------------------------| +| `--add-host` | `stringSlice` | | Add a custom host-to-IP mapping (format: `host:ip`) | +| `--allow` | `stringArray` | | Allow extra privileged entitlement (e.g., `network.host`, `security.insecure`, `device`) | +| `--annotation` | `stringArray` | | Add annotation to the image | +| `--attest` | `stringArray` | | Attestation parameters (format: `type=sbom,generator=image`) | +| `--build-arg` | `stringArray` | | Set build-time variables | +| `--build-context` | `stringArray` | | Additional build contexts (e.g., name=path) | +| `--builder` | `string` | | Override the configured builder instance | +| `--cache-from` | `stringArray` | | External cache sources (e.g., `user/app:cache`, `type=local,src=path/to/dir`) | +| `--cache-to` | `stringArray` | | Cache export destinations (e.g., `user/app:cache`, `type=local,dest=path/to/dir`) | +| `--call` | `string` | `build` | Set method for evaluating build (`check`, `outline`, `targets`) | +| `--cgroup-parent` | `string` | | Set the parent cgroup for the `RUN` instructions during build | +| `--check` | `bool` | | Shorthand for `--call=check` | +| `-D`, `--debug` | `bool` | | Enable debug logging | +| `-f`, `--file` | `string` | | Name of the Dockerfile (default: `PATH/Dockerfile`) | +| `--iidfile` | `string` | | Write the image ID to a file | +| `--label` | `stringArray` | | Set metadata for an image | +| `--load` | `bool` | | Shorthand for `--output=type=docker` | +| `--metadata-file` | `string` | | Write build result metadata to a file | +| `--network` | `string` | `default` | Set the networking mode for the `RUN` instructions during build | +| `--no-cache` | `bool` | | Do not use cache when building the image | +| `--no-cache-filter` | `stringArray` | | Do not cache specified stages | +| `-o`, `--output` | `stringArray` | | Output destination (format: `type=local,dest=path`) | +| `--platform` | `stringArray` | | Set target platform for build | +| `--policy` | `stringArray` | | Policy configuration (format: `filename=path[,filename=path][,reset=true\|false][,disabled=true\|false][,strict=true\|false][,log-level=level]`) | +| `--progress` | `string` | `auto` | Set type of progress output (`auto`, `none`, `plain`, `quiet`, `rawjson`, `tty`). Use plain to show container output | +| `--provenance` | `string` | | Shorthand for `--attest=type=provenance` | +| `--pull` | `bool` | | Always attempt to pull all referenced images | +| `--push` | `bool` | | Shorthand for `--output=type=registry,unpack=false` | +| `-q`, `--quiet` | `bool` | | Suppress the build output and print image ID on success | +| `--sbom` | `string` | | Shorthand for `--attest=type=sbom` | +| `--secret` | `stringArray` | | Secret to expose to the build (format: `id=mysecret[,src=/local/secret]`) | +| `--shm-size` | `bytes` | `0` | Shared memory size for build containers | +| `--ssh` | `stringArray` | | SSH agent socket or keys to expose to the build (format: `default\|[=\|[,]]`) | +| `-t`, `--tag` | `stringArray` | | Image identifier (format: `[registry/]repository[:tag]`) | +| `--target` | `string` | | Set the target build stage to build | +| `--ulimit` | `ulimit` | | Ulimit options | diff --git a/docs/reference/buildx_debug_build.md b/docs/reference/buildx_debug_build.md index e444d9470..6b04c7c0c 100644 --- a/docs/reference/buildx_debug_build.md +++ b/docs/reference/buildx_debug_build.md @@ -9,43 +9,44 @@ Start a build ### Options -| Name | Type | Default | Description | -|:--------------------|:--------------|:----------|:----------------------------------------------------------------------------------------------------------------------| -| `--add-host` | `stringSlice` | | Add a custom host-to-IP mapping (format: `host:ip`) | -| `--allow` | `stringArray` | | Allow extra privileged entitlement (e.g., `network.host`, `security.insecure`, `device`) | -| `--annotation` | `stringArray` | | Add annotation to the image | -| `--attest` | `stringArray` | | Attestation parameters (format: `type=sbom,generator=image`) | -| `--build-arg` | `stringArray` | | Set build-time variables | -| `--build-context` | `stringArray` | | Additional build contexts (e.g., name=path) | -| `--builder` | `string` | | Override the configured builder instance | -| `--cache-from` | `stringArray` | | External cache sources (e.g., `user/app:cache`, `type=local,src=path/to/dir`) | -| `--cache-to` | `stringArray` | | Cache export destinations (e.g., `user/app:cache`, `type=local,dest=path/to/dir`) | -| `--call` | `string` | `build` | Set method for evaluating build (`check`, `outline`, `targets`) | -| `--cgroup-parent` | `string` | | Set the parent cgroup for the `RUN` instructions during build | -| `--check` | `bool` | | Shorthand for `--call=check` | -| `-D`, `--debug` | `bool` | | Enable debug logging | -| `-f`, `--file` | `string` | | Name of the Dockerfile (default: `PATH/Dockerfile`) | -| `--iidfile` | `string` | | Write the image ID to a file | -| `--label` | `stringArray` | | Set metadata for an image | -| `--load` | `bool` | | Shorthand for `--output=type=docker` | -| `--metadata-file` | `string` | | Write build result metadata to a file | -| `--network` | `string` | `default` | Set the networking mode for the `RUN` instructions during build | -| `--no-cache` | `bool` | | Do not use cache when building the image | -| `--no-cache-filter` | `stringArray` | | Do not cache specified stages | -| `-o`, `--output` | `stringArray` | | Output destination (format: `type=local,dest=path`) | -| `--platform` | `stringArray` | | Set target platform for build | -| `--progress` | `string` | `auto` | Set type of progress output (`auto`, `none`, `plain`, `quiet`, `rawjson`, `tty`). Use plain to show container output | -| `--provenance` | `string` | | Shorthand for `--attest=type=provenance` | -| `--pull` | `bool` | | Always attempt to pull all referenced images | -| `--push` | `bool` | | Shorthand for `--output=type=registry,unpack=false` | -| `-q`, `--quiet` | `bool` | | Suppress the build output and print image ID on success | -| `--sbom` | `string` | | Shorthand for `--attest=type=sbom` | -| `--secret` | `stringArray` | | Secret to expose to the build (format: `id=mysecret[,src=/local/secret]`) | -| `--shm-size` | `bytes` | `0` | Shared memory size for build containers | -| `--ssh` | `stringArray` | | SSH agent socket or keys to expose to the build (format: `default\|[=\|[,]]`) | -| `-t`, `--tag` | `stringArray` | | Image identifier (format: `[registry/]repository[:tag]`) | -| `--target` | `string` | | Set the target build stage to build | -| `--ulimit` | `ulimit` | | Ulimit options | +| Name | Type | Default | Description | +|:--------------------|:--------------|:----------|:-------------------------------------------------------------------------------------------------------------------------------------------------| +| `--add-host` | `stringSlice` | | Add a custom host-to-IP mapping (format: `host:ip`) | +| `--allow` | `stringArray` | | Allow extra privileged entitlement (e.g., `network.host`, `security.insecure`, `device`) | +| `--annotation` | `stringArray` | | Add annotation to the image | +| `--attest` | `stringArray` | | Attestation parameters (format: `type=sbom,generator=image`) | +| `--build-arg` | `stringArray` | | Set build-time variables | +| `--build-context` | `stringArray` | | Additional build contexts (e.g., name=path) | +| `--builder` | `string` | | Override the configured builder instance | +| `--cache-from` | `stringArray` | | External cache sources (e.g., `user/app:cache`, `type=local,src=path/to/dir`) | +| `--cache-to` | `stringArray` | | Cache export destinations (e.g., `user/app:cache`, `type=local,dest=path/to/dir`) | +| `--call` | `string` | `build` | Set method for evaluating build (`check`, `outline`, `targets`) | +| `--cgroup-parent` | `string` | | Set the parent cgroup for the `RUN` instructions during build | +| `--check` | `bool` | | Shorthand for `--call=check` | +| `-D`, `--debug` | `bool` | | Enable debug logging | +| `-f`, `--file` | `string` | | Name of the Dockerfile (default: `PATH/Dockerfile`) | +| `--iidfile` | `string` | | Write the image ID to a file | +| `--label` | `stringArray` | | Set metadata for an image | +| `--load` | `bool` | | Shorthand for `--output=type=docker` | +| `--metadata-file` | `string` | | Write build result metadata to a file | +| `--network` | `string` | `default` | Set the networking mode for the `RUN` instructions during build | +| `--no-cache` | `bool` | | Do not use cache when building the image | +| `--no-cache-filter` | `stringArray` | | Do not cache specified stages | +| `-o`, `--output` | `stringArray` | | Output destination (format: `type=local,dest=path`) | +| `--platform` | `stringArray` | | Set target platform for build | +| `--policy` | `stringArray` | | Policy configuration (format: `filename=path[,filename=path][,reset=true\|false][,disabled=true\|false][,strict=true\|false][,log-level=level]`) | +| `--progress` | `string` | `auto` | Set type of progress output (`auto`, `none`, `plain`, `quiet`, `rawjson`, `tty`). Use plain to show container output | +| `--provenance` | `string` | | Shorthand for `--attest=type=provenance` | +| `--pull` | `bool` | | Always attempt to pull all referenced images | +| `--push` | `bool` | | Shorthand for `--output=type=registry,unpack=false` | +| `-q`, `--quiet` | `bool` | | Suppress the build output and print image ID on success | +| `--sbom` | `string` | | Shorthand for `--attest=type=sbom` | +| `--secret` | `stringArray` | | Secret to expose to the build (format: `id=mysecret[,src=/local/secret]`) | +| `--shm-size` | `bytes` | `0` | Shared memory size for build containers | +| `--ssh` | `stringArray` | | SSH agent socket or keys to expose to the build (format: `default\|[=\|[,]]`) | +| `-t`, `--tag` | `stringArray` | | Image identifier (format: `[registry/]repository[:tag]`) | +| `--target` | `string` | | Set the target build stage to build | +| `--ulimit` | `ulimit` | | Ulimit options | diff --git a/docs/reference/buildx_policy.md b/docs/reference/buildx_policy.md new file mode 100644 index 000000000..642d1f865 --- /dev/null +++ b/docs/reference/buildx_policy.md @@ -0,0 +1,24 @@ +# docker buildx policy + + +Commands for working with build policies + +### Subcommands + +| Name | Description | +|:----------------------------------------------|:-----------------------------| +| [`eval`](buildx_policy_eval.md) | Evaluate policy for a source | +| [`json-schema`](buildx_policy_json-schema.md) | Print policy JSON schema | +| [`test`](buildx_policy_test.md) | Run policy tests | + + +### Options + +| Name | Type | Default | Description | +|:----------------|:---------|:--------|:-----------------------------------------| +| `--builder` | `string` | | Override the configured builder instance | +| `-D`, `--debug` | `bool` | | Enable debug logging | + + + + diff --git a/docs/reference/buildx_policy_eval.md b/docs/reference/buildx_policy_eval.md new file mode 100644 index 000000000..ad8d8d0b0 --- /dev/null +++ b/docs/reference/buildx_policy_eval.md @@ -0,0 +1,18 @@ +# docker buildx policy eval + + +Evaluate policy for a source + +### Options + +| Name | Type | Default | Description | +|:----------------|:--------------|:-------------|:-----------------------------------------| +| `--builder` | `string` | | Override the configured builder instance | +| `-D`, `--debug` | `bool` | | Enable debug logging | +| `--fields` | `stringSlice` | | Fields to evaluate | +| `--filename` | `string` | `Dockerfile` | Policy filename to evaluate | +| `--print` | `bool` | | Print policy output | + + + + diff --git a/docs/reference/buildx_policy_json-schema.md b/docs/reference/buildx_policy_json-schema.md new file mode 100644 index 000000000..61b391f05 --- /dev/null +++ b/docs/reference/buildx_policy_json-schema.md @@ -0,0 +1,15 @@ +# docker buildx policy json-schema + + +Print policy JSON schema + +### Options + +| Name | Type | Default | Description | +|:----------------|:---------|:--------|:-----------------------------------------| +| `--builder` | `string` | | Override the configured builder instance | +| `-D`, `--debug` | `bool` | | Enable debug logging | + + + + diff --git a/docs/reference/buildx_policy_test.md b/docs/reference/buildx_policy_test.md new file mode 100644 index 000000000..5781a9e42 --- /dev/null +++ b/docs/reference/buildx_policy_test.md @@ -0,0 +1,15 @@ +# docker buildx policy test + + +Run policy tests + +### Options + +| Name | Type | Default | Description | +|:----------------|:---------|:--------|:-----------------------------------------| +| `--builder` | `string` | | Override the configured builder instance | +| `-D`, `--debug` | `bool` | | Enable debug logging | + + + + diff --git a/policy/multipolicy_test.go b/policy/multipolicy_test.go index ff7a9e2f9..320f255d0 100644 --- a/policy/multipolicy_test.go +++ b/policy/multipolicy_test.go @@ -5,9 +5,9 @@ import ( "testing" gwpb "github.com/moby/buildkit/frontend/gateway/pb" + solverpb "github.com/moby/buildkit/solver/pb" moby_buildkit_v1_sourcepolicy "github.com/moby/buildkit/sourcepolicy/pb" "github.com/moby/buildkit/sourcepolicy/policysession" - solverpb "github.com/moby/buildkit/solver/pb" "github.com/stretchr/testify/require" ) diff --git a/policy/signatures.go b/policy/signatures.go index a34aff35e..982e545da 100644 --- a/policy/signatures.go +++ b/policy/signatures.go @@ -4,18 +4,57 @@ import ( "bytes" "context" "encoding/json" + "path/filepath" + "sync" "github.com/containerd/containerd/v2/core/content" "github.com/containerd/containerd/v2/core/remotes" cerrderfs "github.com/containerd/errdefs" + "github.com/docker/buildx/util/confutil" gwpb "github.com/moby/buildkit/frontend/gateway/pb" + policyverifier "github.com/moby/policy-helpers" policyimage "github.com/moby/policy-helpers/image" "github.com/opencontainers/go-digest" ocispecs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" ) -func (p *Policy) parseSignatures(ctx context.Context, ac *gwpb.AttestationChain, platform *ocispecs.Platform) ([]AttestationSignature, error) { +type PolicyVerifierProvider func() (*policyverifier.Verifier, error) + +func SignatureVerifier(cfg *confutil.Config) PolicyVerifierProvider { + if cfg == nil { + return nil + } + var ( + mu sync.Mutex + v *policyverifier.Verifier + ) + return func() (*policyverifier.Verifier, error) { + mu.Lock() + defer mu.Unlock() + + if v != nil { + return v, nil + } + + root := cfg.Dir() + confDir := filepath.Join(root, "policy") + if err := cfg.MkdirAll("policy/tuf", 0o755); err != nil { + return nil, errors.Wrapf(err, "failed to create policy verifier config dir") + } + + nv, err := policyverifier.NewVerifier(policyverifier.Config{ + StateDir: confDir, + }) + if err != nil { + return nil, errors.Wrapf(err, "failed to create policy verifier") + } + v = nv + return v, nil + } +} + +func parseSignatures(ctx context.Context, getVerifier PolicyVerifierProvider, ac *gwpb.AttestationChain, platform *ocispecs.Platform) ([]AttestationSignature, error) { if ac.Root == "" || ac.AttestationManifest == "" || len(ac.SignatureManifests) == 0 { return nil, nil } @@ -63,7 +102,10 @@ func (p *Policy) parseSignatures(ctx context.Context, ac *gwpb.AttestationChain, return nil, errors.Errorf("attestation manifest digest mismatch: expected %s, got %s", att, sc.AttestationManifest.Digest) } - v, err := p.getVerifier() + if getVerifier == nil { + return nil, errors.New("policy verifier is not configured") + } + v, err := getVerifier() if err != nil { return nil, errors.Wrapf(err, "getting policy verifier") } diff --git a/policy/validate.go b/policy/validate.go index f85151f43..3a7f56f60 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -9,7 +9,6 @@ import ( "net/url" "os" "path" - "path/filepath" "slices" "strconv" "strings" @@ -18,14 +17,12 @@ import ( "github.com/containerd/platforms" "github.com/distribution/reference" - "github.com/docker/buildx/util/confutil" gwpb "github.com/moby/buildkit/frontend/gateway/pb" "github.com/moby/buildkit/solver/pb" moby_buildkit_v1_sourcepolicy "github.com/moby/buildkit/sourcepolicy/pb" "github.com/moby/buildkit/sourcepolicy/policysession" "github.com/moby/buildkit/util/gitutil" "github.com/moby/buildkit/util/gitutil/gitobject" - policyverifier "github.com/moby/policy-helpers" "github.com/open-policy-agent/opa/v1/ast" "github.com/open-policy-agent/opa/v1/rego" "github.com/open-policy-agent/opa/v1/topdown/print" @@ -52,9 +49,6 @@ func debugf(format string, v ...any) { type Policy struct { opt Opt funcs []fun - - verifierMu sync.Mutex - verifier *policyverifier.Verifier } type state struct { @@ -77,11 +71,11 @@ type fun struct { } type Opt struct { - Files []File - Env Env - Log func(string) - FS func() (fs.StatFS, func() error, error) - Config *confutil.Config + Files []File + Env Env + Log func(string) + FS func() (fs.StatFS, func() error, error) + VerifierProvider PolicyVerifierProvider } var _ policysession.PolicyCallback = (&Policy{}).CheckPolicy @@ -99,227 +93,13 @@ func NewPolicy(opt Opt) *Policy { return p } -func (p *Policy) getVerifier() (*policyverifier.Verifier, error) { - p.verifierMu.Lock() - defer p.verifierMu.Unlock() - - if p.verifier != nil { - return p.verifier, nil - } - - root := p.opt.Config.Dir() - - confDir := filepath.Join(root, "policy") - if err := p.opt.Config.MkdirAll("policy/tuf", 0o755); err != nil { - return nil, errors.Wrapf(err, "failed to create policy verifier config dir") - } - - v, err := policyverifier.NewVerifier(policyverifier.Config{ - StateDir: confDir, - }) - if err != nil { - return nil, errors.Wrapf(err, "failed to create policy verifier") - } - p.verifier = v - return p.verifier, nil -} - func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *gwpb.ResolveSourceMetaRequest, error) { - var inp Input - var unknowns []string - inp.Env = p.opt.Env - if req.Source == nil || req.Source.Source == nil { return nil, nil, errors.Errorf("no source info in request") } src := req.Source - - scheme, refstr, ok := strings.Cut(src.Source.Identifier, "://") - if !ok { - return nil, nil, errors.Errorf("invalid source identifier: %s", src.Source.Identifier) - } - - switch scheme { - case "http", "https": - u, err := url.Parse(src.Source.Identifier) - if err != nil { - return nil, nil, errors.Wrapf(err, "failed to parse http source url") - } - inp.HTTP = &HTTP{ - URL: src.Source.Identifier, - Schema: scheme, - Host: u.Host, - Path: u.Path, - Query: u.Query(), - } - if _, ok := src.Source.Attrs[pb.AttrHTTPAuthHeaderSecret]; ok { - inp.HTTP.HasAuth = true - } - if req.Source.Image == nil { - unknowns = append(unknowns, "input.http.checksum") - } else { - inp.HTTP.Checksum = req.Source.Image.Digest - } - case "git": - if !gitutil.IsGitTransport(refstr) { - refstr = "https://" + refstr - } - u, err := gitutil.ParseURL(refstr) - if err != nil { - return nil, nil, err - } - g := &Git{ - Schema: u.Scheme, - Remote: u.Remote, - Host: u.Host, - } - var ref string - var isFullRef bool - if u.Opts != nil { - ref = u.Opts.Ref - g.Subdir = u.Opts.Subdir - if sd := path.Clean(g.Subdir); sd == "/" || sd == "." { - g.Subdir = "" - } - } - if v, ok := src.Source.Attrs[pb.AttrFullRemoteURL]; !ok { - if !gitutil.IsGitTransport(v) { - v = "https://" + v - } - u, err := gitutil.ParseURL(v) - if err != nil { - return nil, nil, err - } - g.Schema = u.Scheme - g.Remote = u.Remote - g.Host = u.Host - g.FullURL = v - } - if tag, ok := strings.CutPrefix(g.Ref, "refs/tags/"); ok { - g.TagName = tag - isFullRef = true - } - if branch, ok := strings.CutPrefix(g.Ref, "refs/heads/"); ok { - g.Branch = branch - isFullRef = true - } - - if gitutil.IsCommitSHA(ref) { - g.IsCommitRef = true - g.Checksum = ref - g.CommitChecksum = ref - isFullRef = true - } - - unk := []string{} - - if src.Git == nil { - if !isFullRef { - unk = append(unk, "tagName", "branch", "ref") - } else { - g.Ref = ref - } - if g.Checksum == "" { - unk = append(unk, "checksum", "isAnnotatedTag", "commitChecksum", "isSHA256") - } - unk = append(unk, "tag", "commit") - } else { - g.Ref = src.Git.Ref - if tag, ok := strings.CutPrefix(g.Ref, "refs/tags/"); ok { - g.TagName = tag - } - if branch, ok := strings.CutPrefix(g.Ref, "refs/heads/"); ok { - g.Branch = branch - } - g.Checksum = src.Git.Checksum - g.CommitChecksum = src.Git.CommitChecksum - if g.CommitChecksum == "" { - g.CommitChecksum = g.Checksum - } - if g.Checksum != g.CommitChecksum { - g.IsAnnotatedTag = true - } - - if len(src.Git.CommitObject) == 0 { - unk = append(unk, "commit", "tag") - } else { - obj, err := gitobject.Parse(src.Git.CommitObject) - if err != nil { - return nil, nil, err - } - if err := obj.VerifyChecksum(g.CommitChecksum); err != nil { - return nil, nil, err - } - c, err := obj.ToCommit() - if err != nil { - return nil, nil, err - } - g.Commit = &Commit{ - Tree: c.Tree, - Message: c.Message, - Parents: c.Parents, - Author: Actor(c.Author), - Committer: Actor(c.Committer), - obj: obj, - } - s := parseGitSignature(obj) - g.Commit.PGPSignature = s.PGPSignature - g.Commit.SSHSignature = s.SSHSignature - - if dt := src.Git.TagObject; len(dt) > 0 { - obj, err := gitobject.Parse(src.Git.TagObject) - if err != nil { - return nil, nil, err - } - if err := obj.VerifyChecksum(g.Checksum); err != nil { - return nil, nil, err - } - t, err := obj.ToTag() - if err != nil { - return nil, nil, err - } - g.Tag = &Tag{ - Object: t.Object, - Message: t.Message, - Type: t.Type, - Tag: t.Tag, - Tagger: Actor(t.Tagger), - obj: obj, - } - s := parseGitSignature(obj) - g.Tag.PGPSignature = s.PGPSignature - g.Tag.SSHSignature = s.SSHSignature - } - } - } - - if len(g.Checksum) == 64 { - g.IsSHA256 = true - } - - unknowns = append(unknowns, withPrefix(unk, "input.git.")...) - inp.Git = g - case "docker-image": - ref, err := reference.ParseNormalizedNamed(refstr) - if err != nil { - return nil, nil, errors.Wrapf(err, "failed to parse image source reference") - } - inp.Image = &Image{ - Ref: ref.String(), - Host: reference.Domain(ref), - Repo: reference.FamiliarName(ref), - FullRepo: ref.Name(), - } - if digested, ok := ref.(reference.Canonical); ok { - inp.Image.Checksum = digested.Digest().String() - inp.Image.IsCanonical = true - } - if tagged, ok := ref.(reference.Tagged); ok { - inp.Image.Tag = tagged.Tag() - } - if req.Platform == nil { - return nil, nil, errors.Errorf("platform required for image source") - } + var platform *ocispecs.Platform + if req.Platform != nil { platformStr := req.Platform.OS + "/" + req.Platform.Architecture if req.Platform.Variant != "" { platformStr += "/" + req.Platform.Variant @@ -329,62 +109,15 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy return nil, nil, errors.Wrapf(err, "failed to parse platform") } pl = platforms.Normalize(pl) - inp.Image.Platform = platforms.Format(pl) - inp.Image.OS = pl.OS - inp.Image.Architecture = pl.Architecture - inp.Image.Variant = pl.Variant - - configFields := []string{ - "labels", "user", "volumes", "workingDir", "env", - } - - if req.Source.Image == nil { - if !inp.Image.IsCanonical { - unknowns = append(unknowns, "input.image.checksum") - } - unknowns = append(unknowns, withPrefix(configFields, "input.image.")...) - unknowns = append(unknowns, "input.image.hasProvenance", "input.image.signatures") - } else { - inp.Image.Checksum = req.Source.Image.Digest - if cfg := req.Source.Image.Config; cfg != nil { - var img ocispecs.Image - if err := json.Unmarshal(cfg, &img); err != nil { - return nil, nil, errors.Wrapf(err, "failed to unmarshal image config") - } - 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 - inp.Image.Volumes = make([]string, 0, len(img.Config.Volumes)) - for v := range img.Config.Volumes { - inp.Image.Volumes = append(inp.Image.Volumes, v) - } - inp.Image.WorkingDir = img.Config.WorkingDir - } else { - unknowns = append(unknowns, withPrefix(configFields, "input.image.")...) - } - - if ac := req.Source.Image.AttestationChain; ac != nil { - inp.Image.HasProvenance = ac.AttestationManifest != "" - signatures, err := p.parseSignatures(ctx, ac, &pl) - if err != nil { - debugf("failed to parse image signatures: %v", err) - } else { - inp.Image.Signatures = signatures - } - } else { - unknowns = append(unknowns, "input.image.hasProvenance", "input.image.signatures") - } - } - case "local": - inp.Local = &Local{ - Name: refstr, - } - default: - // oci-layout not supported yet - return nil, nil, errors.Errorf("unsupported source scheme: %s", scheme) + platform = &pl } + inp, unknowns, err := SourceToInput(ctx, p.opt.VerifierProvider, src, platform) + if err != nil { + return nil, nil, errors.Wrapf(err, "failed to convert source to policy input") + } + inp.Env = p.opt.Env + caps := &ast.Capabilities{ Builtins: builtins(), Features: slices.Clone(ast.Features), @@ -509,48 +242,10 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy Source: req.Source.Source, Platform: req.Platform, } - unk2 := make([]string, 0, len(unk)) - for _, u := range unk { - k := strings.TrimPrefix(u, "input.") - k = trimKey(k) - switch k { - case "image", "git", "http", "local": - // parents are returned as unknowns for some reason, ignore - continue - default: - unk2 = append(unk2, k) - } + if err := AddUnknowns(next, unk); err != nil { + return nil, nil, err } - if len(unk2) > 0 { - debugf("collected unknowns: %+v", unk2) - for _, u := range unk2 { - switch u { - case "image.labels", "image.user", "image.volumes", "image.workingDir", "image.env": - if next.Image == nil { - next.Image = &gwpb.ResolveSourceImageRequest{} - } - next.Image.NoConfig = false - case "image.hasProvenance", "image.signatures": - if next.Image == nil { - next.Image = &gwpb.ResolveSourceImageRequest{ - NoConfig: true, - } - } - next.Image.AttestationChain = true - case "image.checksum": - - case "git.ref", "git.checksum", "git.commitChecksum", "git.isAnnotatedTag", "git.isSHA256", "git.tagName", "git.branch": - - case "git.commit", "git.tag": - if next.Git == nil { - next.Git = &gwpb.ResolveSourceGitRequest{} - } - next.Git.ReturnObject = true - - default: - return nil, nil, errors.Errorf("unhandled unknown property %s", u) - } - } + if next.Image != nil || next.Git != nil { debugf("next resolve meta request: %+v", next) return nil, next, nil } @@ -629,6 +324,261 @@ 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) { + var inp Input + var unknowns []string + + if src == nil || src.Source == nil { + return inp, nil, errors.Errorf("no source info in request") + } + + scheme, refstr, ok := strings.Cut(src.Source.Identifier, "://") + if !ok { + return inp, nil, errors.Errorf("invalid source identifier: %s", src.Source.Identifier) + } + + switch scheme { + case "http", "https": + u, err := url.Parse(src.Source.Identifier) + if err != nil { + return inp, nil, errors.Wrapf(err, "failed to parse http source url") + } + inp.HTTP = &HTTP{ + URL: src.Source.Identifier, + Schema: scheme, + Host: u.Host, + Path: u.Path, + Query: u.Query(), + } + if _, ok := src.Source.Attrs[pb.AttrHTTPAuthHeaderSecret]; ok { + inp.HTTP.HasAuth = true + } + if src.Image == nil { + unknowns = append(unknowns, "input.http.checksum") + } else { + inp.HTTP.Checksum = src.Image.Digest + } + case "git": + if !gitutil.IsGitTransport(refstr) { + refstr = "https://" + refstr + } + u, err := gitutil.ParseURL(refstr) + if err != nil { + return inp, nil, err + } + g := &Git{ + Schema: u.Scheme, + Remote: u.Remote, + Host: u.Host, + } + var ref string + var isFullRef bool + if u.Opts != nil { + ref = u.Opts.Ref + g.Subdir = u.Opts.Subdir + if sd := path.Clean(g.Subdir); sd == "/" || sd == "." { + g.Subdir = "" + } + } + if v, ok := src.Source.Attrs[pb.AttrFullRemoteURL]; !ok { + if !gitutil.IsGitTransport(v) { + v = "https://" + v + } + u, err := gitutil.ParseURL(v) + if err != nil { + return inp, nil, err + } + g.Schema = u.Scheme + g.Remote = u.Remote + g.Host = u.Host + g.FullURL = v + } + if tag, ok := strings.CutPrefix(g.Ref, "refs/tags/"); ok { + g.TagName = tag + isFullRef = true + } + if branch, ok := strings.CutPrefix(g.Ref, "refs/heads/"); ok { + g.Branch = branch + isFullRef = true + } + + if gitutil.IsCommitSHA(ref) { + g.IsCommitRef = true + g.Checksum = ref + g.CommitChecksum = ref + isFullRef = true + } + + unk := []string{} + + if src.Git == nil { + if !isFullRef { + unk = append(unk, "tagName", "branch", "ref") + } else { + g.Ref = ref + } + if g.Checksum == "" { + unk = append(unk, "checksum", "isAnnotatedTag", "commitChecksum", "isSHA256") + } + unk = append(unk, "tag", "commit") + } else { + g.Ref = src.Git.Ref + if tag, ok := strings.CutPrefix(g.Ref, "refs/tags/"); ok { + g.TagName = tag + } + if branch, ok := strings.CutPrefix(g.Ref, "refs/heads/"); ok { + g.Branch = branch + } + g.Checksum = src.Git.Checksum + g.CommitChecksum = src.Git.CommitChecksum + if g.CommitChecksum == "" { + g.CommitChecksum = g.Checksum + } + if g.Checksum != g.CommitChecksum { + g.IsAnnotatedTag = true + } + + if len(src.Git.CommitObject) == 0 { + unk = append(unk, "commit", "tag") + } else { + obj, err := gitobject.Parse(src.Git.CommitObject) + if err != nil { + return inp, nil, err + } + if err := obj.VerifyChecksum(g.CommitChecksum); err != nil { + return inp, nil, err + } + c, err := obj.ToCommit() + if err != nil { + return inp, nil, err + } + g.Commit = &Commit{ + Tree: c.Tree, + Message: c.Message, + Parents: c.Parents, + Author: Actor(c.Author), + Committer: Actor(c.Committer), + obj: obj, + } + s := parseGitSignature(obj) + g.Commit.PGPSignature = s.PGPSignature + g.Commit.SSHSignature = s.SSHSignature + + if dt := src.Git.TagObject; len(dt) > 0 { + obj, err := gitobject.Parse(src.Git.TagObject) + if err != nil { + return inp, nil, err + } + if err := obj.VerifyChecksum(g.Checksum); err != nil { + return inp, nil, err + } + t, err := obj.ToTag() + if err != nil { + return inp, nil, err + } + g.Tag = &Tag{ + Object: t.Object, + Message: t.Message, + Type: t.Type, + Tag: t.Tag, + Tagger: Actor(t.Tagger), + obj: obj, + } + s := parseGitSignature(obj) + g.Tag.PGPSignature = s.PGPSignature + g.Tag.SSHSignature = s.SSHSignature + } + } + } + + if len(g.Checksum) == 64 { + g.IsSHA256 = true + } + + unknowns = append(unknowns, withPrefix(unk, "input.git.")...) + inp.Git = g + case "docker-image": + ref, err := reference.ParseNormalizedNamed(refstr) + if err != nil { + return inp, nil, errors.Wrapf(err, "failed to parse image source reference") + } + inp.Image = &Image{ + Ref: ref.String(), + Host: reference.Domain(ref), + Repo: reference.FamiliarName(ref), + FullRepo: ref.Name(), + } + if digested, ok := ref.(reference.Canonical); ok { + inp.Image.Checksum = digested.Digest().String() + inp.Image.IsCanonical = true + } + if tagged, ok := ref.(reference.Tagged); ok { + inp.Image.Tag = tagged.Tag() + } + if platform == nil { + return inp, nil, errors.Errorf("platform required for image source") + } + inp.Image.Platform = platforms.Format(*platform) + inp.Image.OS = platform.OS + inp.Image.Architecture = platform.Architecture + inp.Image.Variant = platform.Variant + + configFields := []string{ + "labels", "user", "volumes", "workingDir", "env", + } + + if src.Image == nil { + if !inp.Image.IsCanonical { + unknowns = append(unknowns, "input.image.checksum") + } + unknowns = append(unknowns, withPrefix(configFields, "input.image.")...) + unknowns = append(unknowns, "input.image.hasProvenance", "input.image.signatures") + } else { + inp.Image.Checksum = src.Image.Digest + if cfg := src.Image.Config; cfg != nil { + var img ocispecs.Image + 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) + inp.Image.Labels = img.Config.Labels + inp.Image.Env = img.Config.Env + inp.Image.User = img.Config.User + inp.Image.Volumes = make([]string, 0, len(img.Config.Volumes)) + for v := range img.Config.Volumes { + inp.Image.Volumes = append(inp.Image.Volumes, v) + } + inp.Image.WorkingDir = img.Config.WorkingDir + } else { + unknowns = append(unknowns, withPrefix(configFields, "input.image.")...) + } + + if ac := src.Image.AttestationChain; ac != nil { + inp.Image.HasProvenance = ac.AttestationManifest != "" + if getVerifier != nil { + signatures, err := parseSignatures(ctx, getVerifier, ac, platform) + if err != nil { + debugf("failed to parse image signatures: %v", err) + } else { + inp.Image.Signatures = signatures + } + } + } else { + unknowns = append(unknowns, "input.image.hasProvenance", "input.image.signatures") + } + } + case "local": + inp.Local = &Local{ + Name: refstr, + } + default: + // oci-layout not supported yet + return inp, nil, errors.Errorf("unsupported source scheme: %s", scheme) + } + + return inp, unknowns, nil +} + func withPrefix(arr []string, prefix string) []string { out := make([]string, len(arr)) for i, s := range arr { @@ -637,6 +587,54 @@ func withPrefix(arr []string, prefix string) []string { return out } +func AddUnknowns(req *gwpb.ResolveSourceMetaRequest, unk []string) error { + unk2 := make([]string, 0, len(unk)) + for _, u := range unk { + k := strings.TrimPrefix(u, "input.") + k = trimKey(k) + switch k { + case "image", "git", "http", "local": + // parents are returned as unknowns for some reason, ignore + continue + default: + unk2 = append(unk2, k) + } + } + if len(unk2) == 0 { + return nil + } + + debugf("collected unknowns: %+v", unk2) + for _, u := range unk2 { + switch u { + case "image.checksum", "image.labels", "image.user", "image.volumes", "image.workingDir", "image.env": + if req.Image == nil { + req.Image = &gwpb.ResolveSourceImageRequest{} + } + req.Image.NoConfig = false + case "image.hasProvenance", "image.signatures": + if req.Image == nil { + req.Image = &gwpb.ResolveSourceImageRequest{ + NoConfig: true, + } + } + req.Image.AttestationChain = true + + case "git.ref", "git.checksum", "git.commitChecksum", "git.isAnnotatedTag", "git.isSHA256", "git.tagName", "git.branch": + + case "git.commit", "git.tag": + if req.Git == nil { + req.Git = &gwpb.ResolveSourceGitRequest{} + } + req.Git.ReturnObject = true + + default: + return errors.Errorf("unhandled unknown property %s", u) + } + } + return nil +} + func collectUnknowns(mods []*ast.Module) []string { seen := map[string]struct{}{} var out []string