From 2f563204945fd9de837e90ae21b2bf41e0a1ef5e Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Tue, 5 May 2026 16:47:36 +0200 Subject: [PATCH 1/3] feat(policies): add suppressed_findings to policy evaluation result Adds a new top-level key on the Rego/WASM policy result alongside findings and violations. Items use the same schema as findings, with two new optional correlation fields on the finding proto types: chainloop_finding_id and chainloop_assessment_ids. Refs: #3091 Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino --- .../frontend/attestation/v1/crafting_state.ts | 136 +++++++++++++++++- ...icyLicenseViolationFinding.jsonschema.json | 22 +++ ....PolicyLicenseViolationFinding.schema.json | 22 +++ ...ation.v1.PolicySASTFinding.jsonschema.json | 22 +++ ...testation.v1.PolicySASTFinding.schema.json | 22 +++ ...PolicyVulnerabilityFinding.jsonschema.json | 22 +++ ....v1.PolicyVulnerabilityFinding.schema.json | 22 +++ .../api/attestation/v1/crafting_state.pb.go | 92 ++++++++++-- .../api/attestation/v1/crafting_state.proto | 18 +++ pkg/policies/engine/engine.go | 13 +- pkg/policies/engine/rego/rego.go | 16 +++ pkg/policies/engine/rego/rego_test.go | 91 ++++++++++++ .../rego/testfiles/suppressed_findings.rego | 56 ++++++++ pkg/policies/engine/wasm/engine.go | 25 +++- 14 files changed, 557 insertions(+), 22 deletions(-) create mode 100644 pkg/policies/engine/rego/testfiles/suppressed_findings.rego 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 36c0dccff..247b601f8 100644 --- a/app/controlplane/api/gen/frontend/attestation/v1/crafting_state.ts +++ b/app/controlplane/api/gen/frontend/attestation/v1/crafting_state.ts @@ -358,6 +358,16 @@ export interface PolicyVulnerabilityFinding { description: string; /** Version that fixes the vulnerability (e.g., "2.0.1", "1.3.4-patch1") */ fixedVersion: string; + /** + * Chainloop finding identifier this Rego finding maps to. Populated when the + * platform-side correlation is known to the policy at evaluation time. + */ + chainloopFindingId: string; + /** + * Chainloop assessment identifiers that drove suppression for this finding. + * Only meaningful for entries listed in the suppressed_findings result key. + */ + chainloopAssessmentIds: string[]; } /** @@ -380,7 +390,19 @@ export interface PolicySASTFinding { /** Suggested fix */ recommendation: string; /** Optional numeric severity score from the scanner (scale is tool-defined) */ - severityScore?: number | undefined; + severityScore?: + | number + | undefined; + /** + * Chainloop finding identifier this Rego finding maps to. Populated when the + * platform-side correlation is known to the policy at evaluation time. + */ + chainloopFindingId: string; + /** + * Chainloop assessment identifiers that drove suppression for this finding. + * Only meaningful for entries listed in the suppressed_findings result key. + */ + chainloopAssessmentIds: string[]; } /** @@ -402,6 +424,16 @@ export interface PolicyLicenseViolationFinding { componentVersion: string; /** Suggested fix */ recommendation: string; + /** + * Chainloop finding identifier this Rego finding maps to. Populated when the + * platform-side correlation is known to the policy at evaluation time. + */ + chainloopFindingId: string; + /** + * Chainloop assessment identifiers that drove suppression for this finding. + * Only meaningful for entries listed in the suppressed_findings result key. + */ + chainloopAssessmentIds: string[]; } export interface Commit { @@ -3248,6 +3280,8 @@ function createBasePolicyVulnerabilityFinding(): PolicyVulnerabilityFinding { recommendation: "", description: "", fixedVersion: "", + chainloopFindingId: "", + chainloopAssessmentIds: [], }; } @@ -3280,6 +3314,12 @@ export const PolicyVulnerabilityFinding = { if (message.fixedVersion !== "") { writer.uint32(74).string(message.fixedVersion); } + if (message.chainloopFindingId !== "") { + writer.uint32(82).string(message.chainloopFindingId); + } + for (const v of message.chainloopAssessmentIds) { + writer.uint32(90).string(v!); + } return writer; }, @@ -3353,6 +3393,20 @@ export const PolicyVulnerabilityFinding = { message.fixedVersion = reader.string(); continue; + case 10: + if (tag !== 82) { + break; + } + + message.chainloopFindingId = reader.string(); + continue; + case 11: + if (tag !== 90) { + break; + } + + message.chainloopAssessmentIds.push(reader.string()); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -3373,6 +3427,10 @@ export const PolicyVulnerabilityFinding = { recommendation: isSet(object.recommendation) ? String(object.recommendation) : "", description: isSet(object.description) ? String(object.description) : "", fixedVersion: isSet(object.fixedVersion) ? String(object.fixedVersion) : "", + chainloopFindingId: isSet(object.chainloopFindingId) ? String(object.chainloopFindingId) : "", + chainloopAssessmentIds: Array.isArray(object?.chainloopAssessmentIds) + ? object.chainloopAssessmentIds.map((e: any) => String(e)) + : [], }; }, @@ -3391,6 +3449,12 @@ export const PolicyVulnerabilityFinding = { message.recommendation !== undefined && (obj.recommendation = message.recommendation); message.description !== undefined && (obj.description = message.description); message.fixedVersion !== undefined && (obj.fixedVersion = message.fixedVersion); + message.chainloopFindingId !== undefined && (obj.chainloopFindingId = message.chainloopFindingId); + if (message.chainloopAssessmentIds) { + obj.chainloopAssessmentIds = message.chainloopAssessmentIds.map((e) => e); + } else { + obj.chainloopAssessmentIds = []; + } return obj; }, @@ -3409,6 +3473,8 @@ export const PolicyVulnerabilityFinding = { message.recommendation = object.recommendation ?? ""; message.description = object.description ?? ""; message.fixedVersion = object.fixedVersion ?? ""; + message.chainloopFindingId = object.chainloopFindingId ?? ""; + message.chainloopAssessmentIds = object.chainloopAssessmentIds?.map((e) => e) || []; return message; }, }; @@ -3423,6 +3489,8 @@ function createBasePolicySASTFinding(): PolicySASTFinding { codeSnippet: "", recommendation: "", severityScore: undefined, + chainloopFindingId: "", + chainloopAssessmentIds: [], }; } @@ -3452,6 +3520,12 @@ export const PolicySASTFinding = { if (message.severityScore !== undefined) { writer.uint32(65).double(message.severityScore); } + if (message.chainloopFindingId !== "") { + writer.uint32(74).string(message.chainloopFindingId); + } + for (const v of message.chainloopAssessmentIds) { + writer.uint32(82).string(v!); + } return writer; }, @@ -3518,6 +3592,20 @@ export const PolicySASTFinding = { message.severityScore = reader.double(); continue; + case 9: + if (tag !== 74) { + break; + } + + message.chainloopFindingId = reader.string(); + continue; + case 10: + if (tag !== 82) { + break; + } + + message.chainloopAssessmentIds.push(reader.string()); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -3537,6 +3625,10 @@ export const PolicySASTFinding = { codeSnippet: isSet(object.codeSnippet) ? String(object.codeSnippet) : "", recommendation: isSet(object.recommendation) ? String(object.recommendation) : "", severityScore: isSet(object.severityScore) ? Number(object.severityScore) : undefined, + chainloopFindingId: isSet(object.chainloopFindingId) ? String(object.chainloopFindingId) : "", + chainloopAssessmentIds: Array.isArray(object?.chainloopAssessmentIds) + ? object.chainloopAssessmentIds.map((e: any) => String(e)) + : [], }; }, @@ -3550,6 +3642,12 @@ export const PolicySASTFinding = { message.codeSnippet !== undefined && (obj.codeSnippet = message.codeSnippet); message.recommendation !== undefined && (obj.recommendation = message.recommendation); message.severityScore !== undefined && (obj.severityScore = message.severityScore); + message.chainloopFindingId !== undefined && (obj.chainloopFindingId = message.chainloopFindingId); + if (message.chainloopAssessmentIds) { + obj.chainloopAssessmentIds = message.chainloopAssessmentIds.map((e) => e); + } else { + obj.chainloopAssessmentIds = []; + } return obj; }, @@ -3567,6 +3665,8 @@ export const PolicySASTFinding = { message.codeSnippet = object.codeSnippet ?? ""; message.recommendation = object.recommendation ?? ""; message.severityScore = object.severityScore ?? undefined; + message.chainloopFindingId = object.chainloopFindingId ?? ""; + message.chainloopAssessmentIds = object.chainloopAssessmentIds?.map((e) => e) || []; return message; }, }; @@ -3580,6 +3680,8 @@ function createBasePolicyLicenseViolationFinding(): PolicyLicenseViolationFindin licenseName: "", componentVersion: "", recommendation: "", + chainloopFindingId: "", + chainloopAssessmentIds: [], }; } @@ -3606,6 +3708,12 @@ export const PolicyLicenseViolationFinding = { if (message.recommendation !== "") { writer.uint32(58).string(message.recommendation); } + if (message.chainloopFindingId !== "") { + writer.uint32(66).string(message.chainloopFindingId); + } + for (const v of message.chainloopAssessmentIds) { + writer.uint32(74).string(v!); + } return writer; }, @@ -3665,6 +3773,20 @@ export const PolicyLicenseViolationFinding = { message.recommendation = reader.string(); continue; + case 8: + if (tag !== 66) { + break; + } + + message.chainloopFindingId = reader.string(); + continue; + case 9: + if (tag !== 74) { + break; + } + + message.chainloopAssessmentIds.push(reader.string()); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -3683,6 +3805,10 @@ export const PolicyLicenseViolationFinding = { licenseName: isSet(object.licenseName) ? String(object.licenseName) : "", componentVersion: isSet(object.componentVersion) ? String(object.componentVersion) : "", recommendation: isSet(object.recommendation) ? String(object.recommendation) : "", + chainloopFindingId: isSet(object.chainloopFindingId) ? String(object.chainloopFindingId) : "", + chainloopAssessmentIds: Array.isArray(object?.chainloopAssessmentIds) + ? object.chainloopAssessmentIds.map((e: any) => String(e)) + : [], }; }, @@ -3695,6 +3821,12 @@ export const PolicyLicenseViolationFinding = { message.licenseName !== undefined && (obj.licenseName = message.licenseName); message.componentVersion !== undefined && (obj.componentVersion = message.componentVersion); message.recommendation !== undefined && (obj.recommendation = message.recommendation); + message.chainloopFindingId !== undefined && (obj.chainloopFindingId = message.chainloopFindingId); + if (message.chainloopAssessmentIds) { + obj.chainloopAssessmentIds = message.chainloopAssessmentIds.map((e) => e); + } else { + obj.chainloopAssessmentIds = []; + } return obj; }, @@ -3713,6 +3845,8 @@ export const PolicyLicenseViolationFinding = { message.licenseName = object.licenseName ?? ""; message.componentVersion = object.componentVersion ?? ""; message.recommendation = object.recommendation ?? ""; + message.chainloopFindingId = object.chainloopFindingId ?? ""; + message.chainloopAssessmentIds = object.chainloopAssessmentIds?.map((e) => e) || []; return message; }, }; diff --git a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyLicenseViolationFinding.jsonschema.json b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyLicenseViolationFinding.jsonschema.json index 753269b92..361712b8c 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyLicenseViolationFinding.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyLicenseViolationFinding.jsonschema.json @@ -4,6 +4,17 @@ "additionalProperties": false, "description": "Output schema for license violation findings from policy evaluation.\n Used when a policy declares finding_type: LICENSE_VIOLATION.", "patternProperties": { + "^(chainloop_assessment_ids)$": { + "description": "Chainloop assessment identifiers that drove suppression for this finding.\n Only meaningful for entries listed in the suppressed_findings result key.", + "items": { + "type": "string" + }, + "type": "array" + }, + "^(chainloop_finding_id)$": { + "description": "Chainloop finding identifier this Rego finding maps to. Populated when the\n platform-side correlation is known to the policy at evaluation time.", + "type": "string" + }, "^(component_name)$": { "description": "Component name (e.g., \"lodash\")", "type": "string" @@ -26,6 +37,17 @@ } }, "properties": { + "chainloopAssessmentIds": { + "description": "Chainloop assessment identifiers that drove suppression for this finding.\n Only meaningful for entries listed in the suppressed_findings result key.", + "items": { + "type": "string" + }, + "type": "array" + }, + "chainloopFindingId": { + "description": "Chainloop finding identifier this Rego finding maps to. Populated when the\n platform-side correlation is known to the policy at evaluation time.", + "type": "string" + }, "componentName": { "description": "Component name (e.g., \"lodash\")", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyLicenseViolationFinding.schema.json b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyLicenseViolationFinding.schema.json index 35f0445c0..476e201e7 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyLicenseViolationFinding.schema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyLicenseViolationFinding.schema.json @@ -4,6 +4,17 @@ "additionalProperties": false, "description": "Output schema for license violation findings from policy evaluation.\n Used when a policy declares finding_type: LICENSE_VIOLATION.", "patternProperties": { + "^(chainloopAssessmentIds)$": { + "description": "Chainloop assessment identifiers that drove suppression for this finding.\n Only meaningful for entries listed in the suppressed_findings result key.", + "items": { + "type": "string" + }, + "type": "array" + }, + "^(chainloopFindingId)$": { + "description": "Chainloop finding identifier this Rego finding maps to. Populated when the\n platform-side correlation is known to the policy at evaluation time.", + "type": "string" + }, "^(componentName)$": { "description": "Component name (e.g., \"lodash\")", "type": "string" @@ -26,6 +37,17 @@ } }, "properties": { + "chainloop_assessment_ids": { + "description": "Chainloop assessment identifiers that drove suppression for this finding.\n Only meaningful for entries listed in the suppressed_findings result key.", + "items": { + "type": "string" + }, + "type": "array" + }, + "chainloop_finding_id": { + "description": "Chainloop finding identifier this Rego finding maps to. Populated when the\n platform-side correlation is known to the policy at evaluation time.", + "type": "string" + }, "component_name": { "description": "Component name (e.g., \"lodash\")", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicySASTFinding.jsonschema.json b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicySASTFinding.jsonschema.json index f9b655c43..d194bc778 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicySASTFinding.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicySASTFinding.jsonschema.json @@ -4,6 +4,17 @@ "additionalProperties": false, "description": "Output schema for SAST findings from policy evaluation.\n Used when a policy declares finding_type: SAST.", "patternProperties": { + "^(chainloop_assessment_ids)$": { + "description": "Chainloop assessment identifiers that drove suppression for this finding.\n Only meaningful for entries listed in the suppressed_findings result key.", + "items": { + "type": "string" + }, + "type": "array" + }, + "^(chainloop_finding_id)$": { + "description": "Chainloop finding identifier this Rego finding maps to. Populated when the\n platform-side correlation is known to the policy at evaluation time.", + "type": "string" + }, "^(code_snippet)$": { "description": "Code snippet showing the issue", "type": "string" @@ -39,6 +50,17 @@ } }, "properties": { + "chainloopAssessmentIds": { + "description": "Chainloop assessment identifiers that drove suppression for this finding.\n Only meaningful for entries listed in the suppressed_findings result key.", + "items": { + "type": "string" + }, + "type": "array" + }, + "chainloopFindingId": { + "description": "Chainloop finding identifier this Rego finding maps to. Populated when the\n platform-side correlation is known to the policy at evaluation time.", + "type": "string" + }, "codeSnippet": { "description": "Code snippet showing the issue", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicySASTFinding.schema.json b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicySASTFinding.schema.json index 3228052d9..97302a00d 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicySASTFinding.schema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicySASTFinding.schema.json @@ -4,6 +4,17 @@ "additionalProperties": false, "description": "Output schema for SAST findings from policy evaluation.\n Used when a policy declares finding_type: SAST.", "patternProperties": { + "^(chainloopAssessmentIds)$": { + "description": "Chainloop assessment identifiers that drove suppression for this finding.\n Only meaningful for entries listed in the suppressed_findings result key.", + "items": { + "type": "string" + }, + "type": "array" + }, + "^(chainloopFindingId)$": { + "description": "Chainloop finding identifier this Rego finding maps to. Populated when the\n platform-side correlation is known to the policy at evaluation time.", + "type": "string" + }, "^(codeSnippet)$": { "description": "Code snippet showing the issue", "type": "string" @@ -39,6 +50,17 @@ } }, "properties": { + "chainloop_assessment_ids": { + "description": "Chainloop assessment identifiers that drove suppression for this finding.\n Only meaningful for entries listed in the suppressed_findings result key.", + "items": { + "type": "string" + }, + "type": "array" + }, + "chainloop_finding_id": { + "description": "Chainloop finding identifier this Rego finding maps to. Populated when the\n platform-side correlation is known to the policy at evaluation time.", + "type": "string" + }, "code_snippet": { "description": "Code snippet showing the issue", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyVulnerabilityFinding.jsonschema.json b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyVulnerabilityFinding.jsonschema.json index 791e6e7c8..051fb9a74 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyVulnerabilityFinding.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyVulnerabilityFinding.jsonschema.json @@ -4,6 +4,17 @@ "additionalProperties": false, "description": "Output schema for vulnerability findings from policy evaluation.\n Used when a policy declares finding_type: VULNERABILITY.", "patternProperties": { + "^(chainloop_assessment_ids)$": { + "description": "Chainloop assessment identifiers that drove suppression for this finding.\n Only meaningful for entries listed in the suppressed_findings result key.", + "items": { + "type": "string" + }, + "type": "array" + }, + "^(chainloop_finding_id)$": { + "description": "Chainloop finding identifier this Rego finding maps to. Populated when the\n platform-side correlation is known to the policy at evaluation time.", + "type": "string" + }, "^(cvss_v3_score)$": { "anyOf": [ { @@ -32,6 +43,17 @@ } }, "properties": { + "chainloopAssessmentIds": { + "description": "Chainloop assessment identifiers that drove suppression for this finding.\n Only meaningful for entries listed in the suppressed_findings result key.", + "items": { + "type": "string" + }, + "type": "array" + }, + "chainloopFindingId": { + "description": "Chainloop finding identifier this Rego finding maps to. Populated when the\n platform-side correlation is known to the policy at evaluation time.", + "type": "string" + }, "cvssV3Score": { "anyOf": [ { diff --git a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyVulnerabilityFinding.schema.json b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyVulnerabilityFinding.schema.json index fe8a2dbc0..d3f21b958 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyVulnerabilityFinding.schema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyVulnerabilityFinding.schema.json @@ -4,6 +4,17 @@ "additionalProperties": false, "description": "Output schema for vulnerability findings from policy evaluation.\n Used when a policy declares finding_type: VULNERABILITY.", "patternProperties": { + "^(chainloopAssessmentIds)$": { + "description": "Chainloop assessment identifiers that drove suppression for this finding.\n Only meaningful for entries listed in the suppressed_findings result key.", + "items": { + "type": "string" + }, + "type": "array" + }, + "^(chainloopFindingId)$": { + "description": "Chainloop finding identifier this Rego finding maps to. Populated when the\n platform-side correlation is known to the policy at evaluation time.", + "type": "string" + }, "^(cvssV3Score)$": { "anyOf": [ { @@ -32,6 +43,17 @@ } }, "properties": { + "chainloop_assessment_ids": { + "description": "Chainloop assessment identifiers that drove suppression for this finding.\n Only meaningful for entries listed in the suppressed_findings result key.", + "items": { + "type": "string" + }, + "type": "array" + }, + "chainloop_finding_id": { + "description": "Chainloop finding identifier this Rego finding maps to. Populated when the\n platform-side correlation is known to the policy at evaluation time.", + "type": "string" + }, "cvss_v3_score": { "anyOf": [ { 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 92fc81fcd..6f3cc6c8a 100644 --- a/pkg/attestation/crafter/api/attestation/v1/crafting_state.pb.go +++ b/pkg/attestation/crafter/api/attestation/v1/crafting_state.pb.go @@ -694,9 +694,15 @@ type PolicyVulnerabilityFinding struct { // Optional longer description of the vulnerability Description string `protobuf:"bytes,8,opt,name=description,proto3" json:"description,omitempty"` // Version that fixes the vulnerability (e.g., "2.0.1", "1.3.4-patch1") - FixedVersion string `protobuf:"bytes,9,opt,name=fixed_version,json=fixedVersion,proto3" json:"fixed_version,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + FixedVersion string `protobuf:"bytes,9,opt,name=fixed_version,json=fixedVersion,proto3" json:"fixed_version,omitempty"` + // Chainloop finding identifier this Rego finding maps to. Populated when the + // platform-side correlation is known to the policy at evaluation time. + ChainloopFindingId string `protobuf:"bytes,10,opt,name=chainloop_finding_id,json=chainloopFindingId,proto3" json:"chainloop_finding_id,omitempty"` + // Chainloop assessment identifiers that drove suppression for this finding. + // Only meaningful for entries listed in the suppressed_findings result key. + ChainloopAssessmentIds []string `protobuf:"bytes,11,rep,name=chainloop_assessment_ids,json=chainloopAssessmentIds,proto3" json:"chainloop_assessment_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *PolicyVulnerabilityFinding) Reset() { @@ -792,6 +798,20 @@ func (x *PolicyVulnerabilityFinding) GetFixedVersion() string { return "" } +func (x *PolicyVulnerabilityFinding) GetChainloopFindingId() string { + if x != nil { + return x.ChainloopFindingId + } + return "" +} + +func (x *PolicyVulnerabilityFinding) GetChainloopAssessmentIds() []string { + if x != nil { + return x.ChainloopAssessmentIds + } + return nil +} + // Output schema for SAST findings from policy evaluation. // Used when a policy declares finding_type: SAST. type PolicySASTFinding struct { @@ -812,8 +832,14 @@ type PolicySASTFinding struct { Recommendation string `protobuf:"bytes,7,opt,name=recommendation,proto3" json:"recommendation,omitempty"` // Optional numeric severity score from the scanner (scale is tool-defined) SeverityScore *float64 `protobuf:"fixed64,8,opt,name=severity_score,json=severityScore,proto3,oneof" json:"severity_score,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Chainloop finding identifier this Rego finding maps to. Populated when the + // platform-side correlation is known to the policy at evaluation time. + ChainloopFindingId string `protobuf:"bytes,9,opt,name=chainloop_finding_id,json=chainloopFindingId,proto3" json:"chainloop_finding_id,omitempty"` + // Chainloop assessment identifiers that drove suppression for this finding. + // Only meaningful for entries listed in the suppressed_findings result key. + ChainloopAssessmentIds []string `protobuf:"bytes,10,rep,name=chainloop_assessment_ids,json=chainloopAssessmentIds,proto3" json:"chainloop_assessment_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *PolicySASTFinding) Reset() { @@ -902,6 +928,20 @@ func (x *PolicySASTFinding) GetSeverityScore() float64 { return 0 } +func (x *PolicySASTFinding) GetChainloopFindingId() string { + if x != nil { + return x.ChainloopFindingId + } + return "" +} + +func (x *PolicySASTFinding) GetChainloopAssessmentIds() []string { + if x != nil { + return x.ChainloopAssessmentIds + } + return nil +} + // Output schema for license violation findings from policy evaluation. // Used when a policy declares finding_type: LICENSE_VIOLATION. type PolicyLicenseViolationFinding struct { @@ -920,8 +960,14 @@ type PolicyLicenseViolationFinding struct { ComponentVersion string `protobuf:"bytes,6,opt,name=component_version,json=componentVersion,proto3" json:"component_version,omitempty"` // Suggested fix Recommendation string `protobuf:"bytes,7,opt,name=recommendation,proto3" json:"recommendation,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Chainloop finding identifier this Rego finding maps to. Populated when the + // platform-side correlation is known to the policy at evaluation time. + ChainloopFindingId string `protobuf:"bytes,8,opt,name=chainloop_finding_id,json=chainloopFindingId,proto3" json:"chainloop_finding_id,omitempty"` + // Chainloop assessment identifiers that drove suppression for this finding. + // Only meaningful for entries listed in the suppressed_findings result key. + ChainloopAssessmentIds []string `protobuf:"bytes,9,rep,name=chainloop_assessment_ids,json=chainloopAssessmentIds,proto3" json:"chainloop_assessment_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *PolicyLicenseViolationFinding) Reset() { @@ -1003,6 +1049,20 @@ func (x *PolicyLicenseViolationFinding) GetRecommendation() string { return "" } +func (x *PolicyLicenseViolationFinding) GetChainloopFindingId() string { + if x != nil { + return x.ChainloopFindingId + } + return "" +} + +func (x *PolicyLicenseViolationFinding) GetChainloopAssessmentIds() []string { + if x != nil { + return x.ChainloopAssessmentIds + } + return nil +} + type Commit struct { state protoimpl.MessageState `protogen:"open.v1"` Hash string `protobuf:"bytes,1,opt,name=hash,proto3" json:"hash,omitempty"` @@ -2776,7 +2836,7 @@ const file_attestation_v1_crafting_state_proto_rawDesc = "" + "\x05input\x18\x01 \x01(\fR\x05input\x12\x16\n" + "\x06output\x18\x02 \x01(\fR\x06output\"\\\n" + "\x16PolicyEvaluationBundle\x12B\n" + - "\vevaluations\x18\x01 \x03(\v2 .attestation.v1.PolicyEvaluationR\vevaluations\"\xf6\x02\n" + + "\vevaluations\x18\x01 \x03(\v2 .attestation.v1.PolicyEvaluationR\vevaluations\"\xe2\x03\n" + "\x1aPolicyVulnerabilityFinding\x12 \n" + "\amessage\x18\x01 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\amessage\x12'\n" + "\vexternal_id\x18\x02 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\n" + @@ -2787,7 +2847,10 @@ const file_attestation_v1_crafting_state_proto_rawDesc = "" + "\x04cwes\x18\x06 \x03(\tR\x04cwes\x12&\n" + "\x0erecommendation\x18\a \x01(\tR\x0erecommendation\x12 \n" + "\vdescription\x18\b \x01(\tR\vdescription\x12#\n" + - "\rfixed_version\x18\t \x01(\tR\ffixedVersion\"\xc9\x02\n" + + "\rfixed_version\x18\t \x01(\tR\ffixedVersion\x120\n" + + "\x14chainloop_finding_id\x18\n" + + " \x01(\tR\x12chainloopFindingId\x128\n" + + "\x18chainloop_assessment_ids\x18\v \x03(\tR\x16chainloopAssessmentIds\"\xb5\x03\n" + "\x11PolicySASTFinding\x12 \n" + "\amessage\x18\x01 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\amessage\x12\x1f\n" + "\arule_id\x18\x02 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\x06ruleId\x12\"\n" + @@ -2797,8 +2860,11 @@ const file_attestation_v1_crafting_state_proto_rawDesc = "" + "lineNumber\x12!\n" + "\fcode_snippet\x18\x06 \x01(\tR\vcodeSnippet\x12&\n" + "\x0erecommendation\x18\a \x01(\tR\x0erecommendation\x12*\n" + - "\x0eseverity_score\x18\b \x01(\x01H\x00R\rseverityScore\x88\x01\x01B\x11\n" + - "\x0f_severity_score\"\xb2\x02\n" + + "\x0eseverity_score\x18\b \x01(\x01H\x00R\rseverityScore\x88\x01\x01\x120\n" + + "\x14chainloop_finding_id\x18\t \x01(\tR\x12chainloopFindingId\x128\n" + + "\x18chainloop_assessment_ids\x18\n" + + " \x03(\tR\x16chainloopAssessmentIdsB\x11\n" + + "\x0f_severity_score\"\x9e\x03\n" + "\x1dPolicyLicenseViolationFinding\x12 \n" + "\amessage\x18\x01 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\amessage\x12-\n" + "\x0ecomponent_name\x18\x02 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\rcomponentName\x12!\n" + @@ -2807,7 +2873,9 @@ const file_attestation_v1_crafting_state_proto_rawDesc = "" + "license_id\x18\x04 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\tlicenseId\x12!\n" + "\flicense_name\x18\x05 \x01(\tR\vlicenseName\x12+\n" + "\x11component_version\x18\x06 \x01(\tR\x10componentVersion\x12&\n" + - "\x0erecommendation\x18\a \x01(\tR\x0erecommendation\"\xce\x06\n" + + "\x0erecommendation\x18\a \x01(\tR\x0erecommendation\x120\n" + + "\x14chainloop_finding_id\x18\b \x01(\tR\x12chainloopFindingId\x128\n" + + "\x18chainloop_assessment_ids\x18\t \x03(\tR\x16chainloopAssessmentIds\"\xce\x06\n" + "\x06Commit\x12\x1b\n" + "\x04hash\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x04hash\x12!\n" + "\fauthor_email\x18\x02 \x01(\tR\vauthorEmail\x12(\n" + diff --git a/pkg/attestation/crafter/api/attestation/v1/crafting_state.proto b/pkg/attestation/crafter/api/attestation/v1/crafting_state.proto index 1bf6493d7..cac22d961 100644 --- a/pkg/attestation/crafter/api/attestation/v1/crafting_state.proto +++ b/pkg/attestation/crafter/api/attestation/v1/crafting_state.proto @@ -336,6 +336,12 @@ message PolicyVulnerabilityFinding { string description = 8; // Version that fixes the vulnerability (e.g., "2.0.1", "1.3.4-patch1") string fixed_version = 9; + // Chainloop finding identifier this Rego finding maps to. Populated when the + // platform-side correlation is known to the policy at evaluation time. + string chainloop_finding_id = 10; + // Chainloop assessment identifiers that drove suppression for this finding. + // Only meaningful for entries listed in the suppressed_findings result key. + repeated string chainloop_assessment_ids = 11; } // Output schema for SAST findings from policy evaluation. @@ -357,6 +363,12 @@ message PolicySASTFinding { string recommendation = 7; // Optional numeric severity score from the scanner (scale is tool-defined) optional double severity_score = 8; + // Chainloop finding identifier this Rego finding maps to. Populated when the + // platform-side correlation is known to the policy at evaluation time. + string chainloop_finding_id = 9; + // Chainloop assessment identifiers that drove suppression for this finding. + // Only meaningful for entries listed in the suppressed_findings result key. + repeated string chainloop_assessment_ids = 10; } // Output schema for license violation findings from policy evaluation. @@ -376,6 +388,12 @@ message PolicyLicenseViolationFinding { string component_version = 6; // Suggested fix string recommendation = 7; + // Chainloop finding identifier this Rego finding maps to. Populated when the + // platform-side correlation is known to the policy at evaluation time. + string chainloop_finding_id = 8; + // Chainloop assessment identifiers that drove suppression for this finding. + // Only meaningful for entries listed in the suppressed_findings result key. + repeated string chainloop_assessment_ids = 9; } message Commit { diff --git a/pkg/policies/engine/engine.go b/pkg/policies/engine/engine.go index 030f08544..1af8cd124 100644 --- a/pkg/policies/engine/engine.go +++ b/pkg/policies/engine/engine.go @@ -142,10 +142,15 @@ type PolicyEngine interface { type EvaluationResult struct { Violations []*PolicyViolation `json:"violations"` - Skipped bool `json:"skipped"` - SkipReason string `json:"skipReason"` - Ignore bool `json:"ignore"` - RawData *RawData `json:"rawData"` + // SuppressedFindings holds findings that the policy emitted in `findings` + // but flagged as suppressed (e.g. because a platform-side assessment matched). + // Every entry here also appears in Violations / findings — this is the + // subset that should not contribute to gating decisions. + SuppressedFindings []*PolicyViolation `json:"suppressedFindings,omitempty"` + Skipped bool `json:"skipped"` + SkipReason string `json:"skipReason"` + Ignore bool `json:"ignore"` + RawData *RawData `json:"rawData"` } type RawData struct { diff --git a/pkg/policies/engine/rego/rego.go b/pkg/policies/engine/rego/rego.go index 12d4481bf..fb83f6532 100644 --- a/pkg/policies/engine/rego/rego.go +++ b/pkg/policies/engine/rego/rego.go @@ -224,6 +224,22 @@ func parseResultRule(res rego.ResultSet, policy *engine.Policy, rawData *engine. } result.Violations = append(result.Violations, pv) } + + // Parse the optional "suppressed_findings" array. Each entry must be a + // structured finding object using the same schema as `findings`. + if suppressedRaw, ok := ruleResult["suppressed_findings"].([]any); ok { + for _, f := range suppressedRaw { + obj, ok := f.(map[string]any) + if !ok { + return nil, fmt.Errorf("suppressed finding must be an object, got %T", f) + } + pv, err := engine.NewStructuredViolation(policy.Name, obj) + if err != nil { + return nil, fmt.Errorf("suppressed finding in policy %q: %w", policy.Name, err) + } + result.SuppressedFindings = append(result.SuppressedFindings, pv) + } + } } else if violations, ok := ruleResult["violations"].([]any); ok { // Fallback: violations (strings or deprecated structured objects). // TODO: remove structured object support once policies are fully migrated to findings. diff --git a/pkg/policies/engine/rego/rego_test.go b/pkg/policies/engine/rego/rego_test.go index f14f4daaf..951df2dd0 100644 --- a/pkg/policies/engine/rego/rego_test.go +++ b/pkg/policies/engine/rego/rego_test.go @@ -642,6 +642,97 @@ func TestRego_StructuredViolations(t *testing.T) { } } +func TestRego_SuppressedFindings(t *testing.T) { + regoContent, err := os.ReadFile("testfiles/suppressed_findings.rego") + require.NoError(t, err) + + r := NewEngine() + policy := &engine.Policy{ + Name: "suppressed-findings-policy", + Source: regoContent, + } + + tests := []struct { + name string + input string + wantFindings int + wantSuppressed int + checkSuppressed func(t *testing.T, sf []*engine.PolicyViolation) + }{ + { + name: "no suppressed findings when none flagged", + input: `{"vulnerabilities": [{"id": "CVE-2024-1", "purl": "pkg:npm/foo@1.0", "severity": "HIGH"}]}`, + wantFindings: 1, + wantSuppressed: 0, + }, + { + name: "suppressed entries surface separately and also in findings", + input: `{"vulnerabilities": [ + {"id": "CVE-2024-1", "purl": "pkg:npm/foo@1.0", "severity": "HIGH"}, + {"id": "CVE-2024-2", "purl": "pkg:npm/bar@2.0", "severity": "LOW", "suppressed": true, "chainloop_finding_id": "fnd_01J", "chainloop_assessment_ids": ["asm_01J", "asm_02K"]} + ]}`, + wantFindings: 2, + wantSuppressed: 1, + checkSuppressed: func(t *testing.T, sf []*engine.PolicyViolation) { + t.Helper() + v := sf[0] + require.NotNil(t, v.RawFinding) + assert.Equal(t, "CVE-2024-2", v.RawFinding["external_id"]) + assert.Equal(t, "fnd_01J", v.RawFinding["chainloop_finding_id"]) + ids, ok := v.RawFinding["chainloop_assessment_ids"].([]any) + require.True(t, ok, "chainloop_assessment_ids must be a slice, got %T", v.RawFinding["chainloop_assessment_ids"]) + assert.ElementsMatch(t, []any{"asm_01J", "asm_02K"}, ids) + }, + }, + { + name: "every suppressed finding also appears in findings", + input: `{"vulnerabilities": [ + {"id": "CVE-S-1", "purl": "pkg:npm/a@1", "severity": "LOW", "suppressed": true, "chainloop_finding_id": "fnd_a", "chainloop_assessment_ids": ["asm_a"]}, + {"id": "CVE-S-2", "purl": "pkg:npm/b@2", "severity": "LOW", "suppressed": true, "chainloop_finding_id": "fnd_b", "chainloop_assessment_ids": ["asm_b"]} + ]}`, + wantFindings: 2, + wantSuppressed: 2, + checkSuppressed: func(t *testing.T, sf []*engine.PolicyViolation) { + t.Helper() + suppressedIDs := map[string]bool{} + for _, v := range sf { + require.NotNil(t, v.RawFinding) + suppressedIDs[v.RawFinding["external_id"].(string)] = true + } + assert.Equal(t, map[string]bool{"CVE-S-1": true, "CVE-S-2": true}, suppressedIDs) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := r.Verify(context.TODO(), policy, []byte(tc.input), nil) + require.NoError(t, err) + assert.Len(t, result.Violations, tc.wantFindings, "findings count mismatch") + assert.Len(t, result.SuppressedFindings, tc.wantSuppressed, "suppressed_findings count mismatch") + + // Invariant: every suppressed entry must also appear in findings (matched by external_id). + findingIDs := map[string]bool{} + for _, v := range result.Violations { + if v.RawFinding != nil { + if id, ok := v.RawFinding["external_id"].(string); ok { + findingIDs[id] = true + } + } + } + for _, sv := range result.SuppressedFindings { + require.NotNil(t, sv.RawFinding) + id, _ := sv.RawFinding["external_id"].(string) + assert.True(t, findingIDs[id], "suppressed finding %q must also appear in findings", id) + } + + if tc.checkSuppressed != nil { + tc.checkSuppressed(t, result.SuppressedFindings) + } + }) + } +} + func TestRego_VulnerabilityBuiltinIntegration(t *testing.T) { regoContent, err := os.ReadFile("testfiles/structured_vulnerability_builtin.rego") require.NoError(t, err) diff --git a/pkg/policies/engine/rego/testfiles/suppressed_findings.rego b/pkg/policies/engine/rego/testfiles/suppressed_findings.rego new file mode 100644 index 000000000..889516686 --- /dev/null +++ b/pkg/policies/engine/rego/testfiles/suppressed_findings.rego @@ -0,0 +1,56 @@ +package main + +import rego.v1 + +################################ +# Common section do NOT change # +################################ + +result := { + "skipped": skipped, + "findings": findings, + "suppressed_findings": suppressed_findings, + "skip_reason": skip_reason, +} + +default skip_reason := "" + +skip_reason := m if { + not valid_input + m := "invalid input" +} + +default skipped := true + +skipped := false if valid_input + +######################################## +# EO Common section, custom code below # +######################################## + +valid_input := true + +findings contains v if { + some vuln in input.vulnerabilities + v := { + "message": sprintf("Found vulnerability %s", [vuln.id]), + "external_id": vuln.id, + "package_purl": vuln.purl, + "severity": vuln.severity, + } +} + +# A finding is suppressed when the input marks it as suppressed; same shape as +# the corresponding entry in `findings`, plus the chainloop_* correlation fields. +suppressed_findings contains v if { + some vuln in input.vulnerabilities + vuln.suppressed == true + v := { + "message": sprintf("Found vulnerability %s", [vuln.id]), + "external_id": vuln.id, + "package_purl": vuln.purl, + "severity": vuln.severity, + "chainloop_finding_id": vuln.chainloop_finding_id, + "chainloop_assessment_ids": vuln.chainloop_assessment_ids, + } +} diff --git a/pkg/policies/engine/wasm/engine.go b/pkg/policies/engine/wasm/engine.go index 9e46fc33b..bdf97cdd0 100644 --- a/pkg/policies/engine/wasm/engine.go +++ b/pkg/policies/engine/wasm/engine.go @@ -189,11 +189,12 @@ func (e *Engine) Verify(ctx context.Context, policy *engine.Policy, input []byte // Parse output var result struct { - Skipped bool `json:"skipped"` - Violations []json.RawMessage `json:"violations"` - Findings []json.RawMessage `json:"findings"` - SkipReason string `json:"skip_reason"` - Ignore bool `json:"ignore"` + Skipped bool `json:"skipped"` + Violations []json.RawMessage `json:"violations"` + Findings []json.RawMessage `json:"findings"` + SuppressedFindings []json.RawMessage `json:"suppressed_findings"` + SkipReason string `json:"skip_reason"` + Ignore bool `json:"ignore"` } if err := json.Unmarshal(output, &result); err != nil { @@ -218,6 +219,20 @@ func (e *Engine) Verify(ctx context.Context, policy *engine.Policy, input []byte } evalResult.Violations = append(evalResult.Violations, pv) } + + // Parse the optional "suppressed_findings" array. Each entry uses the same + // schema as `findings` and represents a finding the policy chose not to + // surface as a gating violation. + if len(result.SuppressedFindings) > 0 { + evalResult.SuppressedFindings = make([]*engine.PolicyViolation, 0, len(result.SuppressedFindings)) + for _, raw := range result.SuppressedFindings { + pv, err := parseWasmFinding(policy.Name, raw) + if err != nil { + return nil, fmt.Errorf("suppressed finding in policy %q: %w", policy.Name, err) + } + evalResult.SuppressedFindings = append(evalResult.SuppressedFindings, pv) + } + } } else { // Fallback: violations (strings or deprecated structured objects). evalResult.Violations = make([]*engine.PolicyViolation, 0, len(result.Violations)) From bb5af38de86c318ed909f69ac2d3ecaa9c619da9 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Tue, 5 May 2026 17:28:14 +0200 Subject: [PATCH 2/3] fix(policies): handle optional correlation fields safely in suppressed_findings fixture Use object.get with zero-value defaults for chainloop_finding_id and chainloop_assessment_ids. Direct dereferences make the entire entry undefined when either field is missing, which silently drops the suppressed finding. Identified by cubic. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino --- pkg/policies/engine/rego/rego_test.go | 18 ++++++++++++++++++ .../rego/testfiles/suppressed_findings.rego | 6 ++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/pkg/policies/engine/rego/rego_test.go b/pkg/policies/engine/rego/rego_test.go index 951df2dd0..e06453108 100644 --- a/pkg/policies/engine/rego/rego_test.go +++ b/pkg/policies/engine/rego/rego_test.go @@ -684,6 +684,24 @@ func TestRego_SuppressedFindings(t *testing.T) { assert.ElementsMatch(t, []any{"asm_01J", "asm_02K"}, ids) }, }, + { + name: "suppressed entry without correlation fields still surfaces with zero-value defaults", + input: `{"vulnerabilities": [ + {"id": "CVE-2024-3", "purl": "pkg:npm/baz@3.0", "severity": "MEDIUM", "suppressed": true} + ]}`, + wantFindings: 1, + wantSuppressed: 1, + checkSuppressed: func(t *testing.T, sf []*engine.PolicyViolation) { + t.Helper() + v := sf[0] + require.NotNil(t, v.RawFinding) + assert.Equal(t, "CVE-2024-3", v.RawFinding["external_id"]) + assert.Equal(t, "", v.RawFinding["chainloop_finding_id"]) + ids, ok := v.RawFinding["chainloop_assessment_ids"].([]any) + require.True(t, ok, "chainloop_assessment_ids must be a slice, got %T", v.RawFinding["chainloop_assessment_ids"]) + assert.Empty(t, ids) + }, + }, { name: "every suppressed finding also appears in findings", input: `{"vulnerabilities": [ diff --git a/pkg/policies/engine/rego/testfiles/suppressed_findings.rego b/pkg/policies/engine/rego/testfiles/suppressed_findings.rego index 889516686..0ad507572 100644 --- a/pkg/policies/engine/rego/testfiles/suppressed_findings.rego +++ b/pkg/policies/engine/rego/testfiles/suppressed_findings.rego @@ -42,6 +42,8 @@ findings contains v if { # A finding is suppressed when the input marks it as suppressed; same shape as # the corresponding entry in `findings`, plus the chainloop_* correlation fields. +# The correlation fields are read with object.get so that omitted fields fall +# back to safe zero values instead of making the whole entry undefined. suppressed_findings contains v if { some vuln in input.vulnerabilities vuln.suppressed == true @@ -50,7 +52,7 @@ suppressed_findings contains v if { "external_id": vuln.id, "package_purl": vuln.purl, "severity": vuln.severity, - "chainloop_finding_id": vuln.chainloop_finding_id, - "chainloop_assessment_ids": vuln.chainloop_assessment_ids, + "chainloop_finding_id": object.get(vuln, "chainloop_finding_id", ""), + "chainloop_assessment_ids": object.get(vuln, "chainloop_assessment_ids", []), } } From 0bbe6de46fe93a28254ec6342a2563c3f0fbeb82 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Tue, 5 May 2026 18:44:38 +0200 Subject: [PATCH 3/3] feat(policies): treat suppressed_findings as disjoint from findings and surface to CAS Reworks the model so suppressed_findings are findings the policy chose not to count as gating violations: they are disjoint from `findings` and may appear even when `findings` is empty. - Parse suppressed_findings independently of findings in both Rego and WASM engines. - Plumb engine.EvaluationResult.SuppressedFindings through engineEvaluationsToAPIViolations and into a new PolicyEvaluation.suppressed_findings repeated field, so they reach the CAS-stored PolicyEvaluationBundle. - Update fixture and tests to reflect the disjoint invariant. Refs: #3091 Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino --- .../frontend/attestation/v1/crafting_state.ts | 29 +++++ ...tation.v1.PolicyEvaluation.jsonschema.json | 14 +++ ...ttestation.v1.PolicyEvaluation.schema.json | 14 +++ .../api/attestation/v1/crafting_state.pb.go | 86 +++++++------ .../api/attestation/v1/crafting_state.proto | 6 + pkg/policies/engine/engine.go | 9 +- pkg/policies/engine/rego/rego.go | 34 ++--- pkg/policies/engine/rego/rego_test.go | 30 +++-- .../rego/testfiles/suppressed_findings.rego | 11 +- pkg/policies/engine/wasm/engine.go | 29 ++--- pkg/policies/policies.go | 118 +++++++++++------- pkg/policies/policies_test.go | 43 ++++++- 12 files changed, 293 insertions(+), 130 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 247b601f8..32e049614 100644 --- a/app/controlplane/api/gen/frontend/attestation/v1/crafting_state.ts +++ b/app/controlplane/api/gen/frontend/attestation/v1/crafting_state.ts @@ -296,6 +296,13 @@ export interface PolicyEvaluation { rawResults: PolicyEvaluation_RawResult[]; /** Whether the policy evaluation result should block the attestation (inherited from the policy attachment) */ gate: boolean; + /** + * Findings that the policy emitted but chose not to count as gating + * violations (e.g. because a platform-side assessment matched). Disjoint + * from `violations` — a suppressed finding does not also appear in + * `violations`. May be populated even when `violations` is empty. + */ + suppressedFindings: PolicyEvaluation_Violation[]; } export interface PolicyEvaluation_AnnotationsEntry { @@ -2415,6 +2422,7 @@ function createBasePolicyEvaluation(): PolicyEvaluation { requirements: [], rawResults: [], gate: false, + suppressedFindings: [], }; } @@ -2474,6 +2482,9 @@ export const PolicyEvaluation = { if (message.gate === true) { writer.uint32(152).bool(message.gate); } + for (const v of message.suppressedFindings) { + PolicyEvaluation_Violation.encode(v!, writer.uint32(162).fork()).ldelim(); + } return writer; }, @@ -2616,6 +2627,13 @@ export const PolicyEvaluation = { message.gate = reader.bool(); continue; + case 20: + if (tag !== 162) { + break; + } + + message.suppressedFindings.push(PolicyEvaluation_Violation.decode(reader, reader.uint32())); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -2665,6 +2683,9 @@ export const PolicyEvaluation = { ? object.rawResults.map((e: any) => PolicyEvaluation_RawResult.fromJSON(e)) : [], gate: isSet(object.gate) ? Boolean(object.gate) : false, + suppressedFindings: Array.isArray(object?.suppressedFindings) + ? object.suppressedFindings.map((e: any) => PolicyEvaluation_Violation.fromJSON(e)) + : [], }; }, @@ -2722,6 +2743,13 @@ export const PolicyEvaluation = { obj.rawResults = []; } message.gate !== undefined && (obj.gate = message.gate); + if (message.suppressedFindings) { + obj.suppressedFindings = message.suppressedFindings.map((e) => + e ? PolicyEvaluation_Violation.toJSON(e) : undefined + ); + } else { + obj.suppressedFindings = []; + } return obj; }, @@ -2766,6 +2794,7 @@ export const PolicyEvaluation = { message.requirements = object.requirements?.map((e) => e) || []; message.rawResults = object.rawResults?.map((e) => PolicyEvaluation_RawResult.fromPartial(e)) || []; message.gate = object.gate ?? false; + message.suppressedFindings = object.suppressedFindings?.map((e) => PolicyEvaluation_Violation.fromPartial(e)) || []; return message; }, }; diff --git a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.jsonschema.json b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.jsonschema.json index 208de81ec..d240890ec 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.jsonschema.json @@ -34,6 +34,13 @@ "type": "string" }, "type": "array" + }, + "^(suppressed_findings)$": { + "description": "Findings that the policy emitted but chose not to count as gating\n violations (e.g. because a platform-side assessment matched). Disjoint\n from `violations` — a suppressed finding does not also appear in\n `violations`. May be populated even when `violations` is empty.", + "items": { + "$ref": "attestation.v1.PolicyEvaluation.Violation.jsonschema.json" + }, + "type": "array" } }, "properties": { @@ -110,6 +117,13 @@ }, "type": "array" }, + "suppressedFindings": { + "description": "Findings that the policy emitted but chose not to count as gating\n violations (e.g. because a platform-side assessment matched). Disjoint\n from `violations` — a suppressed finding does not also appear in\n `violations`. May be populated even when `violations` is empty.", + "items": { + "$ref": "attestation.v1.PolicyEvaluation.Violation.jsonschema.json" + }, + "type": "array" + }, "type": { "anyOf": [ { diff --git a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.schema.json b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.schema.json index 36f7559a8..98c745f10 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.schema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.schema.json @@ -34,6 +34,13 @@ "type": "string" }, "type": "array" + }, + "^(suppressedFindings)$": { + "description": "Findings that the policy emitted but chose not to count as gating\n violations (e.g. because a platform-side assessment matched). Disjoint\n from `violations` — a suppressed finding does not also appear in\n `violations`. May be populated even when `violations` is empty.", + "items": { + "$ref": "attestation.v1.PolicyEvaluation.Violation.schema.json" + }, + "type": "array" } }, "properties": { @@ -110,6 +117,13 @@ }, "type": "array" }, + "suppressed_findings": { + "description": "Findings that the policy emitted but chose not to count as gating\n violations (e.g. because a platform-side assessment matched). Disjoint\n from `violations` — a suppressed finding does not also appear in\n `violations`. May be populated even when `violations` is empty.", + "items": { + "$ref": "attestation.v1.PolicyEvaluation.Violation.schema.json" + }, + "type": "array" + }, "type": { "anyOf": [ { 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 6f3cc6c8a..d2a8dfd8e 100644 --- a/pkg/attestation/crafter/api/attestation/v1/crafting_state.pb.go +++ b/pkg/attestation/crafter/api/attestation/v1/crafting_state.pb.go @@ -464,9 +464,14 @@ type PolicyEvaluation struct { // Raw inputs and outputs from the policy engine, preserved for debugging. RawResults []*PolicyEvaluation_RawResult `protobuf:"bytes,18,rep,name=raw_results,json=rawResults,proto3" json:"raw_results,omitempty"` // Whether the policy evaluation result should block the attestation (inherited from the policy attachment) - Gate bool `protobuf:"varint,19,opt,name=gate,proto3" json:"gate,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Gate bool `protobuf:"varint,19,opt,name=gate,proto3" json:"gate,omitempty"` + // Findings that the policy emitted but chose not to count as gating + // violations (e.g. because a platform-side assessment matched). Disjoint + // from `violations` — a suppressed finding does not also appear in + // `violations`. May be populated even when `violations` is empty. + SuppressedFindings []*PolicyEvaluation_Violation `protobuf:"bytes,20,rep,name=suppressed_findings,json=suppressedFindings,proto3" json:"suppressed_findings,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *PolicyEvaluation) Reset() { @@ -628,6 +633,13 @@ func (x *PolicyEvaluation) GetGate() bool { return false } +func (x *PolicyEvaluation) GetSuppressedFindings() []*PolicyEvaluation_Violation { + if x != nil { + return x.SuppressedFindings + } + return nil +} + // Bundle of all policy evaluations for an attestation, stored as a CAS object. type PolicyEvaluationBundle struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2788,7 +2800,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\"\xf5\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" + @@ -2812,7 +2824,8 @@ const file_attestation_v1_crafting_state_proto_rawDesc = "" + "\frequirements\x18\x11 \x03(\tR\frequirements\x12K\n" + "\vraw_results\x18\x12 \x03(\v2*.attestation.v1.PolicyEvaluation.RawResultR\n" + "rawResults\x12\x12\n" + - "\x04gate\x18\x13 \x01(\bR\x04gate\x1a>\n" + + "\x04gate\x18\x13 \x01(\bR\x04gate\x12[\n" + + "\x13suppressed_findings\x18\x14 \x03(\v2*.attestation.v1.PolicyEvaluation.ViolationR\x12suppressedFindings\x1a>\n" + "\x10AnnotationsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a7\n" + @@ -3023,37 +3036,38 @@ var file_attestation_v1_crafting_state_proto_depIdxs = []int32{ 30, // 18: attestation.v1.PolicyEvaluation.policy_reference:type_name -> attestation.v1.PolicyEvaluation.Reference 30, // 19: attestation.v1.PolicyEvaluation.group_reference:type_name -> attestation.v1.PolicyEvaluation.Reference 31, // 20: attestation.v1.PolicyEvaluation.raw_results:type_name -> attestation.v1.PolicyEvaluation.RawResult - 4, // 21: attestation.v1.PolicyEvaluationBundle.evaluations:type_name -> attestation.v1.PolicyEvaluation - 35, // 22: attestation.v1.Commit.date:type_name -> google.protobuf.Timestamp - 32, // 23: attestation.v1.Commit.remotes:type_name -> attestation.v1.Commit.Remote - 33, // 24: attestation.v1.Commit.platform_verification:type_name -> attestation.v1.Commit.CommitVerification - 38, // 25: attestation.v1.CraftingState.input_schema:type_name -> workflowcontract.v1.CraftingSchema - 39, // 26: attestation.v1.CraftingState.schema_v2:type_name -> workflowcontract.v1.CraftingSchemaV2 - 2, // 27: attestation.v1.CraftingState.attestation:type_name -> attestation.v1.Attestation - 12, // 28: attestation.v1.WorkflowMetadata.version:type_name -> attestation.v1.ProjectVersion - 34, // 29: attestation.v1.ResourceDescriptor.digest:type_name -> attestation.v1.ResourceDescriptor.DigestEntry - 40, // 30: attestation.v1.ResourceDescriptor.annotations:type_name -> google.protobuf.Struct - 16, // 31: attestation.v1.Attestation.MaterialsEntry.value:type_name -> attestation.v1.Attestation.Material - 22, // 32: attestation.v1.Attestation.Material.string:type_name -> attestation.v1.Attestation.Material.KeyVal - 23, // 33: attestation.v1.Attestation.Material.container_image:type_name -> attestation.v1.Attestation.Material.ContainerImage - 24, // 34: attestation.v1.Attestation.Material.artifact:type_name -> attestation.v1.Attestation.Material.Artifact - 25, // 35: attestation.v1.Attestation.Material.sbom_artifact:type_name -> attestation.v1.Attestation.Material.SBOMArtifact - 35, // 36: attestation.v1.Attestation.Material.added_at:type_name -> google.protobuf.Timestamp - 37, // 37: attestation.v1.Attestation.Material.material_type:type_name -> workflowcontract.v1.CraftingSchema.Material.MaterialType - 21, // 38: attestation.v1.Attestation.Material.annotations:type_name -> attestation.v1.Attestation.Material.AnnotationsEntry - 0, // 39: attestation.v1.Attestation.Auth.type:type_name -> attestation.v1.Attestation.Auth.AuthType - 41, // 40: attestation.v1.Attestation.Material.ContainerImage.has_latest_tag:type_name -> google.protobuf.BoolValue - 24, // 41: attestation.v1.Attestation.Material.SBOMArtifact.artifact:type_name -> attestation.v1.Attestation.Material.Artifact - 26, // 42: attestation.v1.Attestation.Material.SBOMArtifact.main_component:type_name -> attestation.v1.Attestation.Material.SBOMArtifact.MainComponent - 6, // 43: attestation.v1.PolicyEvaluation.Violation.vulnerability:type_name -> attestation.v1.PolicyVulnerabilityFinding - 7, // 44: attestation.v1.PolicyEvaluation.Violation.sast:type_name -> attestation.v1.PolicySASTFinding - 8, // 45: attestation.v1.PolicyEvaluation.Violation.license_violation:type_name -> attestation.v1.PolicyLicenseViolationFinding - 1, // 46: attestation.v1.Commit.CommitVerification.status:type_name -> attestation.v1.Commit.CommitVerification.VerificationStatus - 47, // [47:47] is the sub-list for method output_type - 47, // [47:47] is the sub-list for method input_type - 47, // [47:47] is the sub-list for extension type_name - 47, // [47:47] is the sub-list for extension extendee - 0, // [0:47] is the sub-list for field type_name + 29, // 21: attestation.v1.PolicyEvaluation.suppressed_findings:type_name -> attestation.v1.PolicyEvaluation.Violation + 4, // 22: attestation.v1.PolicyEvaluationBundle.evaluations:type_name -> attestation.v1.PolicyEvaluation + 35, // 23: attestation.v1.Commit.date:type_name -> google.protobuf.Timestamp + 32, // 24: attestation.v1.Commit.remotes:type_name -> attestation.v1.Commit.Remote + 33, // 25: attestation.v1.Commit.platform_verification:type_name -> attestation.v1.Commit.CommitVerification + 38, // 26: attestation.v1.CraftingState.input_schema:type_name -> workflowcontract.v1.CraftingSchema + 39, // 27: attestation.v1.CraftingState.schema_v2:type_name -> workflowcontract.v1.CraftingSchemaV2 + 2, // 28: attestation.v1.CraftingState.attestation:type_name -> attestation.v1.Attestation + 12, // 29: attestation.v1.WorkflowMetadata.version:type_name -> attestation.v1.ProjectVersion + 34, // 30: attestation.v1.ResourceDescriptor.digest:type_name -> attestation.v1.ResourceDescriptor.DigestEntry + 40, // 31: attestation.v1.ResourceDescriptor.annotations:type_name -> google.protobuf.Struct + 16, // 32: attestation.v1.Attestation.MaterialsEntry.value:type_name -> attestation.v1.Attestation.Material + 22, // 33: attestation.v1.Attestation.Material.string:type_name -> attestation.v1.Attestation.Material.KeyVal + 23, // 34: attestation.v1.Attestation.Material.container_image:type_name -> attestation.v1.Attestation.Material.ContainerImage + 24, // 35: attestation.v1.Attestation.Material.artifact:type_name -> attestation.v1.Attestation.Material.Artifact + 25, // 36: attestation.v1.Attestation.Material.sbom_artifact:type_name -> attestation.v1.Attestation.Material.SBOMArtifact + 35, // 37: attestation.v1.Attestation.Material.added_at:type_name -> google.protobuf.Timestamp + 37, // 38: attestation.v1.Attestation.Material.material_type:type_name -> workflowcontract.v1.CraftingSchema.Material.MaterialType + 21, // 39: attestation.v1.Attestation.Material.annotations:type_name -> attestation.v1.Attestation.Material.AnnotationsEntry + 0, // 40: attestation.v1.Attestation.Auth.type:type_name -> attestation.v1.Attestation.Auth.AuthType + 41, // 41: attestation.v1.Attestation.Material.ContainerImage.has_latest_tag:type_name -> google.protobuf.BoolValue + 24, // 42: attestation.v1.Attestation.Material.SBOMArtifact.artifact:type_name -> attestation.v1.Attestation.Material.Artifact + 26, // 43: attestation.v1.Attestation.Material.SBOMArtifact.main_component:type_name -> attestation.v1.Attestation.Material.SBOMArtifact.MainComponent + 6, // 44: attestation.v1.PolicyEvaluation.Violation.vulnerability:type_name -> attestation.v1.PolicyVulnerabilityFinding + 7, // 45: attestation.v1.PolicyEvaluation.Violation.sast:type_name -> attestation.v1.PolicySASTFinding + 8, // 46: attestation.v1.PolicyEvaluation.Violation.license_violation:type_name -> attestation.v1.PolicyLicenseViolationFinding + 1, // 47: attestation.v1.Commit.CommitVerification.status:type_name -> attestation.v1.Commit.CommitVerification.VerificationStatus + 48, // [48:48] is the sub-list for method output_type + 48, // [48:48] is the sub-list for method input_type + 48, // [48:48] is the sub-list for extension type_name + 48, // [48:48] is the sub-list for extension extendee + 0, // [0:48] is the sub-list for field type_name } func init() { file_attestation_v1_crafting_state_proto_init() } diff --git a/pkg/attestation/crafter/api/attestation/v1/crafting_state.proto b/pkg/attestation/crafter/api/attestation/v1/crafting_state.proto index cac22d961..89d8a8df2 100644 --- a/pkg/attestation/crafter/api/attestation/v1/crafting_state.proto +++ b/pkg/attestation/crafter/api/attestation/v1/crafting_state.proto @@ -272,6 +272,12 @@ message PolicyEvaluation { // Whether the policy evaluation result should block the attestation (inherited from the policy attachment) bool gate = 19; + // Findings that the policy emitted but chose not to count as gating + // violations (e.g. because a platform-side assessment matched). Disjoint + // from `violations` — a suppressed finding does not also appear in + // `violations`. May be populated even when `violations` is empty. + repeated Violation suppressed_findings = 20; + message Violation { string subject = 1 [(buf.validate.field).required = true]; string message = 2 [(buf.validate.field).required = true]; diff --git a/pkg/policies/engine/engine.go b/pkg/policies/engine/engine.go index 1af8cd124..3282e78d8 100644 --- a/pkg/policies/engine/engine.go +++ b/pkg/policies/engine/engine.go @@ -142,10 +142,11 @@ type PolicyEngine interface { type EvaluationResult struct { Violations []*PolicyViolation `json:"violations"` - // SuppressedFindings holds findings that the policy emitted in `findings` - // but flagged as suppressed (e.g. because a platform-side assessment matched). - // Every entry here also appears in Violations / findings — this is the - // subset that should not contribute to gating decisions. + // SuppressedFindings holds findings the policy emitted but chose not to + // count as gating violations (e.g. because a platform-side assessment + // matched). They are disjoint from Violations / findings — a suppressed + // finding does not also appear in findings, and a policy can emit + // SuppressedFindings without emitting any findings. SuppressedFindings []*PolicyViolation `json:"suppressedFindings,omitempty"` Skipped bool `json:"skipped"` SkipReason string `json:"skipReason"` diff --git a/pkg/policies/engine/rego/rego.go b/pkg/policies/engine/rego/rego.go index fb83f6532..794722a4a 100644 --- a/pkg/policies/engine/rego/rego.go +++ b/pkg/policies/engine/rego/rego.go @@ -224,22 +224,6 @@ func parseResultRule(res rego.ResultSet, policy *engine.Policy, rawData *engine. } result.Violations = append(result.Violations, pv) } - - // Parse the optional "suppressed_findings" array. Each entry must be a - // structured finding object using the same schema as `findings`. - if suppressedRaw, ok := ruleResult["suppressed_findings"].([]any); ok { - for _, f := range suppressedRaw { - obj, ok := f.(map[string]any) - if !ok { - return nil, fmt.Errorf("suppressed finding must be an object, got %T", f) - } - pv, err := engine.NewStructuredViolation(policy.Name, obj) - if err != nil { - return nil, fmt.Errorf("suppressed finding in policy %q: %w", policy.Name, err) - } - result.SuppressedFindings = append(result.SuppressedFindings, pv) - } - } } else if violations, ok := ruleResult["violations"].([]any); ok { // Fallback: violations (strings or deprecated structured objects). // TODO: remove structured object support once policies are fully migrated to findings. @@ -259,6 +243,24 @@ func parseResultRule(res rego.ResultSet, policy *engine.Policy, rawData *engine. } } // If neither findings nor violations is present, result has zero violations. + + // Parse the optional "suppressed_findings" array independently of findings. + // suppressed_findings are findings that the policy chose not to count as + // gating violations — they're disjoint from `findings` and may appear + // even when `findings` is empty. + if suppressedRaw, ok := ruleResult["suppressed_findings"].([]any); ok { + for _, f := range suppressedRaw { + obj, ok := f.(map[string]any) + if !ok { + return nil, fmt.Errorf("suppressed finding must be an object, got %T", f) + } + pv, err := engine.NewStructuredViolation(policy.Name, obj) + if err != nil { + return nil, fmt.Errorf("suppressed finding in policy %q: %w", policy.Name, err) + } + result.SuppressedFindings = append(result.SuppressedFindings, pv) + } + } } } diff --git a/pkg/policies/engine/rego/rego_test.go b/pkg/policies/engine/rego/rego_test.go index e06453108..fb1a77ef1 100644 --- a/pkg/policies/engine/rego/rego_test.go +++ b/pkg/policies/engine/rego/rego_test.go @@ -660,18 +660,18 @@ func TestRego_SuppressedFindings(t *testing.T) { checkSuppressed func(t *testing.T, sf []*engine.PolicyViolation) }{ { - name: "no suppressed findings when none flagged", + name: "no suppression — vulnerability becomes a finding", input: `{"vulnerabilities": [{"id": "CVE-2024-1", "purl": "pkg:npm/foo@1.0", "severity": "HIGH"}]}`, wantFindings: 1, wantSuppressed: 0, }, { - name: "suppressed entries surface separately and also in findings", + name: "suppressed entries are disjoint from findings", input: `{"vulnerabilities": [ {"id": "CVE-2024-1", "purl": "pkg:npm/foo@1.0", "severity": "HIGH"}, {"id": "CVE-2024-2", "purl": "pkg:npm/bar@2.0", "severity": "LOW", "suppressed": true, "chainloop_finding_id": "fnd_01J", "chainloop_assessment_ids": ["asm_01J", "asm_02K"]} ]}`, - wantFindings: 2, + wantFindings: 1, wantSuppressed: 1, checkSuppressed: func(t *testing.T, sf []*engine.PolicyViolation) { t.Helper() @@ -685,17 +685,25 @@ func TestRego_SuppressedFindings(t *testing.T) { }, }, { - name: "suppressed entry without correlation fields still surfaces with zero-value defaults", + name: "suppressed without any findings", input: `{"vulnerabilities": [ - {"id": "CVE-2024-3", "purl": "pkg:npm/baz@3.0", "severity": "MEDIUM", "suppressed": true} + {"id": "CVE-2024-3", "purl": "pkg:npm/baz@3.0", "severity": "MEDIUM", "suppressed": true, "chainloop_finding_id": "fnd_x", "chainloop_assessment_ids": ["asm_x"]} ]}`, - wantFindings: 1, + wantFindings: 0, + wantSuppressed: 1, + }, + { + name: "suppressed entry without optional correlation fields surfaces with zero-value defaults", + input: `{"vulnerabilities": [ + {"id": "CVE-2024-4", "purl": "pkg:npm/qux@4.0", "severity": "MEDIUM", "suppressed": true} + ]}`, + wantFindings: 0, wantSuppressed: 1, checkSuppressed: func(t *testing.T, sf []*engine.PolicyViolation) { t.Helper() v := sf[0] require.NotNil(t, v.RawFinding) - assert.Equal(t, "CVE-2024-3", v.RawFinding["external_id"]) + assert.Equal(t, "CVE-2024-4", v.RawFinding["external_id"]) assert.Equal(t, "", v.RawFinding["chainloop_finding_id"]) ids, ok := v.RawFinding["chainloop_assessment_ids"].([]any) require.True(t, ok, "chainloop_assessment_ids must be a slice, got %T", v.RawFinding["chainloop_assessment_ids"]) @@ -703,12 +711,12 @@ func TestRego_SuppressedFindings(t *testing.T) { }, }, { - name: "every suppressed finding also appears in findings", + name: "multiple suppressed entries", input: `{"vulnerabilities": [ {"id": "CVE-S-1", "purl": "pkg:npm/a@1", "severity": "LOW", "suppressed": true, "chainloop_finding_id": "fnd_a", "chainloop_assessment_ids": ["asm_a"]}, {"id": "CVE-S-2", "purl": "pkg:npm/b@2", "severity": "LOW", "suppressed": true, "chainloop_finding_id": "fnd_b", "chainloop_assessment_ids": ["asm_b"]} ]}`, - wantFindings: 2, + wantFindings: 0, wantSuppressed: 2, checkSuppressed: func(t *testing.T, sf []*engine.PolicyViolation) { t.Helper() @@ -729,7 +737,7 @@ func TestRego_SuppressedFindings(t *testing.T) { assert.Len(t, result.Violations, tc.wantFindings, "findings count mismatch") assert.Len(t, result.SuppressedFindings, tc.wantSuppressed, "suppressed_findings count mismatch") - // Invariant: every suppressed entry must also appear in findings (matched by external_id). + // Invariant: findings and suppressed_findings are disjoint sets. findingIDs := map[string]bool{} for _, v := range result.Violations { if v.RawFinding != nil { @@ -741,7 +749,7 @@ func TestRego_SuppressedFindings(t *testing.T) { for _, sv := range result.SuppressedFindings { require.NotNil(t, sv.RawFinding) id, _ := sv.RawFinding["external_id"].(string) - assert.True(t, findingIDs[id], "suppressed finding %q must also appear in findings", id) + assert.False(t, findingIDs[id], "suppressed finding %q must NOT also appear in findings", id) } if tc.checkSuppressed != nil { diff --git a/pkg/policies/engine/rego/testfiles/suppressed_findings.rego b/pkg/policies/engine/rego/testfiles/suppressed_findings.rego index 0ad507572..dc3fc092f 100644 --- a/pkg/policies/engine/rego/testfiles/suppressed_findings.rego +++ b/pkg/policies/engine/rego/testfiles/suppressed_findings.rego @@ -30,8 +30,12 @@ skipped := false if valid_input valid_input := true +# A vulnerability becomes a finding (a real violation) only when it is not +# suppressed. Suppressed entries surface in `suppressed_findings` instead and +# do NOT appear in `findings`. findings contains v if { some vuln in input.vulnerabilities + not vuln.suppressed v := { "message": sprintf("Found vulnerability %s", [vuln.id]), "external_id": vuln.id, @@ -40,10 +44,9 @@ findings contains v if { } } -# A finding is suppressed when the input marks it as suppressed; same shape as -# the corresponding entry in `findings`, plus the chainloop_* correlation fields. -# The correlation fields are read with object.get so that omitted fields fall -# back to safe zero values instead of making the whole entry undefined. +# Suppressed findings: same shape as a finding, plus the chainloop_* +# correlation fields. Read with object.get so that omitted optional fields +# fall back to safe zero values instead of making the whole entry undefined. suppressed_findings contains v if { some vuln in input.vulnerabilities vuln.suppressed == true diff --git a/pkg/policies/engine/wasm/engine.go b/pkg/policies/engine/wasm/engine.go index bdf97cdd0..2b963d910 100644 --- a/pkg/policies/engine/wasm/engine.go +++ b/pkg/policies/engine/wasm/engine.go @@ -219,20 +219,6 @@ func (e *Engine) Verify(ctx context.Context, policy *engine.Policy, input []byte } evalResult.Violations = append(evalResult.Violations, pv) } - - // Parse the optional "suppressed_findings" array. Each entry uses the same - // schema as `findings` and represents a finding the policy chose not to - // surface as a gating violation. - if len(result.SuppressedFindings) > 0 { - evalResult.SuppressedFindings = make([]*engine.PolicyViolation, 0, len(result.SuppressedFindings)) - for _, raw := range result.SuppressedFindings { - pv, err := parseWasmFinding(policy.Name, raw) - if err != nil { - return nil, fmt.Errorf("suppressed finding in policy %q: %w", policy.Name, err) - } - evalResult.SuppressedFindings = append(evalResult.SuppressedFindings, pv) - } - } } else { // Fallback: violations (strings or deprecated structured objects). evalResult.Violations = make([]*engine.PolicyViolation, 0, len(result.Violations)) @@ -245,6 +231,21 @@ func (e *Engine) Verify(ctx context.Context, policy *engine.Policy, input []byte } } + // Parse the optional "suppressed_findings" array independently of findings. + // suppressed_findings are findings that the policy chose not to count as + // gating violations — they're disjoint from `findings` and may appear even + // when `findings` is empty. + if len(result.SuppressedFindings) > 0 { + evalResult.SuppressedFindings = make([]*engine.PolicyViolation, 0, len(result.SuppressedFindings)) + for _, raw := range result.SuppressedFindings { + pv, err := parseWasmFinding(policy.Name, raw) + if err != nil { + return nil, fmt.Errorf("suppressed finding in policy %q: %w", policy.Name, err) + } + evalResult.SuppressedFindings = append(evalResult.SuppressedFindings, pv) + } + } + e.logger.Debug().Str("policy", policy.Name).Int("violations", len(evalResult.Violations)).Bool("skipped", evalResult.Skipped).Msg("WASM policy evaluation complete") // Include raw data if requested diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index b1dcdb9e2..dd1112c43 100644 --- a/pkg/policies/policies.go +++ b/pkg/policies/policies.go @@ -390,7 +390,7 @@ func (pv *PolicyVerifier) evaluatePolicyAttachment(ctx context.Context, attachme } findingType := policy.GetMetadata().GetFindingType() - apiViolations, warnings, err := engineEvaluationsToAPIViolations(evalResults, findingType) + apiViolations, suppressedFindings, warnings, err := engineEvaluationsToAPIViolations(evalResults, findingType) if err != nil { return nil, NewPolicyError(fmt.Errorf("policy %q: %w", policy.GetMetadata().GetName(), err)) } @@ -405,13 +405,14 @@ func (pv *PolicyVerifier) evaluatePolicyAttachment(ctx context.Context, attachme MaterialName: opts.name, Sources: evaluationSources, // merge all violations - Violations: apiViolations, - Annotations: policy.GetMetadata().GetAnnotations(), - Description: policy.GetMetadata().GetDescription(), - With: args, - Type: opts.kind, - ReferenceName: ref.GetURI(), - ReferenceDigest: ref.GetDigest(), + Violations: apiViolations, + SuppressedFindings: suppressedFindings, + Annotations: policy.GetMetadata().GetAnnotations(), + Description: policy.GetMetadata().GetDescription(), + With: args, + Type: opts.kind, + ReferenceName: ref.GetURI(), + ReferenceDigest: ref.GetDigest(), PolicyReference: &v12.PolicyEvaluation_Reference{ Name: policy.GetMetadata().GetName(), Digest: ref.GetDigest(), @@ -787,58 +788,87 @@ func splitArgs(s string) []string { return result } -func engineEvaluationsToAPIViolations(results []*engine.EvaluationResult, findingType string) ([]*v12.PolicyEvaluation_Violation, []string, error) { +func engineEvaluationsToAPIViolations(results []*engine.EvaluationResult, findingType string) ([]*v12.PolicyEvaluation_Violation, []*v12.PolicyEvaluation_Violation, []string, error) { res := make([]*v12.PolicyEvaluation_Violation, 0) + suppressed := make([]*v12.PolicyEvaluation_Violation, 0) var warnings []string - warnedNoFindingType := false - warnedNoStructuredData := false + state := &violationConversionState{} for _, r := range results { for _, v := range r.Violations { - apiV := &v12.PolicyEvaluation_Violation{ - Subject: v.Subject, - Message: v.Violation, + apiV, err := policyViolationToAPI(v, findingType, state, &warnings) + if err != nil { + return nil, nil, nil, err } + res = append(res, apiV) + } - hasStructuredData := v.RawFinding != nil + // Suppressed findings are disjoint from violations: same per-entry + // schema, but kept in their own list so consumers can distinguish + // gating violations from informational ones. + for _, v := range r.SuppressedFindings { + apiV, err := policyViolationToAPI(v, findingType, state, &warnings) + if err != nil { + return nil, nil, nil, fmt.Errorf("suppressed finding: %w", err) + } + suppressed = append(suppressed, apiV) + } + } - switch { - case findingType == "" && !hasStructuredData: - // No finding_type, string violation — current behavior + return res, suppressed, warnings, nil +} - case findingType == "" && hasStructuredData: - if !warnedNoFindingType { - warnings = append(warnings, - "policy returns structured violation objects but does not declare finding_type in metadata — finding data will be ignored") - warnedNoFindingType = true - } +// violationConversionState tracks one-shot warning flags so we don't emit the +// same advisory message once per violation when converting a result set. +type violationConversionState struct { + warnedNoFindingType bool + warnedNoStructuredData bool +} - case findingType != "" && !hasStructuredData: - // Policy declares a finding type but this violation is a plain string. - // This can happen when some evaluation branches do not support structured output yet. - // Fall back to treating it as a regular string violation without the typed finding. - if !warnedNoStructuredData { - warnings = append(warnings, - fmt.Sprintf("policy declares finding_type %q but some violations are plain strings — structured finding data will not be available for those", findingType)) - warnedNoStructuredData = true - } +// policyViolationToAPI converts a single engine PolicyViolation to its API +// counterpart. Used for both regular violations and suppressed findings since +// the per-entry schema is identical. +func policyViolationToAPI(v *engine.PolicyViolation, findingType string, state *violationConversionState, warnings *[]string) (*v12.PolicyEvaluation_Violation, error) { + apiV := &v12.PolicyEvaluation_Violation{ + Subject: v.Subject, + Message: v.Violation, + } - case findingType != "" && hasStructuredData: - finding, err := findings.ValidateFinding(findingType, v.RawFinding) - if err != nil { - return nil, nil, fmt.Errorf("structured violation validation: %w", err) - } + hasStructuredData := v.RawFinding != nil - if err := findings.SetViolationFinding(apiV, findingType, finding); err != nil { - return nil, nil, fmt.Errorf("setting violation finding: %w", err) - } - } + switch { + case findingType == "" && !hasStructuredData: + // No finding_type, string violation — current behavior - res = append(res, apiV) + case findingType == "" && hasStructuredData: + if !state.warnedNoFindingType { + *warnings = append(*warnings, + "policy returns structured violation objects but does not declare finding_type in metadata — finding data will be ignored") + state.warnedNoFindingType = true + } + + case findingType != "" && !hasStructuredData: + // Policy declares a finding type but this violation is a plain string. + // This can happen when some evaluation branches do not support structured output yet. + // Fall back to treating it as a regular string violation without the typed finding. + if !state.warnedNoStructuredData { + *warnings = append(*warnings, + fmt.Sprintf("policy declares finding_type %q but some violations are plain strings — structured finding data will not be available for those", findingType)) + state.warnedNoStructuredData = true + } + + case findingType != "" && hasStructuredData: + finding, err := findings.ValidateFinding(findingType, v.RawFinding) + if err != nil { + return nil, fmt.Errorf("structured violation validation: %w", err) + } + + if err := findings.SetViolationFinding(apiV, findingType, finding); err != nil { + return nil, fmt.Errorf("setting violation finding: %w", err) } } - return res, warnings, nil + return apiV, nil } // returns the list of polices to be applied to a material, following these rules: diff --git a/pkg/policies/policies_test.go b/pkg/policies/policies_test.go index bdda2f3b2..dae6de485 100644 --- a/pkg/policies/policies_test.go +++ b/pkg/policies/policies_test.go @@ -1720,7 +1720,7 @@ func (s *testSuite) TestEngineEvaluationsToAPIViolationsBehaviorMatrix() { {Violations: tc.violations}, } - violations, warnings, err := engineEvaluationsToAPIViolations(results, tc.findingType) + violations, _, warnings, err := engineEvaluationsToAPIViolations(results, tc.findingType) if tc.wantErr != "" { s.Require().Error(err) s.Contains(err.Error(), tc.wantErr) @@ -1736,3 +1736,44 @@ func (s *testSuite) TestEngineEvaluationsToAPIViolationsBehaviorMatrix() { }) } } + +func (s *testSuite) TestEngineEvaluationsToAPIViolations_SuppressedFindings() { + rawSuppressed := map[string]any{ + "message": "vuln found", + "external_id": "CVE-2024-9999", + "package_purl": "pkg:golang/example.com/lib@v1.0.0", + "severity": "HIGH", + "chainloop_finding_id": "fnd_abc", + "chainloop_assessment_ids": []any{"asm_1", "asm_2"}, + } + + results := []*engine.EvaluationResult{ + { + Violations: []*engine.PolicyViolation{ + {Subject: "p", Violation: "real violation", RawFinding: map[string]any{ + "message": "real violation", "external_id": "CVE-2024-1", + "package_purl": "pkg:npm/foo@1.0", "severity": "CRITICAL", + }}, + }, + SuppressedFindings: []*engine.PolicyViolation{ + {Subject: "p", Violation: "vuln found", RawFinding: rawSuppressed}, + }, + }, + } + + violations, suppressed, warnings, err := engineEvaluationsToAPIViolations(results, "VULNERABILITY") + s.Require().NoError(err) + s.Empty(warnings) + s.Len(violations, 1) + s.Len(suppressed, 1) + + v := violations[0].GetVulnerability() + s.Require().NotNil(v) + s.Equal("CVE-2024-1", v.GetExternalId()) + + sf := suppressed[0].GetVulnerability() + s.Require().NotNil(sf) + s.Equal("CVE-2024-9999", sf.GetExternalId()) + s.Equal("fnd_abc", sf.GetChainloopFindingId()) + s.Equal([]string{"asm_1", "asm_2"}, sf.GetChainloopAssessmentIds()) +}