From 2c225f3ccd117591edd4a62dd9605bde70f1ec6e Mon Sep 17 00:00:00 2001 From: Konstantin Vyatkin Date: Wed, 21 Jan 2026 10:04:57 +0100 Subject: [PATCH 1/5] Add --source-policy-file flag for BuildKit-compatible source policies Implements support for the BuildKit source policy feature, bringing feature parity with `buildctl build --source-policy-file`. The JSON schema is compatible with BuildKit's source policy format. Features: - New `--source-policy-file` flag for `buildah build` - ALLOW, DENY, and CONVERT actions for controlling source references - EXACT and WILDCARD match types for flexible policy rules - Automatic image reference normalization to docker-image:// format This allows organizations to: - Pin base image tags to specific digests at build time - Deny specific sources from being used to enforce security policies - Transform source references without modifying Containerfiles Changes: - pkg/sourcepolicy/: New package for policy parsing, validation, matching - define/build.go: Added SourcePolicyFile field to BuildOptions - pkg/cli/common.go: Added --source-policy-file flag definition - imagebuildah/executor.go: Policy loading in newExecutor() - imagebuildah/stage_executor.go: Policy evaluation in prepare() - docs/buildah-build.1.md: Man page documentation with examples - tests/source-policy.bats: Integration tests - pkg/sourcepolicy/policy_test.go: Unit tests Signed-off-by: Konstantin Vyatkin Co-Authored-By: Claude Opus 4.5 --- define/build.go | 6 + docs/buildah-build.1.md | 60 +++ imagebuildah/executor.go | 12 + imagebuildah/stage_executor.go | 50 +++ pkg/cli/build.go | 1 + pkg/cli/common.go | 3 + pkg/sourcepolicy/policy.go | 315 +++++++++++++++ pkg/sourcepolicy/policy_test.go | 658 ++++++++++++++++++++++++++++++++ tests/source-policy.bats | 423 ++++++++++++++++++++ 9 files changed, 1528 insertions(+) create mode 100644 pkg/sourcepolicy/policy.go create mode 100644 pkg/sourcepolicy/policy_test.go create mode 100644 tests/source-policy.bats diff --git a/define/build.go b/define/build.go index e07a01d7d..75bbf918f 100644 --- a/define/build.go +++ b/define/build.go @@ -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. diff --git a/docs/buildah-build.1.md b/docs/buildah-build.1.md index 9f334c1e8..8c77df695 100644 --- a/docs/buildah-build.1.md +++ b/docs/buildah-build.1.md @@ -1045,6 +1045,62 @@ 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` (default) and `WILDCARD` (supports `*` and `?` glob patterns). +- **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. + +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 +1483,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 diff --git a/imagebuildah/executor.go b/imagebuildah/executor.go index 2ebb418f1..d08f42231 100644 --- a/imagebuildah/executor.go +++ b/imagebuildah/executor.go @@ -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, diff --git a/imagebuildah/stage_executor.go b/imagebuildah/stage_executor.go index 876e558f5..177fc3971 100644 --- a/imagebuildah/stage_executor.go +++ b/imagebuildah/stage_executor.go @@ -25,6 +25,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" @@ -978,6 +979,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) diff --git a/pkg/cli/build.go b/pkg/cli/build.go index 2b7c2ff7c..f6d7788d6 100644 --- a/pkg/cli/build.go +++ b/pkg/cli/build.go @@ -445,6 +445,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, diff --git a/pkg/cli/common.go b/pkg/cli/common.go index 6d68f42b4..313848ff0 100644 --- a/pkg/cli/common.go +++ b/pkg/cli/common.go @@ -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 diff --git a/pkg/sourcepolicy/policy.go b/pkg/sourcepolicy/policy.go new file mode 100644 index 000000000..b0fc7d4ee --- /dev/null +++ b/pkg/sourcepolicy/policy.go @@ -0,0 +1,315 @@ +// 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 Dockerfiles +// +// The policy file format is compatible with BuildKit's source policy JSON schema. +package sourcepolicy + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// 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 = MatchTypeExact + } + + 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 + } + + // Split into registry/repo:tag components + parts := strings.SplitN(ref, "/", 2) + + // Check if first part looks like a registry (contains . or :, or is localhost) + hasRegistry := len(parts) == 2 && (strings.Contains(parts[0], ".") || + strings.Contains(parts[0], ":") || + parts[0] == "localhost") + + if !hasRegistry { + // Add docker.io as default registry + if len(parts) == 1 { + // Single name like "alpine" or "alpine:3.18" + ref = "docker.io/library/" + ref + } else { + // Name with one slash like "myuser/myimage" (user namespace) + ref = "docker.io/" + ref + } + } + + return ref +} + +// 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 +} diff --git a/pkg/sourcepolicy/policy_test.go b/pkg/sourcepolicy/policy_test.go new file mode 100644 index 000000000..78646cadc --- /dev/null +++ b/pkg/sourcepolicy/policy_test.go @@ -0,0 +1,658 @@ +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" + } + } + ] + }`, + 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" + } + } + ] + }`, + 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" + }, + "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 + {"alpine@sha256:abc123", "docker-image://docker.io/library/alpine@sha256:abc123"}, + {"docker.io/library/alpine@sha256:abc123", "docker-image://docker.io/library/alpine@sha256:abc123"}, + } + + 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) + } + }) + } +} diff --git a/tests/source-policy.bats b/tests/source-policy.bats new file mode 100644 index 000000000..8ab3b9d22 --- /dev/null +++ b/tests/source-policy.bats @@ -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" +} From d6eaeaa4b8fe5920d1925cc853d31bc8885fd424 Mon Sep 17 00:00:00 2001 From: Konstantin Vyatkin Date: Tue, 27 Jan 2026 01:35:09 +0100 Subject: [PATCH 2/5] fix: default matchType to WILDCARD per BuildKit spec Change the default match type from EXACT to WILDCARD based on BuildKit's protobuf/json tagging which indicates wildcard should be the default behavior. Addresses: https://github.com/containers/buildah/pull/6647#discussion_r2727850169 Co-Authored-By: Claude Opus 4.5 Signed-off-by: Konstantin Vyatkin --- pkg/sourcepolicy/policy.go | 2 +- pkg/sourcepolicy/policy_test.go | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/pkg/sourcepolicy/policy.go b/pkg/sourcepolicy/policy.go index b0fc7d4ee..4ae03661a 100644 --- a/pkg/sourcepolicy/policy.go +++ b/pkg/sourcepolicy/policy.go @@ -204,7 +204,7 @@ func (p *Policy) Evaluate(sourceIdentifier string) (Decision, bool, error) { func (r *Rule) Matches(sourceIdentifier string) (bool, error) { matchType := r.Selector.MatchType if matchType == "" { - matchType = MatchTypeExact + matchType = MatchTypeWildcard } switch matchType { diff --git a/pkg/sourcepolicy/policy_test.go b/pkg/sourcepolicy/policy_test.go index 78646cadc..2a499e271 100644 --- a/pkg/sourcepolicy/policy_test.go +++ b/pkg/sourcepolicy/policy_test.go @@ -317,7 +317,8 @@ func TestEvaluate(t *testing.T) { { "action": "DENY", "selector": { - "identifier": "docker-image://docker.io/library/alpine:latest" + "identifier": "docker-image://docker.io/library/alpine:latest", + "matchType": "EXACT" } } ] @@ -333,7 +334,8 @@ func TestEvaluate(t *testing.T) { { "action": "DENY", "selector": { - "identifier": "docker-image://docker.io/library/ubuntu:latest" + "identifier": "docker-image://docker.io/library/ubuntu:latest", + "matchType": "EXACT" } } ] @@ -348,7 +350,8 @@ func TestEvaluate(t *testing.T) { { "action": "CONVERT", "selector": { - "identifier": "docker-image://docker.io/library/alpine:latest" + "identifier": "docker-image://docker.io/library/alpine:latest", + "matchType": "EXACT" }, "updates": { "identifier": "docker-image://docker.io/library/alpine:latest@sha256:abc123" From 406a7dbe992d95c0d4eb5f607c9f715ca68ef6f1 Mon Sep 17 00:00:00 2001 From: Konstantin Vyatkin Date: Tue, 27 Jan 2026 01:36:16 +0100 Subject: [PATCH 3/5] fix: document source policy processing order in man page Add note explaining that CONVERT rules are processed after --build-context substitutions but before containers-registries.conf substitutions. Also update matchType default documentation to reflect WILDCARD being the default. Addresses: https://github.com/containers/buildah/pull/6647#discussion_r2727811695 Co-Authored-By: Claude Opus 4.5 Signed-off-by: Konstantin Vyatkin --- docs/buildah-build.1.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/buildah-build.1.md b/docs/buildah-build.1.md index 8c77df695..bff9df396 100644 --- a/docs/buildah-build.1.md +++ b/docs/buildah-build.1.md @@ -1063,12 +1063,17 @@ The policy file is a JSON document containing an array of rules. Each rule has: - **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` (default) and `WILDCARD` (supports `*` and `?` glob patterns). + - **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 { From 23dec9cd40b8ce56ec18b6a1e3f90bd7cbf533b5 Mon Sep 17 00:00:00 2001 From: Konstantin Vyatkin Date: Tue, 27 Jan 2026 01:38:11 +0100 Subject: [PATCH 4/5] fix: use reference.ParseNormalizedNamed for image normalization Replace custom image reference normalization with the proper go.podman.io/image/v5/docker/reference.ParseNormalizedNamed() function. Updated test cases to use valid SHA256 digests. Addresses: https://github.com/containers/buildah/pull/6647#discussion_r2727802792 Co-Authored-By: Claude Opus 4.5 Signed-off-by: Konstantin Vyatkin --- pkg/sourcepolicy/policy.go | 26 ++++++++------------------ pkg/sourcepolicy/policy_test.go | 6 +++--- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/pkg/sourcepolicy/policy.go b/pkg/sourcepolicy/policy.go index 4ae03661a..e471aabbe 100644 --- a/pkg/sourcepolicy/policy.go +++ b/pkg/sourcepolicy/policy.go @@ -14,6 +14,8 @@ import ( "fmt" "os" "strings" + + "go.podman.io/image/v5/docker/reference" ) // Action represents the action to take when a rule matches. @@ -282,26 +284,14 @@ func normalizeImageRef(ref string) string { return ref } - // Split into registry/repo:tag components - parts := strings.SplitN(ref, "/", 2) - - // Check if first part looks like a registry (contains . or :, or is localhost) - hasRegistry := len(parts) == 2 && (strings.Contains(parts[0], ".") || - strings.Contains(parts[0], ":") || - parts[0] == "localhost") - - if !hasRegistry { - // Add docker.io as default registry - if len(parts) == 1 { - // Single name like "alpine" or "alpine:3.18" - ref = "docker.io/library/" + ref - } else { - // Name with one slash like "myuser/myimage" (user namespace) - ref = "docker.io/" + 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 ref + return named.String() } // ExtractImageRef extracts the image reference from a BuildKit-style source identifier. diff --git a/pkg/sourcepolicy/policy_test.go b/pkg/sourcepolicy/policy_test.go index 2a499e271..1e6710833 100644 --- a/pkg/sourcepolicy/policy_test.go +++ b/pkg/sourcepolicy/policy_test.go @@ -620,9 +620,9 @@ func TestImageSourceIdentifier(t *testing.T) { // Scratch (special case) {"scratch", "docker-image://scratch"}, - // With digest - {"alpine@sha256:abc123", "docker-image://docker.io/library/alpine@sha256:abc123"}, - {"docker.io/library/alpine@sha256:abc123", "docker-image://docker.io/library/alpine@sha256:abc123"}, + // 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 { From a2ef353bfea3b13f6751ea5700b270e24dcd0dc6 Mon Sep 17 00:00:00 2001 From: Konstantin Vyatkin Date: Tue, 27 Jan 2026 01:39:03 +0100 Subject: [PATCH 5/5] docs: mention Containerfiles also Co-authored-by: Tom Sweeney Signed-off-by: Konstantin Vyatkin --- pkg/sourcepolicy/policy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/sourcepolicy/policy.go b/pkg/sourcepolicy/policy.go index e471aabbe..c67047816 100644 --- a/pkg/sourcepolicy/policy.go +++ b/pkg/sourcepolicy/policy.go @@ -4,7 +4,7 @@ // 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 Dockerfiles +// - Transform source references without modifying Containerfiles or Dockerfiles // // The policy file format is compatible with BuildKit's source policy JSON schema. package sourcepolicy