Files
buildkit/client/policy_test.go
Tonis Tiigi 93999f4071 sourcepolicy: normalize parsed source identifiers
Render parsed source identifiers back to their canonical SourceOp form before
source policy evaluation. This lets Git subdir cleanup use the existing source
parser and avoids policy-specific Git parsing.

Add String methods for source identifiers and cover them with unit tests, plus
a client integration regression for canonical Git subdir policy matching.

Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
2026-07-22 11:36:47 +02:00

1801 lines
58 KiB
Go

package client
import (
"context"
"crypto"
"crypto/sha256"
"crypto/sha512"
"encoding/json"
"fmt"
"hash"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/containerd/platforms"
intoto "github.com/in-toto/in-toto-golang/in_toto"
"github.com/moby/buildkit/client/llb"
"github.com/moby/buildkit/client/llb/sourceresolver"
gateway "github.com/moby/buildkit/frontend/gateway/client"
pb "github.com/moby/buildkit/frontend/gateway/pb"
solvererrdefs "github.com/moby/buildkit/solver/errdefs"
provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types"
opspb "github.com/moby/buildkit/solver/pb"
"github.com/moby/buildkit/sourcepolicy"
sourcepolicypb "github.com/moby/buildkit/sourcepolicy/pb"
"github.com/moby/buildkit/sourcepolicy/policysession"
"github.com/moby/buildkit/util/entitlements"
"github.com/moby/buildkit/util/pgpsign"
"github.com/moby/buildkit/util/testutil/integration"
"github.com/moby/buildkit/util/testutil/workers"
policyimage "github.com/moby/policy-helpers/image"
digest "github.com/opencontainers/go-digest"
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
)
func testProxyNetworkNoRootless(t *testing.T, sb integration.Sandbox) {
integration.SkipOnPlatform(t, "windows")
ctx := sb.Context()
c, err := New(ctx, sb.Address())
require.NoError(t, err)
defer c.Close()
payload := []byte("buildkit proxy ok\n")
convertedPayload := []byte("buildkit proxy converted ok\n")
httpSrv, httpURL := newProxyHTTPServer(t, "0.0.0.0", testHostIP(t), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/allowed":
_, _ = w.Write(payload)
case "/bar":
_, _ = w.Write(convertedPayload)
default:
http.NotFound(w, r)
return
}
}))
defer httpSrv.Close()
var leakHit atomic.Int32
leakSrv, leakURL := newProxyHTTPServer(t, "0.0.0.0", testHostIP(t), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
leakHit.Add(1)
_, _ = w.Write([]byte("host namespace leak\n"))
}))
defer leakSrv.Close()
_, leakPort, err := net.SplitHostPort(strings.TrimPrefix(leakURL, "http://"))
require.NoError(t, err)
st := llb.Image("alpine:latest").
Run(llb.Shlexf(`sh -c 'wget -q -O- %s/allowed | grep "buildkit proxy ok"'`, httpURL)).
Root().
Run(llb.Shlex(`sh -c '! wget -S -O- https://buildkit-ca-test.invalid/denied 2>/tmp/wget.log; grep "HTTP/1.1 502 Bad Gateway" /tmp/wget.log'`)).
Root().
Run(llb.Shlex(`sh -c 'unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy ALL_PROXY all_proxy NO_PROXY no_proxy; ! wget -T 2 -q -O- http://1.1.1.1/'`)).
Root().
Run(llb.Shlexf(`sh -c 'proxy=${HTTP_PROXY#http://}; host=${proxy%%:*}; unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy ALL_PROXY all_proxy NO_PROXY no_proxy; ! wget -T 2 -q -O- http://$host:%s/'`, leakPort)).
Root().
Run(llb.Shlex(`sh -c 'grep "buildkit proxy CA begin" /etc/ssl/certs/ca-certificates.crt'`)).
Root().
Run(llb.Shlex(`sh -c '! grep "buildkit proxy CA begin" /etc/ssl/certs/ca-certificates.crt'`), llb.Network(llb.NetModeNone))
def, err := st.Marshal(ctx)
require.NoError(t, err)
_, err = c.Solve(ctx, def, SolveOpt{
ProxyNetwork: true,
}, nil)
require.NoError(t, err)
require.Equal(t, int32(0), leakHit.Load())
var checked atomic.Int32
denyProvider := policysession.NewPolicyProvider(func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
if req.Source.Source.Identifier != httpURL+"/allowed" {
return &policysession.DecisionResponse{
Action: sourcepolicypb.PolicyAction_ALLOW,
}, nil, nil
}
checked.Add(1)
return &policysession.DecisionResponse{
Action: sourcepolicypb.PolicyAction_DENY,
}, nil, nil
})
deny := llb.Image("alpine:latest").
Run(llb.Shlexf(`wget -q -O- %s/allowed`, httpURL), llb.IgnoreCache)
def, err = deny.Marshal(ctx)
require.NoError(t, err)
_, err = c.Solve(ctx, def, SolveOpt{
ProxyNetwork: true,
SourcePolicyProvider: denyProvider,
}, nil)
require.Error(t, err)
require.Equal(t, int32(1), checked.Load())
destDir := t.TempDir()
withProvenance := llb.Image("alpine:latest").
Run(llb.Shlexf(`sh -c 'wget -q -O /out/proxy-material %s/allowed'`, httpURL)).
AddMount("/out", llb.Scratch())
def, err = withProvenance.Marshal(ctx)
require.NoError(t, err)
materialURL := httpURL + "/allowed"
statusCh := make(chan *SolveStatus)
logsCh := make(chan string, 1)
go func() {
var b strings.Builder
for st := range statusCh {
for _, l := range st.Logs {
b.Write(l.Data)
}
}
logsCh <- b.String()
}()
_, err = c.Solve(ctx, def, SolveOpt{
ProxyNetwork: true,
FrontendAttrs: map[string]string{
"attest:provenance": "mode=max,version=v1",
},
Exports: []ExportEntry{{
Type: ExporterLocal,
OutputDir: destDir,
}},
}, statusCh)
require.NoError(t, err)
logOutput := <-logsCh
require.Contains(t, logOutput, "proxy network requests:\n- GET "+materialURL)
dt, err := os.ReadFile(filepath.Join(destDir, "proxy-material"))
require.NoError(t, err)
require.Equal(t, payload, dt)
provDt, err := os.ReadFile(filepath.Join(destDir, "provenance.json"))
require.NoError(t, err)
var stmt struct {
intoto.StatementHeader
Predicate provenancetypes.ProvenancePredicateSLSA1 `json:"predicate"`
}
require.NoError(t, json.Unmarshal(provDt, &stmt))
foundMaterial := false
expectedDigest := digest.FromBytes(payload)
for _, m := range stmt.Predicate.BuildDefinition.ResolvedDependencies {
if m.URI == materialURL {
foundMaterial = true
require.Equal(t, expectedDigest.Hex(), m.Digest["sha256"])
}
}
require.True(t, foundMaterial, "expected to find %q in %+v", materialURL, stmt.Predicate.BuildDefinition.ResolvedDependencies)
require.False(t, stmt.Predicate.RunDetails.Metadata.Hermetic)
require.True(t, stmt.Predicate.RunDetails.Metadata.Completeness.ResolvedDependencies)
require.NotNil(t, stmt.Predicate.RunDetails.Metadata.BuildKitMetadata.Network)
require.Equal(t, "proxy", stmt.Predicate.RunDetails.Metadata.BuildKitMetadata.Network.Mode)
convertDestDir := t.TempDir()
convertFooURL := httpURL + "/foo"
convertBarURL := httpURL + "/bar"
convert := llb.Image("alpine:latest").
Run(llb.Shlexf(`sh -c 'wget -q -O /out/proxy-material %s'`, convertFooURL)).
AddMount("/out", llb.Scratch())
def, err = convert.Marshal(ctx)
require.NoError(t, err)
_, err = c.Solve(ctx, def, SolveOpt{
ProxyNetwork: true,
SourcePolicy: &sourcepolicypb.Policy{
Rules: []*sourcepolicypb.Rule{
{
Action: sourcepolicypb.PolicyAction_CONVERT,
Selector: &sourcepolicypb.Selector{
Identifier: convertFooURL,
},
Updates: &sourcepolicypb.Update{
Identifier: convertBarURL,
},
},
},
},
FrontendAttrs: map[string]string{
"attest:provenance": "mode=max,version=v1",
},
Exports: []ExportEntry{{
Type: ExporterLocal,
OutputDir: convertDestDir,
}},
}, nil)
require.NoError(t, err)
dt, err = os.ReadFile(filepath.Join(convertDestDir, "proxy-material"))
require.NoError(t, err)
require.Equal(t, convertedPayload, dt)
provDt, err = os.ReadFile(filepath.Join(convertDestDir, "provenance.json"))
require.NoError(t, err)
require.NoError(t, json.Unmarshal(provDt, &stmt))
foundMaterial = false
expectedDigest = digest.FromBytes(convertedPayload)
for _, m := range stmt.Predicate.BuildDefinition.ResolvedDependencies {
require.NotEqual(t, convertFooURL, m.URI)
if m.URI == convertBarURL {
foundMaterial = true
require.Equal(t, expectedDigest.Hex(), m.Digest["sha256"])
}
}
require.True(t, foundMaterial, "expected to find %q in %+v", convertBarURL, stmt.Predicate.BuildDefinition.ResolvedDependencies)
strict := llb.Image("alpine:latest").
Run(llb.Shlexf(`sh -c 'wget -q -O- %s/missing || true'`, httpURL)).
AddMount("/out", llb.Scratch())
def, err = strict.Marshal(ctx)
require.NoError(t, err)
_, err = c.Solve(ctx, def, SolveOpt{
ProxyNetwork: true,
FrontendAttrs: map[string]string{
"attest:provenance": "mode=max,version=v1,complete-materials=true",
},
Exports: []ExportEntry{{
Type: ExporterLocal,
OutputDir: t.TempDir(),
}},
}, nil)
require.Error(t, err)
require.ErrorContains(t, err, "provenance materials are incomplete")
require.ErrorContains(t, err, "/missing")
var materialsErr *solvererrdefs.ProvenanceMaterialsIncompleteError
require.ErrorAs(t, err, &materialsErr)
require.Len(t, materialsErr.Incomplete, 1)
require.Equal(t, httpURL+"/missing", materialsErr.Incomplete[0].Uri)
require.Equal(t, "unsuccessful_response", materialsErr.Incomplete[0].Reason)
}
func testProxyNetworkModesNoRootless(t *testing.T, sb integration.Sandbox) {
integration.SkipOnPlatform(t, "windows")
workers.CheckFeatureCompat(t, sb, workers.FeatureCNINetwork)
if os.Getenv("BUILDKIT_RUN_NETWORK_INTEGRATION_TESTS") == "" {
t.SkipNow()
}
if sb.Rootless() {
t.SkipNow()
}
ctx := sb.Context()
c, err := New(ctx, sb.Address())
require.NoError(t, err)
defer c.Close()
hostPayload := []byte("proxy host ok\n")
var hostHit atomic.Int32
hostSrv, hostURL := newProxyHTTPServer(t, "127.0.0.1", "127.0.0.1", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hostHit.Add(1)
if r.URL.Path != "/host" {
http.NotFound(w, r)
return
}
_, _ = w.Write(hostPayload)
}))
defer hostSrv.Close()
hostPath := hostURL + "/host"
internetURL := "http://example.com/"
allowedHost := []string{entitlements.EntitlementNetworkHost.String()}
defaultHasHostLoopback := proxyNetModeDefaultHasHostLoopback(sb)
// Host mode without the network.host entitlement should be rejected before exec starts.
hostWithoutEntitlement := llb.Image("alpine:latest").
Run(llb.Shlexf(`wget -q -O- %s`, hostPath), llb.Network(llb.NetModeHost), llb.IgnoreCache).
Root()
def, err := hostWithoutEntitlement.Marshal(ctx)
require.NoError(t, err)
_, err = c.Solve(ctx, def, SolveOpt{
ProxyNetwork: true,
}, nil)
require.Error(t, err)
require.ErrorContains(t, err, "network.host is not allowed")
require.Equal(t, int32(0), hostHit.Load())
runProxySolve := func(name string, st llb.State, allowedEntitlements []string) (string, error) {
t.Helper()
def, err := st.Marshal(ctx)
require.NoError(t, err, name)
statusCh := make(chan *SolveStatus)
logsCh := make(chan string, 1)
go func() {
var b strings.Builder
for st := range statusCh {
for _, l := range st.Logs {
b.Write(l.Data)
}
}
logsCh <- b.String()
}()
_, err = c.Solve(ctx, def, SolveOpt{
ProxyNetwork: true,
AllowedEntitlements: allowedEntitlements,
}, statusCh)
return <-logsCh, err
}
// Default proxy egress should allow normal external network access.
logOutput, err := runProxySolve("default internet", llb.Image("alpine:latest").
Run(llb.Shlexf(`wget -q -O /tmp/internet %s`, internetURL), llb.IgnoreCache).
Root(), nil)
require.NoError(t, err)
require.Contains(t, logOutput, "proxy network requests:\n- GET "+internetURL+" -> 200")
require.Equal(t, int32(0), hostHit.Load())
expectedHostHits := int32(0)
if defaultHasHostLoopback {
// Proxy UNSET should follow the worker default provider, including host fallback/default.
logOutput, err = runProxySolve("default host fallback", llb.Image("alpine:latest").
Run(llb.Shlexf(`sh -c 'env NO_PROXY= no_proxy= wget -q -O- %s | grep "proxy host ok"'`, hostPath), llb.IgnoreCache).
Root(), nil)
require.NoError(t, err)
require.Contains(t, logOutput, "proxy network requests:\n- GET "+hostPath+" -> 200")
expectedHostHits = 1
require.Equal(t, expectedHostHits, hostHit.Load())
} else {
// Bridge proxy egress should not reach services bound to buildkitd host loopback.
logOutput, err = runProxySolve("bridge host", llb.Image("alpine:latest").
// Clear NO_PROXY so the localhost URL is forced through the BuildKit proxy.
Run(llb.Shlexf(`sh -c '! env NO_PROXY= no_proxy= wget -T 2 -q -O- %s'`, hostPath), llb.IgnoreCache).
Root(), nil)
require.NoError(t, err)
require.Contains(t, logOutput, "proxy network requests:\n- GET "+hostPath+" -> 502")
require.Equal(t, expectedHostHits, hostHit.Load())
}
// Host proxy egress is allowed only with the network.host entitlement.
logOutput, err = runProxySolve("host allowed", llb.Image("alpine:latest").
Run(llb.Shlexf(`sh -c 'env NO_PROXY= no_proxy= wget -q -O- %s | grep "proxy host ok"'`, hostPath), llb.Network(llb.NetModeHost), llb.IgnoreCache).
Root(), allowedHost)
require.NoError(t, err)
require.Contains(t, logOutput, "proxy network requests:\n- GET "+hostPath+" -> 200")
expectedHostHits++
require.Equal(t, expectedHostHits, hostHit.Load())
// None mode should not inject proxy env or allow external network access.
logOutput, err = runProxySolve("none internet", llb.Image("alpine:latest").
Run(llb.Shlexf(`sh -c '! env | grep -i "^HTTP_PROXY="; ! wget -T 2 -q -O- %s'`, internetURL), llb.Network(llb.NetModeNone), llb.IgnoreCache).
Root(), nil)
require.NoError(t, err)
require.NotContains(t, logOutput, internetURL)
// None mode should also be unable to reach buildkitd host loopback.
logOutput, err = runProxySolve("none host", llb.Image("alpine:latest").
Run(llb.Shlexf(`sh -c '! env | grep -i "^HTTP_PROXY="; ! wget -T 2 -q -O- %s'`, hostPath), llb.Network(llb.NetModeNone), llb.IgnoreCache).
Root(), nil)
require.NoError(t, err)
require.NotContains(t, logOutput, hostPath)
require.Equal(t, expectedHostHits, hostHit.Load())
}
func testProxyNetworkDefaultEgressNoRootless(t *testing.T, sb integration.Sandbox) {
integration.SkipOnPlatform(t, "windows")
if sb.Rootless() {
t.SkipNow()
}
ctx := sb.Context()
c, err := New(ctx, sb.Address())
require.NoError(t, err)
defer c.Close()
hostSrv, hostURL := newProxyHTTPServer(t, "127.0.0.1", "127.0.0.1", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/host-default", "/denied":
default:
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte("proxy host default ok\n"))
}))
defer hostSrv.Close()
runProxySolve := func(st llb.State, opt SolveOpt) (string, error) {
t.Helper()
def, err := st.Marshal(ctx)
require.NoError(t, err)
statusCh := make(chan *SolveStatus)
logsCh := make(chan string, 1)
go func() {
var b strings.Builder
for st := range statusCh {
for _, l := range st.Logs {
b.Write(l.Data)
}
}
logsCh <- b.String()
}()
_, err = c.Solve(ctx, def, opt, statusCh)
return <-logsCh, err
}
defaultHasHostLoopback := proxyNetModeDefaultHasHostLoopback(sb)
hostDefaultCmd := llb.Shlexf(`sh -c 'env NO_PROXY= no_proxy= wget -q -O- %s/host-default | grep "proxy host default ok"'`, hostURL)
if !defaultHasHostLoopback {
hostDefaultCmd = llb.Shlexf(`sh -c '! env NO_PROXY= no_proxy= wget -T 2 -q -O- %s/host-default'`, hostURL)
}
st := llb.Image("alpine:latest").
Run(hostDefaultCmd, llb.IgnoreCache).
Root()
logOutput, err := runProxySolve(st, SolveOpt{
ProxyNetwork: true,
})
require.NoError(t, err)
if defaultHasHostLoopback {
require.Contains(t, logOutput, "proxy network requests:\n- GET "+hostURL+"/host-default -> 200")
} else {
require.Contains(t, logOutput, "proxy network requests:\n- GET "+hostURL+"/host-default -> 502")
}
var checked atomic.Int32
denyProvider := policysession.NewPolicyProvider(func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
if req.Source.Source.Identifier == hostURL+"/denied" {
checked.Add(1)
return &policysession.DecisionResponse{
Action: sourcepolicypb.PolicyAction_DENY,
}, nil, nil
}
return &policysession.DecisionResponse{
Action: sourcepolicypb.PolicyAction_ALLOW,
}, nil, nil
})
deny := llb.Image("alpine:latest").
Run(llb.Shlexf(`env NO_PROXY= no_proxy= wget -S -O- %s/denied`, hostURL), llb.IgnoreCache).
Root()
logOutput, err = runProxySolve(deny, SolveOpt{
ProxyNetwork: true,
SourcePolicyProvider: denyProvider,
})
require.Error(t, err)
require.ErrorContains(t, err, "exit code: 1")
require.Contains(t, logOutput, "HTTP/1.1 403 Forbidden")
require.Equal(t, int32(1), checked.Load())
require.NotContains(t, err.Error(), "unknown proxy egress network mode UNSET")
}
func proxyNetModeDefaultHasHostLoopback(sb integration.Sandbox) bool {
if sb.DockerAddress() != "" {
return false
}
switch sb.Value("netmode").(type) {
case *netModeProxyDefaultNoCNI, *netModeProxyHost:
return true
default:
return false
}
}
func newProxyHTTPServer(t *testing.T, listenHost, urlHost string, handler http.Handler) (*httptest.Server, string) {
t.Helper()
var lc net.ListenConfig
ln, err := lc.Listen(t.Context(), "tcp4", net.JoinHostPort(listenHost, "0"))
require.NoError(t, err)
srv := httptest.NewUnstartedServer(handler)
srv.Listener = ln
srv.Start()
_, port, err := net.SplitHostPort(ln.Addr().String())
require.NoError(t, err)
return srv, "http://" + net.JoinHostPort(urlHost, port)
}
func testHostIP(t *testing.T) string {
t.Helper()
conn, err := (&net.Dialer{}).DialContext(t.Context(), "udp4", "8.8.8.8:80")
if err == nil {
defer conn.Close()
if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok && !addr.IP.IsLoopback() {
return addr.IP.String()
}
}
ifaces, err := net.Interfaces()
require.NoError(t, err)
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
require.NoError(t, err)
for _, addr := range addrs {
ipnet, ok := addr.(*net.IPNet)
if !ok {
continue
}
ip := ipnet.IP.To4()
if ip != nil && !ip.IsLoopback() {
return ip.String()
}
}
}
t.Fatal("could not find non-loopback host IP for proxy integration test")
return ""
}
func testSourcePolicySession(t *testing.T, sb integration.Sandbox) {
requiresLinux(t)
ctx := sb.Context()
c, err := New(ctx, sb.Address())
require.NoError(t, err)
defer c.Close()
type tcase struct {
name string
state func() llb.State
callbacks []policysession.PolicyCallback
expectedError string
}
tcases := []tcase{
{
name: "basic alpine",
state: func() llb.State { return llb.Image("alpine") },
callbacks: []policysession.PolicyCallback{
func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
require.Equal(t, runtime.GOOS, req.Platform.OS)
require.Equal(t, runtime.GOARCH, req.Platform.Architecture)
require.Equal(t, "docker-image://docker.io/library/alpine:latest", req.Source.Source.Identifier)
return &policysession.DecisionResponse{
Action: sourcepolicypb.PolicyAction_ALLOW,
}, nil, nil
},
},
},
{
name: "alpine with attrs",
state: func() llb.State { return llb.Image("alpine", llb.WithLayerLimit(1)) },
callbacks: []policysession.PolicyCallback{
func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
require.Equal(t, "docker-image://docker.io/library/alpine:latest", req.Source.Source.Identifier)
require.Equal(t, map[string]string{
"image.layerlimit": "1",
}, req.Source.Source.Attrs)
return &policysession.DecisionResponse{
Action: sourcepolicypb.PolicyAction_ALLOW,
}, nil, nil
},
},
},
{
name: "deny alpine",
state: func() llb.State { return llb.Image("alpine") },
callbacks: []policysession.PolicyCallback{
func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
require.Equal(t, "docker-image://docker.io/library/alpine:latest", req.Source.Source.Identifier)
return nil, nil, errors.New("policy denied")
},
},
expectedError: "policy denied",
},
{
name: "alpine with digest policy",
state: func() llb.State { return llb.Image("alpine") },
callbacks: []policysession.PolicyCallback{
func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
require.Equal(t, "docker-image://docker.io/library/alpine:latest", req.Source.Source.Identifier)
require.Nil(t, req.Source.Image)
return nil, &pb.ResolveSourceMetaRequest{
Source: req.Source.Source,
Platform: req.Platform,
}, nil
},
func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
require.Equal(t, "docker-image://docker.io/library/alpine:latest", req.Source.Source.Identifier)
require.NotEmpty(t, req.Source.Image.Digest)
_, err := digest.Parse(req.Source.Image.Digest)
require.NoError(t, err)
require.NotEmpty(t, req.Source.Image.Config)
var cfg ocispecs.Image
err = json.Unmarshal(req.Source.Image.Config, &cfg)
require.NoError(t, err)
require.NotEmpty(t, cfg.RootFS)
return &policysession.DecisionResponse{
Action: sourcepolicypb.PolicyAction_ALLOW,
}, nil, nil
},
},
},
}
for _, tc := range tcases {
t.Run(tc.name, func(t *testing.T) {
st := tc.state()
def, err := st.Marshal(ctx)
require.NoError(t, err)
callCounter := 0
p := policysession.NewPolicyProvider(func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
if callCounter >= len(tc.callbacks) {
return nil, nil, errors.Errorf("too many calls to policy callback %d", callCounter)
}
cb := tc.callbacks[callCounter]
callCounter++
return cb(ctx, req)
})
_, err = c.Solve(ctx, def, SolveOpt{
SourcePolicyProvider: p,
}, nil)
if tc.expectedError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.expectedError)
return
}
require.NoError(t, err)
require.Equal(t, len(tc.callbacks), callCounter, "not all policy callbacks were called")
})
}
}
func testSourcePolicySessionDenyMessages(t *testing.T, sb integration.Sandbox) {
requiresLinux(t)
ctx := sb.Context()
c, err := New(ctx, sb.Address())
require.NoError(t, err)
defer c.Close()
def, err := llb.Image("alpine").Marshal(ctx)
require.NoError(t, err)
p := policysession.NewPolicyProvider(func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
require.Equal(t, "docker-image://docker.io/library/alpine:latest", req.Source.Source.Identifier)
return &policysession.DecisionResponse{
Action: sourcepolicypb.PolicyAction_DENY,
DenyMessages: []*policysession.DenyMessage{
{Message: "policy blocked alpine"},
{Message: "use busybox instead"},
},
}, nil, nil
})
_, err = c.Solve(ctx, def, SolveOpt{
SourcePolicyProvider: p,
}, nil)
require.Error(t, err)
denyMessages := policysession.DenyMessages(err)
require.Len(t, denyMessages, 2)
require.Equal(t, "policy blocked alpine", denyMessages[0].GetMessage())
require.Equal(t, "use busybox instead", denyMessages[1].GetMessage())
}
func testSourceMetaPolicySession(t *testing.T, sb integration.Sandbox) {
requiresLinux(t)
ctx := sb.Context()
c, err := New(ctx, sb.Address())
require.NoError(t, err)
defer c.Close()
type tcase struct {
name string
source func() (*opspb.SourceOp, sourceresolver.Opt)
callbacks []policysession.PolicyCallback
expectedError string
}
tcases := []tcase{
{
name: "basic alpine",
source: func() (*opspb.SourceOp, sourceresolver.Opt) {
p := platforms.DefaultSpec()
return &opspb.SourceOp{
Identifier: "docker-image://docker.io/library/alpine:latest",
}, sourceresolver.Opt{
ImageOpt: &sourceresolver.ResolveImageOpt{
Platform: &p,
},
}
},
callbacks: []policysession.PolicyCallback{
func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
require.Equal(t, runtime.GOOS, req.Platform.OS)
require.Equal(t, runtime.GOARCH, req.Platform.Architecture)
require.Equal(t, "docker-image://docker.io/library/alpine:latest", req.Source.Source.Identifier)
return &policysession.DecisionResponse{
Action: sourcepolicypb.PolicyAction_ALLOW,
}, nil, nil
},
},
},
{
name: "alpine denied",
source: func() (*opspb.SourceOp, sourceresolver.Opt) {
return &opspb.SourceOp{
Identifier: "docker-image://docker.io/library/alpine:latest",
}, sourceresolver.Opt{}
},
callbacks: []policysession.PolicyCallback{
func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
require.Equal(t, "docker-image://docker.io/library/alpine:latest", req.Source.Source.Identifier)
return nil, nil, errors.New("policy denied")
},
},
expectedError: "policy denied",
},
}
for _, tc := range tcases {
t.Run(tc.name, func(t *testing.T) {
callCounter := 0
p := policysession.NewPolicyProvider(func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
if callCounter >= len(tc.callbacks) {
return nil, nil, errors.Errorf("too many calls to policy callback %d", callCounter)
}
cb := tc.callbacks[callCounter]
callCounter++
return cb(ctx, req)
})
_, err = c.Build(ctx, SolveOpt{
SourcePolicyProvider: p,
}, "test", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) {
sop, opts := tc.source()
_, err = c.ResolveSourceMetadata(ctx, sop, opts)
return nil, err
}, nil)
if tc.expectedError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.expectedError)
return
}
require.NoError(t, err)
require.Equal(t, len(tc.callbacks), callCounter, "not all policy callbacks were called")
})
}
}
func testSourceMetaPolicySessionResolveAttestations(t *testing.T, sb integration.Sandbox) {
workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush, workers.FeatureProvenance)
requiresLinux(t)
ctx := sb.Context()
c, err := New(ctx, sb.Address())
require.NoError(t, err)
defer c.Close()
target, platform := buildProvenanceImage(ctx, t, c, sb)
sourceID := "docker-image://" + target
requestedPredicateType := policyimage.SLSAProvenancePredicateType1
callbackCalls := 0
p := policysession.NewPolicyProvider(func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
switch callbackCalls {
case 0:
callbackCalls++
require.Equal(t, sourceID, req.Source.Source.Identifier)
require.Nil(t, req.Source.Image)
return nil, &pb.ResolveSourceMetaRequest{
Source: req.Source.Source,
Platform: req.Platform,
Image: &pb.ResolveSourceImageRequest{
NoConfig: true,
ResolveAttestations: []string{requestedPredicateType},
},
}, nil
case 1:
callbackCalls++
require.Equal(t, sourceID, req.Source.Source.Identifier)
require.NotNil(t, req.Source.Image)
require.Empty(t, req.Source.Image.Config)
require.NotNil(t, req.Source.Image.AttestationChain)
ac := req.Source.Image.AttestationChain
require.NotEmpty(t, ac.AttestationManifest)
att, ok := ac.Blobs[ac.AttestationManifest]
require.True(t, ok)
require.NotEmpty(t, att.Data)
var manifest ocispecs.Manifest
require.NoError(t, json.Unmarshal(att.Data, &manifest))
require.NotEmpty(t, manifest.Layers)
foundRequestedType := false
imageManifestDigest, err := digest.Parse(ac.ImageManifest)
require.NoError(t, err)
for _, layer := range manifest.Layers {
layerPredicateType := layer.Annotations["in-toto.io/predicate-type"]
if layerPredicateType != requestedPredicateType {
continue
}
foundRequestedType = true
blob, ok := ac.Blobs[string(layer.Digest)]
require.True(t, ok, "missing blob for requested predicate type %q", layerPredicateType)
require.NotEmpty(t, blob.Data, "empty blob data for requested predicate type %q", layerPredicateType)
var stmt intoto.Statement
require.NoError(t, json.Unmarshal(blob.Data, &stmt))
require.Equal(t, intoto.StatementInTotoV1, stmt.Type)
require.Equal(t, layerPredicateType, stmt.PredicateType)
require.NotEmpty(t, stmt.Subject)
require.Equal(t, imageManifestDigest.Hex(), stmt.Subject[0].Digest["sha256"])
}
require.True(t, foundRequestedType, "requested predicate type %q not found in attestation manifest layers", requestedPredicateType)
return &policysession.DecisionResponse{
Action: sourcepolicypb.PolicyAction_ALLOW,
}, nil, nil
default:
return nil, nil, errors.Errorf("too many policy callbacks: %d", callbackCalls)
}
})
_, err = c.Build(ctx, SolveOpt{
SourcePolicyProvider: p,
}, "test", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) {
_, err := c.ResolveSourceMetadata(ctx, &opspb.SourceOp{
Identifier: sourceID,
}, sourceresolver.Opt{
ImageOpt: &sourceresolver.ResolveImageOpt{
Platform: &platform,
},
})
return nil, err
}, nil)
require.NoError(t, err)
require.Equal(t, 2, callbackCalls)
}
func testSourcePolicyParallelSession(t *testing.T, sb integration.Sandbox) {
requiresLinux(t)
ctx := sb.Context()
c, err := New(ctx, sb.Address())
require.NoError(t, err)
defer c.Close()
def, err := llb.Image("alpine").File(llb.Copy(llb.Image("busybox"), "/etc/passwd", "passwd2")).Marshal(ctx)
require.NoError(t, err)
countAlpine := 0
countBusybox := 0
waitBusyboxStart := make(chan struct{})
waitAlpineDone := make(chan struct{})
p := policysession.NewPolicyProvider(func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
switch req.Source.Source.Identifier {
case "docker-image://docker.io/library/alpine:latest":
switch countAlpine {
case 0:
<-waitBusyboxStart
require.Nil(t, req.Source.Image)
countAlpine++
return nil, &pb.ResolveSourceMetaRequest{
Source: req.Source.Source,
Platform: req.Platform,
}, nil
case 1:
require.NotNil(t, req.Source.Image)
require.True(t, strings.HasPrefix(req.Source.Image.Digest, "sha256:"))
countAlpine++
close(waitAlpineDone)
return &policysession.DecisionResponse{
Action: sourcepolicypb.PolicyAction_ALLOW,
}, nil, nil
default:
require.Fail(t, "too many calls for alpine")
}
case "docker-image://docker.io/library/busybox:latest":
time.Sleep(200 * time.Millisecond)
close(waitBusyboxStart)
countBusybox++
<-waitAlpineDone
return &policysession.DecisionResponse{
Action: sourcepolicypb.PolicyAction_ALLOW,
}, nil, nil
}
return nil, nil, errors.Errorf("unexpected source %q", req.Source.Source.Identifier)
})
_, err = c.Solve(ctx, def, SolveOpt{
SourcePolicyProvider: p,
}, nil)
require.NoError(t, err)
require.Equal(t, 2, countAlpine)
require.Equal(t, 1, countBusybox)
}
func testSourcePolicySignedCommit(t *testing.T, sb integration.Sandbox) {
requiresLinux(t)
ctx := sb.Context()
c, err := New(ctx, sb.Address())
require.NoError(t, err)
defer c.Close()
signFixturesPath, ok := os.LookupEnv("BUILDKIT_TEST_SIGN_FIXTURES")
if !ok {
t.Skip("missing BUILDKIT_TEST_SIGN_FIXTURES")
}
withSign := func(user, method string) []string {
return []string{
"GIT_CONFIG_GLOBAL=" + filepath.Join(signFixturesPath, user+"."+method+".gitconfig"),
}
}
gitDir := t.TempDir()
gitCommands := []string{
"git init",
"git config --local user.email test",
"git config --local user.name test",
"echo a > a",
"git add a",
"git commit -m a",
"git tag -a v0.1 -m v0.1",
}
err = runInDir(gitDir, gitCommands...)
require.NoError(t, err)
gitCommands = []string{
"echo b > b",
"git add b",
"git commit -m b",
"git checkout -B v2",
}
err = runInDirEnv(gitDir, withSign("user1", "gpg"), gitCommands...)
require.NoError(t, err)
gitCommands = []string{
"git tag -s -a v2.0 -m v2.0-tag",
"git update-server-info",
}
err = runInDirEnv(gitDir, withSign("user2", "ssh"), gitCommands...)
require.NoError(t, err)
server := httptest.NewServer(http.FileServer(http.Dir(filepath.Clean(gitDir))))
defer server.Close()
pubKeyUser1gpg, err := os.ReadFile(filepath.Join(signFixturesPath, "user1.gpg.pub"))
require.NoError(t, err)
pubKeyUser2ssh, err := os.ReadFile(filepath.Join(signFixturesPath, "user2.ssh.pub"))
require.NoError(t, err)
type testCase struct {
state func() llb.State
name string
srcPol *sourcepolicypb.Policy
expectedErr string
}
gitURL := "git://" + strings.TrimPrefix(server.URL, "http://") + "/.git"
tests := []testCase{
{
name: "unsigned commit fails",
srcPol: &sourcepolicypb.Policy{
Rules: []*sourcepolicypb.Rule{
{
Action: sourcepolicypb.PolicyAction_CONVERT,
Selector: &sourcepolicypb.Selector{
Identifier: gitURL + "#v0.1",
},
Updates: &sourcepolicypb.Update{
Identifier: gitURL + "#v0.1",
Attrs: map[string]string{
"git.sig.pubkey": string(pubKeyUser1gpg),
},
},
},
},
},
state: func() llb.State {
return llb.Git(server.URL+"/.git", "", llb.GitRef("v0.1"))
},
expectedErr: "git object is not signed",
},
{
name: "valid gpg signature for branch",
srcPol: &sourcepolicypb.Policy{
Rules: []*sourcepolicypb.Rule{
{
Action: sourcepolicypb.PolicyAction_CONVERT,
Selector: &sourcepolicypb.Selector{
Identifier: gitURL + "#v2",
},
Updates: &sourcepolicypb.Update{
Identifier: gitURL + "#v2",
Attrs: map[string]string{
"git.sig.pubkey": string(pubKeyUser1gpg),
"git.sig.rejectexpired": "true",
"git.sig.ignoresignedtag": "false",
},
},
},
},
},
state: func() llb.State {
return llb.Git(server.URL+"/.git", "", llb.GitRef("v2"))
},
},
{
name: "valid ssh signature for signed tag",
srcPol: &sourcepolicypb.Policy{
Rules: []*sourcepolicypb.Rule{
{
Action: sourcepolicypb.PolicyAction_CONVERT,
Selector: &sourcepolicypb.Selector{
Identifier: gitURL + "#v2.0",
},
Updates: &sourcepolicypb.Update{
Identifier: gitURL + "#v2.0",
Attrs: map[string]string{
"git.sig.pubkey": string(pubKeyUser2ssh),
"git.sig.requiresignedtag": "true",
"git.sig.rejectexpired": "true",
},
},
},
},
},
state: func() llb.State {
return llb.Git(server.URL+"/.git", "", llb.GitRef("v2.0"))
},
},
{
name: "invalid ssh signature for signed tag",
srcPol: &sourcepolicypb.Policy{
Rules: []*sourcepolicypb.Rule{
{
Action: sourcepolicypb.PolicyAction_CONVERT,
Selector: &sourcepolicypb.Selector{
Identifier: gitURL + "#v2.0",
},
Updates: &sourcepolicypb.Update{
Identifier: gitURL + "#v2.0",
Attrs: map[string]string{
"git.sig.pubkey": string(pubKeyUser1gpg),
"git.sig.requiresignedtag": "true",
"git.sig.rejectexpired": "true",
},
},
},
},
},
expectedErr: "failed to parse ssh public key",
state: func() llb.State {
return llb.Git(server.URL+"/.git", "", llb.GitRef("v2.0"))
},
},
{
name: "commit ssh signature for signed tag",
srcPol: &sourcepolicypb.Policy{
Rules: []*sourcepolicypb.Rule{
{
Action: sourcepolicypb.PolicyAction_CONVERT,
Selector: &sourcepolicypb.Selector{
Identifier: gitURL + "#v2.0",
},
Updates: &sourcepolicypb.Update{
Identifier: gitURL + "#v2.0",
Attrs: map[string]string{
"git.sig.pubkey": string(pubKeyUser1gpg),
"git.sig.requiresignedtag": "false",
"git.sig.rejectexpired": "true",
},
},
},
},
},
state: func() llb.State {
return llb.Git(server.URL+"/.git", "", llb.GitRef("v2.0"))
},
},
{
name: "invalid tag signature for commit",
srcPol: &sourcepolicypb.Policy{
Rules: []*sourcepolicypb.Rule{
{
Action: sourcepolicypb.PolicyAction_CONVERT,
Selector: &sourcepolicypb.Selector{
Identifier: gitURL + "#v2.0",
},
Updates: &sourcepolicypb.Update{
Identifier: gitURL + "#v2.0",
Attrs: map[string]string{
"git.sig.pubkey": string(pubKeyUser2ssh),
"git.sig.rejectexpired": "true",
"git.sig.ignoresignedtag": "true",
},
},
},
},
},
expectedErr: "failed to read armored public key",
state: func() llb.State {
return llb.Git(server.URL+"/.git", "", llb.GitRef("v2.0"))
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) {
st := llb.Scratch().File(
llb.Copy(tt.state(), "a", "/a2"),
)
def, err := st.Marshal(sb.Context())
if err != nil {
return nil, err
}
return c.Solve(ctx, gateway.SolveRequest{
Definition: def.ToPB(),
})
}
_, err := c.Build(sb.Context(), SolveOpt{
SourcePolicy: tt.srcPol,
}, "", frontend, nil)
if tt.expectedErr == "" {
require.NoError(t, err, "test case %q failed", tt.name)
return
}
require.ErrorContains(t, err, tt.expectedErr, "test case %q failed", tt.name)
})
}
// session policy based test cases
type tcase struct {
name string
state func() llb.State
callbacks []policysession.PolicyCallback
expectedError string
}
tcases := []tcase{
{
name: "gitchecksum",
state: func() llb.State { return llb.Git(server.URL+"/.git", "", llb.GitRef("v2.0")) },
callbacks: []policysession.PolicyCallback{
func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
require.Equal(t, gitURL+"#v2.0", req.Source.Source.Identifier)
require.Nil(t, req.Source.Git)
return nil, &pb.ResolveSourceMetaRequest{
Source: req.Source.Source,
Platform: req.Platform,
}, nil
},
func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
require.Equal(t, gitURL+"#v2.0", req.Source.Source.Identifier)
require.NotNil(t, req.Source.Git)
require.Len(t, req.Source.Git.Checksum, 40)
require.Len(t, req.Source.Git.CommitChecksum, 40)
require.NotEqual(t, req.Source.Git.Checksum, req.Source.Git.CommitChecksum)
require.Nil(t, req.Source.Git.CommitObject)
return &policysession.DecisionResponse{
Action: sourcepolicypb.PolicyAction_ALLOW,
}, nil, nil
},
},
},
{
name: "gitobjects",
state: func() llb.State { return llb.Git(server.URL+"/.git", "", llb.GitRef("v2.0")) },
callbacks: []policysession.PolicyCallback{
func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
require.Equal(t, gitURL+"#v2.0", req.Source.Source.Identifier)
require.Nil(t, req.Source.Git)
return nil, &pb.ResolveSourceMetaRequest{
Source: req.Source.Source,
Platform: req.Platform,
Git: &pb.ResolveSourceGitRequest{
ReturnObject: true,
},
}, nil
},
func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
require.Equal(t, gitURL+"#v2.0", req.Source.Source.Identifier)
require.NotNil(t, req.Source.Git)
require.Len(t, req.Source.Git.Checksum, 40)
require.Len(t, req.Source.Git.CommitChecksum, 40)
require.NotEqual(t, req.Source.Git.Checksum, req.Source.Git.CommitChecksum)
require.NotNil(t, req.Source.Git.CommitObject)
require.Greater(t, len(req.Source.Git.CommitObject), 50)
return &policysession.DecisionResponse{
Action: sourcepolicypb.PolicyAction_ALLOW,
}, nil, nil
},
},
},
}
for _, tc := range tcases {
t.Run(tc.name, func(t *testing.T) {
st := tc.state()
def, err := st.Marshal(ctx)
require.NoError(t, err)
callCounter := 0
p := policysession.NewPolicyProvider(func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
if callCounter >= len(tc.callbacks) {
return nil, nil, errors.Errorf("too many calls to policy callback %d", callCounter)
}
cb := tc.callbacks[callCounter]
callCounter++
return cb(ctx, req)
})
_, err = c.Solve(ctx, def, SolveOpt{
SourcePolicyProvider: p,
}, nil)
if tc.expectedError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.expectedError)
return
}
require.NoError(t, err)
require.Equal(t, len(tc.callbacks), callCounter, "not all policy callbacks were called")
})
}
}
func testSourcePolicySessionConvert(t *testing.T, sb integration.Sandbox) {
requiresLinux(t)
ctx := sb.Context()
c, err := New(ctx, sb.Address())
require.NoError(t, err)
defer c.Close()
type tcase struct {
name string
state func() llb.State
callbacks []policysession.PolicyCallback
expectedError string
}
tcases := []tcase{
{
name: "convert and allow",
state: func() llb.State { return llb.Image("alpine") },
callbacks: []policysession.PolicyCallback{
func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
require.Equal(t, "docker-image://docker.io/library/alpine:latest", req.Source.Source.Identifier)
require.Nil(t, req.Source.Image)
src := req.Source.Source
src.Identifier = "docker-image://docker.io/library/busybox:latest"
if src.Attrs == nil {
src.Attrs = map[string]string{}
}
src.Attrs["foo"] = "bar"
return &policysession.DecisionResponse{
Action: sourcepolicypb.PolicyAction_CONVERT,
Update: src,
}, nil, nil
},
func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
require.Equal(t, "docker-image://docker.io/library/busybox:latest", req.Source.Source.Identifier)
require.Nil(t, req.Source.Image)
require.Equal(t, "bar", req.Source.Source.Attrs["foo"])
return &policysession.DecisionResponse{
Action: sourcepolicypb.PolicyAction_ALLOW,
}, nil, nil
},
},
},
{
name: "convert and deny",
state: func() llb.State { return llb.Image("alpine") },
callbacks: []policysession.PolicyCallback{
func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
require.Equal(t, "docker-image://docker.io/library/alpine:latest", req.Source.Source.Identifier)
require.Nil(t, req.Source.Image)
src := req.Source.Source
if src.Attrs == nil {
src.Attrs = map[string]string{}
}
src.Attrs["foo"] = "bar"
return &policysession.DecisionResponse{
Action: sourcepolicypb.PolicyAction_CONVERT,
Update: src,
}, nil, nil
},
func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
require.Equal(t, "docker-image://docker.io/library/alpine:latest", req.Source.Source.Identifier)
require.Nil(t, req.Source.Image)
require.Equal(t, "bar", req.Source.Source.Attrs["foo"])
src := req.Source.Source
src.Attrs["foo"] = "baz"
return &policysession.DecisionResponse{
Action: sourcepolicypb.PolicyAction_CONVERT,
Update: src,
}, nil, nil
},
func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
require.Equal(t, "docker-image://docker.io/library/alpine:latest", req.Source.Source.Identifier)
require.Nil(t, req.Source.Image)
require.Equal(t, "baz", req.Source.Source.Attrs["foo"])
return &policysession.DecisionResponse{
Action: sourcepolicypb.PolicyAction_DENY,
}, nil, nil
},
},
expectedError: "not allowed by policy",
},
}
for _, tc := range tcases {
t.Run(tc.name, func(t *testing.T) {
st := tc.state()
def, err := st.Marshal(ctx)
require.NoError(t, err)
callCounter := 0
p := policysession.NewPolicyProvider(func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
if callCounter >= len(tc.callbacks) {
return nil, nil, errors.Errorf("too many calls to policy callback %d", callCounter)
}
cb := tc.callbacks[callCounter]
callCounter++
return cb(ctx, req)
})
_, err = c.Solve(ctx, def, SolveOpt{
SourcePolicyProvider: p,
}, nil)
if tc.expectedError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.expectedError)
return
}
require.NoError(t, err)
require.Equal(t, len(tc.callbacks), callCounter, "not all policy callbacks were called")
})
}
// policy loop test
t.Run("convert loop", func(t *testing.T) {
def, err := llb.Image("alpine").Marshal(ctx)
require.NoError(t, err)
calls := 0
p := policysession.NewPolicyProvider(func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
require.Equal(t, "docker-image://docker.io/library/alpine:latest", req.Source.Source.Identifier)
require.Nil(t, req.Source.Image)
calls++
return &policysession.DecisionResponse{
Action: sourcepolicypb.PolicyAction_CONVERT,
Update: req.Source.Source,
}, nil, nil
})
_, err = c.Solve(ctx, def, SolveOpt{
SourcePolicyProvider: p,
}, nil)
require.ErrorContains(t, err, "too many policy requests")
require.Equal(t, 10, calls) // this is not strict value but just to make sure calls happened. future version may optimize this with less calls.
})
}
func testSourcePolicySessionHTTPChecksumAssist(t *testing.T, sb integration.Sandbox) {
requiresLinux(t)
ctx := sb.Context()
c, err := New(ctx, sb.Address())
require.NoError(t, err)
defer c.Close()
signFixturesPath, ok := os.LookupEnv("BUILDKIT_TEST_SIGN_FIXTURES")
if !ok {
t.Skip("missing BUILDKIT_TEST_SIGN_FIXTURES")
}
payload, err := os.ReadFile(filepath.Join(signFixturesPath, "user1.http.artifact"))
require.NoError(t, err)
sigData, err := os.ReadFile(filepath.Join(signFixturesPath, "user1.http.artifact.asc"))
require.NoError(t, err)
pubKeyData, err := os.ReadFile(filepath.Join(signFixturesPath, "user1.gpg.pub"))
require.NoError(t, err)
sig, _, err := pgpsign.ParseArmoredDetachedSignature(sigData)
require.NoError(t, err)
keyring, err := pgpsign.ReadAllArmoredKeyRings(pubKeyData)
require.NoError(t, err)
httpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/artifact.txt" {
http.NotFound(w, r)
return
}
_, _ = w.Write(payload)
}))
defer httpSrv.Close()
def, err := llb.Scratch().File(llb.Copy(llb.HTTP(httpSrv.URL+"/artifact.txt"), "artifact.txt", "/artifact.txt")).Marshal(ctx)
require.NoError(t, err)
t.Run("valid checksum request", func(t *testing.T) {
algo, err := toPBChecksumAlgo(sig.Hash)
require.NoError(t, err)
expectedDigest, err := payloadWithSuffixDigest(sig.Hash, payload, sig.HashSuffix)
require.NoError(t, err)
callCounter := 0
p := policysession.NewPolicyProvider(func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
switch callCounter {
case 0:
callCounter++
return nil, &pb.ResolveSourceMetaRequest{
Source: req.Source.Source,
Platform: req.Platform,
HTTP: &pb.ResolveSourceHTTPRequest{
ChecksumRequest: &pb.ChecksumRequest{
Algo: algo,
Suffix: slices.Clone(sig.HashSuffix),
},
},
}, nil
case 1:
callCounter++
require.NotNil(t, req.Source.HTTP)
require.NotNil(t, req.Source.HTTP.ChecksumResponse)
require.Equal(t, expectedDigest, req.Source.HTTP.ChecksumResponse.Digest)
require.Equal(t, sig.HashSuffix, req.Source.HTTP.ChecksumResponse.Suffix)
responseDigest, err := digest.Parse(req.Source.HTTP.ChecksumResponse.Digest)
require.NoError(t, err)
require.NoError(t, pgpsign.VerifySignatureWithDigest(sig, keyring, responseDigest))
// Negative check: tampered digest must fail signature verification.
badDigest := tamperDigestHex(responseDigest)
err = pgpsign.VerifySignatureWithDigest(sig, keyring, badDigest)
require.Error(t, err)
require.ErrorContains(t, err, "failed to verify signature with checksum digest")
return &policysession.DecisionResponse{
Action: sourcepolicypb.PolicyAction_ALLOW,
}, nil, nil
default:
return nil, nil, errors.Errorf("too many calls to policy callback %d", callCounter)
}
})
_, err = c.Solve(ctx, def, SolveOpt{
SourcePolicyProvider: p,
}, nil)
require.NoError(t, err)
require.Equal(t, 2, callCounter)
})
t.Run("oversized suffix denied", func(t *testing.T) {
callCounter := 0
p := policysession.NewPolicyProvider(func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
callCounter++
return nil, &pb.ResolveSourceMetaRequest{
Source: req.Source.Source,
Platform: req.Platform,
HTTP: &pb.ResolveSourceHTTPRequest{
ChecksumRequest: &pb.ChecksumRequest{
Algo: pb.ChecksumRequest_CHECKSUM_ALGO_SHA256,
Suffix: make([]byte, 4097),
},
},
}, nil
})
_, err = c.Solve(ctx, def, SolveOpt{
SourcePolicyProvider: p,
}, nil)
require.Error(t, err)
require.ErrorContains(t, err, "suffix exceeds max size")
require.Equal(t, 1, callCounter)
})
t.Run("unsupported algo denied", func(t *testing.T) {
callCounter := 0
p := policysession.NewPolicyProvider(func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *pb.ResolveSourceMetaRequest, error) {
callCounter++
return nil, &pb.ResolveSourceMetaRequest{
Source: req.Source.Source,
Platform: req.Platform,
HTTP: &pb.ResolveSourceHTTPRequest{
ChecksumRequest: &pb.ChecksumRequest{
Algo: pb.ChecksumRequest_ChecksumAlgo(99),
Suffix: []byte{1, 2, 3},
},
},
}, nil
})
_, err = c.Solve(ctx, def, SolveOpt{
SourcePolicyProvider: p,
}, nil)
require.Error(t, err)
require.ErrorContains(t, err, "unsupported checksum algorithm")
require.Equal(t, 1, callCounter)
})
}
func toPBChecksumAlgo(in crypto.Hash) (pb.ChecksumRequest_ChecksumAlgo, error) {
switch in {
case crypto.SHA256:
return pb.ChecksumRequest_CHECKSUM_ALGO_SHA256, nil
case crypto.SHA384:
return pb.ChecksumRequest_CHECKSUM_ALGO_SHA384, nil
case crypto.SHA512:
return pb.ChecksumRequest_CHECKSUM_ALGO_SHA512, nil
default:
return 0, errors.Errorf("unsupported signature hash algorithm %v", in)
}
}
func payloadWithSuffixDigest(algo crypto.Hash, payload, suffix []byte) (string, error) {
var (
h hash.Hash
algoName string
)
switch algo {
case crypto.SHA256:
h = sha256.New()
algoName = "sha256"
case crypto.SHA384:
h = sha512.New384()
algoName = "sha384"
case crypto.SHA512:
h = sha512.New()
algoName = "sha512"
default:
return "", errors.Errorf("unsupported signature hash algorithm %v", algo)
}
if _, err := h.Write(payload); err != nil {
return "", err
}
if _, err := h.Write(suffix); err != nil {
return "", err
}
return fmt.Sprintf("%s:%x", algoName, h.Sum(nil)), nil
}
func tamperDigestHex(dgst digest.Digest) digest.Digest {
hexPart := []byte(dgst.Encoded())
if len(hexPart) == 0 {
return dgst
}
if hexPart[len(hexPart)-1] == '0' {
hexPart[len(hexPart)-1] = '1'
} else {
hexPart[len(hexPart)-1] = '0'
}
return digest.NewDigestFromEncoded(dgst.Algorithm(), string(hexPart))
}
func testSourcePolicy(t *testing.T, sb integration.Sandbox) {
requiresLinux(t)
c, err := New(sb.Context(), sb.Address())
require.NoError(t, err)
defer c.Close()
frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) {
st := llb.Image("busybox:1.34.1-uclibc").File(
llb.Copy(llb.HTTP("https://raw.githubusercontent.com/moby/buildkit/v0.10.1/README.md"),
"README.md", "README.md"))
def, err := st.Marshal(sb.Context())
if err != nil {
return nil, err
}
return c.Solve(ctx, gateway.SolveRequest{
Definition: def.ToPB(),
})
}
type testCase struct {
srcPol *sourcepolicypb.Policy
expectedErr string
}
testCases := []testCase{
{
// Valid
srcPol: &sourcepolicypb.Policy{
Rules: []*sourcepolicypb.Rule{
{
Action: sourcepolicypb.PolicyAction_CONVERT,
Selector: &sourcepolicypb.Selector{
Identifier: "docker-image://docker.io/library/busybox:1.34.1-uclibc",
},
Updates: &sourcepolicypb.Update{
Identifier: "docker-image://docker.io/library/busybox:1.34.1-uclibc@sha256:3614ca5eacf0a3a1bcc361c939202a974b4902b9334ff36eb29ffe9011aaad83",
},
},
{
Action: sourcepolicypb.PolicyAction_CONVERT,
Selector: &sourcepolicypb.Selector{
Identifier: "https://raw.githubusercontent.com/moby/buildkit/v0.10.1/README.md",
},
Updates: &sourcepolicypb.Update{
Identifier: "https://raw.githubusercontent.com/moby/buildkit/v0.10.1/README.md",
Attrs: map[string]string{"http.checksum": "sha256:6e4b94fc270e708e1068be28bd3551dc6917a4fc5a61293d51bb36e6b75c4b53"},
},
},
},
},
expectedErr: "",
},
{
// Invalid docker-image source
srcPol: &sourcepolicypb.Policy{
Rules: []*sourcepolicypb.Rule{
{
Action: sourcepolicypb.PolicyAction_CONVERT,
Selector: &sourcepolicypb.Selector{
Identifier: "docker-image://docker.io/library/busybox:1.34.1-uclibc",
},
Updates: &sourcepolicypb.Update{
Identifier: "docker-image://docker.io/library/busybox:1.34.1-uclibc@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // invalid
},
},
},
},
expectedErr: "docker.io/library/busybox:1.34.1-uclibc@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: not found",
},
{
// Invalid http source
srcPol: &sourcepolicypb.Policy{
Rules: []*sourcepolicypb.Rule{
{
Action: sourcepolicypb.PolicyAction_CONVERT,
Selector: &sourcepolicypb.Selector{
Identifier: "https://raw.githubusercontent.com/moby/buildkit/v0.10.1/README.md",
},
Updates: &sourcepolicypb.Update{
Attrs: map[string]string{opspb.AttrHTTPChecksum: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, // invalid
},
},
},
},
expectedErr: "digest mismatch sha256:6e4b94fc270e708e1068be28bd3551dc6917a4fc5a61293d51bb36e6b75c4b53: sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
},
}
for i, tc := range testCases {
t.Run(strconv.Itoa(i), func(t *testing.T) {
_, err = c.Build(sb.Context(), SolveOpt{SourcePolicy: tc.srcPol}, "", frontend, nil)
if tc.expectedErr == "" {
require.NoError(t, err)
} else {
require.Error(t, err)
require.Contains(t, err.Error(), tc.expectedErr)
}
})
}
t.Run("deny canonical git subdir", func(t *testing.T) {
const (
src = "git://github.com/user/repo.git#main:../absolute/path"
canonical = "git://github.com/user/repo.git#main:absolute/path"
)
frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) {
st := llb.NewState(llb.NewSource(src, nil, llb.Constraints{}).Output())
def, err := st.Marshal(sb.Context())
if err != nil {
return nil, err
}
return c.Solve(ctx, gateway.SolveRequest{
Definition: def.ToPB(),
})
}
_, err = c.Build(sb.Context(), SolveOpt{
SourcePolicy: &sourcepolicypb.Policy{
Rules: []*sourcepolicypb.Rule{
{
Action: sourcepolicypb.PolicyAction_DENY,
Selector: &sourcepolicypb.Selector{
Identifier: canonical,
MatchType: sourcepolicypb.MatchType_EXACT,
},
},
},
},
}, "", frontend, nil)
require.ErrorContains(t, err, sourcepolicy.ErrSourceDenied.Error())
})
t.Run("Frontend policies", func(t *testing.T) {
t.Run("deny http", func(t *testing.T) {
denied := "https://raw.githubusercontent.com/moby/buildkit/v0.10.1/README.md"
frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) {
st := llb.Image("busybox:1.34.1-uclibc").File(
llb.Copy(llb.HTTP(denied),
"README.md", "README.md"))
def, err := st.Marshal(sb.Context())
if err != nil {
return nil, err
}
return c.Solve(ctx, gateway.SolveRequest{
Definition: def.ToPB(),
SourcePolicies: []*sourcepolicypb.Policy{{
Rules: []*sourcepolicypb.Rule{
{
Action: sourcepolicypb.PolicyAction_DENY,
Selector: &sourcepolicypb.Selector{
Identifier: denied,
},
},
},
}},
})
}
_, err = c.Build(sb.Context(), SolveOpt{}, "", frontend, nil)
require.ErrorContains(t, err, sourcepolicy.ErrSourceDenied.Error())
})
t.Run("resolve image config", func(t *testing.T) {
frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) {
const (
origRef = "docker.io/library/busybox:1.34.1-uclibc"
updatedRef = "docker.io/library/busybox:latest"
)
pol := []*sourcepolicypb.Policy{
{
Rules: []*sourcepolicypb.Rule{
{
Action: sourcepolicypb.PolicyAction_DENY,
Selector: &sourcepolicypb.Selector{
Identifier: "*",
},
},
{
Action: sourcepolicypb.PolicyAction_ALLOW,
Selector: &sourcepolicypb.Selector{
Identifier: "docker-image://" + updatedRef + "*",
},
},
{
Action: sourcepolicypb.PolicyAction_CONVERT,
Selector: &sourcepolicypb.Selector{
Identifier: "docker-image://" + origRef,
},
Updates: &sourcepolicypb.Update{
Identifier: "docker-image://" + updatedRef,
},
},
},
},
}
ref, dgst, _, err := c.ResolveImageConfig(ctx, origRef, sourceresolver.Opt{
SourcePolicies: pol,
})
if err != nil {
return nil, err
}
require.Equal(t, updatedRef, ref)
st := llb.Image(ref + "@" + dgst.String())
def, err := st.Marshal(sb.Context())
if err != nil {
return nil, err
}
return c.Solve(ctx, gateway.SolveRequest{
Definition: def.ToPB(),
SourcePolicies: pol,
})
}
_, err = c.Build(sb.Context(), SolveOpt{}, "", frontend, nil)
require.NoError(t, err)
})
})
}