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",