diff --git a/build/opt.go b/build/opt.go index a1c48a837..1642304b2 100644 --- a/build/opt.go +++ b/build/opt.go @@ -48,6 +48,7 @@ import ( "github.com/moby/buildkit/util/entitlements" "github.com/moby/buildkit/util/gitutil" "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/tonistiigi/fsutil" @@ -66,29 +67,28 @@ type policyProgressLogger struct { ch chan *client.SolveStatus done chan struct{} dgst digest.Digest - started time.Time name string + started time.Time + mu sync.Mutex + timer *time.Timer + window int + open bool + closed bool } +const policyProgressWindow = 500 * time.Millisecond + func newPolicyProgressLogger(pw progress.Writer, name string) *policyProgressLogger { if pw == nil { return nil } ch, done := progress.NewChannel(pw) dgst := digest.FromBytes([]byte(identity.NewID())) - tm := time.Now() - vtx := client.Vertex{ - Digest: dgst, - Name: name, - Started: &tm, - } - ch <- &client.SolveStatus{Vertexes: []*client.Vertex{&vtx}} return &policyProgressLogger{ - ch: ch, - done: done, - dgst: dgst, - started: tm, - name: name, + ch: ch, + done: done, + dgst: dgst, + name: name, } } @@ -96,6 +96,34 @@ func (l *policyProgressLogger) Log(msg string) { if l == nil || msg == "" { return } + needStart := false + var started time.Time + var window int + + l.mu.Lock() + if l.closed { + l.mu.Unlock() + return + } + if !l.open { + needStart = true + l.open = true + l.window++ + window = l.window + started = time.Now() + l.started = started + } else { + window = l.window + } + if l.timer != nil { + l.timer.Stop() + } + l.timer = time.AfterFunc(policyProgressWindow, func() { + l.completeWindow(window, nil) + }) + if needStart { + l.sendVertexStart(started) + } if !strings.HasSuffix(msg, "\n") { msg += "\n" } @@ -107,6 +135,7 @@ func (l *policyProgressLogger) Log(msg string) { Timestamp: time.Now(), }}, } + l.mu.Unlock() } func (l *policyProgressLogger) Write(p []byte) (int, error) { @@ -120,19 +149,66 @@ func (l *policyProgressLogger) Close(err error) { if l == nil { return } + shouldComplete := false + var started time.Time + + l.mu.Lock() + if l.closed { + l.mu.Unlock() + return + } + l.closed = true + if l.open { + shouldComplete = true + started = l.started + l.open = false + } + l.window++ + if l.timer != nil { + l.timer.Stop() + l.timer = nil + } + if shouldComplete { + l.sendVertexComplete(started, err) + } + l.mu.Unlock() + close(l.ch) + <-l.done +} + +func (l *policyProgressLogger) completeWindow(window int, err error) { + l.mu.Lock() + if l.closed || !l.open || window != l.window { + l.mu.Unlock() + return + } + started := l.started + l.open = false + l.sendVertexComplete(started, err) + l.mu.Unlock() +} + +func (l *policyProgressLogger) sendVertexStart(started time.Time) { + vtx := client.Vertex{ + Digest: l.dgst, + Name: l.name, + Started: &started, + } + l.ch <- &client.SolveStatus{Vertexes: []*client.Vertex{&vtx}} +} + +func (l *policyProgressLogger) sendVertexComplete(started time.Time, err error) { tm := time.Now() vtx := client.Vertex{ Digest: l.dgst, Name: l.name, - Started: &l.started, + Started: &started, Completed: &tm, } if err != nil { vtx.Error = err.Error() } l.ch <- &client.SolveStatus{Vertexes: []*client.Vertex{&vtx}} - close(l.ch) - <-l.done } func toSolveOpt(ctx context.Context, node builder.Node, multiDriver bool, opt *Options, bopts gateway.BuildOpts, cfg *confutil.Config, pw progress.Writer, docker *dockerutil.Client) (_ *client.SolveOpt, release func(), err error) { @@ -457,6 +533,7 @@ func toSolveOpt(ctx context.Context, node builder.Node, multiDriver bool, opt *O Log: logf, FS: opt.Inputs.policy.FS, VerifierProvider: policy.SignatureVerifier(cfg), + DefaultPlatform: defaultPlatform(bopts), }) cbs = append(cbs, p.CheckPolicy) if popt.Strict { @@ -1233,3 +1310,12 @@ func parseOCILayoutPath(s string) (localPath, dgst, tag string) { } return } + +func defaultPlatform(bopts gateway.BuildOpts) *ocispecs.Platform { + pl := bopts.Workers[0].Platforms + if len(pl) == 0 { + return nil + } + p := platforms.Normalize(pl[0]) + return &p +} diff --git a/cmd/buildx/main.go b/cmd/buildx/main.go index 7ebf42d82..772566a84 100644 --- a/cmd/buildx/main.go +++ b/cmd/buildx/main.go @@ -16,6 +16,7 @@ import ( "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/debug" solvererrdefs "github.com/moby/buildkit/solver/errdefs" + "github.com/moby/buildkit/sourcepolicy/policysession" "github.com/moby/buildkit/util/grpcerrors" "github.com/moby/buildkit/util/stack" "github.com/pkg/errors" @@ -108,6 +109,11 @@ func main() { if errors.As(err, &exitCodeErr) { os.Exit(int(exitCodeErr)) } + for _, msg := range policysession.DenyMessages(err) { + if msg.GetMessage() != "" { + fmt.Fprintf(os.Stderr, "Policy: %s\n", msg.GetMessage()) + } + } for _, s := range solvererrdefs.Sources(err) { s.Print(cmd.Err()) diff --git a/commands/policy/eval.go b/commands/policy/eval.go index bdad0d08e..2d516215c 100644 --- a/commands/policy/eval.go +++ b/commands/policy/eval.go @@ -216,6 +216,7 @@ func runEval(ctx context.Context, dockerCli command.Cli, source string, opts eva Env: env, FS: fsProvider, VerifierProvider: verifier, + DefaultPlatform: &p, }) srcReq := &gwpb.ResolveSourceMetaResponse{ @@ -387,6 +388,7 @@ func parseSource(input string) (*pb.SourceOp, error) { if err != nil { return nil, errors.Wrapf(err, "failed to parse image source reference") } + ref = reference.TagNameOnly(ref) return &pb.SourceOp{Identifier: "docker-image://" + ref.String()}, nil } if strings.HasPrefix(input, "git://") { diff --git a/policy/builtin_module.go b/policy/builtin_module.go new file mode 100644 index 000000000..03513d053 --- /dev/null +++ b/policy/builtin_module.go @@ -0,0 +1,18 @@ +package policy + +import ( + _ "embed" + + "github.com/open-policy-agent/opa/v1/ast" +) + +const builtinPolicyModuleFilename = "builtin/buildx_defaults.rego" + +//go:embed builtins.rego +var builtinPolicyModule string + +func builtinPolicyModuleAST() (*ast.Module, error) { + return ast.ParseModuleWithOpts(builtinPolicyModuleFilename, builtinPolicyModule, ast.ParserOptions{ + RegoVersion: ast.RegoV1, + }) +} diff --git a/policy/builtins.rego b/policy/builtins.rego new file mode 100644 index 000000000..62462d061 --- /dev/null +++ b/policy/builtins.rego @@ -0,0 +1,23 @@ +package docker + +docker_github_builder(image, repo) if { + image.hasProvenance + some sig in image.signatures + docker_github_builder_signature(sig, repo) +} + +docker_github_builder_tag(image, repo, tag) if { + docker_github_builder(image, repo) + some sig in image.signatures + sig.signer.sourceRepositoryRef == sprintf("refs/tags/%s", [tag]) +} + +docker_github_builder_signature(sig, repo) if { + sig.kind == "docker-github-builder" + sig.type == "bundle-v0.3" + sig.signer.certificateIssuer == "CN=sigstore-intermediate,O=sigstore.dev" + sig.signer.issuer == "https://token.actions.githubusercontent.com" + sig.signer.sourceRepositoryURI == sprintf("https://github.com/%s", [repo]) + sig.signer.runnerEnvironment == "github-hosted" + count(sig.timestamps) > 0 +} diff --git a/policy/signatures.go b/policy/signatures.go index 982e545da..daa0178d2 100644 --- a/policy/signatures.go +++ b/policy/signatures.go @@ -89,6 +89,10 @@ func parseSignatures(ctx context.Context, getVerifier PolicyVerifierProvider, ac } desc := toOCIDescriptor(rootBlob.Descriptor_) + if desc.MediaType != ocispecs.MediaTypeImageIndex { + return nil, nil + } + sc, err := policyimage.ResolveSignatureChain(ctx, acp, desc, platform) if err != nil { return nil, errors.Wrapf(err, "resolving signature chain for image %s", desc.Digest) @@ -120,6 +124,8 @@ func parseSignatures(ctx context.Context, getVerifier PolicyVerifierProvider, ac Timestamps: siRaw.Timestamps, IsDHI: siRaw.IsDHI, DockerReference: siRaw.DockerReference, + SignatureType: toSignatureType(siRaw.SignatureType), + SignatureKind: toSignatureKind(siRaw.Kind), } // TODO: signature type after upstream update diff --git a/policy/tester.go b/policy/tester.go index 90ed59b1d..b5f3bfaaa 100644 --- a/policy/tester.go +++ b/policy/tester.go @@ -195,6 +195,13 @@ func loadPolicyModules(root fs.StatFS, filename string) (map[string]*ast.Module, modules := map[string]*ast.Module{ filepath.ToSlash(policyFile): mod, } + builtinMod, err := builtinPolicyModuleAST() + if err != nil { + return nil, nil, errors.Wrapf(err, "parse builtin policy module %s", builtinPolicyModuleFilename) + } + if _, ok := modules[builtinPolicyModuleFilename]; !ok { + modules[builtinPolicyModuleFilename] = builtinMod + } files := []File{ { Filename: filepath.ToSlash(policyFile), @@ -593,7 +600,7 @@ func missingInputRefs(mods []*ast.Module, input *Input) []string { return nil } inputMap := normalizeInput(input) - refs := collectUnknowns(mods) + refs := collectUnknowns(mods, nil) missing := make([]string, 0, len(refs)) for _, ref := range refs { key := strings.TrimPrefix(ref, "input.") diff --git a/policy/types.go b/policy/types.go index 5f0c95502..2c28d416f 100644 --- a/policy/types.go +++ b/policy/types.go @@ -132,7 +132,8 @@ type Image struct { } type AttestationSignature struct { - SignatureType SignatureType `json:"signatureType,omitempty"` + SignatureKind SignatureKind `json:"kind,omitempty"` + SignatureType SignatureType `json:"type,omitempty"` Timestamps []policytypes.TimestampVerificationResult `json:"timestamps,omitempty"` DockerReference string `json:"dockerReference,omitempty"` IsDHI bool `json:"isDHI,omitempty"` @@ -171,10 +172,46 @@ type SignerInfo struct { type SignatureType string const ( - SignatureTypeBundle SignatureType = "bundle-v0.3" - SignatureTypeHashedRecord SignatureType = "hashedreckord" + SignatureTypeBundleV03 SignatureType = "bundle-v0.3" + SignatureTypeSimpleSigningV1 SignatureType = "simplesigning-v1" ) +func toSignatureType(st policytypes.SignatureType) SignatureType { + switch st { + case policytypes.SignatureBundleV03: + return SignatureTypeBundleV03 + case policytypes.SignatureSimpleSigningV1: + return SignatureTypeSimpleSigningV1 + } + return "" +} + +type SignatureKind string + +const ( + SignatureKindDockerGithubBuilder SignatureKind = "docker-github-builder" + SignatureKindDockerHardenedImage SignatureKind = "docker-hardened-image" + SignatureKindSelfSignedGithubRepo SignatureKind = "self-signed-github-repo" + SignatureKindSelfSigned SignatureKind = "self-signed" + SignatureKindUntrusted SignatureKind = "untrusted" +) + +func toSignatureKind(k policytypes.Kind) SignatureKind { + switch k { + case policytypes.KindDockerGithubBuilder: + return SignatureKindDockerGithubBuilder + case policytypes.KindDockerHardenedImage: + return SignatureKindDockerHardenedImage + case policytypes.KindSelfSignedGithubRepo: + return SignatureKindSelfSignedGithubRepo + case policytypes.KindSelfSigned: + return SignatureKindSelfSigned + case policytypes.KindUntrusted: + return SignatureKindUntrusted + } + return "" +} + type Local struct { Name string `json:"name,omitempty"` } diff --git a/policy/validate.go b/policy/validate.go index 0c83da3cd..a66b3a6ca 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -59,6 +59,7 @@ type Opt struct { Log func(logrus.Level, string) FS func() (fs.StatFS, func() error, error) VerifierProvider PolicyVerifierProvider + DefaultPlatform *ocispecs.Platform } var _ policysession.PolicyCallback = (&Policy{}).CheckPolicy @@ -90,16 +91,13 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy src := req.Source var platform *ocispecs.Platform if req.Platform != nil { - platformStr := req.Platform.OS + "/" + req.Platform.Architecture - if req.Platform.Variant != "" { - platformStr += "/" + req.Platform.Variant - } - pl, err := platforms.Parse(platformStr) + pl, err := platformFromReq(req) if err != nil { - return nil, nil, errors.Wrapf(err, "failed to parse platform") + return nil, nil, err } - pl = platforms.Normalize(pl) - platform = &pl + platform = pl + } else { + platform = p.opt.DefaultPlatform } inp, unknowns, err := SourceToInputWithLogger(ctx, p.opt.VerifierProvider, src, platform, p.opt.Log) @@ -203,6 +201,7 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy opts = append(opts, f.impl(st)) } + opts = append(opts, rego.Module(builtinPolicyModuleFilename, builtinPolicyModule)) for _, file := range p.opt.Files { opts = append(opts, rego.Module(file.Filename, string(file.Data))) } @@ -210,7 +209,7 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy if err != nil { return nil, nil, errors.Wrapf(err, "failed to marshal policy input") } - p.log(logrus.InfoLevel, "checking policy for source %s", src.Source.Identifier) + p.log(logrus.InfoLevel, "checking policy for source %s", sourceName(req)) p.log(logrus.DebugLevel, "policy input: %s", dt) if len(unknowns) > 0 { @@ -224,10 +223,11 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy if err != nil { return nil, nil, err } - unk := collectUnknowns(pq.Support) + unk := collectUnknowns(pq.Support, unknowns) if _, ok := st.Unknowns[funcVerifyGitSignature]; ok { unk = append(unk, "input.git.commit") } + if len(unk) > 0 { next := &gwpb.ResolveSourceMetaRequest{ Source: req.Source.Source, @@ -237,7 +237,7 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy return nil, nil, err } if next.Image != nil || next.Git != nil || hasHTTPUnknowns(unk) { - p.log(logrus.InfoLevel, "policy decision for source %s: resolve missing fields %+v", src.Source.Identifier, summarizeUnknownsForLog(unk)) + p.log(logrus.InfoLevel, "policy decision for source %s: resolve missing fields %+v", sourceName(req), summarizeUnknownsForLog(unk)) return nil, next, nil } } @@ -289,14 +289,14 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy if resp.Action == moby_buildkit_v1_sourcepolicy.PolicyAction_ALLOW { if len(st.ImagePins) > 1 { - return nil, nil, errors.Errorf("multiple image pins set to %s: %v", src.Source.Identifier, st.ImagePins) + return nil, nil, errors.Errorf("multiple image pins set to %s: %v", sourceName(req), st.ImagePins) } if len(st.ImagePins) == 1 { newSrc, err := addPinToImage(src.Source, slices.Collect(maps.Keys(st.ImagePins))[0]) if err != nil { return nil, nil, errors.Wrapf(err, "failed to add image pin to source") } - p.log(logrus.InfoLevel, "policy decision for source %s: convert to %s", src.Source.Identifier, newSrc.Identifier) + p.log(logrus.InfoLevel, "policy decision for source %s: convert to %s", sourceName(req), newSrc.Identifier) return &policysession.DecisionResponse{ Action: moby_buildkit_v1_sourcepolicy.PolicyAction_CONVERT, @@ -305,11 +305,38 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy } } - p.log(logrus.InfoLevel, "policy decision for source %s: %s %v", src.Source.Identifier, resp.Action, resp.DenyMessages) + p.log(logrus.InfoLevel, "policy decision for source %s: %s", sourceName(req), resp.Action) + for _, dm := range resp.DenyMessages { + p.log(logrus.InfoLevel, " - %s", dm.Message) + } return resp, nil, nil } +func platformFromReq(req *policysession.CheckPolicyRequest) (*ocispecs.Platform, error) { + if req.Platform != nil { + platformStr := req.Platform.OS + "/" + req.Platform.Architecture + if req.Platform.Variant != "" { + platformStr += "/" + req.Platform.Variant + } + pl, err := platforms.Parse(platformStr) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse platform") + } + pl = platforms.Normalize(pl) + return &pl, nil + } + return nil, nil +} + +func sourceName(req *policysession.CheckPolicyRequest) string { + name := req.Source.Source.Identifier + if p, _ := platformFromReq(req); p != nil { + name += " (" + platforms.Format(*p) + ")" + } + return name +} + func (p *Policy) Print(ctx print.Context, msg string) error { if p.opt.Log != nil { p.opt.Log(logrus.InfoLevel, ctx.Location.Format("%s", msg)) @@ -646,7 +673,7 @@ func AddUnknownsWithLogger(logf func(logrus.Level, string), req *gwpb.ResolveSou return nil } -func collectUnknowns(mods []*ast.Module) []string { +func collectUnknowns(mods []*ast.Module, allowed []string) []string { seen := map[string]struct{}{} var out []string @@ -654,6 +681,7 @@ func collectUnknowns(mods []*ast.Module) []string { ast.WalkRefs(mod, func(ref ast.Ref) bool { if ref.HasPrefix(ast.InputRootRef) { s := ref.String() // e.g. "input.request.path" + s = "input." + trimKey(strings.TrimPrefix(s, "input.")) if _, ok := seen[s]; !ok { seen[s] = struct{}{} out = append(out, s) @@ -662,7 +690,23 @@ func collectUnknowns(mods []*ast.Module) []string { return true }) } - return out + if allowed == nil { + return out + } + + valid := map[string]struct{}{} + for _, k := range allowed { + valid[k] = struct{}{} + } + + filtered := make([]string, 0, len(out)) + for _, k := range out { + if _, ok := valid[k]; ok { + filtered = append(filtered, k) + } + } + + return filtered } func summarizeUnknownsForLog(unk []string) []string { diff --git a/tests/policy_test.go b/tests/policy_test.go index b477aefdb..7f51696c9 100644 --- a/tests/policy_test.go +++ b/tests/policy_test.go @@ -12,6 +12,7 @@ var policyTestTests = []func(t *testing.T, sb integration.Sandbox){ testPolicyTestRunFilter, testPolicyTestFailMissingInput, testPolicyTestNestedPath, + testPolicyTestDockerGitHubBuilder, } func testPolicyTestRunFilter(t *testing.T, sb integration.Sandbox) { @@ -182,3 +183,128 @@ test_allowlisted_repo if { require.NoError(t, err, string(out)) require.Contains(t, string(out), "test_allowlisted_repo: PASS") } + +func testPolicyTestDockerGitHubBuilder(t *testing.T, sb integration.Sandbox) { + skipNoCompatBuildKit(t, sb, ">= 0.26.0-0", "policy input requires BuildKit v0.26.0+") + dir := tmpdir( + t, + fstest.CreateFile("policy.rego", []byte(` +package docker + +default allow = false + +allow if docker_github_builder(input.image, "org/repo") + +decision := {"allow": allow} +`), 0600), + fstest.CreateFile("policy_test.rego", []byte(` +package docker + +test_docker_github_builder if { + result := data.docker.decision with input as { + "image": { + "hasProvenance": true, + "signatures": [{ + "kind": "docker-github-builder", + "type": "bundle-v0.3", + "signer": { + "certificateIssuer": "CN=sigstore-intermediate,O=sigstore.dev", + "issuer": "https://token.actions.githubusercontent.com", + "sourceRepositoryURI": "https://github.com/org/repo", + "runnerEnvironment": "github-hosted" + }, + "timestamps": [{ + "type": "tlog", + "uri": "https://example.com/tlog", + "timestamp": "2024-01-01T00:00:00Z" + }] + }] + } + } + result.allow +} + +test_docker_github_builder_denied if { + result := data.docker.decision with input as { + "image": { + "hasProvenance": true, + "signatures": [{ + "kind": "docker-github-builder", + "type": "bundle-v0.3", + "signer": { + "certificateIssuer": "CN=sigstore-intermediate,O=sigstore.dev", + "issuer": "https://token.actions.githubusercontent.com", + "sourceRepositoryURI": "https://github.com/other/repo", + "runnerEnvironment": "github-hosted" + }, + "timestamps": [{ + "type": "tlog", + "uri": "https://example.com/tlog", + "timestamp": "2024-01-01T00:00:00Z" + }] + }] + } + } + not result.allow +} + +test_docker_github_builder_tag if { + docker_github_builder_tag({ + "hasProvenance": true, + "signatures": [{ + "kind": "docker-github-builder", + "type": "bundle-v0.3", + "signer": { + "certificateIssuer": "CN=sigstore-intermediate,O=sigstore.dev", + "issuer": "https://token.actions.githubusercontent.com", + "sourceRepositoryURI": "https://github.com/org/repo", + "sourceRepositoryRef": "refs/tags/v1.2.3", + "runnerEnvironment": "github-hosted" + }, + "timestamps": [{ + "type": "tlog", + "uri": "https://example.com/tlog", + "timestamp": "2024-01-01T00:00:00Z" + }] + }] + }, "org/repo", "v1.2.3") +} + +test_docker_github_builder_tag_denied if { + not docker_github_builder_tag({ + "hasProvenance": true, + "signatures": [{ + "kind": "docker-github-builder", + "type": "bundle-v0.3", + "signer": { + "certificateIssuer": "CN=sigstore-intermediate,O=sigstore.dev", + "issuer": "https://token.actions.githubusercontent.com", + "sourceRepositoryURI": "https://github.com/org/repo", + "sourceRepositoryRef": "refs/tags/other", + "runnerEnvironment": "github-hosted" + }, + "timestamps": [{ + "type": "tlog", + "uri": "https://example.com/tlog", + "timestamp": "2024-01-01T00:00:00Z" + }] + }] + }, "org/repo", "v1.2.3") +} +`), 0600), + ) + + cmd := buildxCmd(sb, withDir(dir), withArgs( + "policy", + "test", + "--filename", + "policy", + ".", + )) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + require.Contains(t, string(out), "test_docker_github_builder: PASS") + require.Contains(t, string(out), "test_docker_github_builder_denied: PASS") + require.Contains(t, string(out), "test_docker_github_builder_tag: PASS") + require.Contains(t, string(out), "test_docker_github_builder_tag_denied: PASS") +}