From aeccc43925f8092e084f7d627162021440194af0 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Mon, 30 Mar 2026 22:51:32 +0200 Subject: [PATCH] feat(policies): lenient structured finding validation at runtime Make structured finding validation errors non-fatal during attestation crafting while keeping strict validation in policy development mode. When a policy returns malformed structured data at runtime, the violation is preserved as a plain string with FindingDegraded=true and a warning, instead of failing the entire evaluation. Add WithLenientFindingValidation() option to PolicyVerifier using the existing functional options pattern. The CLI policydevel path keeps strict validation (default) for fast feedback during policy authoring. Signed-off-by: Miguel Martinez Trivino --- .../frontend/attestation/v1/crafting_state.ts | 34 ++++- ...PolicyEvaluation.Violation.jsonschema.json | 8 ++ ....v1.PolicyEvaluation.Violation.schema.json | 8 ++ .../api/attestation/v1/crafting_state.pb.go | 26 +++- .../api/attestation/v1/crafting_state.proto | 7 + pkg/attestation/crafter/crafter.go | 4 + pkg/policies/policies.go | 123 ++++++++++++------ pkg/policies/policies_test.go | 96 ++++++++++++-- 8 files changed, 245 insertions(+), 61 deletions(-) diff --git a/app/controlplane/api/gen/frontend/attestation/v1/crafting_state.ts b/app/controlplane/api/gen/frontend/attestation/v1/crafting_state.ts index 66d6287e2..7e635bbf7 100644 --- a/app/controlplane/api/gen/frontend/attestation/v1/crafting_state.ts +++ b/app/controlplane/api/gen/frontend/attestation/v1/crafting_state.ts @@ -313,7 +313,17 @@ export interface PolicyEvaluation_Violation { message: string; vulnerability?: PolicyVulnerabilityFinding | undefined; sast?: PolicySASTFinding | undefined; - licenseViolation?: PolicyLicenseViolationFinding | undefined; + licenseViolation?: + | PolicyLicenseViolationFinding + | undefined; + /** + * True when the policy declared a finding_type and returned structured data, + * but validation failed at runtime. The violation was kept as a plain string + * and the finding oneof is NOT set. Consumers should treat this as a + * data-quality signal: the violation exists but structured finding data is + * missing due to a policy authoring issue. + */ + findingDegraded: boolean; } export interface PolicyEvaluation_Reference { @@ -2873,7 +2883,14 @@ export const PolicyEvaluation_WithEntry = { }; function createBasePolicyEvaluation_Violation(): PolicyEvaluation_Violation { - return { subject: "", message: "", vulnerability: undefined, sast: undefined, licenseViolation: undefined }; + return { + subject: "", + message: "", + vulnerability: undefined, + sast: undefined, + licenseViolation: undefined, + findingDegraded: false, + }; } export const PolicyEvaluation_Violation = { @@ -2893,6 +2910,9 @@ export const PolicyEvaluation_Violation = { if (message.licenseViolation !== undefined) { PolicyLicenseViolationFinding.encode(message.licenseViolation, writer.uint32(42).fork()).ldelim(); } + if (message.findingDegraded === true) { + writer.uint32(48).bool(message.findingDegraded); + } return writer; }, @@ -2938,6 +2958,13 @@ export const PolicyEvaluation_Violation = { message.licenseViolation = PolicyLicenseViolationFinding.decode(reader, reader.uint32()); continue; + case 6: + if (tag !== 48) { + break; + } + + message.findingDegraded = reader.bool(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -2958,6 +2985,7 @@ export const PolicyEvaluation_Violation = { licenseViolation: isSet(object.licenseViolation) ? PolicyLicenseViolationFinding.fromJSON(object.licenseViolation) : undefined, + findingDegraded: isSet(object.findingDegraded) ? Boolean(object.findingDegraded) : false, }; }, @@ -2973,6 +3001,7 @@ export const PolicyEvaluation_Violation = { message.licenseViolation !== undefined && (obj.licenseViolation = message.licenseViolation ? PolicyLicenseViolationFinding.toJSON(message.licenseViolation) : undefined); + message.findingDegraded !== undefined && (obj.findingDegraded = message.findingDegraded); return obj; }, @@ -2993,6 +3022,7 @@ export const PolicyEvaluation_Violation = { message.licenseViolation = (object.licenseViolation !== undefined && object.licenseViolation !== null) ? PolicyLicenseViolationFinding.fromPartial(object.licenseViolation) : undefined; + message.findingDegraded = object.findingDegraded ?? false; return message; }, }; diff --git a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.Violation.jsonschema.json b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.Violation.jsonschema.json index e7d821106..df9932881 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.Violation.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.Violation.jsonschema.json @@ -3,11 +3,19 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "patternProperties": { + "^(finding_degraded)$": { + "description": "True when the policy declared a finding_type and returned structured data,\n but validation failed at runtime. The violation was kept as a plain string\n and the finding oneof is NOT set. Consumers should treat this as a\n data-quality signal: the violation exists but structured finding data is\n missing due to a policy authoring issue.", + "type": "boolean" + }, "^(license_violation)$": { "$ref": "attestation.v1.PolicyLicenseViolationFinding.jsonschema.json" } }, "properties": { + "findingDegraded": { + "description": "True when the policy declared a finding_type and returned structured data,\n but validation failed at runtime. The violation was kept as a plain string\n and the finding oneof is NOT set. Consumers should treat this as a\n data-quality signal: the violation exists but structured finding data is\n missing due to a policy authoring issue.", + "type": "boolean" + }, "licenseViolation": { "$ref": "attestation.v1.PolicyLicenseViolationFinding.jsonschema.json" }, diff --git a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.Violation.schema.json b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.Violation.schema.json index dc57a847c..32e7a5aa5 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.Violation.schema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.Violation.schema.json @@ -3,11 +3,19 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "patternProperties": { + "^(findingDegraded)$": { + "description": "True when the policy declared a finding_type and returned structured data,\n but validation failed at runtime. The violation was kept as a plain string\n and the finding oneof is NOT set. Consumers should treat this as a\n data-quality signal: the violation exists but structured finding data is\n missing due to a policy authoring issue.", + "type": "boolean" + }, "^(licenseViolation)$": { "$ref": "attestation.v1.PolicyLicenseViolationFinding.schema.json" } }, "properties": { + "finding_degraded": { + "description": "True when the policy declared a finding_type and returned structured data,\n but validation failed at runtime. The violation was kept as a plain string\n and the finding oneof is NOT set. Consumers should treat this as a\n data-quality signal: the violation exists but structured finding data is\n missing due to a policy authoring issue.", + "type": "boolean" + }, "license_violation": { "$ref": "attestation.v1.PolicyLicenseViolationFinding.schema.json" }, diff --git a/pkg/attestation/crafter/api/attestation/v1/crafting_state.pb.go b/pkg/attestation/crafter/api/attestation/v1/crafting_state.pb.go index 292493eae..f0143f726 100644 --- a/pkg/attestation/crafter/api/attestation/v1/crafting_state.pb.go +++ b/pkg/attestation/crafter/api/attestation/v1/crafting_state.pb.go @@ -2225,9 +2225,15 @@ type PolicyEvaluation_Violation struct { // *PolicyEvaluation_Violation_Vulnerability // *PolicyEvaluation_Violation_Sast // *PolicyEvaluation_Violation_LicenseViolation - Finding isPolicyEvaluation_Violation_Finding `protobuf_oneof:"finding"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Finding isPolicyEvaluation_Violation_Finding `protobuf_oneof:"finding"` + // True when the policy declared a finding_type and returned structured data, + // but validation failed at runtime. The violation was kept as a plain string + // and the finding oneof is NOT set. Consumers should treat this as a + // data-quality signal: the violation exists but structured finding data is + // missing due to a policy authoring issue. + FindingDegraded bool `protobuf:"varint,6,opt,name=finding_degraded,json=findingDegraded,proto3" json:"finding_degraded,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *PolicyEvaluation_Violation) Reset() { @@ -2308,6 +2314,13 @@ func (x *PolicyEvaluation_Violation) GetLicenseViolation() *PolicyLicenseViolati return nil } +func (x *PolicyEvaluation_Violation) GetFindingDegraded() bool { + if x != nil { + return x.FindingDegraded + } + return false +} + type isPolicyEvaluation_Violation_Finding interface { isPolicyEvaluation_Violation_Finding() } @@ -2701,7 +2714,7 @@ const file_attestation_v1_crafting_state_proto_rawDesc = "" + "\venvironment\x18\x02 \x01(\tR\venvironment\x12$\n" + "\rauthenticated\x18\x03 \x01(\bR\rauthenticated\x12I\n" + "\x04type\x18\x04 \x01(\x0e25.workflowcontract.v1.CraftingSchema.Runner.RunnerTypeR\x04type\x12\x10\n" + - "\x03url\x18\x05 \x01(\tR\x03url\"\x98\x0e\n" + + "\x03url\x18\x05 \x01(\tR\x03url\"\xc3\x0e\n" + "\x10PolicyEvaluation\x12\x97\x01\n" + "\x04name\x18\x01 \x01(\tB\x82\x01\xbaH\x7f\xba\x01|\n" + "\rname.dns-1123\x12:must contain only lowercase letters, numbers, and hyphens.\x1a/this.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')R\x04name\x12#\n" + @@ -2731,13 +2744,14 @@ const file_attestation_v1_crafting_state_proto_rawDesc = "" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a7\n" + "\tWithEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a\xc5\x02\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a\xf0\x02\n" + "\tViolation\x12 \n" + "\asubject\x18\x01 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\asubject\x12 \n" + "\amessage\x18\x02 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\amessage\x12R\n" + "\rvulnerability\x18\x03 \x01(\v2*.attestation.v1.PolicyVulnerabilityFindingH\x00R\rvulnerability\x127\n" + "\x04sast\x18\x04 \x01(\v2!.attestation.v1.PolicySASTFindingH\x00R\x04sast\x12\\\n" + - "\x11license_violation\x18\x05 \x01(\v2-.attestation.v1.PolicyLicenseViolationFindingH\x00R\x10licenseViolationB\t\n" + + "\x11license_violation\x18\x05 \x01(\v2-.attestation.v1.PolicyLicenseViolationFindingH\x00R\x10licenseViolation\x12)\n" + + "\x10finding_degraded\x18\x06 \x01(\bR\x0ffindingDegradedB\t\n" + "\afinding\x1a\xfc\x01\n" + "\tReference\x12\x97\x01\n" + "\x04name\x18\x01 \x01(\tB\x82\x01\xbaH\x7f\xba\x01|\n" + diff --git a/pkg/attestation/crafter/api/attestation/v1/crafting_state.proto b/pkg/attestation/crafter/api/attestation/v1/crafting_state.proto index 9ede42ad1..55165812d 100644 --- a/pkg/attestation/crafter/api/attestation/v1/crafting_state.proto +++ b/pkg/attestation/crafter/api/attestation/v1/crafting_state.proto @@ -284,6 +284,13 @@ message PolicyEvaluation { PolicySASTFinding sast = 4; PolicyLicenseViolationFinding license_violation = 5; } + + // True when the policy declared a finding_type and returned structured data, + // but validation failed at runtime. The violation was kept as a plain string + // and the finding oneof is NOT set. Consumers should treat this as a + // data-quality signal: the violation exists but structured finding data is + // missing due to a policy authoring issue. + bool finding_degraded = 6; } message Reference { diff --git a/pkg/attestation/crafter/crafter.go b/pkg/attestation/crafter/crafter.go index b55eaca20..0236cfec1 100644 --- a/pkg/attestation/crafter/crafter.go +++ b/pkg/attestation/crafter/crafter.go @@ -706,6 +706,7 @@ func (c *Crafter) addMaterial(ctx context.Context, m *schemaapi.CraftingSchema_M c.Logger, policies.WithAllowedHostnames(c.CraftingState.Attestation.PoliciesAllowedHostnames...), policies.WithDefaultGate(c.CraftingState.Attestation.GetBlockOnPolicyViolation()), + policies.WithLenientFindingValidation(), ) policyGroupResults, err := pgv.VerifyMaterial(ctx, mt, value) if err != nil { @@ -723,6 +724,7 @@ func (c *Crafter) addMaterial(ctx context.Context, m *schemaapi.CraftingSchema_M c.Logger, policies.WithAllowedHostnames(c.CraftingState.Attestation.PoliciesAllowedHostnames...), policies.WithDefaultGate(c.CraftingState.Attestation.GetBlockOnPolicyViolation()), + policies.WithLenientFindingValidation(), ) policyResults, err := pv.VerifyMaterial(ctx, mt, value) if err != nil { @@ -758,6 +760,7 @@ func (c *Crafter) EvaluateAttestationPolicies(ctx context.Context, attestationID policies.WithAllowedHostnames(c.CraftingState.Attestation.PoliciesAllowedHostnames...), policies.WithDefaultGate(c.CraftingState.Attestation.GetBlockOnPolicyViolation()), policies.WithEvalPhase(phase), + policies.WithLenientFindingValidation(), ) policyEvaluations, err := pv.VerifyStatement(ctx, statement) if err != nil { @@ -768,6 +771,7 @@ func (c *Crafter) EvaluateAttestationPolicies(ctx context.Context, attestationID policies.WithAllowedHostnames(c.CraftingState.Attestation.PoliciesAllowedHostnames...), policies.WithDefaultGate(c.CraftingState.Attestation.GetBlockOnPolicyViolation()), policies.WithEvalPhase(phase), + policies.WithLenientFindingValidation(), ) policyGroupResults, err := pgv.VerifyStatement(ctx, statement) if err != nil { diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index c29abecdc..0c26dd1dc 100644 --- a/pkg/policies/policies.go +++ b/pkg/policies/policies.go @@ -84,32 +84,34 @@ const ( var defaultMaxConcurrency = max(runtime.NumCPU(), 5) type PolicyVerifier struct { - policies *v1.Policies - logger *zerolog.Logger - client v13.AttestationServiceClient - grpcConn *grpc.ClientConn - allowedHostnames []string - defaultGate bool - includeRawData bool - enablePrint bool - evalPhase EvalPhase - maxConcurrency int - policyCache cache.Cache[*policyWithReference] - groupCache cache.Cache[*groupWithReference] + policies *v1.Policies + logger *zerolog.Logger + client v13.AttestationServiceClient + grpcConn *grpc.ClientConn + allowedHostnames []string + defaultGate bool + includeRawData bool + enablePrint bool + evalPhase EvalPhase + maxConcurrency int + lenientFindingValidation bool + policyCache cache.Cache[*policyWithReference] + groupCache cache.Cache[*groupWithReference] } var _ Verifier = (*PolicyVerifier)(nil) type PolicyVerifierOptions struct { - AllowedHostnames []string - DefaultGate bool - IncludeRawData bool - EnablePrint bool - GRPCConn *grpc.ClientConn - EvalPhase EvalPhase - MaxConcurrency int - PolicyCache cache.Cache[*policyWithReference] - GroupCache cache.Cache[*groupWithReference] + AllowedHostnames []string + DefaultGate bool + IncludeRawData bool + EnablePrint bool + GRPCConn *grpc.ClientConn + EvalPhase EvalPhase + MaxConcurrency int + LenientFindingValidation bool + PolicyCache cache.Cache[*policyWithReference] + GroupCache cache.Cache[*groupWithReference] } type PolicyVerifierOption func(*PolicyVerifierOptions) @@ -168,6 +170,15 @@ func WithGroupCache(c cache.Cache[*groupWithReference]) PolicyVerifierOption { } } +// WithLenientFindingValidation makes structured finding validation errors non-fatal. +// When enabled, validation failures produce warnings and the violation is kept as a +// plain string with FindingDegraded=true instead of returning a hard error. +func WithLenientFindingValidation() PolicyVerifierOption { + return func(o *PolicyVerifierOptions) { + o.LenientFindingValidation = true + } +} + const defaultPolicyCacheTTL = 5 * time.Minute func NewPolicyVerifier(policies *v1.Policies, client v13.AttestationServiceClient, logger *zerolog.Logger, opts ...PolicyVerifierOption) *PolicyVerifier { @@ -190,18 +201,19 @@ func NewPolicyVerifier(policies *v1.Policies, client v13.AttestationServiceClien } return &PolicyVerifier{ - policies: policies, - client: client, - logger: logger, - grpcConn: options.GRPCConn, - allowedHostnames: options.AllowedHostnames, - defaultGate: options.DefaultGate, - includeRawData: options.IncludeRawData, - enablePrint: options.EnablePrint, - evalPhase: options.EvalPhase, - maxConcurrency: maxConcurrency, - policyCache: options.PolicyCache, - groupCache: options.GroupCache, + policies: policies, + client: client, + logger: logger, + grpcConn: options.GRPCConn, + allowedHostnames: options.AllowedHostnames, + defaultGate: options.DefaultGate, + includeRawData: options.IncludeRawData, + enablePrint: options.EnablePrint, + evalPhase: options.EvalPhase, + maxConcurrency: maxConcurrency, + lenientFindingValidation: options.LenientFindingValidation, + policyCache: options.PolicyCache, + groupCache: options.GroupCache, } } @@ -389,7 +401,7 @@ func (pv *PolicyVerifier) evaluatePolicyAttachment(ctx context.Context, attachme } findingType := policy.GetMetadata().GetFindingType() - apiViolations, warnings, err := engineEvaluationsToAPIViolations(evalResults, findingType) + apiViolations, warnings, err := engineEvaluationsToAPIViolations(evalResults, findingType, pv.lenientFindingValidation) if err != nil { return nil, NewPolicyError(fmt.Errorf("policy %q: %w", policy.GetMetadata().GetName(), err)) } @@ -754,11 +766,35 @@ func splitArgs(s string) []string { return result } -func engineEvaluationsToAPIViolations(results []*engine.EvaluationResult, findingType string) ([]*v12.PolicyEvaluation_Violation, []string, error) { +// validateAndSetFinding validates a structured finding and sets it on the violation. +// When lenient is true and validation fails, it returns degraded=true with a descriptive +// error for use as a warning. When lenient is false, it returns degraded=false with the +// error for the caller to treat as fatal. +func validateAndSetFinding(apiV *v12.PolicyEvaluation_Violation, findingType string, rawFinding map[string]any, lenient bool) (degraded bool, err error) { + finding, err := findings.ValidateFinding(findingType, rawFinding) + if err != nil { + if lenient { + return true, fmt.Errorf("structured finding validation failed for finding_type %q: %w — violations will be treated as plain strings", findingType, err) + } + return false, fmt.Errorf("structured violation validation: %w", err) + } + + if err := findings.SetViolationFinding(apiV, findingType, finding); err != nil { + if lenient { + return true, fmt.Errorf("failed to set structured finding for finding_type %q: %w — violations will be treated as plain strings", findingType, err) + } + return false, fmt.Errorf("setting violation finding: %w", err) + } + + return false, nil +} + +func engineEvaluationsToAPIViolations(results []*engine.EvaluationResult, findingType string, lenientValidation bool) ([]*v12.PolicyEvaluation_Violation, []string, error) { res := make([]*v12.PolicyEvaluation_Violation, 0) var warnings []string warnedNoFindingType := false warnedNoStructuredData := false + warnedValidationFailed := false for _, r := range results { for _, v := range r.Violations { @@ -771,7 +807,7 @@ func engineEvaluationsToAPIViolations(results []*engine.EvaluationResult, findin switch { case findingType == "" && !hasStructuredData: - // No finding_type, string violation — current behavior + // no-op: plain string violation without finding_type case findingType == "" && hasStructuredData: if !warnedNoFindingType { @@ -791,13 +827,14 @@ func engineEvaluationsToAPIViolations(results []*engine.EvaluationResult, findin } case findingType != "" && hasStructuredData: - finding, err := findings.ValidateFinding(findingType, v.RawFinding) - if err != nil { - return nil, nil, fmt.Errorf("structured violation validation: %w", err) - } - - if err := findings.SetViolationFinding(apiV, findingType, finding); err != nil { - return nil, nil, fmt.Errorf("setting violation finding: %w", err) + if degraded, err := validateAndSetFinding(apiV, findingType, v.RawFinding, lenientValidation); degraded { + apiV.FindingDegraded = true + if !warnedValidationFailed && err != nil { + warnings = append(warnings, err.Error()) + warnedValidationFailed = true + } + } else if err != nil { + return nil, nil, err } } diff --git a/pkg/policies/policies_test.go b/pkg/policies/policies_test.go index 1b130b50d..1b23d28ae 100644 --- a/pkg/policies/policies_test.go +++ b/pkg/policies/policies_test.go @@ -1492,13 +1492,14 @@ func (s *testSuite) TestPolicyAttachmentGate() { func (s *testSuite) TestEngineEvaluationsToAPIViolationsBehaviorMatrix() { cases := []struct { - name string - findingType string - violations []*engine.PolicyViolation - wantErr string - wantWarnings int - wantViolations int - checkFn func(violations []*v1.PolicyEvaluation_Violation) + name string + findingType string + lenientValidation bool + violations []*engine.PolicyViolation + wantErr string + wantWarnings int + wantViolations int + checkFn func(violations []*v1.PolicyEvaluation_Violation) }{ { name: "no finding_type + string violations - current behavior", @@ -1602,7 +1603,7 @@ func (s *testSuite) TestEngineEvaluationsToAPIViolationsBehaviorMatrix() { }, }, { - name: "finding_type + invalid structured violations - validation error", + name: "finding_type + invalid structured violations - strict validation error", findingType: "VULNERABILITY", violations: []*engine.PolicyViolation{ {Subject: "p1", Violation: "vuln found", RawFinding: map[string]any{ @@ -1613,7 +1614,7 @@ func (s *testSuite) TestEngineEvaluationsToAPIViolationsBehaviorMatrix() { wantErr: "validation failed", }, { - name: "unknown finding_type - error", + name: "unknown finding_type - strict error", findingType: "UNKNOWN", violations: []*engine.PolicyViolation{ {Subject: "p1", Violation: "something", RawFinding: map[string]any{ @@ -1628,6 +1629,81 @@ func (s *testSuite) TestEngineEvaluationsToAPIViolationsBehaviorMatrix() { violations: []*engine.PolicyViolation{}, wantViolations: 0, }, + // --- Lenient validation mode --- + { + name: "lenient - invalid structured violations - fallback with warning and degraded flag", + findingType: "VULNERABILITY", + lenientValidation: true, + violations: []*engine.PolicyViolation{ + {Subject: "p1", Violation: "vuln found", RawFinding: map[string]any{ + "message": "vuln found", "external_id": "CVE-1", + // missing package_purl and severity (required) + }}, + }, + wantViolations: 1, + wantWarnings: 1, + checkFn: func(violations []*v1.PolicyEvaluation_Violation) { + s.Equal("vuln found", violations[0].GetMessage()) + s.Nil(violations[0].GetVulnerability()) + s.True(violations[0].GetFindingDegraded()) + }, + }, + { + name: "lenient - unknown finding_type - fallback with warning and degraded flag", + findingType: "UNKNOWN", + lenientValidation: true, + violations: []*engine.PolicyViolation{ + {Subject: "p1", Violation: "something", RawFinding: map[string]any{ + "message": "something", + }}, + }, + wantViolations: 1, + wantWarnings: 1, + checkFn: func(violations []*v1.PolicyEvaluation_Violation) { + s.Equal("something", violations[0].GetMessage()) + s.Nil(violations[0].GetVulnerability()) + s.True(violations[0].GetFindingDegraded()) + }, + }, + { + name: "lenient - valid structured violations - still validates and sets oneof", + findingType: "VULNERABILITY", + lenientValidation: true, + violations: []*engine.PolicyViolation{ + {Subject: "p1", Violation: "vuln found", RawFinding: map[string]any{ + "message": "vuln found", "external_id": "CVE-2024-1234", + "package_purl": "pkg:golang/example.com/lib@v1.0.0", "severity": "HIGH", + }}, + }, + wantViolations: 1, + checkFn: func(violations []*v1.PolicyEvaluation_Violation) { + f := violations[0].GetVulnerability() + s.Require().NotNil(f) + s.Equal("CVE-2024-1234", f.GetExternalId()) + s.False(violations[0].GetFindingDegraded()) + }, + }, + { + name: "lenient - multiple invalid violations - warning deduplicated, all degraded", + findingType: "VULNERABILITY", + lenientValidation: true, + violations: []*engine.PolicyViolation{ + {Subject: "p1", Violation: "vuln 1", RawFinding: map[string]any{ + "message": "vuln 1", "external_id": "CVE-1", + }}, + {Subject: "p2", Violation: "vuln 2", RawFinding: map[string]any{ + "message": "vuln 2", "external_id": "CVE-2", + }}, + }, + wantViolations: 2, + wantWarnings: 1, // deduplicated + checkFn: func(violations []*v1.PolicyEvaluation_Violation) { + s.True(violations[0].GetFindingDegraded()) + s.True(violations[1].GetFindingDegraded()) + s.Nil(violations[0].GetVulnerability()) + s.Nil(violations[1].GetVulnerability()) + }, + }, } for _, tc := range cases { @@ -1636,7 +1712,7 @@ func (s *testSuite) TestEngineEvaluationsToAPIViolationsBehaviorMatrix() { {Violations: tc.violations}, } - violations, warnings, err := engineEvaluationsToAPIViolations(results, tc.findingType) + violations, warnings, err := engineEvaluationsToAPIViolations(results, tc.findingType, tc.lenientValidation) if tc.wantErr != "" { s.Require().Error(err) s.Contains(err.Error(), tc.wantErr)