1
0
mirror of https://github.com/containers/buildah.git synced 2026-02-05 09:45:38 +01:00

Merge pull request #6647 from tinovyatkin/feature/source-policy-file

Add --source-policy-file flag for BuildKit-compatible source policies
This commit is contained in:
Tom Sweeney
2026-01-29 18:38:26 -05:00
committed by GitHub
9 changed files with 1526 additions and 0 deletions

View File

@@ -204,6 +204,12 @@ type BuildOptions struct {
// specified, indicating that the shared, system-wide default policy
// should be used.
SignaturePolicyPath string
// SourcePolicyFile specifies the path to a BuildKit-compatible source
// policy JSON file. When specified, source references (e.g., base images
// in FROM instructions) are evaluated against the policy rules. Rules
// can DENY specific sources or CONVERT them to different references
// (e.g., pinning tags to digests).
SourcePolicyFile string
// SkipUnusedStages allows users to skip stages in a multi-stage builds
// which do not contribute anything to the target stage. Expected default
// value is true.

View File

@@ -1045,6 +1045,67 @@ will bear exactly the specified timestamp.
Conflicts with the similar **--timestamp** flag, which also sets its specified
time on the contents of new layers.
**--source-policy-file** *pathname*
Specifies the path to a BuildKit-compatible source policy JSON file. When
specified, source references (e.g., base images in FROM instructions) are
evaluated against the policy rules before being used.
Source policies allow controlling which images can be used as base images and
optionally converting image references (e.g., pinning tags to specific digests)
without modifying Containerfiles. This is useful for enforcing organizational
policies and ensuring build reproducibility.
The policy file is a JSON document containing an array of rules. Each rule has:
- **action**: The action to take when the rule matches. Valid actions are:
- **ALLOW**: Explicitly allow the source (no transformation).
- **DENY**: Block the source and fail the build.
- **CONVERT**: Transform the source to a different reference specified in `updates`.
- **selector**: Specifies which sources the rule applies to.
- **identifier**: The source identifier to match (e.g., `docker-image://docker.io/library/alpine:latest`).
- **matchType**: How to match the identifier. Valid types are `EXACT` and `WILDCARD` (supports `*` and `?` glob patterns). Defaults to `WILDCARD` if not specified.
- **updates**: For `CONVERT` actions, specifies the replacement identifier.
Rules are evaluated in order; the first matching rule wins. If no rule matches,
the source is allowed by default.
Note: Source policy CONVERT rules are processed after **--build-context** substitutions
but before any substitutions specified in **containers-registries.conf(5)**. This provides
multiple ways to override which base image is used for a particular stage, in order of
precedence: `--build-context`, then source policy, then registries.conf.
Example policy file that pins alpine:latest to a specific digest:
```json
{
"rules": [
{
"action": "CONVERT",
"selector": {
"identifier": "docker-image://docker.io/library/alpine:latest"
},
"updates": {
"identifier": "docker-image://docker.io/library/alpine@sha256:..."
}
}
]
}
```
Example policy file that denies all ubuntu images:
```json
{
"rules": [
{
"action": "DENY",
"selector": {
"identifier": "docker-image://docker.io/library/ubuntu:*",
"matchType": "WILDCARD"
}
}
]
}
```
**--squash**
Squash all layers, including those from base image(s), into one single layer. (Default is false).
@@ -1427,6 +1488,10 @@ buildah build --secret=id=mysecret,src=.mysecret,type=file .
buildah build --secret=id=mysecret,src=.mysecret .
### Building an image with a source policy
buildah build --source-policy-file /etc/buildah/source-policy.json -t imageName .
### Building an multi-architecture image using the --manifest option (requires emulation software)
buildah build --arch arm --manifest myimage /tmp/mysrc

View File

@@ -19,6 +19,7 @@ import (
"github.com/containers/buildah/internal/metadata"
internalUtil "github.com/containers/buildah/internal/util"
"github.com/containers/buildah/pkg/parse"
"github.com/containers/buildah/pkg/sourcepolicy"
"github.com/containers/buildah/pkg/sshagent"
"github.com/containers/buildah/util"
encconfig "github.com/containers/ocicrypt/config"
@@ -92,6 +93,7 @@ type executor struct {
out io.Writer
err io.Writer
signaturePolicyPath string
sourcePolicy *sourcepolicy.Policy
skipUnusedStages types.OptionalBool
systemContext *types.SystemContext
reportWriter io.Writer
@@ -226,6 +228,15 @@ func newExecutor(logger *logrus.Logger, logPrefix string, store storage.Store, o
return nil, err
}
// Load source policy if specified
var srcPolicy *sourcepolicy.Policy
if options.SourcePolicyFile != "" {
srcPolicy, err = sourcepolicy.LoadFromFile(options.SourcePolicyFile)
if err != nil {
return nil, fmt.Errorf("loading source policy: %w", err)
}
}
writer := options.ReportWriter
if options.Quiet {
writer = io.Discard
@@ -275,6 +286,7 @@ func newExecutor(logger *logrus.Logger, logPrefix string, store storage.Store, o
outputFormat: options.OutputFormat,
additionalTags: options.AdditionalTags,
signaturePolicyPath: options.SignaturePolicyPath,
sourcePolicy: srcPolicy,
skipUnusedStages: options.SkipUnusedStages,
systemContext: options.SystemContext,
log: options.Log,

View File

@@ -26,6 +26,7 @@ import (
internalUtil "github.com/containers/buildah/internal/util"
"github.com/containers/buildah/pkg/parse"
"github.com/containers/buildah/pkg/rusage"
"github.com/containers/buildah/pkg/sourcepolicy"
"github.com/containers/buildah/util"
docker "github.com/fsouza/go-dockerclient"
buildkitparser "github.com/moby/buildkit/frontend/dockerfile/parser"
@@ -979,6 +980,55 @@ func (s *stageExecutor) prepare(ctx context.Context, from string, initializeIBCo
}
from = base
}
// Apply source policy if one is configured and this is not "scratch" or a stage reference.
// Stage references are handled separately and don't need policy evaluation since they
// refer to images built within this same build.
if s.executor.sourcePolicy != nil && from != "scratch" {
// Check if 'from' references a previous stage by name, index, or image ID
isStageRef := false
for i, st := range s.stages[:s.index] {
if st.Name == from || strconv.Itoa(i) == from {
isStageRef = true
break
}
}
// Also check if 'from' is an image ID that was created by a previous stage
// (this happens when execute() resolves stage names to image IDs before calling prepare)
if !isStageRef {
s.executor.stagesLock.Lock()
for _, imgID := range s.executor.imageMap {
if imgID == from {
isStageRef = true
break
}
}
s.executor.stagesLock.Unlock()
}
if !isStageRef {
sourceID := sourcepolicy.ImageSourceIdentifier(from)
decision, matched, err := s.executor.sourcePolicy.Evaluate(sourceID)
if err != nil {
return nil, fmt.Errorf("evaluating source policy for %q: %w", from, err)
}
if matched {
switch decision.Action {
case sourcepolicy.ActionDeny:
return nil, fmt.Errorf("source %q denied by source policy: %s", from, decision.Reason)
case sourcepolicy.ActionConvert:
// Extract the new image reference from the policy decision
newFrom := sourcepolicy.ExtractImageRef(decision.TargetRef)
logrus.Debugf("source policy: converting %q to %q (%s)", from, newFrom, decision.Reason)
from = newFrom
case sourcepolicy.ActionAllow:
logrus.Debugf("source policy: allowing %q (%s)", from, decision.Reason)
}
}
}
}
sanitizedFrom, err := s.sanitizeFrom(from, tmpdir.GetTempDir())
if err != nil {
return nil, fmt.Errorf("invalid base image specification %q: %w", from, err)

View File

@@ -446,6 +446,7 @@ func GenBuildOptions(c *cobra.Command, inputArgs []string, iopts BuildOptions) (
SBOMScanOptions: sbomScanOptions,
SignBy: iopts.SignBy,
SignaturePolicyPath: iopts.SignaturePolicy,
SourcePolicyFile: iopts.SourcePolicyFile,
SkipUnusedStages: skipUnusedStages,
SourceDateEpoch: sourceDateEpoch,
Squash: iopts.Squash,

View File

@@ -130,6 +130,7 @@ type BudResults struct {
SourceDateEpoch string
RewriteTimestamp bool
CreatedAnnotation bool
SourcePolicyFile string
}
// FromAndBugResults represents the results for common flags
@@ -314,6 +315,7 @@ newer: only pull base and SBOM scanner images when newer images exist on the r
if err := fs.MarkHidden("signature-policy"); err != nil {
panic(fmt.Sprintf("error marking the signature-policy flag as hidden: %v", err))
}
fs.StringVar(&flags.SourcePolicyFile, "source-policy-file", "", "`pathname` of source policy file for controlling source references during build")
fs.BoolVar(&flags.SkipUnusedStages, "skip-unused-stages", true, "skips stages in multi-stage builds which do not affect the final target")
sourceDateEpochUsageDefault := ", defaults to current time"
if v := os.Getenv(internal.SourceDateEpochName); v != "" {
@@ -383,6 +385,7 @@ func GetBudFlagsCompletions() commonComp.FlagCompletions {
flagCompletion["secret"] = commonComp.AutocompleteNone
flagCompletion["sign-by"] = commonComp.AutocompleteNone
flagCompletion["signature-policy"] = commonComp.AutocompleteNone
flagCompletion["source-policy-file"] = commonComp.AutocompleteDefault
flagCompletion["ssh"] = commonComp.AutocompleteNone
flagCompletion["source-date-epoch"] = commonComp.AutocompleteNone
flagCompletion["tag"] = commonComp.AutocompleteNone

305
pkg/sourcepolicy/policy.go Normal file
View File

@@ -0,0 +1,305 @@
// Package sourcepolicy implements BuildKit-compatible source policy evaluation
// for controlling and transforming source references during builds.
//
// Source policies allow users to:
// - Pin base image tags to specific digests at build time
// - Deny specific sources from being used
// - Transform source references without modifying Containerfiles or Dockerfiles
//
// The policy file format is compatible with BuildKit's source policy JSON schema.
package sourcepolicy
import (
"encoding/json"
"fmt"
"os"
"strings"
"go.podman.io/image/v5/docker/reference"
)
// Action represents the action to take when a rule matches.
type Action string
const (
// ActionAllow explicitly allows a source (no transformation).
ActionAllow Action = "ALLOW"
// ActionDeny blocks the source and fails the build.
ActionDeny Action = "DENY"
// ActionConvert transforms the source to a different reference.
ActionConvert Action = "CONVERT"
)
// MatchType represents how the selector identifier should be matched.
type MatchType string
const (
// MatchTypeExact requires an exact string match.
MatchTypeExact MatchType = "EXACT"
// MatchTypeWildcard allows * and ? glob patterns.
MatchTypeWildcard MatchType = "WILDCARD"
// MatchTypeRegex allows regular expression patterns (not implemented in MVP).
MatchTypeRegex MatchType = "REGEX"
)
// Selector specifies which sources a rule applies to.
type Selector struct {
// Identifier is the source identifier to match.
// For docker images, this is typically "docker-image://registry/repo:tag".
Identifier string `json:"identifier"`
// MatchType specifies how the identifier should be matched.
// Defaults to EXACT if not specified.
MatchType MatchType `json:"matchType,omitempty"`
}
// Updates specifies how to transform a matched source.
type Updates struct {
// Identifier is the new source identifier to use.
// For CONVERT actions, this replaces the original identifier.
Identifier string `json:"identifier,omitempty"`
// Attrs contains additional attributes (e.g., http.checksum).
// Reserved for future use with HTTP sources.
Attrs map[string]string `json:"attrs,omitempty"`
}
// Rule represents a single policy rule.
type Rule struct {
// Action specifies what to do when this rule matches.
Action Action `json:"action"`
// Selector specifies which sources this rule applies to.
Selector Selector `json:"selector"`
// Updates specifies how to transform the source (for CONVERT action).
Updates *Updates `json:"updates,omitempty"`
}
// Policy represents a source policy containing multiple rules.
type Policy struct {
// Rules is the list of policy rules, evaluated in order.
// First matching rule wins.
Rules []Rule `json:"rules"`
}
// Decision represents the result of evaluating a source against a policy.
type Decision struct {
// Action is the action to take (ALLOW, DENY, or CONVERT).
Action Action
// TargetRef is the new reference to use (for CONVERT actions).
TargetRef string
// Reason provides context for the decision (e.g., which rule matched).
Reason string
}
// LoadFromFile loads a source policy from a JSON file.
func LoadFromFile(path string) (*Policy, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading source policy file %q: %w", path, err)
}
return Parse(data)
}
// Parse parses a source policy from JSON data.
func Parse(data []byte) (*Policy, error) {
var policy Policy
if err := json.Unmarshal(data, &policy); err != nil {
return nil, fmt.Errorf("parsing source policy JSON: %w", err)
}
if err := policy.Validate(); err != nil {
return nil, fmt.Errorf("validating source policy: %w", err)
}
return &policy, nil
}
// Validate checks that the policy is well-formed.
func (p *Policy) Validate() error {
if len(p.Rules) == 0 {
// Empty policy is valid - it just means no rules apply
return nil
}
for i, rule := range p.Rules {
if err := rule.Validate(); err != nil {
return fmt.Errorf("rule %d: %w", i, err)
}
}
return nil
}
// Validate checks that a rule is well-formed.
func (r *Rule) Validate() error {
// Validate action
switch r.Action {
case ActionAllow, ActionDeny, ActionConvert:
// Valid actions
case "":
return fmt.Errorf("action is required")
default:
return fmt.Errorf("unknown action %q (valid: ALLOW, DENY, CONVERT)", r.Action)
}
// Validate selector
if r.Selector.Identifier == "" {
return fmt.Errorf("selector.identifier is required")
}
// Validate match type
switch r.Selector.MatchType {
case MatchTypeExact, MatchTypeWildcard, "":
// Valid match types (empty defaults to EXACT)
case MatchTypeRegex:
return fmt.Errorf("REGEX match type is not supported in this version")
default:
return fmt.Errorf("unknown matchType %q (valid: EXACT, WILDCARD)", r.Selector.MatchType)
}
// Validate updates for CONVERT action
if r.Action == ActionConvert {
if r.Updates == nil || r.Updates.Identifier == "" {
return fmt.Errorf("updates.identifier is required for CONVERT action")
}
}
return nil
}
// Evaluate checks a source identifier against the policy and returns a decision.
// The first matching rule wins. If no rule matches, returns (Decision{}, false, nil).
func (p *Policy) Evaluate(sourceIdentifier string) (Decision, bool, error) {
if p == nil || len(p.Rules) == 0 {
return Decision{}, false, nil
}
for i, rule := range p.Rules {
matched, err := rule.Matches(sourceIdentifier)
if err != nil {
return Decision{}, false, fmt.Errorf("evaluating rule %d: %w", i, err)
}
if matched {
decision := Decision{
Action: rule.Action,
Reason: fmt.Sprintf("matched rule %d (selector: %q)", i, rule.Selector.Identifier),
}
if rule.Action == ActionConvert && rule.Updates != nil {
decision.TargetRef = rule.Updates.Identifier
}
return decision, true, nil
}
}
return Decision{}, false, nil
}
// Matches checks if a source identifier matches this rule's selector.
func (r *Rule) Matches(sourceIdentifier string) (bool, error) {
matchType := r.Selector.MatchType
if matchType == "" {
matchType = MatchTypeWildcard
}
switch matchType {
case MatchTypeExact:
return r.Selector.Identifier == sourceIdentifier, nil
case MatchTypeWildcard:
return matchWildcard(r.Selector.Identifier, sourceIdentifier), nil
default:
return false, fmt.Errorf("unsupported match type: %s", matchType)
}
}
// matchWildcard performs glob-style pattern matching.
// Supports * (matches any sequence of characters) and ? (matches any single character).
func matchWildcard(pattern, str string) bool {
// Use a simple recursive approach for wildcard matching
return wildcardMatch(pattern, str)
}
// wildcardMatch implements recursive wildcard matching.
func wildcardMatch(pattern, str string) bool {
for len(pattern) > 0 {
switch pattern[0] {
case '*':
// * matches zero or more characters
// Try matching the rest of the pattern against all possible suffixes
pattern = pattern[1:]
if len(pattern) == 0 {
// Trailing * matches everything
return true
}
// Try matching at each position
for i := 0; i <= len(str); i++ {
if wildcardMatch(pattern, str[i:]) {
return true
}
}
return false
case '?':
// ? matches exactly one character
if len(str) == 0 {
return false
}
pattern = pattern[1:]
str = str[1:]
default:
// Regular character must match exactly
if len(str) == 0 || pattern[0] != str[0] {
return false
}
pattern = pattern[1:]
str = str[1:]
}
}
return len(str) == 0
}
// ImageSourceIdentifier creates a BuildKit-style source identifier for a docker image.
// This normalizes image references to the format "docker-image://registry/repo:tag".
func ImageSourceIdentifier(imageRef string) string {
// If already in docker-image:// format, return as-is
if strings.HasPrefix(imageRef, "docker-image://") {
return imageRef
}
// Normalize the image reference
normalized := normalizeImageRef(imageRef)
return "docker-image://" + normalized
}
// normalizeImageRef normalizes an image reference to include registry and library prefix.
func normalizeImageRef(ref string) string {
// Handle scratch specially
if ref == "scratch" {
return ref
}
// Use go.podman.io/image/v5/docker/reference for proper normalization
named, err := reference.ParseNormalizedNamed(ref)
if err != nil {
// If parsing fails, return the original reference
return ref
}
return named.String()
}
// ExtractImageRef extracts the image reference from a BuildKit-style source identifier.
// It returns the original identifier if it's not a docker-image:// reference.
func ExtractImageRef(sourceIdentifier string) string {
const prefix = "docker-image://"
if strings.HasPrefix(sourceIdentifier, prefix) {
return sourceIdentifier[len(prefix):]
}
return sourceIdentifier
}

View File

@@ -0,0 +1,661 @@
package sourcepolicy
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestParse(t *testing.T) {
tests := []struct {
name string
json string
wantErr bool
errContains string
}{
{
name: "valid empty policy",
json: `{"rules": []}`,
},
{
name: "valid policy with DENY rule",
json: `{
"rules": [
{
"action": "DENY",
"selector": {
"identifier": "docker-image://docker.io/library/ubuntu:latest"
}
}
]
}`,
},
{
name: "valid policy with CONVERT rule",
json: `{
"rules": [
{
"action": "CONVERT",
"selector": {
"identifier": "docker-image://docker.io/library/alpine:latest"
},
"updates": {
"identifier": "docker-image://docker.io/library/alpine:latest@sha256:abc123"
}
}
]
}`,
},
{
name: "valid policy with ALLOW rule",
json: `{
"rules": [
{
"action": "ALLOW",
"selector": {
"identifier": "docker-image://docker.io/library/alpine:3.18"
}
}
]
}`,
},
{
name: "valid policy with WILDCARD match type",
json: `{
"rules": [
{
"action": "DENY",
"selector": {
"identifier": "docker-image://docker.io/library/*:latest",
"matchType": "WILDCARD"
}
}
]
}`,
},
{
name: "invalid JSON",
json: `{invalid}`,
wantErr: true,
errContains: "parsing source policy JSON",
},
{
name: "missing action",
json: `{
"rules": [
{
"selector": {
"identifier": "docker-image://test"
}
}
]
}`,
wantErr: true,
errContains: "action is required",
},
{
name: "unknown action",
json: `{
"rules": [
{
"action": "UNKNOWN",
"selector": {
"identifier": "docker-image://test"
}
}
]
}`,
wantErr: true,
errContains: "unknown action",
},
{
name: "missing selector identifier",
json: `{
"rules": [
{
"action": "DENY",
"selector": {}
}
]
}`,
wantErr: true,
errContains: "selector.identifier is required",
},
{
name: "CONVERT without updates",
json: `{
"rules": [
{
"action": "CONVERT",
"selector": {
"identifier": "docker-image://test"
}
}
]
}`,
wantErr: true,
errContains: "updates.identifier is required for CONVERT",
},
{
name: "CONVERT with empty updates identifier",
json: `{
"rules": [
{
"action": "CONVERT",
"selector": {
"identifier": "docker-image://test"
},
"updates": {
"identifier": ""
}
}
]
}`,
wantErr: true,
errContains: "updates.identifier is required for CONVERT",
},
{
name: "REGEX match type not supported",
json: `{
"rules": [
{
"action": "DENY",
"selector": {
"identifier": "docker-image://.*",
"matchType": "REGEX"
}
}
]
}`,
wantErr: true,
errContains: "REGEX match type is not supported",
},
{
name: "unknown match type",
json: `{
"rules": [
{
"action": "DENY",
"selector": {
"identifier": "docker-image://test",
"matchType": "UNKNOWN"
}
}
]
}`,
wantErr: true,
errContains: "unknown matchType",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
policy, err := Parse([]byte(tt.json))
if tt.wantErr {
if err == nil {
t.Errorf("Parse() error = nil, wantErr %v", tt.wantErr)
return
}
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("Parse() error = %v, want error containing %q", err, tt.errContains)
}
return
}
if err != nil {
t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr)
return
}
if policy == nil {
t.Error("Parse() returned nil policy without error")
}
})
}
}
func TestLoadFromFile(t *testing.T) {
// Create a temporary directory for test files
tmpDir := t.TempDir()
// Create a valid policy file
validPolicy := `{
"rules": [
{
"action": "DENY",
"selector": {
"identifier": "docker-image://test"
}
}
]
}`
validFile := filepath.Join(tmpDir, "valid.json")
if err := os.WriteFile(validFile, []byte(validPolicy), 0o644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
// Create an invalid policy file
invalidPolicy := `{invalid json}`
invalidFile := filepath.Join(tmpDir, "invalid.json")
if err := os.WriteFile(invalidFile, []byte(invalidPolicy), 0o644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
tests := []struct {
name string
path string
wantErr bool
errContains string
}{
{
name: "valid file",
path: validFile,
},
{
name: "non-existent file",
path: filepath.Join(tmpDir, "nonexistent.json"),
wantErr: true,
errContains: "reading source policy file",
},
{
name: "invalid JSON file",
path: invalidFile,
wantErr: true,
errContains: "parsing source policy JSON",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
policy, err := LoadFromFile(tt.path)
if tt.wantErr {
if err == nil {
t.Errorf("LoadFromFile() error = nil, wantErr %v", tt.wantErr)
return
}
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("LoadFromFile() error = %v, want error containing %q", err, tt.errContains)
}
return
}
if err != nil {
t.Errorf("LoadFromFile() error = %v, wantErr %v", err, tt.wantErr)
return
}
if policy == nil {
t.Error("LoadFromFile() returned nil policy without error")
}
})
}
}
func TestEvaluate(t *testing.T) {
tests := []struct {
name string
policyJSON string
sourceID string
wantMatched bool
wantAction Action
wantTargetRef string
wantErr bool
}{
{
name: "nil policy returns no match",
policyJSON: "",
sourceID: "docker-image://docker.io/library/alpine:latest",
wantMatched: false,
},
{
name: "empty policy returns no match",
policyJSON: `{"rules": []}`,
sourceID: "docker-image://docker.io/library/alpine:latest",
wantMatched: false,
},
{
name: "exact match DENY",
policyJSON: `{
"rules": [
{
"action": "DENY",
"selector": {
"identifier": "docker-image://docker.io/library/alpine:latest",
"matchType": "EXACT"
}
}
]
}`,
sourceID: "docker-image://docker.io/library/alpine:latest",
wantMatched: true,
wantAction: ActionDeny,
},
{
name: "exact match no match",
policyJSON: `{
"rules": [
{
"action": "DENY",
"selector": {
"identifier": "docker-image://docker.io/library/ubuntu:latest",
"matchType": "EXACT"
}
}
]
}`,
sourceID: "docker-image://docker.io/library/alpine:latest",
wantMatched: false,
},
{
name: "exact match CONVERT",
policyJSON: `{
"rules": [
{
"action": "CONVERT",
"selector": {
"identifier": "docker-image://docker.io/library/alpine:latest",
"matchType": "EXACT"
},
"updates": {
"identifier": "docker-image://docker.io/library/alpine:latest@sha256:abc123"
}
}
]
}`,
sourceID: "docker-image://docker.io/library/alpine:latest",
wantMatched: true,
wantAction: ActionConvert,
wantTargetRef: "docker-image://docker.io/library/alpine:latest@sha256:abc123",
},
{
name: "wildcard match DENY - star matches any",
policyJSON: `{
"rules": [
{
"action": "DENY",
"selector": {
"identifier": "docker-image://docker.io/library/*:latest",
"matchType": "WILDCARD"
}
}
]
}`,
sourceID: "docker-image://docker.io/library/alpine:latest",
wantMatched: true,
wantAction: ActionDeny,
},
{
name: "wildcard match - question mark matches single char",
policyJSON: `{
"rules": [
{
"action": "DENY",
"selector": {
"identifier": "docker-image://docker.io/library/alpine:3.1?",
"matchType": "WILDCARD"
}
}
]
}`,
sourceID: "docker-image://docker.io/library/alpine:3.18",
wantMatched: true,
wantAction: ActionDeny,
},
{
name: "wildcard no match",
policyJSON: `{
"rules": [
{
"action": "DENY",
"selector": {
"identifier": "docker-image://docker.io/library/*:stable",
"matchType": "WILDCARD"
}
}
]
}`,
sourceID: "docker-image://docker.io/library/alpine:latest",
wantMatched: false,
},
{
name: "first match wins - DENY before ALLOW",
policyJSON: `{
"rules": [
{
"action": "DENY",
"selector": {
"identifier": "docker-image://docker.io/library/alpine:latest"
}
},
{
"action": "ALLOW",
"selector": {
"identifier": "docker-image://docker.io/library/alpine:latest"
}
}
]
}`,
sourceID: "docker-image://docker.io/library/alpine:latest",
wantMatched: true,
wantAction: ActionDeny,
},
{
name: "first match wins - ALLOW before DENY",
policyJSON: `{
"rules": [
{
"action": "ALLOW",
"selector": {
"identifier": "docker-image://docker.io/library/alpine:latest"
}
},
{
"action": "DENY",
"selector": {
"identifier": "docker-image://docker.io/library/alpine:latest"
}
}
]
}`,
sourceID: "docker-image://docker.io/library/alpine:latest",
wantMatched: true,
wantAction: ActionAllow,
},
{
name: "multiple rules - second matches",
policyJSON: `{
"rules": [
{
"action": "DENY",
"selector": {
"identifier": "docker-image://docker.io/library/ubuntu:latest"
}
},
{
"action": "CONVERT",
"selector": {
"identifier": "docker-image://docker.io/library/alpine:latest"
},
"updates": {
"identifier": "docker-image://myregistry/alpine:pinned"
}
}
]
}`,
sourceID: "docker-image://docker.io/library/alpine:latest",
wantMatched: true,
wantAction: ActionConvert,
wantTargetRef: "docker-image://myregistry/alpine:pinned",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var policy *Policy
var err error
if tt.policyJSON != "" {
policy, err = Parse([]byte(tt.policyJSON))
if err != nil {
t.Fatalf("Failed to parse test policy: %v", err)
}
}
decision, matched, err := policy.Evaluate(tt.sourceID)
if tt.wantErr {
if err == nil {
t.Errorf("Evaluate() error = nil, wantErr %v", tt.wantErr)
}
return
}
if err != nil {
t.Errorf("Evaluate() error = %v, wantErr %v", err, tt.wantErr)
return
}
if matched != tt.wantMatched {
t.Errorf("Evaluate() matched = %v, want %v", matched, tt.wantMatched)
}
if matched {
if decision.Action != tt.wantAction {
t.Errorf("Evaluate() action = %v, want %v", decision.Action, tt.wantAction)
}
if tt.wantTargetRef != "" && decision.TargetRef != tt.wantTargetRef {
t.Errorf("Evaluate() targetRef = %v, want %v", decision.TargetRef, tt.wantTargetRef)
}
}
})
}
}
func TestWildcardMatch(t *testing.T) {
tests := []struct {
pattern string
str string
want bool
}{
// Basic exact matches
{"abc", "abc", true},
{"abc", "abcd", false},
{"abc", "ab", false},
// Star wildcard
{"*", "", true},
{"*", "anything", true},
{"a*", "a", true},
{"a*", "abc", true},
{"a*", "b", false},
{"*c", "c", true},
{"*c", "abc", true},
{"*c", "cd", false},
{"a*c", "ac", true},
{"a*c", "abc", true},
{"a*c", "abbc", true},
{"a*c", "ab", false},
// Question mark wildcard
{"?", "a", true},
{"?", "", false},
{"?", "ab", false},
{"a?c", "abc", true},
{"a?c", "adc", true},
{"a?c", "ac", false},
{"a?c", "abbc", false},
// Combined wildcards
{"a*?", "ab", true},
{"a*?", "abc", true},
{"a*?", "a", false},
{"a?*", "ab", true},
{"a?*", "abc", true},
{"a?*", "a", false},
// Multiple stars
{"*a*", "a", true},
{"*a*", "ba", true},
{"*a*", "ab", true},
{"*a*", "bab", true},
{"*a*", "b", false},
// Real-world patterns
{"docker-image://docker.io/library/*:latest", "docker-image://docker.io/library/alpine:latest", true},
{"docker-image://docker.io/library/*:latest", "docker-image://docker.io/library/ubuntu:latest", true},
{"docker-image://docker.io/library/*:latest", "docker-image://docker.io/library/alpine:3.18", false},
{"docker-image://*/*:*", "docker-image://docker.io/library/alpine:3.18", true},
{"docker-image://*/library/alpine:*", "docker-image://docker.io/library/alpine:latest", true},
{"docker-image://*/library/alpine:*", "docker-image://gcr.io/library/alpine:v1", true},
}
for _, tt := range tests {
t.Run(tt.pattern+"_"+tt.str, func(t *testing.T) {
got := matchWildcard(tt.pattern, tt.str)
if got != tt.want {
t.Errorf("matchWildcard(%q, %q) = %v, want %v", tt.pattern, tt.str, got, tt.want)
}
})
}
}
func TestImageSourceIdentifier(t *testing.T) {
tests := []struct {
imageRef string
want string
}{
// Already in docker-image:// format
{"docker-image://docker.io/library/alpine:latest", "docker-image://docker.io/library/alpine:latest"},
// Simple image names (no registry)
{"alpine", "docker-image://docker.io/library/alpine"},
{"alpine:latest", "docker-image://docker.io/library/alpine:latest"},
{"alpine:3.18", "docker-image://docker.io/library/alpine:3.18"},
// User images (no registry, with username)
{"myuser/myimage", "docker-image://docker.io/myuser/myimage"},
{"myuser/myimage:latest", "docker-image://docker.io/myuser/myimage:latest"},
// Full registry paths
{"docker.io/library/alpine:latest", "docker-image://docker.io/library/alpine:latest"},
{"gcr.io/project/image:tag", "docker-image://gcr.io/project/image:tag"},
{"localhost:5000/myimage", "docker-image://localhost:5000/myimage"},
{"myregistry.com:8080/project/image:v1", "docker-image://myregistry.com:8080/project/image:v1"},
// Scratch (special case)
{"scratch", "docker-image://scratch"},
// With digest (using valid 64-character hex digests)
{"alpine@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "docker-image://docker.io/library/alpine@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"},
{"docker.io/library/alpine@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "docker-image://docker.io/library/alpine@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"},
}
for _, tt := range tests {
t.Run(tt.imageRef, func(t *testing.T) {
got := ImageSourceIdentifier(tt.imageRef)
if got != tt.want {
t.Errorf("ImageSourceIdentifier(%q) = %q, want %q", tt.imageRef, got, tt.want)
}
})
}
}
func TestExtractImageRef(t *testing.T) {
tests := []struct {
sourceID string
want string
}{
{"docker-image://docker.io/library/alpine:latest", "docker.io/library/alpine:latest"},
{"docker-image://gcr.io/project/image:tag", "gcr.io/project/image:tag"},
{"docker-image://alpine", "alpine"},
// Non-docker-image sources (returned as-is)
{"https://example.com/file.tar.gz", "https://example.com/file.tar.gz"},
{"git://github.com/user/repo.git#main", "git://github.com/user/repo.git#main"},
{"alpine:latest", "alpine:latest"},
}
for _, tt := range tests {
t.Run(tt.sourceID, func(t *testing.T) {
got := ExtractImageRef(tt.sourceID)
if got != tt.want {
t.Errorf("ExtractImageRef(%q) = %q, want %q", tt.sourceID, got, tt.want)
}
})
}
}

423
tests/source-policy.bats Normal file
View File

@@ -0,0 +1,423 @@
#!/usr/bin/env bats
load helpers
@test "source-policy: DENY rule blocks base image" {
# Create a policy that denies alpine
policyfile=${TEST_SCRATCH_DIR}/policy.json
cat > $policyfile << 'EOF'
{
"rules": [
{
"action": "DENY",
"selector": {
"identifier": "docker-image://docker.io/library/alpine:latest"
}
}
]
}
EOF
# Create a simple Dockerfile
dockerfile=${TEST_SCRATCH_DIR}/Dockerfile
cat > $dockerfile << 'EOF'
FROM alpine:latest
RUN echo hello
EOF
# Build should fail with source policy denial
run_buildah 125 build $WITH_POLICY_JSON --source-policy-file $policyfile -f $dockerfile ${TEST_SCRATCH_DIR}
expect_output --substring "denied by source policy"
}
@test "source-policy: DENY rule with WILDCARD match" {
# Create a policy that denies all ubuntu images
policyfile=${TEST_SCRATCH_DIR}/policy.json
cat > $policyfile << 'EOF'
{
"rules": [
{
"action": "DENY",
"selector": {
"identifier": "docker-image://docker.io/library/ubuntu:*",
"matchType": "WILDCARD"
}
}
]
}
EOF
# Create a simple Dockerfile using ubuntu
dockerfile=${TEST_SCRATCH_DIR}/Dockerfile
cat > $dockerfile << 'EOF'
FROM ubuntu:22.04
RUN echo hello
EOF
# Build should fail with source policy denial
run_buildah 125 build $WITH_POLICY_JSON --source-policy-file $policyfile -f $dockerfile ${TEST_SCRATCH_DIR}
expect_output --substring "denied by source policy"
}
@test "source-policy: CONVERT rule rewrites base image to pinned digest" {
_prefetch alpine
# Get the digest of the alpine image
run_buildah inspect --format '{{.FromImageDigest}}' alpine
alpine_digest="$output"
# Create a policy that converts alpine:latest to the pinned digest
policyfile=${TEST_SCRATCH_DIR}/policy.json
cat > $policyfile << EOF
{
"rules": [
{
"action": "CONVERT",
"selector": {
"identifier": "docker-image://docker.io/library/alpine:latest"
},
"updates": {
"identifier": "docker-image://docker.io/library/alpine@${alpine_digest}"
}
}
]
}
EOF
# Create a simple Dockerfile
dockerfile=${TEST_SCRATCH_DIR}/Dockerfile
cat > $dockerfile << 'EOF'
FROM alpine:latest
RUN echo converted
EOF
imgname="img-$(safename)"
# Build should succeed with the converted reference
run_buildah build $WITH_POLICY_JSON --source-policy-file $policyfile -t $imgname -f $dockerfile ${TEST_SCRATCH_DIR}
# Verify the image was built
run_buildah inspect $imgname
}
@test "source-policy: ALLOW rule explicitly allows source" {
_prefetch alpine
# Create a policy that explicitly allows alpine
policyfile=${TEST_SCRATCH_DIR}/policy.json
cat > $policyfile << 'EOF'
{
"rules": [
{
"action": "ALLOW",
"selector": {
"identifier": "docker-image://docker.io/library/alpine:latest"
}
}
]
}
EOF
# Create a simple Dockerfile
dockerfile=${TEST_SCRATCH_DIR}/Dockerfile
cat > $dockerfile << 'EOF'
FROM alpine:latest
RUN echo allowed
EOF
imgname="img-$(safename)"
# Build should succeed
run_buildah build $WITH_POLICY_JSON --source-policy-file $policyfile -t $imgname -f $dockerfile ${TEST_SCRATCH_DIR}
# Verify the image was built
run_buildah inspect $imgname
}
@test "source-policy: first matching rule wins" {
_prefetch alpine
# Create a policy where ALLOW comes before DENY for the same image
policyfile=${TEST_SCRATCH_DIR}/policy.json
cat > $policyfile << 'EOF'
{
"rules": [
{
"action": "ALLOW",
"selector": {
"identifier": "docker-image://docker.io/library/alpine:latest"
}
},
{
"action": "DENY",
"selector": {
"identifier": "docker-image://docker.io/library/alpine:latest"
}
}
]
}
EOF
# Create a simple Dockerfile
dockerfile=${TEST_SCRATCH_DIR}/Dockerfile
cat > $dockerfile << 'EOF'
FROM alpine:latest
RUN echo first-match-wins
EOF
imgname="img-$(safename)"
# Build should succeed because ALLOW matches first
run_buildah build $WITH_POLICY_JSON --source-policy-file $policyfile -t $imgname -f $dockerfile ${TEST_SCRATCH_DIR}
# Verify the image was built
run_buildah inspect $imgname
}
@test "source-policy: invalid policy file fails build" {
# Create an invalid policy file (bad JSON)
policyfile=${TEST_SCRATCH_DIR}/policy.json
cat > $policyfile << 'EOF'
{invalid json}
EOF
# Create a simple Dockerfile
dockerfile=${TEST_SCRATCH_DIR}/Dockerfile
cat > $dockerfile << 'EOF'
FROM alpine:latest
EOF
# Build should fail with parsing error
run_buildah 125 build $WITH_POLICY_JSON --source-policy-file $policyfile -f $dockerfile ${TEST_SCRATCH_DIR}
expect_output --substring "loading source policy"
}
@test "source-policy: missing policy file fails build" {
# Create a simple Dockerfile
dockerfile=${TEST_SCRATCH_DIR}/Dockerfile
cat > $dockerfile << 'EOF'
FROM alpine:latest
EOF
# Build should fail with file not found error
run_buildah 125 build $WITH_POLICY_JSON --source-policy-file /nonexistent/policy.json -f $dockerfile ${TEST_SCRATCH_DIR}
expect_output --substring "loading source policy"
}
@test "source-policy: scratch base image is not evaluated" {
# Create a policy that would deny everything
policyfile=${TEST_SCRATCH_DIR}/policy.json
cat > $policyfile << 'EOF'
{
"rules": [
{
"action": "DENY",
"selector": {
"identifier": "docker-image://*",
"matchType": "WILDCARD"
}
}
]
}
EOF
# Create a simple file to copy into the image
echo "test content" > ${TEST_SCRATCH_DIR}/testfile.txt
# Create a Dockerfile using scratch that doesn't reference external images
dockerfile=${TEST_SCRATCH_DIR}/Dockerfile
cat > $dockerfile << 'EOF'
FROM scratch
COPY testfile.txt /testfile.txt
EOF
imgname="img-$(safename)"
# Build from scratch should succeed - scratch is not evaluated against policy
run_buildah build $WITH_POLICY_JSON --source-policy-file $policyfile -t $imgname -f $dockerfile ${TEST_SCRATCH_DIR}
# Verify the image was built
run_buildah inspect $imgname
}
@test "source-policy: multi-stage build with stage reference not evaluated" {
_prefetch alpine
# Create a policy that denies everything except alpine
policyfile=${TEST_SCRATCH_DIR}/policy.json
cat > $policyfile << 'EOF'
{
"rules": [
{
"action": "ALLOW",
"selector": {
"identifier": "docker-image://docker.io/library/alpine:latest"
}
},
{
"action": "DENY",
"selector": {
"identifier": "docker-image://*",
"matchType": "WILDCARD"
}
}
]
}
EOF
# Create a multi-stage Dockerfile where stage references another stage by name
dockerfile=${TEST_SCRATCH_DIR}/Dockerfile
cat > $dockerfile << 'EOF'
FROM alpine:latest AS builder
RUN echo "building"
FROM builder AS final
RUN echo "final"
EOF
imgname="img-$(safename)"
# Build should succeed - stage reference "builder" should not be evaluated against policy
run_buildah build $WITH_POLICY_JSON --source-policy-file $policyfile -t $imgname -f $dockerfile ${TEST_SCRATCH_DIR}
# Verify the image was built
run_buildah inspect $imgname
}
@test "source-policy: no policy file means no restrictions" {
_prefetch alpine
# Create a simple Dockerfile
dockerfile=${TEST_SCRATCH_DIR}/Dockerfile
cat > $dockerfile << 'EOF'
FROM alpine:latest
RUN echo "no policy"
EOF
imgname="img-$(safename)"
# Build without --source-policy-file should work normally
run_buildah build $WITH_POLICY_JSON -t $imgname -f $dockerfile ${TEST_SCRATCH_DIR}
# Verify the image was built
run_buildah inspect $imgname
}
@test "source-policy: CONVERT with normalized image references" {
_prefetch alpine
# Get the digest of the alpine image
run_buildah inspect --format '{{.FromImageDigest}}' alpine
alpine_digest="$output"
# Create a policy that converts short name "alpine" to full reference with digest
policyfile=${TEST_SCRATCH_DIR}/policy.json
cat > $policyfile << EOF
{
"rules": [
{
"action": "CONVERT",
"selector": {
"identifier": "docker-image://docker.io/library/alpine"
},
"updates": {
"identifier": "docker-image://docker.io/library/alpine@${alpine_digest}"
}
}
]
}
EOF
# Create a Dockerfile with short name (no tag)
dockerfile=${TEST_SCRATCH_DIR}/Dockerfile
cat > $dockerfile << 'EOF'
FROM alpine
RUN echo converted
EOF
imgname="img-$(safename)"
# Build should succeed with the converted reference
run_buildah build $WITH_POLICY_JSON --source-policy-file $policyfile -t $imgname -f $dockerfile ${TEST_SCRATCH_DIR}
# Verify the image was built
run_buildah inspect $imgname
}
@test "source-policy: policy validation - missing action" {
# Create a policy missing required action field
policyfile=${TEST_SCRATCH_DIR}/policy.json
cat > $policyfile << 'EOF'
{
"rules": [
{
"selector": {
"identifier": "docker-image://test"
}
}
]
}
EOF
# Create a simple Dockerfile
dockerfile=${TEST_SCRATCH_DIR}/Dockerfile
cat > $dockerfile << 'EOF'
FROM alpine:latest
EOF
# Build should fail with validation error
run_buildah 125 build $WITH_POLICY_JSON --source-policy-file $policyfile -f $dockerfile ${TEST_SCRATCH_DIR}
expect_output --substring "action is required"
}
@test "source-policy: policy validation - missing selector identifier" {
# Create a policy missing selector identifier
policyfile=${TEST_SCRATCH_DIR}/policy.json
cat > $policyfile << 'EOF'
{
"rules": [
{
"action": "DENY",
"selector": {}
}
]
}
EOF
# Create a simple Dockerfile
dockerfile=${TEST_SCRATCH_DIR}/Dockerfile
cat > $dockerfile << 'EOF'
FROM alpine:latest
EOF
# Build should fail with validation error
run_buildah 125 build $WITH_POLICY_JSON --source-policy-file $policyfile -f $dockerfile ${TEST_SCRATCH_DIR}
expect_output --substring "selector.identifier is required"
}
@test "source-policy: policy validation - CONVERT without updates" {
# Create a CONVERT policy without updates field
policyfile=${TEST_SCRATCH_DIR}/policy.json
cat > $policyfile << 'EOF'
{
"rules": [
{
"action": "CONVERT",
"selector": {
"identifier": "docker-image://test"
}
}
]
}
EOF
# Create a simple Dockerfile
dockerfile=${TEST_SCRATCH_DIR}/Dockerfile
cat > $dockerfile << 'EOF'
FROM alpine:latest
EOF
# Build should fail with validation error
run_buildah 125 build $WITH_POLICY_JSON --source-policy-file $policyfile -f $dockerfile ${TEST_SCRATCH_DIR}
expect_output --substring "updates.identifier is required for CONVERT"
}