Files
buildx/policy/tester.go
T
Tonis Tiigi dce5289722 policy: separate sourcemetaresolver utility to reusable pkg
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>
2026-02-13 13:59:33 -08:00

842 lines
21 KiB
Go

package policy
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"maps"
"path/filepath"
"slices"
"sort"
"strings"
"github.com/containerd/platforms"
gwpb "github.com/moby/buildkit/frontend/gateway/pb"
"github.com/moby/buildkit/solver/pb"
"github.com/moby/buildkit/sourcepolicy/policysession"
"github.com/open-policy-agent/opa/v1/ast"
"github.com/open-policy-agent/opa/v1/rego"
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/pkg/errors"
)
type TestOptions struct {
Run string
Filename string
Root fs.StatFS
Provider *TestOptionsProvider
}
type TestSummary struct {
Results []TestResult
Failed int
}
type TestResult struct {
Name string
Package string
Passed bool
Allow *bool
DenyMessages []string
Input *Input
Decision *Decision
MissingInput []string
MetadataNeeded []string
}
type testDef struct {
Name string
PkgPath string
}
type TestOptionsProvider struct {
Resolve func(context.Context, *pb.SourceOp, *gwpb.ResolveSourceMetaRequest) (*gwpb.ResolveSourceMetaResponse, error)
Platform func(context.Context) (*ocispecs.Platform, error)
VerifierProvider PolicyVerifierProvider
}
func RunPolicyTests(ctx context.Context, path string, opts TestOptions) (TestSummary, error) {
var summary TestSummary
if opts.Root == nil {
return summary, errors.New("policy root filesystem is required")
}
policyModules, policyFiles, err := loadPolicyModules(opts.Root, opts.Filename)
if err != nil {
return summary, err
}
testModules, _, err := LoadTestModules(opts.Root, path)
if err != nil {
return summary, err
}
modules := make(map[string]*ast.Module, len(policyModules)+len(testModules))
maps.Copy(modules, policyModules)
maps.Copy(modules, testModules)
fsProvider := func() (fs.StatFS, func() error, error) {
return opts.Root, nil, nil
}
p := NewPolicy(Opt{
FS: fsProvider,
})
comp, closeLoader, err := compilePolicyModules(modules, p, fsProvider)
if err != nil {
if closeLoader != nil {
_ = closeLoader()
}
return summary, err
}
if closeLoader != nil {
defer closeLoader()
}
tests := findPolicyTests(testModules)
if opts.Run != "" {
tests = filterPolicyTests(tests, opts.Run)
}
if len(tests) == 0 {
return summary, errors.New("no tests found")
}
for _, t := range tests {
result, err := runPolicyTest(ctx, policyModules, testModules, policyFiles, comp, p, t, opts, fsProvider)
if err != nil {
return summary, err
}
if !result.Passed {
summary.Failed++
}
summary.Results = append(summary.Results, result)
}
return summary, nil
}
func LoadTestModules(root fs.StatFS, path string) (map[string]*ast.Module, []File, error) {
path = filepath.ToSlash(path)
path = strings.TrimSuffix(path, "/")
if path == "" {
path = "."
}
info, err := root.Stat(path)
if err != nil {
return nil, nil, errors.Wrapf(err, "stat %s", path)
}
var files []string
if info.IsDir() {
entries, err := fs.ReadDir(root, path)
if err != nil {
return nil, nil, errors.Wrapf(err, "read dir %s", path)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, "_test.rego") {
continue
}
files = append(files, filepath.ToSlash(filepath.Join(path, name)))
}
} else {
if !strings.HasSuffix(path, "_test.rego") {
return nil, nil, errors.Errorf("test file must have _test.rego suffix: %s", path)
}
files = append(files, filepath.ToSlash(path))
}
if len(files) == 0 {
return nil, nil, errors.New("no policy tests found")
}
sort.Strings(files)
modules := make(map[string]*ast.Module, len(files))
entries := make([]File, 0, len(files))
for _, file := range files {
dt, err := fs.ReadFile(root, file)
if err != nil {
return nil, nil, errors.Wrapf(err, "read policy test module %s", file)
}
mod, err := ast.ParseModuleWithOpts(file, string(dt), ast.ParserOptions{
RegoVersion: ast.RegoV1,
})
if err != nil {
return nil, nil, errors.Wrapf(err, "parse policy test module %s", file)
}
modules[file] = mod
entries = append(entries, File{
Filename: file,
Data: dt,
})
}
return modules, entries, nil
}
func loadPolicyModules(root fs.StatFS, filename string) (map[string]*ast.Module, []File, error) {
if filename == "" {
return nil, nil, errors.New("policy filename is required")
}
policyFile := filename + ".rego"
dt, err := fs.ReadFile(root, policyFile)
if err != nil {
return nil, nil, errors.Wrapf(err, "read policy module %s", policyFile)
}
mod, err := ast.ParseModuleWithOpts(policyFile, string(dt), ast.ParserOptions{
RegoVersion: ast.RegoV1,
})
if err != nil {
return nil, nil, errors.Wrapf(err, "parse policy module %s", policyFile)
}
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),
Data: dt,
},
}
return modules, files, nil
}
func compilePolicyModules(modules map[string]*ast.Module, p *Policy, fsProvider func() (fs.StatFS, func() error, error)) (*ast.Compiler, func() error, error) {
caps := &ast.Capabilities{
Builtins: builtins(),
Features: slices.Clone(ast.Features),
}
comp := ast.NewCompiler().WithCapabilities(caps).WithKeepModules(true)
builtinDefs := make(map[string]*ast.Builtin)
for _, f := range p.funcs {
builtinDefs[f.decl.Name] = &ast.Builtin{
Name: f.decl.Name,
Decl: f.decl.Decl,
}
}
comp = comp.WithBuiltins(builtinDefs)
loader, closeLoader := newPolicyModuleLoader(fsProvider)
comp = comp.WithModuleLoader(loader)
comp.Compile(modules)
if comp.Failed() {
return nil, closeLoader, errors.Errorf("compile: %v", comp.Errors)
}
return comp, closeLoader, nil
}
func findPolicyTests(modules map[string]*ast.Module) []testDef {
seen := map[string]testDef{}
for _, mod := range modules {
pkgPath := mod.Package.Path.String()
for _, rule := range mod.Rules {
if len(rule.Head.Args) > 0 {
continue
}
if rule.Head.Value != nil {
if _, ok := rule.Head.Value.Value.(ast.Boolean); !ok {
continue
}
}
name := string(rule.Head.Name)
if strings.HasPrefix(name, "test_") {
seen[name] = testDef{Name: name, PkgPath: pkgPath}
}
}
}
out := make([]testDef, 0, len(seen))
for _, t := range seen {
out = append(out, t)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func filterPolicyTests(tests []testDef, substr string) []testDef {
out := make([]testDef, 0, len(tests))
for _, t := range tests {
if strings.Contains(t.Name, substr) {
out = append(out, t)
}
}
return out
}
func runPolicyTest(ctx context.Context, policyModules map[string]*ast.Module, testModules map[string]*ast.Module, policyFiles []File, compiler *ast.Compiler, p *Policy, t testDef, opts TestOptions, fsProvider func() (fs.StatFS, func() error, error)) (TestResult, error) {
result := TestResult{
Name: t.Name,
Package: t.PkgPath,
}
policyPackageModules := modulesForPackage(policyModules, t.PkgPath)
input, err := lookupTestInput(testModules, t)
if err != nil {
return result, err
}
effectiveInput := input
if opts.Provider != nil {
resolvedInput, ok, err := resolveTestInput(ctx, policyFiles, opts.Provider, policyPackageModules, input, fsProvider)
if err != nil {
return result, err
}
if ok {
effectiveInput = resolvedInput
}
}
result.Input = effectiveInput
testState := stateFromInput(effectiveInput)
query := fmt.Sprintf("%s.%s", t.PkgPath, t.Name)
ok, err := evalBool(ctx, compiler, p, testState, query, effectiveInput)
if err != nil {
return result, err
}
result.Passed = ok
decisionState := stateFromInput(effectiveInput)
decision, allow, deny := evalDecision(ctx, compiler, p, decisionState, t.PkgPath, effectiveInput)
result.Decision = decision
result.Allow = allow
result.DenyMessages = deny
missing := missingInputRefs(policyPackageModules, effectiveInput, runtimeUnknownInputRefs(testState), runtimeUnknownInputRefs(decisionState))
result.MissingInput = uniqueSortedStrings(missing)
result.MetadataNeeded = summarizeMetadataRequests(result.MissingInput)
return result, nil
}
func lookupTestInput(testModules map[string]*ast.Module, t testDef) (*Input, error) {
if len(testModules) == 0 {
return nil, nil
}
var inputTerm *ast.Term
for _, mod := range modulesForPackage(testModules, t.PkgPath) {
for _, rule := range mod.Rules {
if string(rule.Head.Name) != t.Name {
continue
}
term, err := inputTermFromRule(rule, t)
if err != nil {
return nil, err
}
if term == nil {
continue
}
if inputTerm != nil && !inputTerm.Equal(term) {
return nil, errors.Errorf("multiple input overrides for %s", t.Name)
}
inputTerm = term
}
}
if inputTerm == nil {
return nil, nil
}
var inp Input
if err := ast.As(inputTerm.Value, &inp); err != nil {
return nil, errors.Wrapf(err, "failed to decode test input for %s", t.Name)
}
return &inp, nil
}
func inputTermFromRule(rule *ast.Rule, t testDef) (*ast.Term, error) {
var inputTerm *ast.Term
for _, expr := range rule.Body {
for _, w := range expr.With {
if w == nil || w.Target == nil || w.Value == nil {
continue
}
ref, ok := w.Target.Value.(ast.Ref)
if !ok || !ref.Equal(ast.InputRootRef) {
continue
}
if inputTerm != nil && !inputTerm.Equal(w.Value) {
return nil, errors.Errorf("multiple input overrides for %s", t.Name)
}
inputTerm = w.Value
}
}
return inputTerm, nil
}
func evalDecision(ctx context.Context, compiler *ast.Compiler, p *Policy, st *state, pkgPath string, input *Input) (*Decision, *bool, []string) {
query := fmt.Sprintf("%s.decision", pkgPath)
val, err := evalValue(ctx, compiler, p, st, query, input)
if err != nil {
return nil, nil, nil
}
decision := decodeDecision(val)
if decision == nil {
return nil, nil, nil
}
deny := decision.DenyMessages
if len(deny) == 0 {
deny = nil
}
return decision, decision.Allow, deny
}
func stateFromInput(input *Input) *state {
st := &state{}
if input == nil {
return st
}
st.Input = *input
return st
}
func resolveTestInput(ctx context.Context, files []File, resolver *TestOptionsProvider, policyModules []*ast.Module, input *Input, fsProvider func() (fs.StatFS, func() error, error)) (*Input, bool, error) {
if resolver == nil {
return nil, false, nil
}
source, err := sourceFromInput(input)
if err != nil || source == nil {
return nil, false, err
}
platform, err := inputPlatform(input)
if err != nil {
return nil, false, err
}
if platform == nil && strings.HasPrefix(source.Identifier, "docker-image://") {
if resolver.Platform == nil {
return nil, false, errors.New("resolver platform not configured")
}
platform, err = resolver.Platform(ctx)
if err != nil {
return nil, false, err
}
}
var env Env
if input != nil && hasEnv(input.Env) {
env = input.Env
}
policyEval := NewPolicy(Opt{
Files: files,
Env: env,
FS: fsProvider,
VerifierProvider: resolver.VerifierProvider,
})
srcReq := &gwpb.ResolveSourceMetaResponse{
Source: source,
}
var platformPB *pb.Platform
if platform != nil {
platformPB = &pb.Platform{
Architecture: platform.Architecture,
OS: platform.OS,
Variant: platform.Variant,
}
}
for range 5 {
_, next, err := policyEval.CheckPolicy(ctx, &policysession.CheckPolicyRequest{
Platform: platformPB,
Source: srcReq,
})
if err != nil {
return nil, false, err
}
if next == nil {
inp, _, err := SourceToInputWithLogger(ctx, resolver.VerifierProvider, srcReq, platform, nil)
if err != nil {
return nil, false, err
}
if hasEnv(env) {
inp.Env = env
}
if resolver.Resolve != nil && len(policyModules) > 0 {
missing := missingInputRefs(policyModules, &inp)
resolveMissing := filterResolvableMissing(missing)
if len(resolveMissing) > 0 {
req := &gwpb.ResolveSourceMetaRequest{}
if err := AddUnknowns(req, resolveMissing); err == nil && (req.Image != nil || req.Git != nil) {
resp, err := resolver.Resolve(ctx, source, req)
if err != nil {
return nil, false, err
}
srcReq = resp
continue
}
}
}
return mergeInputOverrides(inp, input), true, nil
}
if resolver.Resolve == nil {
return nil, false, nil
}
resp, err := resolver.Resolve(ctx, source, next)
if err != nil {
return nil, false, err
}
srcReq = resp
}
return nil, false, errors.New("maximum attempts reached for resolving policy metadata")
}
func sourceFromInput(input *Input) (*pb.SourceOp, error) {
if input != nil && input.Image != nil {
ref := ""
switch {
case input.Image.Ref != "":
ref = input.Image.Ref
case input.Image.FullRepo != "":
ref = input.Image.FullRepo
case input.Image.Repo != "":
ref = input.Image.Repo
}
if ref != "" && input.Image.Tag != "" && !strings.Contains(ref, ":") {
ref = ref + ":" + input.Image.Tag
}
if ref != "" {
return &pb.SourceOp{Identifier: "docker-image://" + ref}, nil
}
}
return nil, nil
}
func mergeInputOverrides(resolved Input, input *Input) *Input {
if input == nil {
return &resolved
}
if hasEnv(input.Env) {
resolved.Env = input.Env
}
return &resolved
}
func hasEnv(env Env) bool {
if env.Filename != "" || env.Target != "" {
return true
}
if len(env.Args) > 0 || len(env.Labels) > 0 {
return true
}
return false
}
func filterResolvableMissing(missing []string) []string {
out := make([]string, 0, len(missing))
for _, m := range missing {
if strings.HasPrefix(m, "image.") || strings.HasPrefix(m, "git.") {
out = append(out, m)
}
}
return out
}
func evalBool(ctx context.Context, compiler *ast.Compiler, p *Policy, st *state, query string, input *Input) (bool, error) {
r := newPolicyRego(compiler, p, st, query, input)
rs, err := r.Eval(ctx)
if err != nil {
return false, err
}
if len(rs) == 0 || len(rs[0].Expressions) == 0 {
return false, nil
}
val, ok := rs[0].Expressions[0].Value.(bool)
if !ok {
return false, nil
}
return val, nil
}
func evalValue(ctx context.Context, compiler *ast.Compiler, p *Policy, st *state, query string, input *Input) (any, error) {
r := newPolicyRego(compiler, p, st, query, input)
rs, err := r.Eval(ctx)
if err != nil {
return nil, err
}
if len(rs) == 0 || len(rs[0].Expressions) == 0 {
return nil, errors.New("empty result")
}
return rs[0].Expressions[0].Value, nil
}
func newPolicyRego(compiler *ast.Compiler, p *Policy, st *state, query string, input *Input) *rego.Rego {
opts := []func(*rego.Rego){
rego.SetRegoVersion(ast.RegoV1),
rego.Query(query),
rego.SkipPartialNamespace(true),
rego.Compiler(compiler),
}
if input != nil {
opts = append(opts, rego.Input(input))
}
for _, f := range p.funcs {
opts = append(opts, f.impl(st))
}
return rego.New(opts...)
}
func modulesForPackage(modules map[string]*ast.Module, pkgPath string) []*ast.Module {
out := make([]*ast.Module, 0, len(modules))
for _, mod := range modules {
if mod.Package.Path.String() == pkgPath {
out = append(out, mod)
}
}
return out
}
func missingInputRefs(mods []*ast.Module, input *Input, extraRefs ...[]string) []string {
if len(mods) == 0 {
return nil
}
inputMap := normalizeInput(input)
refs := collectUnknowns(mods, nil)
for _, er := range extraRefs {
refs = append(refs, er...)
}
seen := map[string]struct{}{}
missing := make([]string, 0, len(refs))
for _, key := range refs {
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if !inputHasPath(inputMap, strings.Split(key, ".")) {
missing = append(missing, key)
}
}
return missing
}
type inputMap map[string]json.RawMessage
func inputHasPath(input inputMap, path []string) bool {
if input == nil {
return false
}
cur := input
for i, p := range path {
next, ok := cur[p]
if !ok {
return false
}
if i == len(path)-1 {
return true
}
var decoded inputMap
if err := json.Unmarshal(next, &decoded); err != nil {
return false
}
cur = decoded
}
return true
}
func normalizeInput(input *Input) inputMap {
if input == nil {
return nil
}
out, err := decodeJSONValue[inputMap](input)
if err != nil {
return nil
}
return out
}
func decodeDecision(decision any) *Decision {
obj, err := decodeJSONValue[map[string]any](decision)
if err != nil {
return nil
}
var allow *bool
if v, ok := obj["allow"]; ok {
if b, ok := v.(bool); ok {
allow = &b
}
}
denyMsgs := []string{}
if v, ok := obj["deny_msg"]; ok {
switch val := v.(type) {
case string:
denyMsgs = append(denyMsgs, val)
case []any:
for _, entry := range val {
if s, ok := entry.(string); ok {
denyMsgs = append(denyMsgs, s)
}
}
}
}
if len(denyMsgs) == 0 {
denyMsgs = nil
}
return &Decision{
Allow: allow,
DenyMessages: denyMsgs,
}
}
func decodeJSONValue[T any](v any) (T, error) {
var out T
b, err := json.Marshal(v)
if err != nil {
return out, err
}
if err := json.Unmarshal(b, &out); err != nil {
return out, err
}
return out, nil
}
func summarizeMetadataRequests(missing []string) []string {
if len(missing) == 0 {
return nil
}
req := &gwpb.ResolveSourceMetaRequest{}
if err := AddUnknowns(req, missing); err != nil {
return nil
}
var out []string
if req.Image != nil {
out = append(out, "image")
}
if req.Git != nil {
out = append(out, "git")
}
sort.Strings(out)
return out
}
func inputPlatform(input *Input) (*ocispecs.Platform, error) {
if input == nil || input.Image == nil {
return nil, nil
}
if input.Image.Platform != "" {
p, err := platforms.Parse(input.Image.Platform)
if err != nil {
return nil, errors.Wrapf(err, "invalid platform %s", input.Image.Platform)
}
p = platforms.Normalize(p)
return &ocispecs.Platform{
OS: p.OS,
Architecture: p.Architecture,
Variant: p.Variant,
}, nil
}
if input.Image.OS != "" || input.Image.Architecture != "" || input.Image.Variant != "" {
return &ocispecs.Platform{
OS: input.Image.OS,
Architecture: input.Image.Architecture,
Variant: input.Image.Variant,
}, nil
}
return nil, nil
}
func uniqueSortedStrings(in []string) []string {
if len(in) == 0 {
return nil
}
seen := map[string]struct{}{}
for _, s := range in {
seen[s] = struct{}{}
}
out := make([]string, 0, len(seen))
for s := range seen {
out = append(out, s)
}
sort.Strings(out)
return out
}
func newPolicyModuleLoader(fsProvider func() (fs.StatFS, func() error, error)) (func(map[string]*ast.Module) (map[string]*ast.Module, error), func() error) {
var (
root fs.StatFS
closeFS func() error
)
loader := func(resolved map[string]*ast.Module) (map[string]*ast.Module, error) {
out := make(map[string]*ast.Module)
for k, v := range resolved {
for _, imp := range v.Imports {
pv := imp.Path.Value.String()
pkgPath, ok := strings.CutPrefix(pv, "data.")
if !ok {
continue
}
if resolvedHasPackage(resolved, pkgPath) {
continue
}
fn := strings.ReplaceAll(pkgPath, ".", "/") + ".rego"
if _, ok := resolved[fn]; ok {
continue
}
if root == nil {
if fsProvider == nil {
return nil, errors.Errorf("no policy FS defined for import %s", pv)
}
f, cf, err := fsProvider()
if err != nil {
return nil, errors.Wrapf(err, "failed to get policy FS for import %s", pv)
}
root = f
closeFS = cf
}
loadName := fn
if _, err := root.Stat(loadName); err != nil {
return nil, errors.Wrapf(err, "import %s not found for module %s", pv, k)
}
dt, err := fs.ReadFile(root, loadName)
if err != nil {
return nil, errors.Wrapf(err, "failed to read imported policy file %s for module %s", loadName, k)
}
mod, err := ast.ParseModuleWithOpts(loadName, string(dt), ast.ParserOptions{
RegoVersion: ast.RegoV1,
})
if err != nil {
return nil, errors.Wrapf(err, "failed to parse imported policy file %s for module %s", loadName, k)
}
// rewrite package to be less strict
pkgParts := strings.Split(pkgPath, ".")
ref := ast.Ref{mod.Package.Path[0]}
for _, p := range pkgParts {
ref = append(ref, ast.StringTerm(p))
}
mod.Package = &ast.Package{Path: ref}
out[fn] = mod
}
}
return out, nil
}
return loader, func() error {
if closeFS != nil {
return closeFS()
}
return nil
}
}
func resolvedHasPackage(resolved map[string]*ast.Module, pkgPath string) bool {
for _, mod := range resolved {
if mod.Package != nil && mod.Package.Path.String() == pkgPath {
return true
}
if mod.Package != nil && strings.TrimPrefix(mod.Package.Path.String(), "data.") == pkgPath {
return true
}
}
return false
}