diff --git a/build/build.go b/build/build.go index 4297fc2ce727..18b66f7143e0 100644 --- a/build/build.go +++ b/build/build.go @@ -140,6 +140,7 @@ type policyFileSpec struct { type policyEvalOpt struct { Strict bool LogLevel *logrus.Level + SkipCaps bool } type policyOpt struct { diff --git a/build/opt.go b/build/opt.go index 8f0410aee112..838c255054f9 100644 --- a/build/opt.go +++ b/build/opt.go @@ -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) { diff --git a/build/opt_test.go b/build/opt_test.go index 0687556819dd..9327d211d37c 100644 --- a/build/opt_test.go +++ b/build/opt_test.go @@ -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), + } +} diff --git a/policy/tester.go b/policy/tester.go index 5cc193e62fe1..4baae4cb2ac3 100644 --- a/policy/tester.go +++ b/policy/tester.go @@ -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, } } diff --git a/policy/types.go b/policy/types.go index d7f80a8f4f86..b0ae44daa220 100644 --- a/policy/types.go +++ b/policy/types.go @@ -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 { diff --git a/policy/validate.go b/policy/validate.go index 37d1470f8adc..0f8a7bfac34c 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -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") - } - 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) + decision, err := policyDecisionFromResult(rs) + if err != nil { + return nil, nil, err } - 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 { diff --git a/policy/validate_test.go b/policy/validate_test.go index efb95918b711..73e8a5664b1d 100644 --- a/policy/validate_test.go +++ b/policy/validate_test.go @@ -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) diff --git a/tests/policy_build.go b/tests/policy_build.go index 35837416b84f..3c1fabf35c0d 100644 --- a/tests/policy_build.go +++ b/tests/policy_build.go @@ -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) {