diff --git a/internal/evaluator/conftest_evaluator.go b/internal/evaluator/conftest_evaluator.go index 29f74678f..f2e434ae6 100644 --- a/internal/evaluator/conftest_evaluator.go +++ b/internal/evaluator/conftest_evaluator.go @@ -646,7 +646,7 @@ func (c conftestEvaluator) Evaluate(ctx context.Context, target EvaluationTarget // Filter results using the unified filter filteredResults, updatedMissingIncludes := unifiedFilter.FilterResults( - allResults, allRules, target.Target, missingIncludes, effectiveTime) + allResults, allRules, target.Target, target.ComponentName, missingIncludes, effectiveTime) // Update missing includes missingIncludes = updatedMissingIncludes @@ -661,7 +661,7 @@ func (c conftestEvaluator) Evaluate(ctx context.Context, target EvaluationTarget result.Skipped = skipped // Replace the placeholder successes slice with the actual successes. - result.Successes = c.computeSuccesses(result, rules, target.Target, missingIncludes, unifiedFilter) + result.Successes = c.computeSuccesses(result, rules, target.Target, target.ComponentName, missingIncludes, unifiedFilter) totalRules += len(result.Warnings) + len(result.Failures) + len(result.Successes) @@ -798,7 +798,8 @@ func toRules(results []output.Result) []Result { func (c conftestEvaluator) computeSuccesses( result Outcome, rules policyRules, - target string, + imageRef string, + componentName string, missingIncludes map[string]bool, unifiedFilter PostEvaluationFilter, ) []Result { @@ -857,7 +858,7 @@ func (c conftestEvaluator) computeSuccesses( if unifiedFilter != nil { // Use the unified filter to check if this success should be included filteredResults, _ := unifiedFilter.FilterResults( - []Result{success}, rules, target, missingIncludes, time.Now()) + []Result{success}, rules, imageRef, componentName, missingIncludes, time.Now()) if len(filteredResults) == 0 { log.Debugf("Skipping result success: %#v", success) @@ -865,7 +866,7 @@ func (c conftestEvaluator) computeSuccesses( } } else { // Fallback to legacy filtering for backward compatibility - if !c.isResultIncluded(success, target, missingIncludes) { + if !c.isResultIncluded(success, imageRef, componentName, missingIncludes) { log.Debugf("Skipping result success: %#v", success) continue } @@ -1120,10 +1121,10 @@ func isResultEffective(failure Result, now time.Time) bool { // isResultIncluded returns whether or not the result should be included or // discarded based on the policy configuration. // 'missingIncludes' is a list of include directives that gets pruned if the result is matched -func (c conftestEvaluator) isResultIncluded(result Result, target string, missingIncludes map[string]bool) bool { +func (c conftestEvaluator) isResultIncluded(result Result, imageRef string, componentName string, missingIncludes map[string]bool) bool { ruleMatchers := LegacyMakeMatchers(result) - includeScore := LegacyScoreMatches(ruleMatchers, c.include.get(target), missingIncludes) - excludeScore := LegacyScoreMatches(ruleMatchers, c.exclude.get(target), map[string]bool{}) + includeScore := LegacyScoreMatches(ruleMatchers, c.include.get(imageRef, componentName), missingIncludes) + excludeScore := LegacyScoreMatches(ruleMatchers, c.exclude.get(imageRef, componentName), map[string]bool{}) return includeScore > excludeScore } diff --git a/internal/evaluator/conftest_evaluator_integration_basic_test.go b/internal/evaluator/conftest_evaluator_integration_basic_test.go index b81958285..3f6a1346a 100644 --- a/internal/evaluator/conftest_evaluator_integration_basic_test.go +++ b/internal/evaluator/conftest_evaluator_integration_basic_test.go @@ -139,3 +139,204 @@ deny contains result if { require.NoError(t, err) assert.NotNil(t, result) } + +func TestConftestEvaluatorIntegrationWithComponentNames(t *testing.T) { + ctx := context.Background() + + // Create a temporary directory for the test + tmpDir := t.TempDir() + policyDir := filepath.Join(tmpDir, "policy") + err := os.MkdirAll(policyDir, 0o755) + require.NoError(t, err) + + // Create policies that will be filtered by ComponentNames + policyContent := `package test + +import rego.v1 + +# METADATA +# title: Check A +# custom: +# short_name: check_a +deny contains result if { + result := { + "code": "test.check_a", + "msg": "Check A always fails" + } +} + +# METADATA +# title: Check B +# custom: +# short_name: check_b +deny contains result if { + result := { + "code": "test.check_b", + "msg": "Check B always fails" + } +} +` + err = os.WriteFile(filepath.Join(policyDir, "policy.rego"), []byte(policyContent), 0o600) + require.NoError(t, err) + + // Create policy source + policySource := &source.PolicyUrl{ + Url: "file://" + policyDir, + Kind: source.PolicyKind, + } + + // Create config provider with ComponentNames filter + configProvider := &mockConfigProvider{} + configProvider.On("EffectiveTime").Return(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)) + configProvider.On("SigstoreOpts").Return(policy.SigstoreOpts{}, nil) + configProvider.On("Spec").Return(ecc.EnterpriseContractPolicySpec{ + Sources: []ecc.Source{ + { + Policy: []string{"file://" + policyDir}, + }, + }, + }) + + // Create evaluator with VolatileConfig that excludes check_a for comp1 + evaluator, err := NewConftestEvaluator(ctx, []source.PolicySource{policySource}, configProvider, ecc.Source{ + VolatileConfig: &ecc.VolatileSourceConfig{ + Exclude: []ecc.VolatileCriteria{ + { + Value: "test.check_a", + ComponentNames: []string{"comp1"}, + EffectiveOn: "2024-01-01T00:00:00Z", + EffectiveUntil: "2025-01-01T00:00:00Z", + }, + }, + }, + }) + require.NoError(t, err) + defer evaluator.Destroy() + + // Debug: Check exclude criteria + conftestEval := evaluator.(conftestEvaluator) + t.Logf("Exclude componentItems: %+v", conftestEval.exclude.componentItems) + t.Logf("Exclude defaultItems: %+v", conftestEval.exclude.defaultItems) + t.Logf("Exclude digestItems: %+v", conftestEval.exclude.digestItems) + + // Create test input + inputData := map[string]interface{}{ + "test": "value", + } + inputBytes, err := json.Marshal(inputData) + require.NoError(t, err) + inputPath := filepath.Join(tmpDir, "input.json") + err = os.WriteFile(inputPath, inputBytes, 0o600) + require.NoError(t, err) + + // Test comp1 - check_a should be excluded + target1 := EvaluationTarget{ + Inputs: []string{inputPath}, + Target: "quay.io/repo/img@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + ComponentName: "comp1", + } + + result1, err := evaluator.Evaluate(ctx, target1) + require.NoError(t, err) + require.NotNil(t, result1) + + // Debug: Print all failures + t.Logf("comp1 results: %d outcomes", len(result1)) + for i, outcome := range result1 { + t.Logf(" Outcome %d: %d failures, %d successes", i, len(outcome.Failures), len(outcome.Successes)) + for _, failure := range outcome.Failures { + t.Logf(" Failure: %s", failure.Metadata["code"]) + } + for _, success := range outcome.Successes { + t.Logf(" Success: %s", success.Metadata["code"]) + } + } + + // Verify check_a is excluded, check_b is not + hasCheckA := false + hasCheckB := false + for _, outcome := range result1 { + for _, failure := range outcome.Failures { + if codeStr, ok := failure.Metadata["code"].(string); ok { + if codeStr == "test.check_a" { + hasCheckA = true + } + if codeStr == "test.check_b" { + hasCheckB = true + } + } + } + } + assert.False(t, hasCheckA, "Expected check_a to be excluded for comp1") + assert.True(t, hasCheckB, "Expected check_b to be evaluated for comp1") + + // Test comp2 - check_a should NOT be excluded + target2 := EvaluationTarget{ + Inputs: []string{inputPath}, + Target: "quay.io/repo/img@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + ComponentName: "comp2", + } + + result2, err := evaluator.Evaluate(ctx, target2) + require.NoError(t, err) + require.NotNil(t, result2) + + // Verify both checks are evaluated for comp2 + hasCheckA2 := false + hasCheckB2 := false + for _, outcome := range result2 { + for _, failure := range outcome.Failures { + if codeStr, ok := failure.Metadata["code"].(string); ok { + if codeStr == "test.check_a" { + hasCheckA2 = true + } + if codeStr == "test.check_b" { + hasCheckB2 = true + } + } + } + } + assert.True(t, hasCheckA2, "Expected check_a to be evaluated for comp2") + assert.True(t, hasCheckB2, "Expected check_b to be evaluated for comp2") + + // Test same image with different components - monorepo scenario + sameImage := "quay.io/monorepo@sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321" + + target3 := EvaluationTarget{ + Inputs: []string{inputPath}, + Target: sameImage, + ComponentName: "comp1", + } + + result3, err := evaluator.Evaluate(ctx, target3) + require.NoError(t, err) + + hasCheckA3 := false + for _, outcome := range result3 { + for _, failure := range outcome.Failures { + if codeStr, ok := failure.Metadata["code"].(string); ok && codeStr == "test.check_a" { + hasCheckA3 = true + } + } + } + assert.False(t, hasCheckA3, "Expected check_a excluded for comp1 even with different image") + + target4 := EvaluationTarget{ + Inputs: []string{inputPath}, + Target: sameImage, + ComponentName: "comp2", + } + + result4, err := evaluator.Evaluate(ctx, target4) + require.NoError(t, err) + + hasCheckA4 := false + for _, outcome := range result4 { + for _, failure := range outcome.Failures { + if codeStr, ok := failure.Metadata["code"].(string); ok && codeStr == "test.check_a" { + hasCheckA4 = true + } + } + } + assert.True(t, hasCheckA4, "Expected check_a evaluated for comp2 with same image") +} diff --git a/internal/evaluator/criteria.go b/internal/evaluator/criteria.go index 437990c95..c8f12f03b 100644 --- a/internal/evaluator/criteria.go +++ b/internal/evaluator/criteria.go @@ -28,10 +28,13 @@ import ( // contains include/exclude items // digestItems stores include/exclude items that are specific with an imageRef // - the imageRef is the key, value is the policy to include/exclude. +// componentItems stores include/exclude items that are specific to a component name +// - the component name is the key, value is the policy to include/exclude. // defaultItems are include/exclude items without an imageRef type Criteria struct { - digestItems map[string][]string - defaultItems []string + digestItems map[string][]string + componentItems map[string][]string + defaultItems []string } func (c *Criteria) len() int { @@ -39,6 +42,9 @@ func (c *Criteria) len() int { for _, items := range c.digestItems { totalLength += len(items) } + for _, items := range c.componentItems { + totalLength += len(items) + } return totalLength } @@ -64,12 +70,20 @@ func (c *Criteria) addArray(key string, values []string) { } } -// This accepts an image ref with digest -// and looks up the image url and digest separately. -func (c *Criteria) get(key string) []string { - ref, err := name.ParseReference(key) +func (c *Criteria) addComponentItem(componentName, value string) { + if c.componentItems == nil { + c.componentItems = make(map[string][]string) + } + c.componentItems[componentName] = append(c.componentItems[componentName], value) +} + +// This accepts an image ref with digest and optional component name, +// and looks up the image url, digest, and component name separately. +func (c *Criteria) get(imageRef string, componentName string) []string { + ref, err := name.ParseReference(imageRef) if err != nil { - log.Debugf("error parsing target image url: %q", key) + log.Debugf("error parsing target image url: %q", imageRef) + // Return only global defaults if image ref is invalid return c.defaultItems } @@ -87,6 +101,13 @@ func (c *Criteria) get(key string) []string { items = append(items, c.getWithKey(k)...) } + // Add component-specific items if component name is provided + if componentName != "" { + if componentItems, ok := c.componentItems[componentName]; ok { + items = append(items, componentItems...) + } + } + // Add any exceptions that pertain to all images. return append(items, c.defaultItems...) } @@ -158,6 +179,10 @@ func collectVolatileConfigItems(items *Criteria, volatileCriteria []ecc.Volatile items.addItem(c.ImageUrl, c.Value) } else if c.ImageDigest != "" { items.addItem(c.ImageDigest, c.Value) + } else if len(c.ComponentNames) > 0 { + for _, componentName := range c.ComponentNames { + items.addComponentItem(string(componentName), c.Value) + } } else { items.addItem("", c.Value) } diff --git a/internal/evaluator/criteria_test.go b/internal/evaluator/criteria_test.go index 4caa93702..78d2f49a9 100644 --- a/internal/evaluator/criteria_test.go +++ b/internal/evaluator/criteria_test.go @@ -38,16 +38,18 @@ func TestLen(t *testing.T) { { name: "Empty Criteria", criteria: &Criteria{ - digestItems: map[string][]string{}, - defaultItems: []string{}, + digestItems: map[string][]string{}, + componentItems: map[string][]string{}, + defaultItems: []string{}, }, expectedLen: 0, }, { name: "Only Default Items", criteria: &Criteria{ - digestItems: map[string][]string{}, - defaultItems: []string{"default1", "default2"}, + digestItems: map[string][]string{}, + componentItems: map[string][]string{}, + defaultItems: []string{"default1", "default2"}, }, expectedLen: 2, }, @@ -58,6 +60,19 @@ func TestLen(t *testing.T) { "key1": {"value1", "value2"}, "key2": {"value3"}, }, + componentItems: map[string][]string{}, + defaultItems: []string{}, + }, + expectedLen: 3, + }, + { + name: "Only Component Items", + criteria: &Criteria{ + digestItems: map[string][]string{}, + componentItems: map[string][]string{ + "comp1": {"value1", "value2"}, + "comp2": {"value3"}, + }, defaultItems: []string{}, }, expectedLen: 3, @@ -69,10 +84,25 @@ func TestLen(t *testing.T) { "key1": {"value1", "value2"}, "key2": {"value3"}, }, - defaultItems: []string{"default1", "default2"}, + componentItems: map[string][]string{}, + defaultItems: []string{"default1", "default2"}, }, expectedLen: 5, }, + { + name: "Default, Digest, and Component Items", + criteria: &Criteria{ + digestItems: map[string][]string{ + "key1": {"value1", "value2"}, + }, + componentItems: map[string][]string{ + "comp1": {"value3"}, + "comp2": {"value4", "value5"}, + }, + defaultItems: []string{"default1"}, + }, + expectedLen: 6, + }, } for _, tt := range tests { @@ -97,12 +127,14 @@ func TestAddItem(t *testing.T) { key: "", value: "defaultValue", initial: &Criteria{ - defaultItems: []string{}, - digestItems: make(map[string][]string), + defaultItems: []string{}, + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), }, expected: &Criteria{ - defaultItems: []string{"defaultValue"}, - digestItems: make(map[string][]string), + defaultItems: []string{"defaultValue"}, + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), }, }, { @@ -110,14 +142,16 @@ func TestAddItem(t *testing.T) { key: "key1", value: "digestValue1", initial: &Criteria{ - defaultItems: []string{}, - digestItems: make(map[string][]string), + defaultItems: []string{}, + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), }, expected: &Criteria{ defaultItems: []string{}, digestItems: map[string][]string{ "key1": {"digestValue1"}, }, + componentItems: make(map[string][]string), }, }, { @@ -129,12 +163,14 @@ func TestAddItem(t *testing.T) { digestItems: map[string][]string{ "key1": {"digestValue1"}, }, + componentItems: make(map[string][]string), }, expected: &Criteria{ defaultItems: []string{}, digestItems: map[string][]string{ "key1": {"digestValue1", "digestValue2"}, }, + componentItems: make(map[string][]string), }, }, } @@ -160,12 +196,14 @@ func TestAddArray(t *testing.T) { key: "", values: []string{"defaultValue1", "defaultValue2"}, initial: &Criteria{ - defaultItems: []string{}, - digestItems: make(map[string][]string), + defaultItems: []string{}, + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), }, expected: &Criteria{ - defaultItems: []string{"defaultValue1", "defaultValue2"}, - digestItems: make(map[string][]string), + defaultItems: []string{"defaultValue1", "defaultValue2"}, + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), }, }, { @@ -173,14 +211,16 @@ func TestAddArray(t *testing.T) { key: "key1", values: []string{"digestValue1", "digestValue2"}, initial: &Criteria{ - defaultItems: []string{}, - digestItems: make(map[string][]string), + defaultItems: []string{}, + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), }, expected: &Criteria{ defaultItems: []string{}, digestItems: map[string][]string{ "key1": {"digestValue1", "digestValue2"}, }, + componentItems: make(map[string][]string), }, }, { @@ -192,12 +232,14 @@ func TestAddArray(t *testing.T) { digestItems: map[string][]string{ "key1": {"digestValue1"}, }, + componentItems: make(map[string][]string), }, expected: &Criteria{ defaultItems: []string{}, digestItems: map[string][]string{ "key1": {"digestValue1", "digestValue2", "digestValue3"}, }, + componentItems: make(map[string][]string), }, }, } @@ -210,13 +252,88 @@ func TestAddArray(t *testing.T) { } } +func TestAddComponentItem(t *testing.T) { + tests := []struct { + name string + componentName string + value string + initial *Criteria + expected *Criteria + }{ + { + name: "Add to componentItems", + componentName: "comp1", + value: "componentValue1", + initial: &Criteria{ + defaultItems: []string{}, + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), + }, + expected: &Criteria{ + defaultItems: []string{}, + digestItems: make(map[string][]string), + componentItems: map[string][]string{ + "comp1": {"componentValue1"}, + }, + }, + }, + { + name: "Add to existing componentItems", + componentName: "comp1", + value: "componentValue2", + initial: &Criteria{ + defaultItems: []string{}, + digestItems: make(map[string][]string), + componentItems: map[string][]string{ + "comp1": {"componentValue1"}, + }, + }, + expected: &Criteria{ + defaultItems: []string{}, + digestItems: make(map[string][]string), + componentItems: map[string][]string{ + "comp1": {"componentValue1", "componentValue2"}, + }, + }, + }, + { + name: "Add to different components", + componentName: "comp2", + value: "componentValue3", + initial: &Criteria{ + defaultItems: []string{}, + digestItems: make(map[string][]string), + componentItems: map[string][]string{ + "comp1": {"componentValue1"}, + }, + }, + expected: &Criteria{ + defaultItems: []string{}, + digestItems: make(map[string][]string), + componentItems: map[string][]string{ + "comp1": {"componentValue1"}, + "comp2": {"componentValue3"}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.initial.addComponentItem(tt.componentName, tt.value) + require.Equal(t, tt.expected, tt.initial) + }) + } +} + func TestGet(t *testing.T) { c := &Criteria{ digestItems: map[string][]string{ "quay.io/test/ec-test": {"item"}, "sha256:2c5e3b2f1e2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c": {"item-digest"}, }, - defaultItems: []string{"default1", "default2"}, + componentItems: map[string][]string{}, + defaultItems: []string{"default1", "default2"}, } tests := []struct { name string @@ -256,7 +373,7 @@ func TestGet(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, c.get(tt.key)) + assert.Equal(t, tt.expected, c.get(tt.key, "")) }) } } @@ -293,8 +410,9 @@ func TestCollectVolatileConfigItems(t *testing.T) { { name: "Successful scenario - criteria within time range", items: &Criteria{ - digestItems: make(map[string][]string), - defaultItems: []string{"existing-item"}, + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), + defaultItems: []string{"existing-item"}, }, volatileCriteria: []ecc.VolatileCriteria{ { @@ -316,15 +434,17 @@ func TestCollectVolatileConfigItems(t *testing.T) { "quay.io/test/image:latest": {"volatile-item-1"}, "sha256:abc123": {"volatile-item-2"}, }, - defaultItems: []string{"existing-item"}, + componentItems: make(map[string][]string), + defaultItems: []string{"existing-item"}, }, expectedSuccess: true, }, { name: "Failed scenario - criteria outside time range", items: &Criteria{ - digestItems: make(map[string][]string), - defaultItems: []string{"existing-item"}, + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), + defaultItems: []string{"existing-item"}, }, volatileCriteria: []ecc.VolatileCriteria{ { @@ -342,16 +462,18 @@ func TestCollectVolatileConfigItems(t *testing.T) { }, configProvider: &MockConfigProvider{effectiveTime: fixedTime}, expectedItems: &Criteria{ - digestItems: make(map[string][]string), - defaultItems: []string{"existing-item"}, + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), + defaultItems: []string{"existing-item"}, }, expectedSuccess: true, // Function doesn't fail, just doesn't add items }, { name: "Warning scenario - invalid time formats", items: &Criteria{ - digestItems: make(map[string][]string), - defaultItems: []string{"existing-item"}, + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), + defaultItems: []string{"existing-item"}, }, volatileCriteria: []ecc.VolatileCriteria{ { @@ -366,24 +488,89 @@ func TestCollectVolatileConfigItems(t *testing.T) { digestItems: map[string][]string{ "sha256:def456": {"partial-invalid-item"}, }, - defaultItems: []string{"existing-item"}, + componentItems: make(map[string][]string), + defaultItems: []string{"existing-item"}, }, expectedSuccess: true, // Function handles invalid times gracefully }, + { + name: "Component names with volatile criteria", + items: &Criteria{ + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), + defaultItems: []string{"existing-item"}, + }, + volatileCriteria: []ecc.VolatileCriteria{ + { + Value: "cve.scanning", + EffectiveOn: "2025-08-01T00:00:00Z", + EffectiveUntil: "2025-08-31T23:59:59Z", + ComponentNames: []string{"comp1", "comp2", "comp3"}, + }, + }, + configProvider: &MockConfigProvider{effectiveTime: fixedTime}, + expectedItems: &Criteria{ + digestItems: make(map[string][]string), + componentItems: map[string][]string{ + "comp1": {"cve.scanning"}, + "comp2": {"cve.scanning"}, + "comp3": {"cve.scanning"}, + }, + defaultItems: []string{"existing-item"}, + }, + expectedSuccess: true, + }, + { + name: "Component names with multiple values", + items: &Criteria{ + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), + defaultItems: []string{"existing-item"}, + }, + volatileCriteria: []ecc.VolatileCriteria{ + { + Value: "cve.scanning", + EffectiveOn: "2025-08-01T00:00:00Z", + EffectiveUntil: "2025-08-31T23:59:59Z", + ComponentNames: []string{"comp1", "comp2"}, + }, + { + Value: "slsa.provenance", + EffectiveOn: "2025-08-01T00:00:00Z", + EffectiveUntil: "2025-08-31T23:59:59Z", + ComponentNames: []string{"comp1"}, + }, + }, + configProvider: &MockConfigProvider{effectiveTime: fixedTime}, + expectedItems: &Criteria{ + digestItems: make(map[string][]string), + componentItems: map[string][]string{ + "comp1": {"cve.scanning", "slsa.provenance"}, + "comp2": {"cve.scanning"}, + }, + defaultItems: []string{"existing-item"}, + }, + expectedSuccess: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create a copy of the initial items to avoid modifying the test data initialItems := &Criteria{ - digestItems: make(map[string][]string), - defaultItems: make([]string, len(tt.items.defaultItems)), + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), + defaultItems: make([]string, len(tt.items.defaultItems)), } copy(initialItems.defaultItems, tt.items.defaultItems) for k, v := range tt.items.digestItems { initialItems.digestItems[k] = make([]string, len(v)) copy(initialItems.digestItems[k], v) } + for k, v := range tt.items.componentItems { + initialItems.componentItems[k] = make([]string, len(v)) + copy(initialItems.componentItems[k], v) + } // Call the function result := collectVolatileConfigItems(initialItems, tt.volatileCriteria, tt.configProvider) @@ -402,3 +589,400 @@ func TestCollectVolatileConfigItems(t *testing.T) { }) } } + +func TestCollectVolatileConfigItemsWithComponentNames(t *testing.T) { + // Create a fixed time for testing + fixedTime := time.Date(2025, 8, 18, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + items *Criteria + volatileCriteria []ecc.VolatileCriteria + configProvider ConfigProvider + expectedComponentItems map[string][]string + expectedDefaultItems []string + }{ + { + name: "ComponentNames only - single component", + items: &Criteria{ + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), + defaultItems: []string{}, + }, + volatileCriteria: []ecc.VolatileCriteria{ + { + Value: "test.check_a", + EffectiveOn: "2025-08-01T00:00:00Z", + EffectiveUntil: "2025-08-31T23:59:59Z", + ComponentNames: []string{"comp1"}, + }, + }, + configProvider: &MockConfigProvider{effectiveTime: fixedTime}, + expectedComponentItems: map[string][]string{ + "comp1": {"test.check_a"}, + }, + expectedDefaultItems: []string{}, + }, + { + name: "ComponentNames - multiple components", + items: &Criteria{ + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), + defaultItems: []string{}, + }, + volatileCriteria: []ecc.VolatileCriteria{ + { + Value: "test.check_b", + EffectiveOn: "2025-08-01T00:00:00Z", + EffectiveUntil: "2025-08-31T23:59:59Z", + ComponentNames: []string{"comp1", "comp2"}, + }, + }, + configProvider: &MockConfigProvider{effectiveTime: fixedTime}, + expectedComponentItems: map[string][]string{ + "comp1": {"test.check_b"}, + "comp2": {"test.check_b"}, + }, + expectedDefaultItems: []string{}, + }, + { + name: "ComponentNames outside time window - effectiveUntil passed", + items: &Criteria{ + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), + defaultItems: []string{}, + }, + volatileCriteria: []ecc.VolatileCriteria{ + { + Value: "test.check_c", + EffectiveOn: "2025-07-01T00:00:00Z", + EffectiveUntil: "2025-07-31T23:59:59Z", // Before fixedTime + ComponentNames: []string{"comp1"}, + }, + }, + configProvider: &MockConfigProvider{effectiveTime: fixedTime}, + expectedComponentItems: map[string][]string{}, + expectedDefaultItems: []string{}, + }, + { + name: "ComponentNames with future effectiveOn", + items: &Criteria{ + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), + defaultItems: []string{}, + }, + volatileCriteria: []ecc.VolatileCriteria{ + { + Value: "test.check_d", + EffectiveOn: "2025-09-01T00:00:00Z", // After fixedTime + EffectiveUntil: "2025-09-30T23:59:59Z", + ComponentNames: []string{"comp1"}, + }, + }, + configProvider: &MockConfigProvider{effectiveTime: fixedTime}, + expectedComponentItems: map[string][]string{}, + expectedDefaultItems: []string{}, + }, + { + name: "ComponentNames within time window", + items: &Criteria{ + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), + defaultItems: []string{}, + }, + volatileCriteria: []ecc.VolatileCriteria{ + { + Value: "test.check_e", + EffectiveOn: "2025-08-01T00:00:00Z", + EffectiveUntil: "2025-08-31T23:59:59Z", + ComponentNames: []string{"comp1"}, + }, + }, + configProvider: &MockConfigProvider{effectiveTime: fixedTime}, + expectedComponentItems: map[string][]string{ + "comp1": {"test.check_e"}, + }, + expectedDefaultItems: []string{}, + }, + { + name: "Multiple criteria - different components", + items: &Criteria{ + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), + defaultItems: []string{}, + }, + volatileCriteria: []ecc.VolatileCriteria{ + { + Value: "test.check_f", + EffectiveOn: "2025-08-01T00:00:00Z", + EffectiveUntil: "2025-08-31T23:59:59Z", + ComponentNames: []string{"comp1"}, + }, + { + Value: "test.check_g", + EffectiveOn: "2025-08-01T00:00:00Z", + EffectiveUntil: "2025-08-31T23:59:59Z", + ComponentNames: []string{"comp2"}, + }, + }, + configProvider: &MockConfigProvider{effectiveTime: fixedTime}, + expectedComponentItems: map[string][]string{ + "comp1": {"test.check_f"}, + "comp2": {"test.check_g"}, + }, + expectedDefaultItems: []string{}, + }, + { + name: "Multiple criteria - same component accumulates", + items: &Criteria{ + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), + defaultItems: []string{}, + }, + volatileCriteria: []ecc.VolatileCriteria{ + { + Value: "test.check_h", + EffectiveOn: "2025-08-01T00:00:00Z", + EffectiveUntil: "2025-08-31T23:59:59Z", + ComponentNames: []string{"comp1"}, + }, + { + Value: "test.check_i", + EffectiveOn: "2025-08-01T00:00:00Z", + EffectiveUntil: "2025-08-31T23:59:59Z", + ComponentNames: []string{"comp1"}, + }, + }, + configProvider: &MockConfigProvider{effectiveTime: fixedTime}, + expectedComponentItems: map[string][]string{ + "comp1": {"test.check_h", "test.check_i"}, + }, + expectedDefaultItems: []string{}, + }, + { + name: "ComponentNames with existing default items", + items: &Criteria{ + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), + defaultItems: []string{"existing.default"}, + }, + volatileCriteria: []ecc.VolatileCriteria{ + { + Value: "test.check_j", + EffectiveOn: "2025-08-01T00:00:00Z", + EffectiveUntil: "2025-08-31T23:59:59Z", + ComponentNames: []string{"comp1"}, + }, + }, + configProvider: &MockConfigProvider{effectiveTime: fixedTime}, + expectedComponentItems: map[string][]string{ + "comp1": {"test.check_j"}, + }, + expectedDefaultItems: []string{"existing.default"}, + }, + { + name: "Mix of ComponentNames and global criteria", + items: &Criteria{ + digestItems: make(map[string][]string), + componentItems: make(map[string][]string), + defaultItems: []string{}, + }, + volatileCriteria: []ecc.VolatileCriteria{ + { + Value: "test.component_check", + EffectiveOn: "2025-08-01T00:00:00Z", + EffectiveUntil: "2025-08-31T23:59:59Z", + ComponentNames: []string{"comp1"}, + }, + { + Value: "test.global_check", + EffectiveOn: "2025-08-01T00:00:00Z", + EffectiveUntil: "2025-08-31T23:59:59Z", + // No ComponentNames, ImageRef, ImageUrl, or ImageDigest - global + }, + }, + configProvider: &MockConfigProvider{effectiveTime: fixedTime}, + expectedComponentItems: map[string][]string{ + "comp1": {"test.component_check"}, + }, + expectedDefaultItems: []string{"test.global_check"}, + }, + { + name: "ComponentNames with existing default items and component items", + items: &Criteria{ + digestItems: make(map[string][]string), + componentItems: map[string][]string{ + "comp1": {"existing.comp_check"}, + }, + defaultItems: []string{"existing.default"}, + }, + volatileCriteria: []ecc.VolatileCriteria{ + { + Value: "test.new_comp_check", + EffectiveOn: "2025-08-01T00:00:00Z", + EffectiveUntil: "2025-08-31T23:59:59Z", + ComponentNames: []string{"comp1"}, + }, + { + Value: "test.new_global", + EffectiveOn: "2025-08-01T00:00:00Z", + EffectiveUntil: "2025-08-31T23:59:59Z", + // Global + }, + }, + configProvider: &MockConfigProvider{effectiveTime: fixedTime}, + expectedComponentItems: map[string][]string{ + "comp1": {"existing.comp_check", "test.new_comp_check"}, + }, + expectedDefaultItems: []string{"existing.default", "test.new_global"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := collectVolatileConfigItems(tt.items, tt.volatileCriteria, tt.configProvider) + + // Verify componentItems + require.Equal(t, len(tt.expectedComponentItems), len(result.componentItems), "componentItems count mismatch") + for expectedKey, expectedValues := range tt.expectedComponentItems { + actualValues, exists := result.componentItems[expectedKey] + require.True(t, exists, "Expected component key %s not found in result", expectedKey) + require.Equal(t, expectedValues, actualValues, "Values mismatch for component %s", expectedKey) + } + + // Verify defaultItems + require.Equal(t, tt.expectedDefaultItems, result.defaultItems, "defaultItems mismatch") + }) + } +} + +func TestCriteriaGetWithComponentName(t *testing.T) { + tests := []struct { + name string + criteria *Criteria + imageRef string + componentName string + expected []string + }{ + { + name: "Component match - returns component-specific + global", + criteria: &Criteria{ + digestItems: map[string][]string{}, + componentItems: map[string][]string{ + "my-component": {"@minimal", "test.some_policy"}, + }, + defaultItems: []string{"*"}, + }, + imageRef: "quay.io/repo/img@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + componentName: "my-component", + expected: []string{"@minimal", "test.some_policy", "*"}, + }, + { + name: "Component no match - returns only global", + criteria: &Criteria{ + digestItems: map[string][]string{}, + componentItems: map[string][]string{ + "my-component": {"@minimal", "test.some_policy"}, + }, + defaultItems: []string{"*"}, + }, + imageRef: "quay.io/repo/img@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + componentName: "other-component", + expected: []string{"*"}, + }, + { + name: "Empty component name - returns only image + global", + criteria: &Criteria{ + digestItems: map[string][]string{ + "quay.io/repo/img": {"test.image_check"}, + }, + componentItems: map[string][]string{ + "my-component": {"@minimal"}, + }, + defaultItems: []string{"*"}, + }, + imageRef: "quay.io/repo/img@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + componentName: "", + expected: []string{"test.image_check", "*"}, + }, + { + name: "Image + Component both match - returns all merged", + criteria: &Criteria{ + digestItems: map[string][]string{ + "quay.io/repo/img": {"test.image_check"}, + "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef": {"test.digest_check"}, + }, + componentItems: map[string][]string{ + "my-component": {"test.component_check"}, + }, + defaultItems: []string{"*"}, + }, + imageRef: "quay.io/repo/img@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + componentName: "my-component", + expected: []string{"test.image_check", "test.digest_check", "test.component_check", "*"}, + }, + { + name: "Invalid image ref - returns only global (error fallback)", + criteria: &Criteria{ + digestItems: map[string][]string{}, + componentItems: map[string][]string{ + "my-component": {"test.component_check"}, + }, + defaultItems: []string{"*"}, + }, + imageRef: "::::invalid:::::", + componentName: "my-component", + expected: []string{"*"}, + }, + { + name: "No matches at all - returns only global", + criteria: &Criteria{ + digestItems: map[string][]string{ + "quay.io/other/img": {"test.other_check"}, + }, + componentItems: map[string][]string{ + "other-component": {"test.other_component"}, + }, + defaultItems: []string{"default1", "default2"}, + }, + imageRef: "quay.io/repo/img@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + componentName: "my-component", + expected: []string{"default1", "default2"}, + }, + { + name: "Multiple component items", + criteria: &Criteria{ + digestItems: map[string][]string{}, + componentItems: map[string][]string{ + "my-component": {"check1", "check2", "check3"}, + }, + defaultItems: []string{"*"}, + }, + imageRef: "quay.io/repo/img@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + componentName: "my-component", + expected: []string{"check1", "check2", "check3", "*"}, + }, + { + name: "Image without digest - returns only repo + component + global", + criteria: &Criteria{ + digestItems: map[string][]string{ + "quay.io/repo/img": {"test.image_check"}, + }, + componentItems: map[string][]string{ + "my-component": {"test.component_check"}, + }, + defaultItems: []string{"*"}, + }, + imageRef: "quay.io/repo/img:latest", + componentName: "my-component", + expected: []string{"test.image_check", "test.component_check", "*"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.criteria.get(tt.imageRef, tt.componentName) + require.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/evaluator/evaluator.go b/internal/evaluator/evaluator.go index 2f6a1b3db..793cfe084 100644 --- a/internal/evaluator/evaluator.go +++ b/internal/evaluator/evaluator.go @@ -21,8 +21,9 @@ import ( ) type EvaluationTarget struct { - Inputs []string - Target string + Inputs []string + Target string + ComponentName string } type Evaluator interface { diff --git a/internal/evaluator/filters.go b/internal/evaluator/filters.go index 5610b8853..d6d2d9448 100644 --- a/internal/evaluator/filters.go +++ b/internal/evaluator/filters.go @@ -71,7 +71,8 @@ type PostEvaluationFilter interface { FilterResults( results []Result, rules policyRules, - target string, + imageRef string, + componentName string, missingIncludes map[string]bool, effectiveTime time.Time, ) ([]Result, map[string]bool) @@ -533,7 +534,7 @@ func (r *basePolicyResolver) baseResolvePolicy(rules policyRules, target string, result := NewPolicyResolutionResult() // Initialize missing includes with all include criteria - for _, include := range r.include.get(target) { + for _, include := range r.include.get(target, "") { result.MissingIncludes[include] = true } @@ -589,10 +590,10 @@ func (r *basePolicyResolver) baseEvaluateRuleInclusion(ruleID string, ruleInfo r matchers := r.createRuleMatchers(ruleID, ruleInfo) // Score against include criteria - includeScore := LegacyScoreMatches(matchers, r.include.get(target), result.MissingIncludes) + includeScore := LegacyScoreMatches(matchers, r.include.get(target, ""), result.MissingIncludes) // Score against exclude criteria - excludeScore := LegacyScoreMatches(matchers, r.exclude.get(target), make(map[string]bool)) + excludeScore := LegacyScoreMatches(matchers, r.exclude.get(target, ""), make(map[string]bool)) // Debug: Log rule scoring log.Debugf("[evaluateRuleInclusion] Rule: %s, includeScore: %d, excludeScore: %d, matchers: %v", ruleID, includeScore, excludeScore, matchers) @@ -608,7 +609,7 @@ func (r *basePolicyResolver) baseEvaluateRuleInclusion(ruleID string, ruleInfo r log.Debugf("[evaluateRuleInclusion] Rule: %s EXCLUDED", ruleID) } else { // No explicit criteria, check default behavior - if len(r.include.get(target)) == 0 || (len(r.include.get(target)) == 1 && r.include.get(target)[0] == "*") { + if len(r.include.get(target, "")) == 0 || (len(r.include.get(target, "")) == 1 && r.include.get(target, "")[0] == "*") { result.IncludedRules[ruleID] = true result.Explanations[ruleID] = "included by default (no explicit includes)" log.Debugf("[evaluateRuleInclusion] Rule: %s INCLUDED by default", ruleID) @@ -785,10 +786,10 @@ func GetIncludeExcludePolicyResolution(source ecc.Source, p ConfigProvider, rule // LegacyIsResultIncluded determines whether a result should be included based on // include/exclude criteria and scoring logic. This is the legacy filtering function. -func LegacyIsResultIncluded(result Result, target string, missingIncludes map[string]bool, include *Criteria, exclude *Criteria) bool { +func LegacyIsResultIncluded(result Result, imageRef string, componentName string, missingIncludes map[string]bool, include *Criteria, exclude *Criteria) bool { ruleMatchers := LegacyMakeMatchers(result) - includeScore := LegacyScoreMatches(ruleMatchers, include.get(target), missingIncludes) - excludeScore := LegacyScoreMatches(ruleMatchers, exclude.get(target), map[string]bool{}) + includeScore := LegacyScoreMatches(ruleMatchers, include.get(imageRef, componentName), missingIncludes) + excludeScore := LegacyScoreMatches(ruleMatchers, exclude.get(imageRef, componentName), map[string]bool{}) return includeScore > excludeScore } @@ -941,7 +942,8 @@ func NewLegacyPostEvaluationFilter(source ecc.Source, p ConfigProvider) PostEval func (f *LegacyPostEvaluationFilter) FilterResults( results []Result, rules policyRules, - target string, + imageRef string, + componentName string, missingIncludes map[string]bool, effectiveTime time.Time, ) ([]Result, map[string]bool) { @@ -950,7 +952,7 @@ func (f *LegacyPostEvaluationFilter) FilterResults( for _, result := range results { // Check if this result should be included using legacy logic // Results without codes are handled by LegacyIsResultIncluded using wildcard matchers - if LegacyIsResultIncluded(result, target, missingIncludes, f.include, f.exclude) { + if LegacyIsResultIncluded(result, imageRef, componentName, missingIncludes, f.include, f.exclude) { filteredResults = append(filteredResults, result) } } @@ -1021,7 +1023,8 @@ func (f *LegacyPostEvaluationFilter) CategorizeResults( func (f *UnifiedPostEvaluationFilter) FilterResults( results []Result, rules policyRules, - target string, + imageRef string, + componentName string, missingIncludes map[string]bool, effectiveTime time.Time, ) ([]Result, map[string]bool) { @@ -1029,7 +1032,7 @@ func (f *UnifiedPostEvaluationFilter) FilterResults( // vs IncludeExcludePolicyResolver (which doesn't) if ecResolver, ok := f.policyResolver.(*ECPolicyResolver); ok { // Use policy resolution for ECPolicyResolver to handle pipeline intentions - policyResolution := ecResolver.ResolvePolicy(rules, target) + policyResolution := ecResolver.ResolvePolicy(rules, imageRef) var filteredResults []Result for _, result := range results { @@ -1068,7 +1071,7 @@ func (f *UnifiedPostEvaluationFilter) FilterResults( } // Use legacy filtering logic for all results - if LegacyIsResultIncluded(result, target, missingIncludes, f.policyResolver.Includes(), f.policyResolver.Excludes()) { + if LegacyIsResultIncluded(result, imageRef, componentName, missingIncludes, f.policyResolver.Includes(), f.policyResolver.Excludes()) { filteredResults = append(filteredResults, result) } } diff --git a/internal/evaluator/filters_test.go b/internal/evaluator/filters_test.go index ab0e385b2..293b2eb59 100644 --- a/internal/evaluator/filters_test.go +++ b/internal/evaluator/filters_test.go @@ -626,7 +626,7 @@ func TestUnifiedPostEvaluationFilter(t *testing.T) { } filteredResults, updatedMissingIncludes := filter.FilterResults( - results, rules, "test-target", missingIncludes, time.Now()) + results, rules, "test-target", "", missingIncludes, time.Now()) // Should include cve.high_severity and tasks.build_task, exclude test.test_data_found assert.Len(t, filteredResults, 2) @@ -695,7 +695,7 @@ func TestUnifiedPostEvaluationFilter(t *testing.T) { } filteredResults, updatedMissingIncludes := filter.FilterResults( - results, rules, "test-target", missingIncludes, time.Now()) + results, rules, "test-target", "", missingIncludes, time.Now()) // Should only include release.security_check (matches pipeline intention) assert.Len(t, filteredResults, 1) @@ -746,7 +746,7 @@ func TestUnifiedPostEvaluationFilter(t *testing.T) { } filteredResults, updatedMissingIncludes := filter.FilterResults( - results, rules, "test-target", missingIncludes, time.Now()) + results, rules, "test-target", "", missingIncludes, time.Now()) // Should include the CVE result assert.Len(t, filteredResults, 1) @@ -873,7 +873,7 @@ func TestUnifiedPostEvaluationFilterVsLegacy(t *testing.T) { "security.*": true, } newFilteredResults, newUpdatedMissingIncludes := newFilter.FilterResults( - results, rules, "test-target", newMissingIncludes, time.Now()) + results, rules, "test-target", "", newMissingIncludes, time.Now()) // Test the legacy approach using the standalone functions legacyMissingIncludes := map[string]bool{ @@ -896,7 +896,7 @@ func TestUnifiedPostEvaluationFilterVsLegacy(t *testing.T) { defaultItems: []string{"test.test_data_found", "slsa3.provenance"}, } - if LegacyIsResultIncluded(result, "test-target", legacyMissingIncludes, include, exclude) { + if LegacyIsResultIncluded(result, "test-target", "", legacyMissingIncludes, include, exclude) { legacyFilteredResults = append(legacyFilteredResults, result) } } diff --git a/internal/image/validate.go b/internal/image/validate.go index 00e519b9e..c5cd964ba 100644 --- a/internal/image/validate.go +++ b/internal/image/validate.go @@ -117,7 +117,10 @@ func ValidateImage(ctx context.Context, comp app.SnapshotComponent, snap *app.Sn for _, e := range evaluators { // Todo maybe: Handle each one concurrently - target := evaluator.EvaluationTarget{Inputs: []string{inputPath}} + target := evaluator.EvaluationTarget{ + Inputs: []string{inputPath}, + ComponentName: comp.Name, + } if ref := a.ImageReference(ctx); ref == "" { log.Debug("Problem getting image reference") } else { diff --git a/internal/image/validate_test.go b/internal/image/validate_test.go index 55c96076e..301db2742 100644 --- a/internal/image/validate_test.go +++ b/internal/image/validate_test.go @@ -349,6 +349,67 @@ func TestEvaluatorLifecycle(t *testing.T) { require.NoError(t, err) } +// TestComponentNamePassedToEvaluator verifies that the component name from SnapshotComponent +// is correctly passed to the evaluator via EvaluationTarget.ComponentName +func TestComponentNamePassedToEvaluator(t *testing.T) { + ctx := context.Background() + client := fake.FakeClient{} + client.On("Head", mock.Anything).Return(&v1.Descriptor{MediaType: types.OCIManifestSchema1}, nil) + client.On("Image", name.MustParseReference(imageRegistry+"@sha256:"+imageDigest), mock.Anything).Return(empty.Image, nil) + client.On("VerifyImageSignatures", refNoTag, mock.Anything).Return([]oci.Signature{validSignature}, true, nil) + client.On("VerifyImageAttestations", refNoTag, mock.Anything).Return([]oci.Signature{validAttestation}, true, nil) + client.On("ResolveDigest", refNoTag).Return("@sha256:"+imageDigest, nil) + ctx = ecoci.WithClient(ctx, &client) + + expectedComponentName := "my-test-component" + component := app.SnapshotComponent{ + Name: expectedComponentName, + ContainerImage: imageRef, + } + + p, err := policy.NewOfflinePolicy(ctx, policy.Now) + require.NoError(t, err) + + // Create a mock evaluator that captures the EvaluationTarget + var capturedTarget evaluator.EvaluationTarget + e := &mockEvaluatorWithCapture{ + captureFunc: func(target evaluator.EvaluationTarget) { + capturedTarget = target + }, + } + + evaluators := []evaluator.Evaluator{e} + + snap := app.SnapshotSpec{ + Components: []app.SnapshotComponent{component}, + } + + _, err = ValidateImage(ctx, component, &snap, p, evaluators, false) + require.NoError(t, err) + + // Verify that ComponentName was correctly passed to the evaluator + assert.Equal(t, expectedComponentName, capturedTarget.ComponentName, + "ComponentName should be passed from SnapshotComponent.Name to EvaluationTarget.ComponentName") +} + +// mockEvaluatorWithCapture is a mock evaluator that captures the EvaluationTarget for verification +type mockEvaluatorWithCapture struct { + captureFunc func(target evaluator.EvaluationTarget) +} + +func (e *mockEvaluatorWithCapture) Evaluate(ctx context.Context, target evaluator.EvaluationTarget) ([]evaluator.Outcome, error) { + if e.captureFunc != nil { + e.captureFunc(target) + } + return []evaluator.Outcome{}, nil +} + +func (e *mockEvaluatorWithCapture) Destroy() {} + +func (e *mockEvaluatorWithCapture) CapabilitiesPath() string { + return "" +} + // createMockVSAChecker creates a mock VSA checker for testing func createMockVSAChecker() *vsa.VSAChecker { // Create a mock retriever that always returns "not found"