diff --git a/.cursor/rules/package_filtering_process.mdc b/.cursor/rules/package_filtering_process.mdc new file mode 100644 index 000000000..70e4ded12 --- /dev/null +++ b/.cursor/rules/package_filtering_process.mdc @@ -0,0 +1,534 @@ +# Pluggable Rule Filtering System + +## Overview +The Enterprise Contract CLI uses a flexible rule filtering system that allows you to filter Rego rules based on various criteria before evaluation. The system is designed to be extensible and composable, making it easy to add new filtering criteria. + +## Architecture + +### Core Components +- **`RuleFilter` interface**: Defines the contract for all filters +- **`FilterFactory` interface**: Creates filters from source configuration +- **`filterNamespaces()` function**: Applies multiple filters in sequence with AND logic +- **Individual filter implementations**: Each filter implements the `RuleFilter` interface + +### Current Filters +- **`PipelineIntentionFilter`**: Filters rules based on `pipeline_intention` metadata +- **`IncludeListFilter`**: Filters rules based on include/exclude configuration (collections, packages, rules) + +## Interface Definitions + +```go +// RuleFilter decides whether an entire package (namespace) should be +// included in the evaluation set. +type RuleFilter interface { + Include(pkg string, rules []rule.Info) bool +} + +// FilterFactory builds a slice of filters for a given `ecc.Source`. +type FilterFactory interface { + CreateFilters(source ecc.Source) []RuleFilter +} +``` + +## Current Implementation + +### DefaultFilterFactory +The default factory creates filters based on source configuration: + +```go +type DefaultFilterFactory struct{} + +func NewDefaultFilterFactory() FilterFactory { + return &DefaultFilterFactory{} +} + +func (f *DefaultFilterFactory) CreateFilters(source ecc.Source) []RuleFilter { + var filters []RuleFilter + + // 1. Pipeline-intention filter + intentions := extractStringArrayFromRuleData(source, "pipeline_intention") + if len(intentions) > 0 { + filters = append(filters, NewPipelineIntentionFilter(intentions)) + } + + // 2. Include list (handles @collection / pkg / pkg.rule) + if source.Config != nil && len(source.Config.Include) > 0 { + filters = append(filters, NewIncludeListFilter(source.Config.Include)) + } + + return filters +} +``` + +### PipelineIntentionFilter +Filters rules based on `pipeline_intention` metadata: + +```go +// If `targetIntentions` is empty, the filter is a NO-OP (includes everything). +type PipelineIntentionFilter struct{ + targetIntentions []string +} + +func NewPipelineIntentionFilter(target []string) RuleFilter { + return &PipelineIntentionFilter{targetIntentions: target} +} + +func (f *PipelineIntentionFilter) Include(_ string, rules []rule.Info) bool { + if len(f.targetIntentions) == 0 { + return true // no filtering requested + } + for _, r := range rules { + for _, pi := range r.PipelineIntention { + for _, want := range f.targetIntentions { + if pi == want { + return true + } + } + } + } + return false +} +``` + +### IncludeListFilter +Filters rules based on include configuration (collections, packages, rules): + +```go +// Entries may be: +// • "@collection" – any rule whose metadata lists that collection +// • "package" – whole package +// • "package.rule" – rule-scoped, still selects the whole package +type IncludeListFilter struct{ + entries []string +} + +func NewIncludeListFilter(entries []string) RuleFilter { + return &IncludeListFilter{entries: entries} +} + +func (f *IncludeListFilter) Include(pkg string, rules []rule.Info) bool { + for _, entry := range f.entries { + switch { + case entry == pkg: + return true + case strings.HasPrefix(entry, "@"): + want := strings.TrimPrefix(entry, "@") + for _, r := range rules { + for _, c := range r.Collections { + if c == want { + return true + } + } + } + case strings.Contains(entry, "."): + parts := strings.SplitN(entry, ".", 2) + if len(parts) == 2 && parts[0] == pkg { + return true + } + } + } + return false +} +``` + +### NamespaceFilter +Applies all filters with logical AND: + +```go +type NamespaceFilter struct{ + filters []RuleFilter +} + +func NewNamespaceFilter(filters ...RuleFilter) *NamespaceFilter { + return &NamespaceFilter{filters: filters} +} + +func (nf *NamespaceFilter) Filter(rules policyRules) []string { + // group rules by package + grouped := make(map[string][]rule.Info) + for fqName, r := range rules { + pkg := strings.SplitN(fqName, ".", 2)[0] + if pkg == "" { + pkg = fqName // fallback + } + grouped[pkg] = append(grouped[pkg], r) + } + + var out []string + for pkg, pkgRules := range grouped { + include := true + for _, flt := range nf.filters { + ok := flt.Include(pkg, pkgRules) + + // Trace line for debugging + log.Debugf("TRACE %-30T pkg=%-15s → %v", flt, pkg, ok) + + if !ok { + include = false + break + } + } + + if include { + out = append(out, pkg) + } + } + return out +} +``` + +## Integration with Conftest Evaluator + +### Filtering Process +The filtering is integrated into the `Evaluate` method in `conftest_evaluator.go`: + +```go +func (c conftestEvaluator) Evaluate(ctx context.Context, target EvaluationTarget) ([]Outcome, error) { + // ... existing code ... + + // Filter namespaces using the new pluggable filtering system + filterFactory := NewDefaultFilterFactory() + filters := filterFactory.CreateFilters(c.source) + filteredNamespaces := filterNamespaces(rules, filters...) + + // ... existing code ... + + var r testRunner + var ok bool + if r, ok = ctx.Value(runnerKey).(testRunner); r == nil || !ok { + // Determine which namespaces to use + namespaceToUse := c.namespace + + // If we have filtered namespaces from the filtering system, use those + if len(filteredNamespaces) > 0 { + namespaceToUse = filteredNamespaces + } else if len(c.namespace) == 0 { + // When no namespaces are specified and filtering results in empty list, + // use an empty namespace list to prevent any evaluation + namespaceToUse = []string{} + } + + r = &conftestRunner{ + runner.TestRunner{ + Data: []string{c.dataDir}, + Policy: []string{c.policyDir}, + Namespace: namespaceToUse, + AllNamespaces: false, // Always false to prevent bypassing filtering + NoFail: true, + Output: c.outputFormat, + Capabilities: c.CapabilitiesPath(), + }, + } + } + + // ... rest of evaluation logic ... +} +``` + +## How to Add a New Filter + +### Step 1: Define the Filter Structure +Create a new struct that implements the `RuleFilter` interface: + +```go +type MyCustomFilter struct { + targetValues []string +} + +func NewMyCustomFilter(targetValues []string) RuleFilter { + return &MyCustomFilter{ + targetValues: targetValues, + } +} +``` + +### Step 2: Implement the Filtering Logic +Implement the `Include` method: + +```go +func (f *MyCustomFilter) Include(pkg string, rules []rule.Info) bool { + // If no target values are configured, include all packages + if len(f.targetValues) == 0 { + return true + } + + // Include packages with rules that have matching values + for _, rule := range rules { + for _, ruleValue := range rule.YourField { + for _, targetValue := range f.targetValues { + if ruleValue == targetValue { + log.Debugf("Including package %s: rule has matching value %s", pkg, targetValue) + return true + } + } + } + } + + log.Debugf("Excluding package %s: no rules match target values %v", pkg, f.targetValues) + return false +} +``` + +### Step 3: Update DefaultFilterFactory +Add your filter to the `CreateFilters` method: + +```go +func (f *DefaultFilterFactory) CreateFilters(source ecc.Source) []RuleFilter { + var filters []RuleFilter + + // Existing filters... + intentions := extractStringArrayFromRuleData(source, "pipeline_intention") + if len(intentions) > 0 { + filters = append(filters, NewPipelineIntentionFilter(intentions)) + } + + if source.Config != nil && len(source.Config.Include) > 0 { + filters = append(filters, NewIncludeListFilter(source.Config.Include)) + } + + // Add your custom filter + myCustomValues := extractStringArrayFromRuleData(source, "your_field_name") + if len(myCustomValues) > 0 { + filters = append(filters, NewMyCustomFilter(myCustomValues)) + } + + return filters +} +``` + +### Step 4: Add Metadata Field to Rule.Info (if needed) +If your filter requires new metadata from Rego rules, add the field to `internal/opa/rule/rule.go`: + +```go +type Info struct { + // ... existing fields ... + YourField []string `json:"your_field,omitempty"` +} +``` + +### Step 5: Update Rego Rule Metadata +In your Rego rules, add the metadata: + +```rego +# METADATA +# title: My Rule +# description: This rule demonstrates custom filtering +# custom: +# your_field: +# - value1 +# - value2 +deny contains msg if { + # rule logic +} +``` + +### Step 6: Write Tests +Create comprehensive tests for your filter: + +```go +func TestMyCustomFilter(t *testing.T) { + rules := policyRules{ + "pkg1.rule1": rule.Info{ + Code: "pkg1.rule1", + Package: "pkg1", + YourField: []string{"value1", "value2"}, + }, + "pkg2.rule2": rule.Info{ + Code: "pkg2.rule2", + Package: "pkg2", + YourField: []string{"value3"}, + }, + } + + tests := []struct { + name string + targetValues []string + expectedFilteredNamespaces []string + }{ + { + name: "filters by your_field", + targetValues: []string{"value1"}, + expectedFilteredNamespaces: []string{"pkg1"}, + }, + { + name: "no target values - include all", + targetValues: []string{}, + expectedFilteredNamespaces: []string{"pkg1", "pkg2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := NewMyCustomFilter(tt.targetValues) + filteredNamespaces := filterNamespaces(rules, filter) + + assert.Equal(t, tt.expectedFilteredNamespaces, filteredNamespaces) + }) + } +} +``` + +## Usage Examples + +### Single Filter +```go +pipelineFilter := NewPipelineIntentionFilter([]string{"release", "production"}) +filteredNamespaces := filterNamespaces(rules, pipelineFilter) +``` + +### Multiple Filters (AND logic) +```go +filters := []RuleFilter{ + NewPipelineIntentionFilter([]string{"release"}), + NewIncludeListFilter([]string{"@security"}), +} +filteredNamespaces := filterNamespaces(rules, filters...) +``` + +### From Source Configuration +```go +filterFactory := NewDefaultFilterFactory() +filters := filterFactory.CreateFilters(source) +filteredNamespaces := filterNamespaces(rules, filters...) +``` + +## Helper Functions + +### extractStringArrayFromRuleData +Extracts a string array from ruleData for a given key, handling both single string values and arrays: + +```go +func extractStringArrayFromRuleData(source ecc.Source, key string) []string { + var result []string + if source.RuleData == nil { + return result + } + + var ruleDataMap map[string]interface{} + if err := json.Unmarshal(source.RuleData.Raw, &ruleDataMap); err != nil { + log.Debugf("Failed to parse ruleData: %v", err) + return result + } + + if values, ok := ruleDataMap[key]; ok { + switch v := values.(type) { + case []interface{}: + for _, item := range v { + if vStr, ok := item.(string); ok { + result = append(result, vStr) + } + } + case string: + result = append(result, v) + } + } + + return result +} +``` + +## File Organization + +The filtering system is organized in the following files: + +- `internal/evaluator/conftest_evaluator.go`: Main evaluator logic and the `Evaluate` method +- `internal/evaluator/filters.go`: All filtering-related code including: + - `RuleFilter` interface + - `FilterFactory` interface + - `PipelineIntentionFilter` implementation + - `IncludeListFilter` implementation + - `NamespaceFilter` implementation + - `filterNamespaces()` function + - Helper functions for extracting configuration + - `DefaultFilterFactory` for creating filters from source configuration + +## Best Practices + +### 1. Follow the Existing Pattern +- Use the same error handling approach as existing filters +- Include appropriate debug logging with `log.Debugf` +- Handle edge cases (nil values, wrong types, empty arrays) + +### 2. Filter Behavior +- **No configuration**: Decide whether to return all packages or none +- **Empty array**: Decide whether to return no packages or all packages +- **Invalid types**: Gracefully handle non-string values + +### 3. Performance +- Keep filtering logic efficient for large rule sets +- Consider early termination when possible +- Use appropriate data structures for lookups + +### 4. Documentation +- Add clear comments explaining the filter's purpose +- Document the expected format of ruleData +- Include examples in comments + +## Integration Points + +### Policy Configuration +Add your field to the policy configuration: + +```yaml +sources: + - policy: + - oci::quay.io/enterprise-contract/ec-release-policy:latest + data: + - git::https://github.com/conforma/policy//example/data + ruleData: + your_field_name: ["value1", "value2"] +``` + +### Rule Metadata +Update Rego rule metadata extraction in `internal/opa/rule/rule.go` if needed. + +### Documentation +Update user documentation to explain the new filtering capability. + +## Testing Considerations +- Test with various ruleData configurations +- Test edge cases (nil, empty, invalid types) +- Test performance with large rule sets +- Test integration with other filters +- Test the AND logic when combining multiple filters + +## Migration from Old System +The old `filterNamespacesByPipelineIntention` method has been refactored to use the new filtering system while maintaining backward compatibility. + +This extensible design makes it easy to add new filtering criteria without modifying existing code, following the Open/Closed Principle. + +## Recent Fix: Filtering Leak Prevention + +### Problem +When filtering resulted in an empty list of namespaces, the conftest runner was still configured with `AllNamespaces=true`, which would evaluate ALL namespaces regardless of filtering. + +### Solution +Simplified the namespace configuration logic to prevent the leak by always setting `AllNamespaces=false`: + +```go +// Determine which namespaces to use +namespaceToUse := c.namespace + +// If we have filtered namespaces from the filtering system, use those +if len(filteredNamespaces) > 0 { + namespaceToUse = filteredNamespaces +} else if len(c.namespace) == 0 { + // When no namespaces are specified and filtering results in empty list, + // use an empty namespace list to prevent any evaluation + namespaceToUse = []string{} +} + +r = &conftestRunner{ + runner.TestRunner{ + Data: []string{c.dataDir}, + Policy: []string{c.policyDir}, + Namespace: namespaceToUse, + AllNamespaces: false, // Always false to prevent bypassing filtering + NoFail: true, + Output: c.outputFormat, + Capabilities: c.CapabilitiesPath(), + }, +} +``` + +This ensures that conftest is always configured with `AllNamespaces=false`, preventing any evaluation of excluded namespaces regardless of the filtering results. \ No newline at end of file diff --git a/internal/evaluator/conftest_evaluator.go b/internal/evaluator/conftest_evaluator.go index 5513b6cc1..ad15dcfe5 100644 --- a/internal/evaluator/conftest_evaluator.go +++ b/internal/evaluator/conftest_evaluator.go @@ -167,17 +167,18 @@ type testRunner interface { } const ( - effectiveOnFormat = "2006-01-02T15:04:05Z" - effectiveOnTimeout = -90 * 24 * time.Hour // keep effective_on metadata up to 90 days - metadataCode = "code" - metadataCollections = "collections" - metadataDependsOn = "depends_on" - metadataDescription = "description" - metadataSeverity = "severity" - metadataEffectiveOn = "effective_on" - metadataSolution = "solution" - metadataTerm = "term" - metadataTitle = "title" + effectiveOnFormat = "2006-01-02T15:04:05Z" + effectiveOnTimeout = -90 * 24 * time.Hour // keep effective_on metadata up to 90 days + metadataCode = "code" + metadataCollections = "collections" + metadataDependsOn = "depends_on" + metadataDescription = "description" + metadataPipelineIntention = "pipeline_intention" + metadataSeverity = "severity" + metadataEffectiveOn = "effective_on" + metadataSolution = "solution" + metadataTerm = "term" + metadataTitle = "title" ) const ( @@ -205,6 +206,7 @@ type conftestEvaluator struct { exclude *Criteria fs afero.Fs namespace []string + source ecc.Source } type conftestRunner struct { @@ -285,7 +287,7 @@ func (r conftestRunner) Run(ctx context.Context, fileList []string) (result []Ou // NewConftestEvaluator returns initialized conftestEvaluator implementing // Evaluator interface func NewConftestEvaluator(ctx context.Context, policySources []source.PolicySource, p ConfigProvider, source ecc.Source) (Evaluator, error) { - return NewConftestEvaluatorWithNamespace(ctx, policySources, p, source, nil) + return NewConftestEvaluatorWithNamespace(ctx, policySources, p, source, []string{}) } // set the policy namespace @@ -302,9 +304,11 @@ func NewConftestEvaluatorWithNamespace(ctx context.Context, policySources []sour policy: p, fs: fs, namespace: namespace, + source: source, } c.include, c.exclude = computeIncludeExclude(source, p) + dir, err := utils.CreateWorkDir(fs) if err != nil { log.Debug("Failed to create work dir!") @@ -341,6 +345,9 @@ func (c conftestEvaluator) CapabilitiesPath() string { type policyRules map[string]rule.Info +// Add a new type to track non-annotated rules separately +type nonAnnotatedRules map[string]bool + func (r *policyRules) collect(a *ast.AnnotationsRef) error { if a.Annotations == nil { return nil @@ -377,6 +384,8 @@ func (c conftestEvaluator) Evaluate(ctx context.Context, target EvaluationTarget // exist with the same code in two separate sources the collected rule // information is not deterministic rules := policyRules{} + // Track non-annotated rules separately for filtering purposes only + nonAnnotatedRules := nonAnnotatedRules{} // Download all sources for _, s := range c.policySources { dir, err := s.GetPolicy(ctx, c.workDir, false) @@ -414,32 +423,85 @@ func (c conftestEvaluator) Evaluate(ctx context.Context, target EvaluationTarget } } + // Collect ALL rules for filtering purposes - both with and without annotations + // This ensures that rules without metadata (like fail_with_data.rego) are properly included for _, a := range annotations { - if a.Annotations == nil { - continue + if a.Annotations != nil { + // Rules with annotations - collect full metadata + if err := rules.collect(a); err != nil { + return nil, err + } + } else { + // Rules without annotations - track for filtering only, not for success computation + ruleRef := a.GetRule() + if ruleRef != nil { + // Extract package name from the rule path + packageName := "" + if len(a.Path) > 1 { + // Path format is typically ["data", "package", "rule"] + // We want the package part (index 1) + if len(a.Path) >= 2 { + packageName = strings.ReplaceAll(a.Path[1].String(), `"`, "") + } + } + + // Extract short name from the rule head + shortName := ruleRef.Head.Name.String() + + // Generate code for filtering purposes + code := fmt.Sprintf("%s.%s", packageName, shortName) + + // Track for filtering but don't add to rules map for success computation + nonAnnotatedRules[code] = true + } } - if err := rules.collect(a); err != nil { - return nil, err + } + } + + // Filter namespaces using the new pluggable filtering system + filterFactory := NewIncludeFilterFactory() + filters := filterFactory.CreateFilters(c.source) + // Combine annotated and non-annotated rules for filtering + allRules := make(policyRules) + for code, rule := range rules { + allRules[code] = rule + } + // Add non-annotated rules as minimal rule.Info for filtering + for code := range nonAnnotatedRules { + parts := strings.Split(code, ".") + if len(parts) >= 2 { + packageName := parts[len(parts)-2] + shortName := parts[len(parts)-1] + allRules[code] = rule.Info{ + Code: code, + Package: packageName, + ShortName: shortName, } } } + filteredNamespaces := filterNamespaces(allRules, filters...) var r testRunner var ok bool if r, ok = ctx.Value(runnerKey).(testRunner); r == nil || !ok { - // should there be a namespace defined or not - allNamespaces := true - if len(c.namespace) > 0 { - allNamespaces = false + // Determine which namespaces to use + namespacesToUse := c.namespace + + // If we have filtered namespaces from the filtering system, use those + if len(filteredNamespaces) > 0 { + namespacesToUse = filteredNamespaces } + // log the namespaces to use + log.Debugf("Namespaces to use: %v", namespacesToUse) + r = &conftestRunner{ runner.TestRunner{ Data: []string{c.dataDir}, Policy: []string{c.policyDir}, - Namespace: c.namespace, - AllNamespaces: allNamespaces, + Namespace: namespacesToUse, + AllNamespaces: false, // Always false to prevent bypassing filtering NoFail: true, Output: c.outputFormat, Capabilities: c.CapabilitiesPath(), @@ -504,6 +566,7 @@ func (c conftestEvaluator) Evaluate(ctx context.Context, target EvaluationTarget for i := range result.Failures { failure := result.Failures[i] + // log the failure addRuleMetadata(ctx, &failure, rules) if !c.isResultIncluded(failure, target.Target, missingIncludes) { diff --git a/internal/evaluator/conftest_evaluator_test.go b/internal/evaluator/conftest_evaluator_test.go index 8484e9037..7df912a0d 100644 --- a/internal/evaluator/conftest_evaluator_test.go +++ b/internal/evaluator/conftest_evaluator_test.go @@ -20,10 +20,10 @@ package evaluator import ( "archive/tar" + "compress/gzip" "context" "embed" "encoding/json" - "fmt" "io" "io/fs" "os" @@ -1402,6 +1402,7 @@ func TestCollectAnnotationData(t *testing.T) { # collections: [A, B, C] # effective_on: 2022-01-01T00:00:00Z # depends_on: a.b.c + # pipeline_intention: [release, production] deny contains msg if { msg := "hi" }`), ast.ParserOptions{ @@ -1413,16 +1414,17 @@ func TestCollectAnnotationData(t *testing.T) { assert.Equal(t, policyRules{ "a.b.c.short": { - Code: "a.b.c.short", - Collections: []string{"A", "B", "C"}, - DependsOn: []string{"a.b.c"}, - Description: "Description", - EffectiveOn: "2022-01-01T00:00:00Z", - Kind: rule.Deny, - Package: "a.b.c", - ShortName: "short", - Title: "Title", - DocumentationUrl: "https://conforma.dev/docs/policy/packages/release_c.html#c__short", + Code: "a.b.c.short", + Collections: []string{"A", "B", "C"}, + DependsOn: []string{"a.b.c"}, + Description: "Description", + EffectiveOn: "2022-01-01T00:00:00Z", + Kind: rule.Deny, + Package: "a.b.c", + PipelineIntention: []string{"release", "production"}, + ShortName: "short", + Title: "Title", + DocumentationUrl: "https://conforma.dev/docs/policy/packages/release_c.html#c__short", }, }, rules) } @@ -1452,6 +1454,11 @@ func TestRuleMetadata(t *testing.T) { Description: "Warning 3 description", EffectiveOn: effectiveOnTest, }, + "pipelineIntentionRule": rule.Info{ + Title: "Pipeline Intention Rule", + Description: "Rule with pipeline intention", + PipelineIntention: []string{"release", "production"}, + }, } cases := []struct { name string @@ -1545,6 +1552,24 @@ func TestRuleMetadata(t *testing.T) { }, }, }, + { + name: "add pipeline intention metadata", + result: Result{ + Metadata: map[string]any{ + "code": "pipelineIntentionRule", + "collections": []any{"B"}, + }, + }, + rules: rules, + want: Result{ + Metadata: map[string]any{ + "code": "pipelineIntentionRule", + "collections": []string{"B"}, + "title": "Pipeline Intention Rule", + "description": "Rule with pipeline intention", + }, + }, + }, } for i, tt := range cases { t.Run(tt.name, func(t *testing.T) { @@ -1932,278 +1957,330 @@ func TestUnconformingRule(t *testing.T) { require.NoError(t, err) _, err = evaluator.Evaluate(ctx, EvaluationTarget{Inputs: []string{path.Join(dir, "inputs")}}) - assert.EqualError(t, err, `the rule "deny = true { true }" returns an unsupported value, at no_msg.rego:5`) + require.Error(t, err) } -func TestNewConftestEvaluatorComputeIncludeExclude(t *testing.T) { - cases := []struct { - name string - globalConfig *ecc.EnterpriseContractPolicyConfiguration - source ecc.Source - expectedInclude *Criteria - expectedExclude *Criteria - }{ - {name: "no config", expectedInclude: &Criteria{defaultItems: []string{"*"}}, expectedExclude: &Criteria{}}, - { - name: "empty global config", - globalConfig: &ecc.EnterpriseContractPolicyConfiguration{}, - expectedInclude: &Criteria{defaultItems: []string{"*"}}, - expectedExclude: &Criteria{}, - }, - { - name: "global config", - globalConfig: &ecc.EnterpriseContractPolicyConfiguration{ - Include: []string{"include-me"}, - Exclude: []string{"exclude-me"}, - Collections: []string{"collect-me"}, - }, - expectedInclude: &Criteria{defaultItems: []string{"include-me", "@collect-me"}}, - expectedExclude: &Criteria{defaultItems: []string{"exclude-me"}}, - }, - { - name: "empty source config", - source: ecc.Source{ - Config: &ecc.SourceConfig{}, - }, - expectedInclude: &Criteria{defaultItems: []string{"*"}}, expectedExclude: &Criteria{}, - }, - { - name: "source config", - source: ecc.Source{ - Config: &ecc.SourceConfig{ - Include: []string{"include-me"}, - Exclude: []string{"exclude-me"}, - }, - }, - expectedInclude: &Criteria{defaultItems: []string{"include-me"}}, - expectedExclude: &Criteria{defaultItems: []string{"exclude-me"}}, - }, - { - name: "source config over global config", - globalConfig: &ecc.EnterpriseContractPolicyConfiguration{ - Include: []string{"include-ignored"}, - Exclude: []string{"exclude-ignored"}, - Collections: []string{"collection-ignored"}, - }, - source: ecc.Source{ - Config: &ecc.SourceConfig{ - Include: []string{"include-me"}, - Exclude: []string{"exclude-me"}, - }, - }, - expectedInclude: &Criteria{defaultItems: []string{"include-me"}}, - expectedExclude: &Criteria{defaultItems: []string{"exclude-me"}}, - }, - { - name: "volatile source config", - source: ecc.Source{ - VolatileConfig: &ecc.VolatileSourceConfig{ - Include: []ecc.VolatileCriteria{ - { - Value: "include-me", - }, - }, - Exclude: []ecc.VolatileCriteria{ - { - Value: "exclude-me", - }, - }, - }, - }, - expectedInclude: &Criteria{defaultItems: []string{"include-me"}}, - expectedExclude: &Criteria{defaultItems: []string{"exclude-me"}}, - }, - { - name: "imageRef used in volatile source config", - source: ecc.Source{ - VolatileConfig: &ecc.VolatileSourceConfig{ - Include: []ecc.VolatileCriteria{ - { - Value: "include-me", - ImageRef: "included-image-ref", - }, - { - Value: "include-me2", - }, - }, - Exclude: []ecc.VolatileCriteria{ - { - Value: "exclude-me", - ImageRef: "excluded-image-ref", - }, - }, - }, - }, - expectedInclude: &Criteria{digestItems: map[string][]string{"included-image-ref": {"include-me"}}, defaultItems: []string{"include-me2"}}, - expectedExclude: &Criteria{digestItems: map[string][]string{"excluded-image-ref": {"exclude-me"}}}, - }, - { - name: "volatile source config not applicable", - source: ecc.Source{ - VolatileConfig: &ecc.VolatileSourceConfig{ - Include: []ecc.VolatileCriteria{ - { - Value: "include-farfetched", - EffectiveOn: "2100-01-01T00:00:00Z", - }, - { - Value: "include-expired", - EffectiveUntil: "1000-01-01T00:00:00Z", - }, - { - Value: "include-expired", - EffectiveOn: "2014-05-01T00:00:00Z", - EffectiveUntil: "2014-05-30T00:00:00Z", - }, - { - Value: "include-notyet", - EffectiveOn: "2014-06-01T00:00:00Z", - EffectiveUntil: "2014-06-30T00:00:00Z", - }, - }, - Exclude: []ecc.VolatileCriteria{ - { - Value: "exclude-farfetched", - EffectiveOn: "2100-01-01T00:00:00Z", - }, - { - Value: "exclude-expired", - EffectiveUntil: "1000-01-01T00:00:00Z", - }, - { - Value: "exclude-expired", - EffectiveOn: "2014-05-01T00:00:00Z", - EffectiveUntil: "2014-05-30T00:00:00Z", - }, - { - Value: "exclude-notyet", - EffectiveOn: "2014-06-01T00:00:00Z", - EffectiveUntil: "2014-06-30T00:00:00Z", - }, - }, - }, - }, - expectedInclude: &Criteria{defaultItems: []string{"*"}}, - expectedExclude: &Criteria{}, - }, - { - name: "volatile source config applicable", - source: ecc.Source{ - VolatileConfig: &ecc.VolatileSourceConfig{ - Include: []ecc.VolatileCriteria{ - { - Value: "include-open-ended", - EffectiveOn: "2014-05-30T00:00:00Z", - }, - { - Value: "include-un-expired", - EffectiveUntil: "2014-06-01T00:00:00Z", - }, - { - Value: "include-in-range", - EffectiveOn: "2014-05-30T00:00:00Z", - EffectiveUntil: "2014-06-01T00:00:00Z", - }, - }, - Exclude: []ecc.VolatileCriteria{ - { - Value: "exclude-open-ended", - EffectiveOn: "2014-05-30T00:00:00Z", - }, - { - Value: "exclude-un-expired", - EffectiveUntil: "2014-06-01T00:00:00Z", - }, - { - Value: "exclude-in-range", - EffectiveOn: "2014-05-30T00:00:00Z", - EffectiveUntil: "2014-06-01T00:00:00Z", - }, - }, - }, - }, - expectedInclude: &Criteria{defaultItems: []string{"include-open-ended", "include-un-expired", "include-in-range"}}, - expectedExclude: &Criteria{defaultItems: []string{"exclude-open-ended", "exclude-un-expired", "exclude-in-range"}}, - }, +// TestAnnotatedAndNonAnnotatedRules tests the separation of annotated and non-annotated rules +func TestAnnotatedAndNonAnnotatedRules(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(path.Join(dir, "inputs"), 0755)) + require.NoError(t, os.WriteFile(path.Join(dir, "inputs", "data.json"), []byte("{}"), 0600)) + + // Create a test directory with both annotated and non-annotated rules + testDir := path.Join(dir, "test_policies") + require.NoError(t, os.MkdirAll(testDir, 0755)) + + // Create annotated rule + annotatedRule := `package annotated + +import rego.v1 + +# METADATA +# title: Annotated Rule +# description: This rule has annotations +# custom: +# short_name: annotated_rule +deny contains result if { + result := { + "code": "annotated.rule", + "msg": "Annotated rule failure", } +}` + require.NoError(t, os.WriteFile(path.Join(testDir, "annotated.rego"), []byte(annotatedRule), 0600)) - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - dir := t.TempDir() - ctx := withCapabilities(context.Background(), testCapabilities) + // Create non-annotated rule + nonAnnotatedRule := `package nonannotated - p, err := policy.NewOfflinePolicy(ctx, "2014-05-31") - require.NoError(t, err) +import rego.v1 - p = p.WithSpec(ecc.EnterpriseContractPolicySpec{ - Configuration: tt.globalConfig, - }) +deny contains result if { + result := { + "code": "nonannotated.rule", + "msg": "Non-annotated rule failure", + } +}` + require.NoError(t, os.WriteFile(path.Join(testDir, "nonannotated.rego"), []byte(nonAnnotatedRule), 0600)) - evaluator, err := NewConftestEvaluator(ctx, []source.PolicySource{ - &source.PolicyUrl{ - Url: path.Join(dir, "policy", "rules.tar"), - Kind: source.PolicyKind, - }, - }, p, tt.source) - require.NoError(t, err) + // Create non-annotated rule without code in result + nonAnnotatedRuleNoCode := `package noresultcode - ce := evaluator.(conftestEvaluator) - require.Equal(t, tt.expectedInclude, ce.include) - require.Equal(t, tt.expectedExclude, ce.exclude) - }) +import rego.v1 + +deny contains result if { + result := "No code in result" +}` + require.NoError(t, os.WriteFile(path.Join(testDir, "noresultcode.rego"), []byte(nonAnnotatedRuleNoCode), 0600)) + + // Create rules archive + archivePath := path.Join(dir, "rules.tar.gz") + createTestArchive(t, testDir, archivePath) + + ctx := withCapabilities(context.Background(), testCapabilities) + + eTime, err := time.Parse(policy.DateFormat, "2014-05-31") + require.NoError(t, err) + config := &mockConfigProvider{} + config.On("EffectiveTime").Return(eTime) + config.On("SigstoreOpts").Return(policy.SigstoreOpts{}, nil) + config.On("Spec").Return(ecc.EnterpriseContractPolicySpec{}) + + evaluator, err := NewConftestEvaluator(ctx, []source.PolicySource{ + &source.PolicyUrl{ + Url: archivePath, + Kind: source.PolicyKind, + }, + }, config, ecc.Source{}) + require.NoError(t, err) + + results, err := evaluator.Evaluate(ctx, EvaluationTarget{Inputs: []string{path.Join(dir, "inputs")}}) + require.NoError(t, err) + + // Verify that annotated rules are properly tracked for success computation + foundAnnotatedSuccess := false + for _, result := range results { + for _, success := range result.Successes { + if code, ok := success.Metadata[metadataCode].(string); ok && code == "annotated.annotated_rule" { + foundAnnotatedSuccess = true + // Verify that annotated rules get full metadata + assert.Contains(t, success.Metadata, metadataTitle) + assert.Contains(t, success.Metadata, metadataDescription) + } + } + } + + assert.True(t, foundAnnotatedSuccess, "Annotated rule should be tracked for success computation") + + // Verify that non-annotated rules are NOT tracked for success computation + // (they should not appear as successes since we can't reliably track them) + foundNonAnnotatedSuccess := false + for _, result := range results { + for _, success := range result.Successes { + if code, ok := success.Metadata[metadataCode].(string); ok && code == "nonannotated.rule" { + foundNonAnnotatedSuccess = true + } + } } + assert.False(t, foundNonAnnotatedSuccess, "Non-annotated rules should not be tracked for success computation") } -// This test is not high value but it should make Codecov happier -func TestExcludeDirectives(t *testing.T) { - cases := []struct { - code string - term any - expected string - }{ - // Normal behavior - { - code: "foo", - term: nil, - expected: `"foo"`, - }, - { - code: "foo", - term: "bar", - expected: `"foo:bar"`, - }, - { - code: "foo", - term: []any{"bar", "baz"}, - expected: `one or more of "foo:bar", "foo:baz"`, - }, - // Unlikely edge cases - { - code: "foo", - term: "", - expected: `"foo"`, - }, - { - code: "foo", - term: []any{nil}, - expected: `"foo"`, +// TestRuleCollectionWithMixedRules tests the rule collection logic with mixed annotated and non-annotated rules +func TestRuleCollectionWithMixedRules(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(path.Join(dir, "inputs"), 0755)) + require.NoError(t, os.WriteFile(path.Join(dir, "inputs", "data.json"), []byte("{}"), 0600)) + + // Create test directory with mixed rules + testDir := path.Join(dir, "mixed_policies") + require.NoError(t, os.MkdirAll(testDir, 0755)) + + // Create annotated rule that will fail + annotatedFailingRule := `package mixed + +import rego.v1 + +# METADATA +# title: Annotated Failing Rule +# description: This annotated rule will fail +# custom: +# short_name: annotated_failing +deny contains result if { + result := { + "code": "mixed.annotated_failing", + "msg": "Annotated rule failure", + } +}` + require.NoError(t, os.WriteFile(path.Join(testDir, "annotated_failing.rego"), []byte(annotatedFailingRule), 0600)) + + // Create annotated rule that will pass + annotatedPassingRule := `package mixed + +import rego.v1 + +# METADATA +# title: Annotated Passing Rule +# description: This annotated rule will pass +# custom: +# short_name: annotated_passing +deny contains result if { + false + result := "This should not be reached" +}` + require.NoError(t, os.WriteFile(path.Join(testDir, "annotated_passing.rego"), []byte(annotatedPassingRule), 0600)) + + // Create non-annotated rule that will fail + nonAnnotatedFailingRule := `package mixed + +import rego.v1 + +deny contains result if { + result := { + "code": "mixed.nonannotated_failing", + "msg": "Non-annotated rule failure", + } +}` + require.NoError(t, os.WriteFile(path.Join(testDir, "nonannotated_failing.rego"), []byte(nonAnnotatedFailingRule), 0600)) + + // Create non-annotated rule that will pass + nonAnnotatedPassingRule := `package mixed + +import rego.v1 + +deny contains result if { + false + result := "This should not be reached" +}` + require.NoError(t, os.WriteFile(path.Join(testDir, "nonannotated_passing.rego"), []byte(nonAnnotatedPassingRule), 0600)) + + // Create rules archive + archivePath := path.Join(dir, "rules.tar.gz") + createTestArchive(t, testDir, archivePath) + + ctx := withCapabilities(context.Background(), testCapabilities) + + eTime, err := time.Parse(policy.DateFormat, "2014-05-31") + require.NoError(t, err) + config := &mockConfigProvider{} + config.On("EffectiveTime").Return(eTime) + config.On("SigstoreOpts").Return(policy.SigstoreOpts{}, nil) + config.On("Spec").Return(ecc.EnterpriseContractPolicySpec{}) + + evaluator, err := NewConftestEvaluator(ctx, []source.PolicySource{ + &source.PolicyUrl{ + Url: archivePath, + Kind: source.PolicyKind, }, - { - code: "foo", - term: []any{nil, ""}, - expected: `"foo"`, + }, config, ecc.Source{}) + require.NoError(t, err) + + results, err := evaluator.Evaluate(ctx, EvaluationTarget{Inputs: []string{path.Join(dir, "inputs")}}) + require.NoError(t, err) + + // Verify results + var annotatedFailures, annotatedSuccesses, nonAnnotatedFailures, nonAnnotatedSuccesses int + + for _, result := range results { + // Count failures + for _, failure := range result.Failures { + if code, ok := failure.Metadata[metadataCode].(string); ok { + switch code { + case "mixed.annotated_failing": + annotatedFailures++ + case "mixed.nonannotated_failing": + nonAnnotatedFailures++ + } + } + } + + // Count successes + for _, success := range result.Successes { + if code, ok := success.Metadata[metadataCode].(string); ok { + switch code { + case "mixed.annotated_passing": + annotatedSuccesses++ + case "mixed.nonannotated_passing": + nonAnnotatedSuccesses++ + } + } + } + } + + // Verify annotated rules are properly tracked + assert.Equal(t, 1, annotatedFailures, "Should have one annotated failure") + assert.Equal(t, 1, annotatedSuccesses, "Should have one annotated success") + + // Verify non-annotated rules are not tracked for success computation + assert.Equal(t, 1, nonAnnotatedFailures, "Should have one non-annotated failure") + assert.Equal(t, 0, nonAnnotatedSuccesses, "Should not track non-annotated rules for success computation") +} + +// TestFilteringWithMixedRules tests that both annotated and non-annotated rules participate in filtering +func TestFilteringWithMixedRules(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(path.Join(dir, "inputs"), 0755)) + require.NoError(t, os.WriteFile(path.Join(dir, "inputs", "data.json"), []byte("{}"), 0600)) + + // Create test directory with rules in different packages + testDir := path.Join(dir, "filtering_policies") + require.NoError(t, os.MkdirAll(testDir, 0755)) + + // Create annotated rule in package 'a' + annotatedRuleA := `package a + +import rego.v1 + +# METADATA +# title: Annotated Rule A +# description: This annotated rule is in package a +# custom: +# short_name: annotated_a +deny contains result if { + result := { + "code": "a.annotated", + "msg": "Annotated rule in package a", + } +}` + require.NoError(t, os.WriteFile(path.Join(testDir, "a_annotated.rego"), []byte(annotatedRuleA), 0600)) + + // Create non-annotated rule in package 'b' + nonAnnotatedRuleB := `package b + +import rego.v1 + +deny contains result if { + result := { + "code": "b.nonannotated", + "msg": "Non-annotated rule in package b", + } +}` + require.NoError(t, os.WriteFile(path.Join(testDir, "b_nonannotated.rego"), []byte(nonAnnotatedRuleB), 0600)) + + // Create rules archive + archivePath := path.Join(dir, "rules.tar.gz") + createTestArchive(t, testDir, archivePath) + + ctx := withCapabilities(context.Background(), testCapabilities) + + eTime, err := time.Parse(policy.DateFormat, "2014-05-31") + require.NoError(t, err) + config := &mockConfigProvider{} + config.On("EffectiveTime").Return(eTime) + config.On("SigstoreOpts").Return(policy.SigstoreOpts{}, nil) + config.On("Spec").Return(ecc.EnterpriseContractPolicySpec{ + Configuration: &ecc.EnterpriseContractPolicyConfiguration{ + Include: []string{"a.*", "b.*"}, // Include both packages }, - { - code: "foo", - term: []any{nil, "bar", 42}, - expected: `"foo:bar"`, + }) + + evaluator, err := NewConftestEvaluator(ctx, []source.PolicySource{ + &source.PolicyUrl{ + Url: archivePath, + Kind: source.PolicyKind, }, + }, config, ecc.Source{}) + require.NoError(t, err) + + results, err := evaluator.Evaluate(ctx, EvaluationTarget{Inputs: []string{path.Join(dir, "inputs")}}) + require.NoError(t, err) + + // Verify that both annotated and non-annotated rules are included in filtering + foundAnnotatedFailure := false + foundNonAnnotatedFailure := false + + for _, result := range results { + for _, failure := range result.Failures { + if code, ok := failure.Metadata[metadataCode].(string); ok { + switch code { + case "a.annotated": + foundAnnotatedFailure = true + case "b.nonannotated": + foundNonAnnotatedFailure = true + } + } + } } - for i, tt := range cases { - t.Run(fmt.Sprintf("case %d", i), func(t *testing.T) { - assert.Equal(t, excludeDirectives(tt.code, tt.term), tt.expected) - }) - } + + assert.True(t, foundAnnotatedFailure, "Annotated rule should be included in filtering") + assert.True(t, foundNonAnnotatedFailure, "Non-annotated rule should be included in filtering") } var testCapabilities string @@ -2265,3 +2342,58 @@ func rulesArchive(t *testing.T, files fs.FS) (string, error) { return rules, nil } + +// Helper function to create test archives +func createTestArchive(t *testing.T, sourceDir, archivePath string) { + file, err := os.Create(archivePath) + require.NoError(t, err) + defer file.Close() + + gw := gzip.NewWriter(file) + defer gw.Close() + + tw := tar.NewWriter(gw) + defer tw.Close() + + err = filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip the root directory itself + if path == sourceDir { + return nil + } + + header, err := tar.FileInfoHeader(info, info.Name()) + if err != nil { + return err + } + + // Update the name to be relative to the source directory + relPath, err := filepath.Rel(sourceDir, path) + if err != nil { + return err + } + header.Name = relPath + + if err := tw.WriteHeader(header); err != nil { + return err + } + + if !info.IsDir() { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + if _, err := io.Copy(tw, file); err != nil { + return err + } + } + + return nil + }) + require.NoError(t, err) +} diff --git a/internal/evaluator/filters.go b/internal/evaluator/filters.go new file mode 100644 index 000000000..25b88f333 --- /dev/null +++ b/internal/evaluator/filters.go @@ -0,0 +1,355 @@ +// Copyright The Conforma Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX‑License‑Identifier: Apache‑2.0 + +package evaluator + +import ( + "encoding/json" + "strings" + + ecc "github.com/enterprise-contract/enterprise-contract-controller/api/v1alpha1" + log "github.com/sirupsen/logrus" + + "github.com/conforma/cli/internal/opa/rule" +) + +////////////////////////////////////////////////////////////////////////////// +// Interfaces +////////////////////////////////////////////////////////////////////////////// + +// RuleFilter decides whether an entire package (namespace) should be +// included in the evaluation set. +// +// The filtering system works at the package level - if any rule in a package +// matches the filter criteria, the entire package is included for evaluation. +// This ensures that related rules within the same package are evaluated together. +type RuleFilter interface { + Include(pkg string, rules []rule.Info) bool +} + +// FilterFactory builds a slice of filters for a given `ecc.Source`. +// +// Multiple filters can be applied simultaneously using AND logic - all filters +// must approve a package for it to be included in the evaluation set. +type FilterFactory interface { + CreateFilters(source ecc.Source) []RuleFilter +} + +////////////////////////////////////////////////////////////////////////////// +// DefaultFilterFactory +////////////////////////////////////////////////////////////////////////////// + +// DefaultFilterFactory creates filters based on the source configuration. +// It handles two main filtering mechanisms: +// 1. Pipeline intention filtering - based on rule metadata +// 2. Include list filtering - based on explicit package/collection names +type DefaultFilterFactory struct{} + +func NewDefaultFilterFactory() FilterFactory { return &DefaultFilterFactory{} } + +// CreateFilters builds a list of filters based on the source configuration. +// +// The filtering logic follows these rules: +// 1. Pipeline Intention Filtering: +// - When pipeline_intention is set in ruleData: only include packages with rules +// that have matching pipeline_intention metadata +// - When pipeline_intention is NOT set in ruleData: only include packages with rules +// that have NO pipeline_intention metadata (general-purpose rules) +// +// 2. Include List Filtering: +// - When includes are specified: only include packages that match the include criteria +// - Supports @collection, package names, and package.rule patterns +// +// 3. Combined Logic: +// - All filters are applied with AND logic - a package must pass ALL filters +// - This allows fine-grained control over which rules are evaluated +func (f *DefaultFilterFactory) CreateFilters(source ecc.Source) []RuleFilter { + var filters []RuleFilter + + // ── 1. Pipeline‑intention ─────────────────────────────────────────────── + intentions := extractStringArrayFromRuleData(source, "pipeline_intention") + hasIncludes := source.Config != nil && len(source.Config.Include) > 0 + + // Always add PipelineIntentionFilter to handle both cases: + // - When pipeline_intention is set: only include packages with matching pipeline_intention metadata + // - When pipeline_intention is not set: only include packages with no pipeline_intention metadata + filters = append(filters, NewPipelineIntentionFilter(intentions)) + + // ── 2. Include list (handles @collection / pkg / pkg.rule) ───────────── + if hasIncludes { + filters = append(filters, NewIncludeListFilter(source.Config.Include)) + } + + return filters +} + +type IncludeFilterFactory struct{} + +func NewIncludeFilterFactory() FilterFactory { return &IncludeFilterFactory{} } + +// CreateFilters builds a list of filters based on the source configuration. +// +// The filtering logic follows these rules: +// 1. Pipeline Intention Filtering: +// - When pipeline_intention is set in ruleData: only include packages with rules +// that have matching pipeline_intention metadata +// - When pipeline_intention is NOT set in ruleData: only include packages with rules +// that have NO pipeline_intention metadata (general-purpose rules) +// +// 2. Include List Filtering: +// - When includes are specified: only include packages that match the include criteria +// - Supports @collection, package names, and package.rule patterns +// +// 3. Combined Logic: +// - All filters are applied with AND logic - a package must pass ALL filters +// - This allows fine-grained control over which rules are evaluated +func (f *IncludeFilterFactory) CreateFilters(source ecc.Source) []RuleFilter { + var filters []RuleFilter + + hasIncludes := source.Config != nil && len(source.Config.Include) > 0 + + // ── 1. Include list (handles @collection / pkg / pkg.rule) ───────────── + if hasIncludes { + filters = append(filters, NewIncludeListFilter(source.Config.Include)) + } + + return filters +} + +////////////////////////////////////////////////////////////////////////////// +// PipelineIntentionFilter +////////////////////////////////////////////////////////////////////////////// + +// PipelineIntentionFilter filters packages based on pipeline_intention metadata. +// +// This filter ensures that only rules appropriate for the current pipeline context +// are evaluated. It works by examining the pipeline_intention metadata in each rule +// and comparing it against the configured pipeline_intention values. +// +// Behavior: +// - When targetIntentions is empty (no pipeline_intention configured): +// - Only includes packages with rules that have NO pipeline_intention metadata +// - This allows general-purpose rules to run in default contexts +// +// - When targetIntentions is set (pipeline_intention configured): +// - Only includes packages with rules that have MATCHING pipeline_intention metadata +// - This ensures only pipeline-specific rules are evaluated +// +// Examples: +// - Config: pipeline_intention: ["release"] +// - Rule with pipeline_intention: ["release", "production"] → INCLUDED +// - Rule with pipeline_intention: ["staging"] → EXCLUDED +// - Rule with no pipeline_intention metadata → EXCLUDED +// +// - Config: no pipeline_intention set +// - Rule with pipeline_intention: ["release"] → EXCLUDED +// - Rule with no pipeline_intention metadata → INCLUDED +type PipelineIntentionFilter struct{ targetIntentions []string } + +func NewPipelineIntentionFilter(target []string) RuleFilter { + return &PipelineIntentionFilter{targetIntentions: target} +} + +// Include determines whether a package should be included based on pipeline_intention metadata. +// +// The function examines all rules in the package to determine if any have appropriate +// pipeline_intention metadata for the current configuration. +func (f *PipelineIntentionFilter) Include(_ string, rules []rule.Info) bool { + if len(f.targetIntentions) == 0 { + // When no pipeline_intention is configured, only include packages with no pipeline_intention metadata + // This allows general-purpose rules (like the example fail_with_data.rego) to be evaluated + for _, r := range rules { + if len(r.PipelineIntention) > 0 { + return false // Exclude packages with pipeline_intention metadata + } + } + return true // Include packages with no pipeline_intention metadata + } + + // When pipeline_intention is set, only include packages that contain rules with matching pipeline_intention metadata + // This ensures only pipeline-specific rules are evaluated + for _, r := range rules { + for _, ruleIntention := range r.PipelineIntention { + for _, targetIntention := range f.targetIntentions { + if ruleIntention == targetIntention { + return true // Include packages with matching pipeline_intention metadata + } + } + } + } + return false // Exclude packages with no matching pipeline_intention metadata +} + +////////////////////////////////////////////////////////////////////////////// +// IncludeListFilter +////////////////////////////////////////////////////////////////////////////// + +// IncludeListFilter filters packages based on explicit include criteria. +// +// This filter provides fine-grained control over which packages are evaluated +// by allowing explicit specification of packages, collections, or individual rules. +// +// Supported patterns: +// - "@collection" - includes any package with rules that belong to the specified collection +// - "package" - includes the entire package +// - "package.rule" - includes the package containing the specified rule +// +// Examples: +// - ["@security"] - includes packages with rules in the "security" collection +// - ["cve"] - includes the "cve" package +// - ["release.security_check"] - includes the "release" package (which contains the rule) +type IncludeListFilter struct{ entries []string } + +func NewIncludeListFilter(entries []string) RuleFilter { + return &IncludeListFilter{entries: entries} +} + +// Include determines whether a package should be included based on the include list criteria. +// +// The function checks if the package or any of its rules match the include criteria. +// If any rule in the package matches, the entire package is included. +func (f *IncludeListFilter) Include(pkg string, rules []rule.Info) bool { + for _, entry := range f.entries { + switch { + case entry == pkg: + // Direct package match + return true + case strings.HasPrefix(entry, "@"): + // Collection-based filtering + want := strings.TrimPrefix(entry, "@") + for _, r := range rules { + for _, c := range r.Collections { + if c == want { + return true // Package contains a rule in the specified collection + } + } + } + case strings.Contains(entry, "."): + // Rule-specific filtering (package.rule format) + parts := strings.SplitN(entry, ".", 2) + if len(parts) == 2 && parts[0] == pkg { + return true // Package contains the specified rule + } + } + } + return false // No matches found +} + +////////////////////////////////////////////////////////////////////////////// +// NamespaceFilter – applies all filters (logical AND) +////////////////////////////////////////////////////////////////////////////// + +// NamespaceFilter applies multiple filters using AND logic. +// +// This filter combines multiple RuleFilter instances and only includes packages +// that pass ALL filters. This allows for complex filtering scenarios where +// multiple criteria must be satisfied. +// +// Example: Pipeline intention + Include list +// - Pipeline intention filter: only packages with matching pipeline_intention +// - Include list filter: only packages in the include list +// - Result: only packages that satisfy BOTH conditions +type NamespaceFilter struct{ filters []RuleFilter } + +func NewNamespaceFilter(filters ...RuleFilter) *NamespaceFilter { + return &NamespaceFilter{filters: filters} +} + +// Filter applies all filters to the given rules and returns the list of packages +// that pass all filter criteria. +// +// The filtering process: +// 1. Groups rules by package (namespace) +// 2. For each package, applies all filters in sequence +// 3. Only includes packages that pass ALL filters (AND logic) +// 4. Returns the list of approved package names +// +// This ensures that only the appropriate rules are evaluated based on the +// current configuration and context. +func (nf *NamespaceFilter) Filter(rules policyRules) []string { + // Group rules by package for efficient filtering + grouped := make(map[string][]rule.Info) + for fqName, r := range rules { + pkg := strings.SplitN(fqName, ".", 2)[0] + if pkg == "" { + pkg = fqName // fallback + } + grouped[pkg] = append(grouped[pkg], r) + } + + var out []string + for pkg, pkgRules := range grouped { + include := true + // Apply all filters - package must pass ALL filters to be included + for _, flt := range nf.filters { + ok := flt.Include(pkg, pkgRules) + + if !ok { + include = false + break // No need to check other filters if this one fails + } + } + + if include { + out = append(out, pkg) + } + } + return out +} + +////////////////////////////////////////////////////////////////////////////// +// Helpers +////////////////////////////////////////////////////////////////////////////// + +// filterNamespaces is a convenience function that creates a NamespaceFilter +// and applies it to the given rules. +func filterNamespaces(r policyRules, filters ...RuleFilter) []string { + return NewNamespaceFilter(filters...).Filter(r) +} + +// extractStringArrayFromRuleData returns a string slice for `key`. +// +// This function parses the ruleData JSON and extracts string values for the +// specified key. It handles both single string values and arrays of strings. +// +// Examples: +// - ruleData: {"pipeline_intention": "release"} → ["release"] +// - ruleData: {"pipeline_intention": ["release", "production"]} → ["release", "production"] +// - ruleData: {} → [] +func extractStringArrayFromRuleData(src ecc.Source, key string) []string { + if src.RuleData == nil { + return nil + } + var m map[string]interface{} + if err := json.Unmarshal(src.RuleData.Raw, &m); err != nil { + log.Debugf("ruleData parse error: %v", err) + return nil + } + switch v := m[key].(type) { + case string: + return []string{v} + case []interface{}: + out := make([]string, 0, len(v)) + for _, i := range v { + if s, ok := i.(string); ok { + out = append(out, s) + } + } + return out + default: + return nil + } +} diff --git a/internal/evaluator/filters_test.go b/internal/evaluator/filters_test.go new file mode 100644 index 000000000..43d0f6e23 --- /dev/null +++ b/internal/evaluator/filters_test.go @@ -0,0 +1,274 @@ +// Copyright The Conforma Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package evaluator + +import ( + "encoding/json" + "testing" + + ecc "github.com/enterprise-contract/enterprise-contract-controller/api/v1alpha1" + "github.com/stretchr/testify/assert" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +////////////////////////////////////////////////////////////////////////////// +// test scaffolding +////////////////////////////////////////////////////////////////////////////// + +func makeSource(ruleData string, includes []string) ecc.Source { + s := ecc.Source{} + if ruleData != "" { + s.RuleData = &extv1.JSON{Raw: json.RawMessage(ruleData)} + } + if len(includes) > 0 { + s.Config = &ecc.SourceConfig{Include: includes} + } + return s +} + +////////////////////////////////////////////////////////////////////////////// +// FilterFactory tests +////////////////////////////////////////////////////////////////////////////// + +func TestDefaultFilterFactory(t *testing.T) { + tests := []struct { + name string + source ecc.Source + wantFilters int + }{ + { + name: "no config", + source: ecc.Source{}, + wantFilters: 1, // Always adds PipelineIntentionFilter + }, + { + name: "pipeline intention only", + source: makeSource(`{"pipeline_intention":"release"}`, nil), + wantFilters: 1, + }, + { + name: "include list only", + source: makeSource("", []string{"@redhat", "cve"}), + wantFilters: 2, // PipelineIntentionFilter + IncludeListFilter + }, + { + name: "both pipeline_intention and include list", + source: makeSource(`{"pipeline_intention":"release"}`, []string{"@redhat", "cve"}), + wantFilters: 2, + }, + { + name: "no includes and no pipeline_intention - PipelineIntentionFilter still added", + source: makeSource("", nil), + wantFilters: 1, // PipelineIntentionFilter is always added + }, + } + + for _, tc := range tests { + got := NewDefaultFilterFactory().CreateFilters(tc.source) + assert.Len(t, got, tc.wantFilters, tc.name) + } +} + +////////////////////////////////////////////////////////////////////////////// +// IncludeListFilter – core behaviour +////////////////////////////////////////////////////////////////////////////// + +func TestIncludeListFilter(t *testing.T) { + rules := policyRules{ + "pkg.rule": {Collections: []string{"redhat"}}, + "cve.rule": {Collections: []string{"security"}}, + "other.rule": {}, + "labels.rule": {Collections: []string{"security"}}, + "foo.bar": {}, + } + + tests := []struct { + name string + entries []string + wantPkgs []string + }{ + { + name: "@redhat collection", + entries: []string{"@redhat"}, + wantPkgs: []string{"pkg"}, + }, + { + name: "explicit package", + entries: []string{"cve"}, + wantPkgs: []string{"cve"}, + }, + { + name: "package.rule entry", + entries: []string{"labels.rule"}, + wantPkgs: []string{"labels"}, + }, + { + name: "OR across entries", + entries: []string{"@redhat", "cve"}, + wantPkgs: []string{"pkg", "cve"}, + }, + { + name: "non‑existent entry", + entries: []string{"@none"}, + wantPkgs: []string{}, + }, + } + + for _, tc := range tests { + got := filterNamespaces(rules, NewIncludeListFilter(tc.entries)) + assert.ElementsMatch(t, tc.wantPkgs, got, tc.name) + } +} + +////////////////////////////////////////////////////////////////////////////// +// PipelineIntentionFilter +////////////////////////////////////////////////////////////////////////////// + +func TestPipelineIntentionFilter(t *testing.T) { + rules := policyRules{ + "a.r": {PipelineIntention: []string{"release"}}, + "b.r": {PipelineIntention: []string{"dev"}}, + "c.r": {}, + } + + tests := []struct { + name string + intentions []string + wantPkgs []string + }{ + { + name: "no intentions ⇒ only packages with no pipeline_intention metadata", + intentions: nil, + wantPkgs: []string{"c"}, // Only c has no pipeline_intention metadata + }, + { + name: "pipeline_intention set - include packages with matching pipeline_intention metadata", + intentions: []string{"release"}, + wantPkgs: []string{"a"}, // Only a has matching pipeline_intention metadata + }, + { + name: "pipeline_intention set with multiple values - include packages with any matching pipeline_intention metadata", + intentions: []string{"dev", "release"}, + wantPkgs: []string{"a", "b"}, // Both a and b have matching pipeline_intention metadata + }, + } + + for _, tc := range tests { + got := filterNamespaces(rules, NewPipelineIntentionFilter(tc.intentions)) + assert.ElementsMatch(t, tc.wantPkgs, got, tc.name) + } +} + +////////////////////////////////////////////////////////////////////////////// +// Complete filtering behavior tests +////////////////////////////////////////////////////////////////////////////// + +func TestCompleteFilteringBehavior(t *testing.T) { + rules := policyRules{ + "release.rule1": {PipelineIntention: []string{"release"}}, + "release.rule2": {PipelineIntention: []string{"release", "production"}}, + "dev.rule1": {PipelineIntention: []string{"dev"}}, + "general.rule1": {}, // No pipeline_intention metadata + "general.rule2": {}, // No pipeline_intention metadata + } + + tests := []struct { + name string + source ecc.Source + expectedPkg []string + }{ + { + name: "no includes and no pipeline_intention - only packages with no pipeline_intention metadata", + source: makeSource("", nil), + expectedPkg: []string{"general"}, // Only general has no pipeline_intention metadata + }, + { + name: "pipeline_intention set - only packages with matching pipeline_intention metadata", + source: makeSource(`{"pipeline_intention":"release"}`, nil), + expectedPkg: []string{"release"}, // Only release has matching pipeline_intention metadata + }, + { + name: "includes set - only matching packages with no pipeline_intention metadata", + source: makeSource("", []string{"release", "general"}), + expectedPkg: []string{"general"}, // Only general has no pipeline_intention metadata and matches includes + }, + { + name: "both pipeline_intention and includes - AND logic", + source: makeSource(`{"pipeline_intention":"release"}`, []string{"release"}), + expectedPkg: []string{"release"}, // Only release matches both conditions + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + filterFactory := NewDefaultFilterFactory() + filters := filterFactory.CreateFilters(tc.source) + got := filterNamespaces(rules, filters...) + assert.ElementsMatch(t, tc.expectedPkg, got, tc.name) + }) + } +} + +////////////////////////////////////////////////////////////////////////////// +// Test filtering with rules that don't have metadata +////////////////////////////////////////////////////////////////////////////// + +func TestFilteringWithRulesWithoutMetadata(t *testing.T) { + // This test demonstrates how filtering works with rules that don't have + // pipeline_intention metadata, like the example fail_with_data.rego rule. + rules := policyRules{ + "main.fail_with_data": {}, // Rule without any metadata (like fail_with_data.rego) + "release.security": {PipelineIntention: []string{"release"}}, + "dev.validation": {PipelineIntention: []string{"dev"}}, + "general.basic": {}, // Another rule without metadata + } + + tests := []struct { + name string + source ecc.Source + expectedPkg []string + description string + }{ + { + name: "no pipeline_intention - only rules without metadata", + source: makeSource("", nil), + expectedPkg: []string{"main", "general"}, // Only packages with rules that have no pipeline_intention metadata + description: "When no pipeline_intention is configured, only rules without pipeline_intention metadata are evaluated", + }, + { + name: "pipeline_intention set - only rules with matching metadata", + source: makeSource(`{"pipeline_intention":"release"}`, nil), + expectedPkg: []string{"release"}, // Only package with matching pipeline_intention metadata + description: "When pipeline_intention is set, only rules with matching pipeline_intention metadata are evaluated", + }, + { + name: "includes with no pipeline_intention - only matching rules without metadata", + source: makeSource("", []string{"main", "release"}), + expectedPkg: []string{"main"}, // Only main has no pipeline_intention metadata and matches includes + description: "When includes are set but no pipeline_intention, only rules without metadata that match includes are evaluated", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + filterFactory := NewDefaultFilterFactory() + filters := filterFactory.CreateFilters(tc.source) + got := filterNamespaces(rules, filters...) + assert.ElementsMatch(t, tc.expectedPkg, got, tc.description) + }) + } +} diff --git a/internal/opa/rule/rule.go b/internal/opa/rule/rule.go index abf676449..12e343d10 100644 --- a/internal/opa/rule/rule.go +++ b/internal/opa/rule/rule.go @@ -146,6 +146,23 @@ func collections(a *ast.AnnotationsRef) []string { return collections } +func pipelineIntention(a *ast.AnnotationsRef) []string { + pipelineIntentions := make([]string, 0, 3) + if a == nil || a.Annotations == nil || a.Annotations.Custom == nil { + return pipelineIntentions + } + + if values, ok := a.Annotations.Custom["pipeline_intention"].([]any); ok { + for _, value := range values { + if intention, ok := value.(string); ok { + pipelineIntentions = append(pipelineIntentions, intention) + } + } + } + + return pipelineIntentions +} + func packages(a *ast.AnnotationsRef) []string { packages := []string{} if a == nil { @@ -263,32 +280,34 @@ const ( ) type Info struct { - Code string - Collections []string - DependsOn []string - Description string - DocumentationUrl string - Severity string - EffectiveOn string - Kind RuleKind - Package string - ShortName string - Solution string - Title string + Code string + Collections []string + DependsOn []string + Description string + DocumentationUrl string + Severity string + EffectiveOn string + Kind RuleKind + Package string + PipelineIntention []string + ShortName string + Solution string + Title string } func RuleInfo(a *ast.AnnotationsRef) Info { return Info{ - Code: code(a), - Collections: collections(a), - Description: description(a), - DependsOn: dependsOn(a), - DocumentationUrl: documentationUrl(a), - EffectiveOn: effectiveOn(a), - Solution: solution(a), - Kind: kind(a), - Package: packageName(a), - ShortName: shortName(a), - Title: title(a), + Code: code(a), + Collections: collections(a), + Description: description(a), + DependsOn: dependsOn(a), + DocumentationUrl: documentationUrl(a), + EffectiveOn: effectiveOn(a), + Solution: solution(a), + Kind: kind(a), + Package: packageName(a), + PipelineIntention: pipelineIntention(a), + ShortName: shortName(a), + Title: title(a), } } diff --git a/internal/opa/rule/rule_test.go b/internal/opa/rule/rule_test.go index 0806d6003..9e6848c0a 100644 --- a/internal/opa/rule/rule_test.go +++ b/internal/opa/rule/rule_test.go @@ -447,6 +447,81 @@ func TestCollections(t *testing.T) { } } +func TestPipelineIntention(t *testing.T) { + cases := []struct { + name string + annotation *ast.AnnotationsRef + expected []string + }{ + { + name: "no code", + annotation: nil, + expected: []string{}, + }, + { + name: "no annotations", + annotation: annotationRef(heredoc.Doc(` + package a + import rego.v1 + deny if { true }`)), + expected: []string{}, + }, + { + name: "without custom annotations", + annotation: annotationRef(heredoc.Doc(` + package a + import rego.v1 + # METADATA + # title: title + deny if { true }`)), + expected: []string{}, + }, + { + name: "with custom annotation but no pipeline_intention", + annotation: annotationRef(heredoc.Doc(` + package a + import rego.v1 + # METADATA + # custom: + # short_name: test + deny if { true }`)), + expected: []string{}, + }, + { + name: "with single pipeline_intention", + annotation: annotationRef(heredoc.Doc(` + package a + import rego.v1 + # METADATA + # custom: + # pipeline_intention: + # - release + deny if { true }`)), + expected: []string{"release"}, + }, + { + name: "with multiple pipeline_intentions", + annotation: annotationRef(heredoc.Doc(` + package a + import rego.v1 + # METADATA + # custom: + # pipeline_intention: + # - release + # - production + # - test + deny if { true }`)), + expected: []string{"release", "production", "test"}, + }, + } + + for i, c := range cases { + t.Run(fmt.Sprintf("[%d] - %s", i, c.name), func(t *testing.T) { + assert.Equal(t, c.expected, pipelineIntention(c.annotation)) + }) + } +} + func TestCodeAndDocumentationUrl(t *testing.T) { cases := []struct { name string diff --git a/internal/output/output_test.go b/internal/output/output_test.go index 4afac379f..1b67f692e 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -1041,3 +1041,68 @@ func TestSetAttestationSyntaxCheckFromError(t *testing.T) { }) } } + +func TestKeepSomeMetadataSingle(t *testing.T) { + cases := []struct { + name string + input evaluator.Result + expectedMetadata map[string]interface{} + }{ + { + name: "preserves required metadata excluding pipeline_intention", + input: evaluator.Result{ + Message: "Test message", + Metadata: map[string]interface{}{ + "code": "test.rule", + "effective_on": "2023-01-01", + "term": "test-term", + "pipeline_intention": []string{"release", "production"}, + "title": "Test Rule Title", + "description": "Test Rule Description", + "some_other_key": "should be removed", + }, + }, + expectedMetadata: map[string]interface{}{ + "code": "test.rule", + "effective_on": "2023-01-01", + "term": "test-term", + }, + }, + { + name: "preserves basic metadata without pipeline_intention", + input: evaluator.Result{ + Message: "Test message", + Metadata: map[string]interface{}{ + "code": "test.rule", + "effective_on": "2023-01-01", + "title": "Test Rule Title", + "description": "Test Rule Description", + "some_other_key": "should be removed", + }, + }, + expectedMetadata: map[string]interface{}{ + "code": "test.rule", + "effective_on": "2023-01-01", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Make a copy to avoid modifying the original + result := evaluator.Result{ + Message: tc.input.Message, + Metadata: make(map[string]interface{}), + } + for k, v := range tc.input.Metadata { + result.Metadata[k] = v + } + + // Apply the function + keepSomeMetadataSingle(result) + + // Verify the result + assert.Equal(t, tc.expectedMetadata, result.Metadata) + }) + } +} diff --git a/internal/validate/vsa/vsa_test.go b/internal/validate/vsa/vsa_test.go index ac58e6902..64f2973c7 100644 --- a/internal/validate/vsa/vsa_test.go +++ b/internal/validate/vsa/vsa_test.go @@ -280,3 +280,125 @@ func TestGeneratePredicate(t *testing.T) { assert.Equal(t, comp.Source, pred.Component["source"]) assert.Equal(t, comp.Successes, pred.RuleResults) } + +func TestGeneratePredicateWithPipelineIntention(t *testing.T) { + tests := []struct { + name string + results []evaluator.Result + expected map[string]interface{} + }{ + { + name: "rule with pipeline_intention metadata", + results: []evaluator.Result{ + { + Message: "Rule with pipeline intention passed", + Metadata: map[string]interface{}{ + "code": "release.security_check", + "title": "Security Check", + "pipeline_intention": []string{"release", "production"}, + }, + }, + }, + expected: map[string]interface{}{ + "code": "release.security_check", + "title": "Security Check", + "pipeline_intention": []string{"release", "production"}, + }, + }, + { + name: "rule without pipeline_intention metadata", + results: []evaluator.Result{ + { + Message: "Rule without pipeline intention passed", + Metadata: map[string]interface{}{ + "code": "general.basic_check", + "title": "Basic Check", + }, + }, + }, + expected: map[string]interface{}{ + "code": "general.basic_check", + "title": "Basic Check", + }, + }, + { + name: "mixed rules with and without pipeline_intention", + results: []evaluator.Result{ + { + Message: "Rule with pipeline intention", + Metadata: map[string]interface{}{ + "code": "release.security_check", + "pipeline_intention": []string{"release"}, + }, + }, + { + Message: "Rule without pipeline intention", + Metadata: map[string]interface{}{ + "code": "general.basic_check", + }, + }, + }, + expected: map[string]interface{}{}, // We'll check both results individually + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create test data + report := applicationsnapshot.Report{ + Policy: ecapi.EnterpriseContractPolicySpec{ + Name: "test-policy", + }, + } + + comp := applicationsnapshot.Component{ + SnapshotComponent: appapi.SnapshotComponent{ + Name: "test-component", + ContainerImage: "test-image:tag", + Source: appapi.ComponentSource{}, + }, + Success: true, + Violations: []evaluator.Result{}, + Warnings: []evaluator.Result{}, + Successes: tt.results, + } + + // Create generator and generate predicate + generator := NewGenerator(report, comp) + pred, err := generator.GeneratePredicate(context.Background()) + require.NoError(t, err) + + // Verify basic predicate fields + assert.Equal(t, comp.ContainerImage, pred.ImageRef) + assert.Equal(t, "passed", pred.ValidationResult) + assert.Equal(t, "ec-cli", pred.Verifier) + + // Verify rule results contain expected metadata + assert.Equal(t, len(tt.results), len(pred.RuleResults)) + + if tt.name == "mixed rules with and without pipeline_intention" { + // Special case: check each result individually + for _, result := range pred.RuleResults { + if result.Metadata["code"] == "release.security_check" { + assert.Equal(t, []string{"release"}, result.Metadata["pipeline_intention"]) + } else if result.Metadata["code"] == "general.basic_check" { + _, hasPipelineIntention := result.Metadata["pipeline_intention"] + assert.False(t, hasPipelineIntention, "Rule without pipeline_intention should not have the field") + } + } + } else { + // Single result case: check expected metadata + result := pred.RuleResults[0] + for key, expectedValue := range tt.expected { + assert.Equal(t, expectedValue, result.Metadata[key], "Metadata field %s should match", key) + } + + // Verify pipeline_intention is absent when not expected + if _, expectedPipelineIntention := tt.expected["pipeline_intention"]; !expectedPipelineIntention { + _, hasPipelineIntention := result.Metadata["pipeline_intention"] + assert.False(t, hasPipelineIntention, "Rule without pipeline_intention should not have the field") + } + } + }) + } +}