Turn the current utility used by policy helper commands that allows getting gateway.Client lazily into reusable package. This is preparation for using this utility also in the policy verification, outside of commands. Only expose SourceMetaResolver as other methods of gateway client are not needed atm by any callers. Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
412 lines
11 KiB
Go
412 lines
11 KiB
Go
package policy
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/containerd/errdefs"
|
|
"github.com/distribution/reference"
|
|
"github.com/docker/buildx/builder"
|
|
"github.com/docker/buildx/policy"
|
|
"github.com/docker/buildx/util/confutil"
|
|
"github.com/docker/buildx/util/sourcemeta"
|
|
"github.com/docker/cli/cli/command"
|
|
"github.com/moby/buildkit/client/llb/sourceresolver"
|
|
"github.com/moby/buildkit/frontend/dockerui"
|
|
gwpb "github.com/moby/buildkit/frontend/gateway/pb"
|
|
"github.com/moby/buildkit/solver/pb"
|
|
spb "github.com/moby/buildkit/sourcepolicy/pb"
|
|
"github.com/moby/buildkit/sourcepolicy/policysession"
|
|
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
|
|
"github.com/pkg/errors"
|
|
"github.com/sirupsen/logrus"
|
|
"github.com/spf13/cobra"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
)
|
|
|
|
type evalOpts struct {
|
|
filename string
|
|
printOutput bool
|
|
fields []string
|
|
builder *string
|
|
}
|
|
|
|
func evalCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command {
|
|
var opts evalOpts
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "eval [OPTIONS] source",
|
|
Short: "Evaluate policy for a source",
|
|
Args: cobra.ExactArgs(1),
|
|
DisableFlagsInUseLine: true,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
opts.builder = rootOpts.Builder
|
|
return runEval(cmd.Context(), dockerCli, args[0], opts)
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&opts.filename, "filename", "Dockerfile", "Policy filename to evaluate")
|
|
cmd.Flags().BoolVar(&opts.printOutput, "print", false, "Print policy output")
|
|
cmd.Flags().StringSliceVar(&opts.fields, "fields", nil, "Fields to evaluate")
|
|
return cmd
|
|
}
|
|
|
|
func runEval(ctx context.Context, dockerCli command.Cli, source string, opts evalOpts) error {
|
|
src, err := parseSource(source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
bopts := []builder.Option{}
|
|
if opts.builder != nil {
|
|
bopts = append(bopts, builder.WithName(*opts.builder))
|
|
}
|
|
|
|
b, err := builder.New(dockerCli, bopts...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
nodes, err := b.LoadNodes(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
c, err := nodes[0].Driver.Client(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
workers, err := c.ListWorkers(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(workers) == 0 {
|
|
return errors.New("no workers available in the builder")
|
|
}
|
|
|
|
defaultPlatform := workers[0].Platforms[0]
|
|
p := ocispecs.Platform{
|
|
Architecture: defaultPlatform.Architecture,
|
|
OS: defaultPlatform.OS,
|
|
Variant: defaultPlatform.Variant,
|
|
}
|
|
metaResolver := sourcemeta.NewResolver(c)
|
|
defer metaResolver.Close()
|
|
|
|
platform := &pb.Platform{
|
|
Architecture: p.Architecture,
|
|
OS: p.OS,
|
|
Variant: p.Variant,
|
|
}
|
|
verifier := policy.SignatureVerifier(confutil.NewConfig(dockerCli))
|
|
|
|
if opts.printOutput {
|
|
srcReq := &gwpb.ResolveSourceMetaResponse{
|
|
Source: src,
|
|
}
|
|
maxAttempts := 5
|
|
var unknowns []string
|
|
var lastUnknowns []string
|
|
var trimmedUnknowns []string
|
|
var input policy.Input
|
|
var doneInvalidCheck bool
|
|
var invalidFields []string
|
|
for {
|
|
maxAttempts--
|
|
if maxAttempts <= 0 {
|
|
return errors.New("maximum attempts reached for resolving source metadata")
|
|
}
|
|
input, unknowns, err = policy.SourceToInput(ctx, verifier, srcReq, &p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
trimmedUnknowns = trimInputPrefixSlice(unknowns)
|
|
if lastUnknowns != nil && slices.Equal(trimmedUnknowns, lastUnknowns) {
|
|
break
|
|
}
|
|
lastUnknowns = slices.Clone(trimmedUnknowns)
|
|
toReload := []string{}
|
|
for _, f := range opts.fields {
|
|
if slices.Contains(trimmedUnknowns, f) {
|
|
toReload = append(toReload, f)
|
|
} else if !doneInvalidCheck {
|
|
invalidFields = append(invalidFields, f)
|
|
}
|
|
}
|
|
doneInvalidCheck = true
|
|
if len(toReload) > 0 {
|
|
req := &gwpb.ResolveSourceMetaRequest{}
|
|
if err := policy.AddUnknowns(req, toReload); err != nil {
|
|
return err
|
|
}
|
|
opt := sourceResolverOpt(req, &p)
|
|
resp, err := metaResolver.ResolveSourceMetadata(ctx, src, opt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
srcReq = buildSourceMetaResponse(resp)
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
|
|
if len(invalidFields) > 0 {
|
|
logrus.Warnf("invalid fields: %v", strings.Join(invalidFields, ", "))
|
|
}
|
|
if len(trimmedUnknowns) > 0 {
|
|
logrus.Infof("unresolved fields: %v", strings.Join(trimmedUnknowns, ", "))
|
|
}
|
|
|
|
dt, err := json.MarshalIndent(input, "", " ")
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to marshal policy input")
|
|
}
|
|
_, _ = fmt.Fprintln(os.Stdout, string(dt))
|
|
return nil
|
|
}
|
|
|
|
if opts.filename == "" {
|
|
return errors.New("filename is required")
|
|
}
|
|
policyName := opts.filename
|
|
policyFile := policyName + ".rego"
|
|
policyData, err := os.ReadFile(policyFile)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "failed to read policy file %s", policyFile)
|
|
}
|
|
fsProvider := func() (fs.StatFS, func() error, error) {
|
|
root, err := os.OpenRoot(".")
|
|
if err != nil {
|
|
return nil, nil, errors.Wrapf(err, "failed to open root for policy file %s", policyFile)
|
|
}
|
|
baseFS := root.FS()
|
|
statFS, ok := baseFS.(fs.StatFS)
|
|
if !ok {
|
|
_ = root.Close()
|
|
return nil, nil, errors.Errorf("invalid root FS type %T", baseFS)
|
|
}
|
|
return statFS, root.Close, nil
|
|
}
|
|
|
|
env := policy.Env{
|
|
Filename: filepath.Base(policyName),
|
|
}
|
|
|
|
policyEval := policy.NewPolicy(policy.Opt{
|
|
Files: []policy.File{
|
|
{
|
|
Filename: filepath.Base(policyFile),
|
|
Data: policyData,
|
|
},
|
|
},
|
|
Env: env,
|
|
FS: fsProvider,
|
|
VerifierProvider: verifier,
|
|
DefaultPlatform: &p,
|
|
})
|
|
|
|
srcReq := &gwpb.ResolveSourceMetaResponse{
|
|
Source: src,
|
|
}
|
|
maxAttempts := 5
|
|
for {
|
|
maxAttempts--
|
|
if maxAttempts <= 0 {
|
|
return errors.New("maximum attempts reached for resolving policy metadata")
|
|
}
|
|
|
|
decision, next, err := policyEval.CheckPolicy(ctx, &policysession.CheckPolicyRequest{
|
|
Platform: platform,
|
|
Source: srcReq,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if next == nil {
|
|
return evalDecisionError(decision)
|
|
}
|
|
|
|
opt := sourceResolverOpt(next, &p)
|
|
resp, err := metaResolver.ResolveSourceMetadata(ctx, src, opt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
srcReq = buildSourceMetaResponse(resp)
|
|
}
|
|
}
|
|
|
|
func toGatewayDescriptor(desc ocispecs.Descriptor) *gwpb.Descriptor {
|
|
return &gwpb.Descriptor{
|
|
MediaType: desc.MediaType,
|
|
Digest: desc.Digest.String(),
|
|
Size: desc.Size,
|
|
Annotations: desc.Annotations,
|
|
}
|
|
}
|
|
|
|
func toGatewayAttestationChain(chain *sourceresolver.AttestationChain) *gwpb.AttestationChain {
|
|
if chain == nil {
|
|
return nil
|
|
}
|
|
signatures := make([]string, 0, len(chain.SignatureManifests))
|
|
for _, dgst := range chain.SignatureManifests {
|
|
signatures = append(signatures, dgst.String())
|
|
}
|
|
blobs := make(map[string]*gwpb.Blob, len(chain.Blobs))
|
|
for dgst, blob := range chain.Blobs {
|
|
blobs[dgst.String()] = &gwpb.Blob{
|
|
Descriptor_: toGatewayDescriptor(blob.Descriptor),
|
|
Data: blob.Data,
|
|
}
|
|
}
|
|
return &gwpb.AttestationChain{
|
|
Root: chain.Root.String(),
|
|
ImageManifest: chain.ImageManifest.String(),
|
|
AttestationManifest: chain.AttestationManifest.String(),
|
|
SignatureManifests: signatures,
|
|
Blobs: blobs,
|
|
}
|
|
}
|
|
|
|
func sourceResolverOpt(req *gwpb.ResolveSourceMetaRequest, platform *ocispecs.Platform) sourceresolver.Opt {
|
|
opt := sourceresolver.Opt{
|
|
LogName: req.LogName,
|
|
SourcePolicies: req.SourcePolicies,
|
|
}
|
|
if req.Image != nil {
|
|
opt.ImageOpt = &sourceresolver.ResolveImageOpt{
|
|
NoConfig: req.Image.NoConfig,
|
|
AttestationChain: req.Image.AttestationChain,
|
|
ResolveAttestations: slices.Clone(req.Image.ResolveAttestations),
|
|
Platform: platform,
|
|
ResolveMode: req.ResolveMode,
|
|
}
|
|
}
|
|
if req.Git != nil {
|
|
opt.GitOpt = &sourceresolver.ResolveGitOpt{
|
|
ReturnObject: req.Git.ReturnObject,
|
|
}
|
|
}
|
|
return opt
|
|
}
|
|
|
|
func buildSourceMetaResponse(resp *sourceresolver.MetaResponse) *gwpb.ResolveSourceMetaResponse {
|
|
out := &gwpb.ResolveSourceMetaResponse{
|
|
Source: resp.Op,
|
|
}
|
|
if resp.Image != nil {
|
|
chain := toGatewayAttestationChain(resp.Image.AttestationChain)
|
|
out.Image = &gwpb.ResolveSourceImageResponse{
|
|
Digest: resp.Image.Digest.String(),
|
|
Config: resp.Image.Config,
|
|
AttestationChain: chain,
|
|
}
|
|
}
|
|
if resp.Git != nil {
|
|
out.Git = &gwpb.ResolveSourceGitResponse{
|
|
Checksum: resp.Git.Checksum,
|
|
Ref: resp.Git.Ref,
|
|
CommitChecksum: resp.Git.CommitChecksum,
|
|
CommitObject: resp.Git.CommitObject,
|
|
TagObject: resp.Git.TagObject,
|
|
}
|
|
}
|
|
if resp.HTTP != nil {
|
|
var lastModified *timestamppb.Timestamp
|
|
if resp.HTTP.LastModified != nil {
|
|
lastModified = timestamppb.New(*resp.HTTP.LastModified)
|
|
}
|
|
out.HTTP = &gwpb.ResolveSourceHTTPResponse{
|
|
Checksum: resp.HTTP.Digest.String(),
|
|
Filename: resp.HTTP.Filename,
|
|
LastModified: lastModified,
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func trimInputPrefixSlice(fields []string) []string {
|
|
if len(fields) == 0 {
|
|
return fields
|
|
}
|
|
out := make([]string, 0, len(fields))
|
|
for _, field := range fields {
|
|
out = append(out, strings.TrimPrefix(field, "input."))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func evalDecisionError(decision *policysession.DecisionResponse) error {
|
|
if decision == nil {
|
|
return errors.New("policy returned no decision")
|
|
}
|
|
switch decision.Action {
|
|
case spb.PolicyAction_ALLOW, spb.PolicyAction_CONVERT:
|
|
return nil
|
|
case spb.PolicyAction_DENY:
|
|
if len(decision.DenyMessages) == 0 {
|
|
return errors.New("policy denied")
|
|
}
|
|
msgs := make([]string, 0, len(decision.DenyMessages))
|
|
for _, msg := range decision.DenyMessages {
|
|
if msg != nil && msg.Message != "" {
|
|
msgs = append(msgs, msg.Message)
|
|
}
|
|
}
|
|
if len(msgs) == 0 {
|
|
return errors.New("policy denied")
|
|
}
|
|
return errors.Errorf("policy denied: %s", strings.Join(msgs, "; "))
|
|
default:
|
|
return errors.Errorf("unknown policy action %s", decision.Action)
|
|
}
|
|
}
|
|
|
|
func parseSource(input string) (*pb.SourceOp, error) {
|
|
if refstr, ok := strings.CutPrefix(input, "docker-image://"); ok {
|
|
ref, err := reference.ParseNormalizedNamed(refstr)
|
|
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://") {
|
|
_, ok, err := dockerui.DetectGitContext(input, nil)
|
|
if !ok {
|
|
return nil, errors.Errorf("invalid git context %s", input)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &pb.SourceOp{Identifier: input}, nil
|
|
}
|
|
if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") {
|
|
_, ok, err := dockerui.DetectGitContext(input, nil)
|
|
if ok {
|
|
return &pb.SourceOp{
|
|
Identifier: "git://" + input,
|
|
Attrs: map[string]string{
|
|
"git.fullurl": input,
|
|
},
|
|
}, nil
|
|
}
|
|
if err != nil && !errors.Is(err, errdefs.ErrInvalidArgument) {
|
|
return nil, err
|
|
}
|
|
return &pb.SourceOp{Identifier: input}, nil
|
|
}
|
|
// everything else is treated as a local path
|
|
if _, err := os.Stat(input); err != nil {
|
|
return nil, errors.Wrapf(err, "invalid local path %s", input)
|
|
}
|
|
return &pb.SourceOp{Identifier: "local://context"}, nil
|
|
}
|