Merge pull request #3895 from tonistiigi/exec-proxy-support

policy: enable proxy network from source policy caps
This commit is contained in:
Tõnis Tiigi
2026-06-10 14:09:56 -07:00
committed by GitHub
8 changed files with 444 additions and 57 deletions
+1
View File
@@ -140,6 +140,7 @@ type policyFileSpec struct {
type policyEvalOpt struct {
Strict bool
LogLevel *logrus.Level
SkipCaps bool
}
type policyOpt struct {
+26
View File
@@ -667,6 +667,7 @@ func configureSourcePolicy(ctx context.Context, np *noderesolver.ResolvedNode, o
Data: policy.DefaultPolicyData(),
}},
}
builtin.SkipCaps = true
popts = append([]policyOpt{builtin}, popts...)
}
@@ -742,6 +743,11 @@ func configureSourcePolicy(ctx context.Context, np *noderesolver.ResolvedNode, o
DefaultPlatform: defaultPlatform(bopts),
SourceResolver: sourceResolver,
})
if !popt.SkipCaps {
if err := applyPolicyCaps(ctx, p, bopts, so); err != nil {
return nil, err
}
}
policies = append(policies, p)
cbs = append(cbs, p.CheckPolicy)
if popt.Strict {
@@ -750,10 +756,30 @@ func configureSourcePolicy(ctx context.Context, np *noderesolver.ResolvedNode, o
}
}
}
if so.ProxyNetwork {
if policyLogger != nil {
policyLogger.Log("policy enabled network proxy")
}
}
so.SourcePolicyProvider = policysession.NewPolicyProvider(policy.MultiPolicyCallback(cbs...))
return defers, nil
}
func applyPolicyCaps(ctx context.Context, p *policy.Policy, bopts gateway.BuildOpts, so *client.SolveOpt) error {
caps, err := p.CheckCaps(ctx)
if err != nil {
return errors.Wrap(err, "failed to evaluate policy caps")
}
if !caps[policy.CapExecProxy] {
return nil
}
if err := bopts.LLBCaps.Supports(pb.CapExecMetaNetworkProxy); err != nil {
return errors.New("network proxy requested by policy is not supported by the current BuildKit daemon, please upgrade to version v0.31+")
}
so.ProxyNetwork = true
return nil
}
func policyEnvFilename(inp Inputs) string {
base := filepath.Base(filepath.Clean(inp.DockerfilePath))
if base != "." && base != string(filepath.Separator) {
+87
View File
@@ -5,11 +5,15 @@ import (
"sync"
"testing"
"github.com/docker/buildx/policy"
"github.com/docker/buildx/util/buildflags"
"github.com/docker/buildx/util/ocilayout"
"github.com/docker/buildx/util/progress"
"github.com/moby/buildkit/client"
"github.com/moby/buildkit/client/ociindex"
gateway "github.com/moby/buildkit/frontend/gateway/client"
"github.com/moby/buildkit/solver/pb"
"github.com/moby/buildkit/util/apicaps"
"github.com/opencontainers/go-digest"
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/pkg/errors"
@@ -163,6 +167,67 @@ func TestProxyArgKeyExists(t *testing.T) {
}
}
func TestApplyPolicyCapsEnablesProxyNetwork(t *testing.T) {
p := policyWithDecision(`
package docker
decision := {
"allow": false,
"caps": {"exec.proxy": true},
}
`)
var so client.SolveOpt
err := applyPolicyCaps(context.Background(), p, buildOptsWithCaps(pb.CapExecMetaNetworkProxy), &so)
require.NoError(t, err)
require.True(t, so.ProxyNetwork)
}
func TestApplyPolicyCapsOrsProxyNetwork(t *testing.T) {
falsePolicy := policyWithDecision(`
package docker
decision := {
"allow": true,
"caps": {"exec.proxy": false},
}
`)
truePolicy := policyWithDecision(`
package docker
decision := {
"allow": true,
"caps": {"exec.proxy": true},
}
`)
var so client.SolveOpt
err := applyPolicyCaps(context.Background(), falsePolicy, buildOptsWithCaps(pb.CapExecMetaNetworkProxy), &so)
require.NoError(t, err)
require.False(t, so.ProxyNetwork)
err = applyPolicyCaps(context.Background(), truePolicy, buildOptsWithCaps(pb.CapExecMetaNetworkProxy), &so)
require.NoError(t, err)
require.True(t, so.ProxyNetwork)
}
func TestApplyPolicyCapsRequiresBuildKitCap(t *testing.T) {
p := policyWithDecision(`
package docker
decision := {
"allow": true,
"caps": {"exec.proxy": true},
}
`)
var so client.SolveOpt
err := applyPolicyCaps(context.Background(), p, buildOptsWithCaps(), &so)
require.ErrorContains(t, err, "network proxy requested by policy is not supported by the current BuildKit daemon")
require.ErrorContains(t, err, "please upgrade to version v0.31+")
require.False(t, so.ProxyNetwork)
}
func TestLoadInputsOCILayoutNamedContext(t *testing.T) {
layoutPath := t.TempDir()
@@ -299,3 +364,25 @@ func (w *captureProgressWriter) ValidateLogSource(digest.Digest, any) bool { ret
func (w *captureProgressWriter) ClearLogSource(any) {}
var _ progress.Writer = (*captureProgressWriter)(nil)
func policyWithDecision(decision string) *policy.Policy {
return policy.NewPolicy(policy.Opt{
Files: []policy.File{{
Filename: "policy.rego",
Data: []byte(decision),
}},
})
}
func buildOptsWithCaps(caps ...apicaps.CapID) gateway.BuildOpts {
out := make([]*apicaps.PBCap, 0, len(caps))
for _, c := range caps {
out = append(out, &apicaps.PBCap{
ID: string(c),
Enabled: true,
})
}
return gateway.BuildOpts{
LLBCaps: pb.Caps.CapSet(out),
}
}
+14
View File
@@ -683,9 +683,23 @@ func decodeDecision(decision any) *Decision {
if len(denyMsgs) == 0 {
denyMsgs = nil
}
caps := Caps{}
if v, ok := obj["caps"]; ok {
if m, ok := v.(map[string]any); ok {
for k, entry := range m {
if b, ok := entry.(bool); ok {
caps[k] = b
}
}
}
}
if len(caps) == 0 {
caps = nil
}
return &Decision{
Allow: allow,
DenyMessages: denyMsgs,
Caps: caps,
}
}
+15 -5
View File
@@ -21,14 +21,24 @@ type Input struct {
type Decision struct {
Allow *bool `json:"allow,omitempty"`
DenyMessages []string `json:"deny_msg,omitempty"`
Caps Caps `json:"caps,omitempty"`
}
type Caps map[string]bool
const CapExecProxy = "exec.proxy"
var KnownCaps = map[string]struct{}{
CapExecProxy: {},
}
type Env struct {
Args map[string]*string `json:"args,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Filename string `json:"filename,omitempty"`
Target string `json:"target,omitempty"`
Depth int `json:"depth"`
Args map[string]*string `json:"args,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Filename string `json:"filename,omitempty"`
Target string `json:"target,omitempty"`
CapsRequest bool `json:"capsRequest,omitempty"`
Depth int `json:"depth"`
}
type HTTP struct {
+145 -52
View File
@@ -130,27 +130,7 @@ func (p *Policy) IsPolicyError(err error) bool {
return false
}
func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *gwpb.ResolveSourceMetaRequest, error) {
if req.Source == nil || req.Source.Source == nil {
return nil, nil, errors.Errorf("no source info in request")
}
var platform *ocispecs.Platform
if req.Platform != nil {
pl, err := platformFromReq(req)
if err != nil {
return nil, nil, err
}
platform = pl
} else {
platform = p.opt.DefaultPlatform
}
inp, err := SourceToInput(ctx, p.opt.VerifierProvider, req.Source, platform, p.opt.Log)
if err != nil {
return nil, nil, errors.Wrap(err, "failed to build policy input")
}
func (p *Policy) regoBaseOpts() ([]func(*rego.Rego), func(), error) {
caps := &ast.Capabilities{
Builtins: builtins(),
Features: slices.Clone(ast.Features),
@@ -171,11 +151,11 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy
var root fs.StatFS
var closeFS func() error
defer func() {
closeRoot := func() {
if closeFS != nil {
closeFS()
}
}()
}
comp = comp.WithModuleLoader(func(resolved map[string]*ast.Module) (parsed map[string]*ast.Module, err error) {
out := make(map[string]*ast.Module)
@@ -240,6 +220,36 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy
baseOpts = append(baseOpts, rego.Module(file.Filename, string(file.Data)))
}
return baseOpts, closeRoot, nil
}
func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *gwpb.ResolveSourceMetaRequest, error) {
if req.Source == nil || req.Source.Source == nil {
return nil, nil, errors.Errorf("no source info in request")
}
var platform *ocispecs.Platform
if req.Platform != nil {
pl, err := platformFromReq(req)
if err != nil {
return nil, nil, err
}
platform = pl
} else {
platform = p.opt.DefaultPlatform
}
inp, err := SourceToInput(ctx, p.opt.VerifierProvider, req.Source, platform, p.opt.Log)
if err != nil {
return nil, nil, errors.Wrap(err, "failed to build policy input")
}
baseOpts, closeRoot, err := p.regoBaseOpts()
if err != nil {
return nil, nil, err
}
defer closeRoot()
p.log(logrus.InfoLevel, "checking policy for source %s", sourceName(req))
for range maxResolveIterations {
@@ -304,42 +314,23 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy
continue
}
if len(rs) == 0 {
return nil, nil, errors.Errorf("policy returned zero result")
decision, err := policyDecisionFromResult(rs)
if err != nil {
return nil, nil, err
}
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)
p.log(logrus.DebugLevel, "policy response: %+v", decision)
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 decision.Allow != nil && *decision.Allow {
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,
})
}
}
}
for _, m := range decision.DenyMessages {
resp.DenyMessages = append(resp.DenyMessages, &policysession.DenyMessage{
Message: m,
})
}
if resp.Action == moby_buildkit_v1_sourcepolicy.PolicyAction_ALLOW {
@@ -373,6 +364,108 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy
return nil, nil, errors.Errorf("maximum attempts reached for resolving policy metadata")
}
func (p *Policy) CheckCaps(ctx context.Context) (Caps, error) {
baseOpts, closeRoot, err := p.regoBaseOpts()
if err != nil {
return nil, err
}
defer closeRoot()
env := p.opt.Env
env.CapsRequest = true
runInput := Input{}
applyEnvWithDepth(&runInput, 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))
}
dt, err := json.MarshalIndent(runInput, "", " ")
if err != nil {
return nil, errors.Wrapf(err, "failed to marshal policy input")
}
p.log(logrus.DebugLevel, "policy input: %s", dt)
rs, err := rego.New(runOpts...).Eval(ctx)
if err != nil {
return nil, err
}
if refs := runtimeUnknownInputRefs(st); len(refs) > 0 || st.checksumNeededForSignature != nil {
return nil, errors.Errorf("policy caps request cannot resolve source metadata: %+v", summarizeUnknownsForLog(refs))
}
decision, err := policyDecisionFromResult(rs)
if err != nil {
return nil, err
}
if len(decision.Caps) == 0 {
return nil, nil
}
return decision.Caps, nil
}
func policyDecisionFromResult(rs rego.ResultSet) (*Decision, error) {
if len(rs) == 0 {
return nil, errors.Errorf("policy returned zero result")
}
rsz := rs[0]
if len(rsz.Expressions) == 0 {
return nil, errors.Errorf("policy returned zero expressions")
}
v := rsz.Expressions[0].Value
vt, ok := v.(map[string]any)
if !ok {
return nil, errors.Errorf("unexpected policy return type: %T %s", v, rsz.Expressions[0].Text)
}
return parsePolicyDecision(vt)
}
func parsePolicyDecision(vt map[string]any) (*Decision, error) {
decision := &Decision{}
if v, ok := vt["allow"]; ok {
vv, ok := v.(bool)
if !ok {
return nil, errors.Errorf("invalid allowed property type %T, expecting bool", v)
}
decision.Allow = &vv
}
if v, ok := vt["deny_msg"]; ok {
if vv, ok := v.([]any); ok {
for _, m := range vv {
if m, ok := m.(string); ok {
decision.DenyMessages = append(decision.DenyMessages, m)
}
}
}
}
if v, ok := vt["caps"]; ok {
vv, ok := v.(map[string]any)
if !ok {
return nil, errors.Errorf("invalid caps property type %T, expecting object", v)
}
decision.Caps = make(Caps, len(vv))
for k, cv := range vv {
if _, ok := KnownCaps[k]; !ok {
return nil, errors.Errorf("unknown policy cap %q", k)
}
b, ok := cv.(bool)
if !ok {
return nil, errors.Errorf("invalid caps.%s property type %T, expecting bool", k, cv)
}
decision.Caps[k] = b
}
}
return decision, nil
}
func (p *Policy) resolveUnknowns(ctx context.Context, input *Input, req *policysession.CheckPolicyRequest, defaultPlatform *ocispecs.Platform, unk []string, st *state) (bool, *gwpb.ResolveSourceMetaRequest, error) {
var resolver SourceMetadataResolver
if p.opt.SourceResolver != nil {
+80
View File
@@ -883,6 +883,86 @@ func TestSourceToInputSingleSource(t *testing.T) {
}
}
func TestCheckCaps(t *testing.T) {
p := NewPolicy(Opt{
Files: []File{{
Filename: "policy.rego",
Data: []byte(`
package docker
decision := {
"allow": false,
"deny_msg": ["ignored for caps"],
"caps": {
"exec.proxy": input.env.capsRequest,
},
} if {
input.env.filename == "Dockerfile"
input.env.target == "release"
input.env.args.MODE == "prod"
}
`),
}},
Env: Env{
Args: map[string]*string{"MODE": stringPtr("prod")},
Filename: "Dockerfile",
Target: "release",
},
})
caps, err := p.CheckCaps(context.Background())
require.NoError(t, err)
require.Equal(t, Caps{
CapExecProxy: true,
}, caps)
}
func TestCheckCapsMalformedCaps(t *testing.T) {
p := NewPolicy(Opt{
Files: []File{{
Filename: "policy.rego",
Data: []byte(`
package docker
decision := {
"allow": true,
"caps": {
"exec.proxy": "yes",
},
}
`),
}},
})
_, err := p.CheckCaps(context.Background())
require.ErrorContains(t, err, "invalid caps.exec.proxy property type string, expecting bool")
}
func TestCheckCapsUnknownCaps(t *testing.T) {
p := NewPolicy(Opt{
Files: []File{{
Filename: "policy.rego",
Data: []byte(`
package docker
decision := {
"allow": true,
"caps": {
"exec.unknown": true,
},
}
`),
}},
})
_, err := p.CheckCaps(context.Background())
require.ErrorContains(t, err, `unknown policy cap "exec.unknown"`)
}
func stringPtr(v string) *string {
return &v
}
func mustMarshalImageConfig(t *testing.T, img ocispecs.Image) []byte {
t.Helper()
dt, err := json.Marshal(img)
+76
View File
@@ -40,6 +40,82 @@ var policyBuildTests = []func(t *testing.T, sb integration.Sandbox){
testBuildPolicyRemotePolicyFiles,
testBuildPolicyRemoteHTTPPolicyFiles,
testBuildPolicyConfigFlags,
testBuildPolicyCapsProxy,
testBuildPolicyCapsProxyUnsupported,
}
func policyCapsProxyFile(serverURL string) []byte {
return fmt.Appendf(nil, `
package docker
default allow = true
default deny_msg := []
default caps := {}
caps := {"exec.proxy": true} if input.env.capsRequest
allow := false if input.http.url == "%s/file"
deny_msg := ["exec proxy http source denied by policy"] if input.http.url == "%s/file"
decision := {"allow": allow, "deny_msg": deny_msg, "caps": caps}
`, serverURL, serverURL)
}
func testBuildPolicyCapsProxy(t *testing.T, sb integration.Sandbox) {
if buildkitTag() != "master" {
skipNoCompatBuildKit(t, sb, ">= 0.31.0-0", "network proxy requires BuildKit v0.31.0+")
}
resp := &httpserver.Response{Content: []byte("policy-caps-proxy")}
server := httpserver.NewTestServer(map[string]*httpserver.Response{
"/file": resp,
})
defer server.Close()
dockerfile := []byte(`
FROM busybox:latest
RUN wget -O- ` + server.URL + `/file
`)
dir := tmpdir(
t,
fstest.CreateFile("Dockerfile", dockerfile, 0600),
fstest.CreateFile("Dockerfile.rego", policyCapsProxyFile(server.URL), 0600),
)
cmd := buildxCmd(sb, withDir(dir), withArgs(
"build",
"--progress=plain",
"--output=type=cacheonly",
dir,
))
out, err := cmd.CombinedOutput()
require.Error(t, err, string(out))
require.Contains(t, string(out), "policy enabled network proxy")
require.Contains(t, string(out), "exec proxy http source denied by policy")
require.Contains(t, string(out), "policy decision for source "+server.URL+"/file")
require.Contains(t, string(out), "DENY")
}
func testBuildPolicyCapsProxyUnsupported(t *testing.T, sb integration.Sandbox) {
skipNoCompatBuildKit(t, sb, ">= 0.26.0-0", "policy input requires BuildKit v0.26.0+")
if buildkitTag() == "master" || matchesBuildKitVersion(t, sb, ">= 0.31.0-0") {
t.Skip("BuildKit daemon supports network proxy")
}
dockerfile := []byte(`
FROM scratch
COPY foo /foo
`)
dir := tmpdir(
t,
fstest.CreateFile("Dockerfile", dockerfile, 0600),
fstest.CreateFile("Dockerfile.rego", policyCapsProxyFile(""), 0600),
fstest.CreateFile("foo", []byte("foo"), 0600),
)
cmd := buildxCmd(sb, withDir(dir), withArgs(
"build",
"--progress=plain",
"--output=type=cacheonly",
dir,
))
out, err := cmd.CombinedOutput()
require.Error(t, err, string(out))
require.Contains(t, string(out), "network proxy requested by policy is not supported by the current BuildKit daemon")
}
func testBuildPolicyAllow(t *testing.T, sb integration.Sandbox) {