Skip to content

Commit b417baf

Browse files
authored
feat(cli): expose structured violation data in policy develop eval output (#2969)
Signed-off-by: Miguel Martinez <migmartri@gmail.com> Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
1 parent 73e3e51 commit b417baf

4 files changed

Lines changed: 93 additions & 7 deletions

File tree

app/cli/internal/policydevel/eval.go

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2024-2025 The Chainloop Authors.
1+
// Copyright 2024-2026 The Chainloop Authors.
22
//
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
@@ -25,6 +25,7 @@ import (
2525
"github.com/chainloop-dev/chainloop/pkg/policies"
2626
"github.com/rs/zerolog"
2727
"google.golang.org/grpc"
28+
"google.golang.org/protobuf/encoding/protojson"
2829

2930
v12 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
3031
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials"
@@ -47,9 +48,10 @@ type EvalOptions struct {
4748
}
4849

4950
type EvalResult struct {
50-
Violations []string `json:"violations"`
51-
SkipReasons []string `json:"skip_reasons"`
52-
Skipped bool `json:"skipped"`
51+
Violations []string `json:"violations"`
52+
StructuredViolations []json.RawMessage `json:"structured_violations,omitempty"`
53+
SkipReasons []string `json:"skip_reasons"`
54+
Skipped bool `json:"skipped"`
5355
}
5456

5557
type EvalSummary struct {
@@ -136,9 +138,25 @@ func verifyMaterial(pol *v1.Policies, material *v12.Attestation_Material, materi
136138
},
137139
}
138140

139-
// Collect violation messages
141+
hasStructuredFindings := false
140142
for _, v := range policyEv.Violations {
141143
summary.Result.Violations = append(summary.Result.Violations, v.Message)
144+
if v.GetFinding() != nil {
145+
hasStructuredFindings = true
146+
}
147+
}
148+
149+
// Include structured violations when any violation has finding data
150+
if hasStructuredFindings {
151+
marshaler := protojson.MarshalOptions{UseProtoNames: true}
152+
summary.Result.StructuredViolations = make([]json.RawMessage, 0, len(policyEv.Violations))
153+
for _, v := range policyEv.Violations {
154+
b, err := marshaler.Marshal(v)
155+
if err != nil {
156+
return nil, fmt.Errorf("marshaling structured violation: %w", err)
157+
}
158+
summary.Result.StructuredViolations = append(summary.Result.StructuredViolations, b)
159+
}
142160
}
143161

144162
// Include raw debug info if requested

app/cli/internal/policydevel/eval_test.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2025 The Chainloop Authors.
1+
// Copyright 2025-2026 The Chainloop Authors.
22
//
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
@@ -15,6 +15,7 @@
1515
package policydevel
1616

1717
import (
18+
"encoding/json"
1819
"os"
1920
"path/filepath"
2021
"testing"
@@ -148,6 +149,49 @@ func TestEvaluateSimplifiedPolicies(t *testing.T) {
148149
assert.Contains(t, result.Result.Violations[0], "at least 2 components")
149150
})
150151

152+
t.Run("structured violations populated for policies with finding_type", func(t *testing.T) {
153+
opts := &EvalOptions{
154+
PolicyPath: "testdata/sbom-structured-vuln-policy.yaml",
155+
MaterialPath: sbomPath,
156+
}
157+
158+
result, err := Evaluate(opts, logger)
159+
require.NoError(t, err)
160+
require.NotNil(t, result)
161+
assert.False(t, result.Result.Skipped)
162+
163+
// Both fields populated: violations (messages) and structured_violations (proto JSON)
164+
require.Len(t, result.Result.Violations, 1)
165+
assert.Contains(t, result.Result.Violations[0], "Vulnerability found in test-component@1.0.0")
166+
167+
require.Len(t, result.Result.StructuredViolations, 1)
168+
var sv map[string]any
169+
require.NoError(t, json.Unmarshal(result.Result.StructuredViolations[0], &sv))
170+
assert.Contains(t, sv["message"], "Vulnerability found in test-component@1.0.0")
171+
172+
vuln, ok := sv["vulnerability"].(map[string]any)
173+
require.True(t, ok, "expected vulnerability finding in structured violation")
174+
assert.Equal(t, "CVE-2024-1234", vuln["external_id"])
175+
assert.Equal(t, "pkg:generic/test-component@1.0.0", vuln["package_purl"])
176+
assert.Equal(t, "HIGH", vuln["severity"])
177+
assert.InDelta(t, 7.5, vuln["cvss_v3_score"], 0.001)
178+
})
179+
180+
t.Run("no structured violations for plain string policies", func(t *testing.T) {
181+
opts := &EvalOptions{
182+
PolicyPath: "testdata/sbom-min-components-policy.yaml",
183+
MaterialPath: sbomPath,
184+
}
185+
186+
result, err := Evaluate(opts, logger)
187+
require.NoError(t, err)
188+
require.NotNil(t, result)
189+
require.Len(t, result.Result.Violations, 1)
190+
assert.Contains(t, result.Result.Violations[0], "at least 2 components")
191+
// No structured_violations when policy returns plain strings
192+
assert.Empty(t, result.Result.StructuredViolations)
193+
})
194+
151195
t.Run("sbom metadata component policy", func(t *testing.T) {
152196
opts := &EvalOptions{
153197
PolicyPath: "testdata/sbom-metadata-component-policy.yaml",
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
apiVersion: chainloop.dev/v1
2+
kind: Policy
3+
metadata:
4+
name: sbom-structured-vuln
5+
description: Policy that returns structured vulnerability violations
6+
finding_type: VULNERABILITY
7+
spec:
8+
policies:
9+
- kind: SBOM_CYCLONEDX_JSON
10+
embedded: |
11+
package main
12+
13+
import rego.v1
14+
15+
violations contains v if {
16+
comp := input.components[_]
17+
v := {
18+
"message": sprintf("Vulnerability found in %s@%s", [comp.name, comp.version]),
19+
"external_id": "CVE-2024-1234",
20+
"package_purl": sprintf("pkg:generic/%s@%s", [comp.name, comp.version]),
21+
"severity": "HIGH",
22+
"cvss_v3_score": 7.5,
23+
}
24+
}

app/cli/pkg/action/policy_develop_eval.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2024-2025 The Chainloop Authors.
2+
// Copyright 2024-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.

0 commit comments

Comments
 (0)