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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
305
pkg/sourcepolicy/policy.go
Normal 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
|
||||
}
|
||||
661
pkg/sourcepolicy/policy_test.go
Normal file
661
pkg/sourcepolicy/policy_test.go
Normal 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
423
tests/source-policy.bats
Normal 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"
|
||||
}
|
||||
Reference in New Issue
Block a user