From 8be95e4dac8f551b778e5d56f4f72afd8004c584 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 16 Sep 2026 23:56:58 +0200 Subject: [PATCH] feat(attestation): add optional location field to PolicyVulnerabilityFinding Vulnerability scanners report one match per location where a package is found, but PolicyVulnerabilityFinding had no field to carry it, so copies of the same package within one artifact were indistinguishable. Add an optional location field mirroring the existing PolicySASTFinding.location. It holds a file path where one is available, falling back to the SBOM component reference otherwise. Policies that do not report per-copy findings omit it, so existing policies are unaffected. The CLI now surfaces the location alongside the package when describing a workflow run. Closes #3446 Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 51d701fa-1ce9-4445-84f7-a0926015258c --- app/cli/cmd/workflow_workflow_run_describe.go | 11 +++++--- .../workflow_workflow_run_describe_test.go | 13 ++++++++++ .../frontend/attestation/v1/crafting_state.ts | 25 ++++++++++++++++++- ...PolicyVulnerabilityFinding.jsonschema.json | 4 +++ ....v1.PolicyVulnerabilityFinding.schema.json | 4 +++ .../api/attestation/v1/crafting_state.pb.go | 19 +++++++++++--- .../api/attestation/v1/crafting_state.proto | 5 ++++ .../rego/builtins/vulnerability_test.go | 17 +++++++++++++ pkg/policies/findings/registry_test.go | 21 ++++++++++++++++ 9 files changed, 112 insertions(+), 7 deletions(-) diff --git a/app/cli/cmd/workflow_workflow_run_describe.go b/app/cli/cmd/workflow_workflow_run_describe.go index 34c7ad5b2..6a8443e31 100644 --- a/app/cli/cmd/workflow_workflow_run_describe.go +++ b/app/cli/cmd/workflow_workflow_run_describe.go @@ -368,9 +368,9 @@ func downloadPolicyEvaluationsHint(ref *action.PolicyEvaluationsRef) string { } // violationSummary builds a single-line description of a violation using the -// structured finding when present (CVE id + severity + package + fix info, -// or SAST rule + location, or license + component). Falls back to the first -// line of Message — vuln policies emit a multi-line markdown report there +// structured finding when present (CVE id + severity + package + location + +// fix info, or SAST rule + location, or license + component). Falls back to +// the first line of Message — vuln policies emit a multi-line markdown report there // which would otherwise break the row layout. The resolved assessment // status, if any, is appended inside the same severity-parens regardless // of suppression so AFFECTED / UNDER_INVESTIGATION / etc. surface on @@ -391,6 +391,11 @@ func violationSummary(v *action.PolicyViolation) string { if pkg := prettyPurl(f.GetPackagePurl()); pkg != "" { head += " " + pkg } + // Only set when the policy reports per-copy findings; it is what tells + // two findings for the same package in the same artifact apart. + if loc := f.GetLocation(); loc != "" { + head += " at " + loc + } if fix := f.GetFixedVersion(); fix != "" { head += " [fix: " + fix + "]" } diff --git a/app/cli/cmd/workflow_workflow_run_describe_test.go b/app/cli/cmd/workflow_workflow_run_describe_test.go index 59b4c5337..6f8e5e1ca 100644 --- a/app/cli/cmd/workflow_workflow_run_describe_test.go +++ b/app/cli/cmd/workflow_workflow_run_describe_test.go @@ -80,6 +80,19 @@ func TestViolationSummary(t *testing.T) { }, want: "CVE-2024-9999 (HIGH) lib@v1.0.0 [fix: 1.0.1]", }, + { + name: "vulnerability with location", + violation: &action.PolicyViolation{ + Vulnerability: &attv1.PolicyVulnerabilityFinding{ + ExternalId: "CVE-2024-9999", + Severity: "high", + PackagePurl: "pkg:maven/com.example/lib@1.0.0", + Location: "/opt/app/lib/ext/lib-1.0.0.jar", + FixedVersion: "1.0.1", + }, + }, + want: "CVE-2024-9999 (HIGH) lib@1.0.0 at /opt/app/lib/ext/lib-1.0.0.jar [fix: 1.0.1]", + }, { name: "active vulnerability surfaces assessment status (AFFECTED)", violation: &action.PolicyViolation{ 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 dd0183705..1c210da9b 100644 --- a/app/controlplane/api/gen/frontend/attestation/v1/crafting_state.ts +++ b/app/controlplane/api/gen/frontend/attestation/v1/crafting_state.ts @@ -381,7 +381,16 @@ export interface PolicyVulnerabilityFinding { /** Version that fixes the vulnerability (e.g., "2.0.1", "1.3.4-patch1") */ fixedVersion: string; /** Optional assessment context. See PolicyAssessmentResult. */ - assessment?: PolicyAssessmentResult | undefined; + assessment?: + | PolicyAssessmentResult + | undefined; + /** + * Optional location of the affected component copy — a file path + * (e.g., "/opt/app/lib/ext/lib-1.2.3.jar") or, when no path is available, + * the SBOM component reference. Distinguishes multiple copies of the same + * package within a single artifact. + */ + location: string; } /** @@ -3362,6 +3371,7 @@ function createBasePolicyVulnerabilityFinding(): PolicyVulnerabilityFinding { description: "", fixedVersion: "", assessment: undefined, + location: "", }; } @@ -3397,6 +3407,9 @@ export const PolicyVulnerabilityFinding = { if (message.assessment !== undefined) { PolicyAssessmentResult.encode(message.assessment, writer.uint32(82).fork()).ldelim(); } + if (message.location !== "") { + writer.uint32(90).string(message.location); + } return writer; }, @@ -3477,6 +3490,13 @@ export const PolicyVulnerabilityFinding = { message.assessment = PolicyAssessmentResult.decode(reader, reader.uint32()); continue; + case 11: + if (tag !== 90) { + break; + } + + message.location = reader.string(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -3498,6 +3518,7 @@ export const PolicyVulnerabilityFinding = { description: isSet(object.description) ? String(object.description) : "", fixedVersion: isSet(object.fixedVersion) ? String(object.fixedVersion) : "", assessment: isSet(object.assessment) ? PolicyAssessmentResult.fromJSON(object.assessment) : undefined, + location: isSet(object.location) ? String(object.location) : "", }; }, @@ -3518,6 +3539,7 @@ export const PolicyVulnerabilityFinding = { message.fixedVersion !== undefined && (obj.fixedVersion = message.fixedVersion); message.assessment !== undefined && (obj.assessment = message.assessment ? PolicyAssessmentResult.toJSON(message.assessment) : undefined); + message.location !== undefined && (obj.location = message.location); return obj; }, @@ -3539,6 +3561,7 @@ export const PolicyVulnerabilityFinding = { message.assessment = (object.assessment !== undefined && object.assessment !== null) ? PolicyAssessmentResult.fromPartial(object.assessment) : undefined; + message.location = object.location ?? ""; return message; }, }; 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 f1b62b62c..f7bbf1943 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyVulnerabilityFinding.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyVulnerabilityFinding.jsonschema.json @@ -69,6 +69,10 @@ "description": "Version that fixes the vulnerability (e.g., \"2.0.1\", \"1.3.4-patch1\")", "type": "string" }, + "location": { + "description": "Optional location of the affected component copy — a file path\n (e.g., \"/opt/app/lib/ext/lib-1.2.3.jar\") or, when no path is available,\n the SBOM component reference. Distinguishes multiple copies of the same\n package within a single artifact.", + "type": "string" + }, "message": { "description": "Human-readable violation description", "type": "string" 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 5c7953d14..3001124ef 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyVulnerabilityFinding.schema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyVulnerabilityFinding.schema.json @@ -69,6 +69,10 @@ "description": "Version that fixes the vulnerability (e.g., \"2.0.1\", \"1.3.4-patch1\")", "type": "string" }, + "location": { + "description": "Optional location of the affected component copy — a file path\n (e.g., \"/opt/app/lib/ext/lib-1.2.3.jar\") or, when no path is available,\n the SBOM component reference. Distinguishes multiple copies of the same\n package within a single artifact.", + "type": "string" + }, "message": { "description": "Human-readable violation description", "type": "string" 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 fada6ff59..b5a46a8ec 100644 --- a/pkg/attestation/crafter/api/attestation/v1/crafting_state.pb.go +++ b/pkg/attestation/crafter/api/attestation/v1/crafting_state.pb.go @@ -718,7 +718,12 @@ type PolicyVulnerabilityFinding struct { // 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"` // Optional assessment context. See PolicyAssessmentResult. - Assessment *PolicyAssessmentResult `protobuf:"bytes,10,opt,name=assessment,proto3,oneof" json:"assessment,omitempty"` + Assessment *PolicyAssessmentResult `protobuf:"bytes,10,opt,name=assessment,proto3,oneof" json:"assessment,omitempty"` + // Optional location of the affected component copy — a file path + // (e.g., "/opt/app/lib/ext/lib-1.2.3.jar") or, when no path is available, + // the SBOM component reference. Distinguishes multiple copies of the same + // package within a single artifact. + Location string `protobuf:"bytes,11,opt,name=location,proto3" json:"location,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -823,6 +828,13 @@ func (x *PolicyVulnerabilityFinding) GetAssessment() *PolicyAssessmentResult { return nil } +func (x *PolicyVulnerabilityFinding) GetLocation() string { + if x != nil { + return x.Location + } + return "" +} + // Output schema for SAST findings from policy evaluation. // Used when a policy declares finding_type: SAST. type PolicySASTFinding struct { @@ -2965,7 +2977,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\"\xd2\x03\n" + + "\vevaluations\x18\x01 \x03(\v2 .attestation.v1.PolicyEvaluationR\vevaluations\"\xee\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" + @@ -2980,7 +2992,8 @@ const file_attestation_v1_crafting_state_proto_rawDesc = "" + "\n" + "assessment\x18\n" + " \x01(\v2&.attestation.v1.PolicyAssessmentResultH\x00R\n" + - "assessment\x88\x01\x01B\r\n" + + "assessment\x88\x01\x01\x12\x1a\n" + + "\blocation\x18\v \x01(\tR\blocationB\r\n" + "\v_assessment\"\xa5\x03\n" + "\x11PolicySASTFinding\x12 \n" + "\amessage\x18\x01 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\amessage\x12\x1f\n" + diff --git a/pkg/attestation/crafter/api/attestation/v1/crafting_state.proto b/pkg/attestation/crafter/api/attestation/v1/crafting_state.proto index 58a37d38d..8849482e2 100644 --- a/pkg/attestation/crafter/api/attestation/v1/crafting_state.proto +++ b/pkg/attestation/crafter/api/attestation/v1/crafting_state.proto @@ -355,6 +355,11 @@ message PolicyVulnerabilityFinding { string fixed_version = 9; // Optional assessment context. See PolicyAssessmentResult. optional PolicyAssessmentResult assessment = 10; + // Optional location of the affected component copy — a file path + // (e.g., "/opt/app/lib/ext/lib-1.2.3.jar") or, when no path is available, + // the SBOM component reference. Distinguishes multiple copies of the same + // package within a single artifact. + string location = 11; } // Output schema for SAST findings from policy evaluation. diff --git a/pkg/policies/engine/rego/builtins/vulnerability_test.go b/pkg/policies/engine/rego/builtins/vulnerability_test.go index ff5404e7f..fb3ee6d10 100644 --- a/pkg/policies/engine/rego/builtins/vulnerability_test.go +++ b/pkg/policies/engine/rego/builtins/vulnerability_test.go @@ -77,6 +77,23 @@ result := object.union( "fixed_version": "1.0.1", }, }, + { + name: "combined with object.union for location", + policy: `package test +import rego.v1 + +result := object.union( + chainloop.vulnerability("CVE found", "CVE-2024-5678", "pkg:maven/com.example/lib@1.0.0", "HIGH"), + {"location": "/opt/app/lib/ext/lib-1.0.0.jar"}, +)`, + expected: map[string]any{ + "message": "CVE found", + "external_id": "CVE-2024-5678", + "package_purl": "pkg:maven/com.example/lib@1.0.0", + "severity": "HIGH", + "location": "/opt/app/lib/ext/lib-1.0.0.jar", + }, + }, { name: "with sprintf interpolation", policy: `package test diff --git a/pkg/policies/findings/registry_test.go b/pkg/policies/findings/registry_test.go index ef0ba7ac0..10fdf7faa 100644 --- a/pkg/policies/findings/registry_test.go +++ b/pkg/policies/findings/registry_test.go @@ -115,6 +115,27 @@ func TestValidateFinding(t *testing.T) { assert.Equal(t, "1.0.1", f.GetFixedVersion()) }, }, + { + name: "valid vulnerability finding with location", + findingType: "VULNERABILITY", + raw: map[string]any{ + "message": "Found CVE-2024-5678", + "external_id": "CVE-2024-5678", + "package_purl": "pkg:maven/com.example/lib@1.0.0", + "severity": "HIGH", + "location": "/opt/app/lib/ext/lib-1.0.0.jar", + }, + checkFn: func(t *testing.T, msg interface{}) { + t.Helper() + f, ok := msg.(*v1.PolicyVulnerabilityFinding) + require.True(t, ok) + assert.Equal(t, "Found CVE-2024-5678", f.GetMessage()) + assert.Equal(t, "CVE-2024-5678", f.GetExternalId()) + assert.Equal(t, "pkg:maven/com.example/lib@1.0.0", f.GetPackagePurl()) + assert.Equal(t, "HIGH", f.GetSeverity()) + assert.Equal(t, "/opt/app/lib/ext/lib-1.0.0.jar", f.GetLocation()) + }, + }, { name: "vulnerability finding missing required field", findingType: "VULNERABILITY",