Skip to content

Commit 234ba8e

Browse files
authored
feat(policies): add description field and split findings for backward compat (#2988)
Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
1 parent 62867c0 commit 234ba8e

19 files changed

Lines changed: 263 additions & 92 deletions

app/cli/internal/policydevel/eval.go

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ type EvalOptions struct {
4949
}
5050

5151
type EvalResult struct {
52-
Violations []json.RawMessage `json:"violations"`
52+
Violations []string `json:"violations"`
53+
Findings []json.RawMessage `json:"findings,omitempty"`
5354
SkipReasons []string `json:"skip_reasons"`
5455
Skipped bool `json:"skipped"`
5556
}
@@ -134,22 +135,29 @@ func verifyMaterial(pol *v1.Policies, material *v12.Attestation_Material, materi
134135
Result: &EvalResult{
135136
Skipped: policyEv.GetSkipped(),
136137
SkipReasons: policyEv.SkipReasons,
137-
Violations: make([]json.RawMessage, 0, len(policyEv.Violations)),
138+
Violations: make([]string, 0, len(policyEv.Violations)),
138139
},
139140
}
140141

141-
// Marshal violations using protojson to match the attestation storage format.
142-
// Subject is cleared since it's redundant in eval context (always the policy name).
142+
// Split violations into string messages and structured findings.
143+
// "violations" contains the message strings (what old CLIs see).
144+
// "findings" contains the full structured data when present.
143145
marshaler := protojson.MarshalOptions{UseProtoNames: true}
144146
for _, v := range policyEv.Violations {
145-
vc := proto.Clone(v).(*v12.PolicyEvaluation_Violation)
146-
vc.Subject = ""
147+
summary.Result.Violations = append(summary.Result.Violations, v.GetMessage())
147148

148-
b, err := marshaler.Marshal(vc)
149-
if err != nil {
150-
return nil, fmt.Errorf("marshaling violation: %w", err)
149+
if f := v.GetFinding(); f != nil {
150+
// Clone to clear subject before marshaling
151+
vc := proto.Clone(v).(*v12.PolicyEvaluation_Violation)
152+
vc.Subject = ""
153+
vc.Message = ""
154+
155+
b, err := marshaler.Marshal(vc)
156+
if err != nil {
157+
return nil, fmt.Errorf("marshaling finding: %w", err)
158+
}
159+
summary.Result.Findings = append(summary.Result.Findings, b)
151160
}
152-
summary.Result.Violations = append(summary.Result.Violations, b)
153161
}
154162

155163
// Include raw debug info if requested

app/cli/internal/policydevel/eval_test.go

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ func TestEvaluateSimplifiedPolicies(t *testing.T) {
149149
assert.Contains(t, string(result.Result.Violations[0]), "at least 2 components")
150150
})
151151

152-
t.Run("violations with finding_type use unified format matching attestation storage", func(t *testing.T) {
152+
t.Run("structured findings are returned separately from string violations", func(t *testing.T) {
153153
opts := &EvalOptions{
154154
PolicyPath: "testdata/sbom-structured-vuln-policy.yaml",
155155
MaterialPath: sbomPath,
@@ -160,23 +160,23 @@ func TestEvaluateSimplifiedPolicies(t *testing.T) {
160160
require.NotNil(t, result)
161161
assert.False(t, result.Result.Skipped)
162162

163-
// Single unified violations field with full violation objects (same as attestation)
163+
// violations contains string messages
164164
require.Len(t, result.Result.Violations, 1)
165-
166-
var v map[string]any
167-
require.NoError(t, json.Unmarshal(result.Result.Violations[0], &v))
168-
assert.Nil(t, v["subject"], "subject should be excluded from eval output")
169-
assert.Contains(t, v["message"], "Vulnerability found in test-component@1.0.0")
170-
171-
vuln, ok := v["vulnerability"].(map[string]any)
172-
require.True(t, ok, "expected vulnerability finding in violation object")
165+
assert.Contains(t, result.Result.Violations[0], "Vulnerability found in test-component@1.0.0")
166+
167+
// findings contains the structured data
168+
require.Len(t, result.Result.Findings, 1)
169+
var f map[string]any
170+
require.NoError(t, json.Unmarshal(result.Result.Findings[0], &f))
171+
vuln, ok := f["vulnerability"].(map[string]any)
172+
require.True(t, ok, "expected vulnerability finding")
173173
assert.Equal(t, "CVE-2024-1234", vuln["external_id"])
174174
assert.Equal(t, "pkg:generic/test-component@1.0.0", vuln["package_purl"])
175175
assert.Equal(t, "HIGH", vuln["severity"])
176176
assert.InDelta(t, 7.5, vuln["cvss_v3_score"], 0.001)
177177
})
178178

179-
t.Run("violations without finding_type use same unified format", func(t *testing.T) {
179+
t.Run("plain string violations have no findings", func(t *testing.T) {
180180
opts := &EvalOptions{
181181
PolicyPath: "testdata/sbom-min-components-policy.yaml",
182182
MaterialPath: sbomPath,
@@ -186,12 +186,8 @@ func TestEvaluateSimplifiedPolicies(t *testing.T) {
186186
require.NoError(t, err)
187187
require.NotNil(t, result)
188188
require.Len(t, result.Result.Violations, 1)
189-
190-
// Same structure as attestation: object with message (subject excluded in eval)
191-
var v map[string]any
192-
require.NoError(t, json.Unmarshal(result.Result.Violations[0], &v))
193-
assert.Nil(t, v["subject"], "subject should be excluded from eval output")
194-
assert.Contains(t, v["message"], "at least 2 components")
189+
assert.Contains(t, result.Result.Violations[0], "at least 2 components")
190+
assert.Empty(t, result.Result.Findings)
195191
})
196192

197193
t.Run("sbom metadata component policy", func(t *testing.T) {

app/controlplane/api/gen/frontend/attestation/v1/crafting_state.ts

Lines changed: 25 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/controlplane/api/gen/jsonschema/attestation.v1.PolicyVulnerabilityFinding.jsonschema.json

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/controlplane/api/gen/jsonschema/attestation.v1.PolicyVulnerabilityFinding.schema.json

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/attestation/crafter/api/attestation/v1/crafting_state.pb.go

Lines changed: 14 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/attestation/crafter/api/attestation/v1/crafting_state.proto

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,8 @@ message PolicyVulnerabilityFinding {
332332
repeated string cwes = 6;
333333
// Suggested fix or upgrade path
334334
string recommendation = 7;
335+
// Optional longer description of the vulnerability
336+
string description = 8;
335337
}
336338

337339
// Output schema for SAST findings from policy evaluation.

pkg/policies/engine/rego/rego.go

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -206,29 +206,43 @@ func parseResultRule(res rego.ResultSet, policy *engine.Policy, rawData *engine.
206206
ignore = val
207207
}
208208

209-
violations, ok := ruleResult["violations"].([]any)
210-
if !ok {
211-
return nil, engine.ResultFormatError{Field: "violations"}
212-
}
213-
214209
result.Skipped = skipped
215210
result.SkipReason = reason
216211
result.Ignore = ignore
217212

218-
for _, violation := range violations {
219-
switch v := violation.(type) {
220-
case string:
221-
result.Violations = append(result.Violations, &engine.PolicyViolation{Subject: policy.Name, Violation: v})
222-
case map[string]any:
223-
pv, err := engine.NewStructuredViolation(policy.Name, v)
213+
// Prefer non-empty "findings" (structured objects) over "violations" for backward compatibility.
214+
findingsRaw, _ := ruleResult["findings"].([]any)
215+
if len(findingsRaw) > 0 {
216+
for _, f := range findingsRaw {
217+
obj, ok := f.(map[string]any)
218+
if !ok {
219+
return nil, fmt.Errorf("finding must be an object, got %T", f)
220+
}
221+
pv, err := engine.NewStructuredViolation(policy.Name, obj)
224222
if err != nil {
225-
return nil, fmt.Errorf("structured violation in policy %q: %w", policy.Name, err)
223+
return nil, fmt.Errorf("structured finding in policy %q: %w", policy.Name, err)
226224
}
227225
result.Violations = append(result.Violations, pv)
228-
default:
229-
return nil, fmt.Errorf("violation must be a string or object, got %T", violation)
226+
}
227+
} else if violations, ok := ruleResult["violations"].([]any); ok {
228+
// Fallback: violations (strings or deprecated structured objects).
229+
// TODO: remove structured object support once policies are fully migrated to findings.
230+
for _, violation := range violations {
231+
switch v := violation.(type) {
232+
case string:
233+
result.Violations = append(result.Violations, &engine.PolicyViolation{Subject: policy.Name, Violation: v})
234+
case map[string]any:
235+
pv, err := engine.NewStructuredViolation(policy.Name, v)
236+
if err != nil {
237+
return nil, fmt.Errorf("structured violation in policy %q: %w", policy.Name, err)
238+
}
239+
result.Violations = append(result.Violations, pv)
240+
default:
241+
return nil, fmt.Errorf("violation must be a string or object, got %T", violation)
242+
}
230243
}
231244
}
245+
// If neither findings nor violations is present, result has zero violations.
232246
}
233247
}
234248

pkg/policies/engine/rego/rego_test.go

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -544,7 +544,7 @@ func TestRego_StructuredViolations(t *testing.T) {
544544
checkFn func(t *testing.T, result *engine.EvaluationResult)
545545
}{
546546
{
547-
name: "structured violations with message field",
547+
name: "structured findings with message field",
548548
fixture: "testfiles/structured_violations.rego",
549549
input: `{"vulnerabilities": [{"id": "CVE-2024-1234", "severity": "CRITICAL", "purl": "pkg:golang/example.com/lib@v1.0.0"}]}`,
550550
wantViolations: 1,
@@ -560,43 +560,45 @@ func TestRego_StructuredViolations(t *testing.T) {
560560
},
561561
},
562562
{
563-
name: "structured violations without message field errors",
563+
name: "structured findings without message field errors",
564564
fixture: "testfiles/structured_no_message.rego",
565565
input: `{"vulnerabilities": [{"id": "CVE-2024-1234", "severity": "HIGH"}]}`,
566-
wantErr: `missing required "message" field`,
566+
wantErr: `structured finding in policy`,
567567
},
568568
{
569-
name: "mixed string and structured violations",
569+
name: "findings takes precedence over violations",
570570
fixture: "testfiles/mixed_violations.rego",
571571
input: `{"has_string_violation": true, "vulnerabilities": [{"id": "CVE-2024-5678", "severity": "HIGH"}]}`,
572-
wantViolations: 2,
572+
wantViolations: 1, // only findings, string violations ignored
573573
checkFn: func(t *testing.T, result *engine.EvaluationResult) {
574574
t.Helper()
575-
var stringViolation, structuredViolation *engine.PolicyViolation
576-
for _, v := range result.Violations {
577-
if v.RawFinding == nil {
578-
stringViolation = v
579-
} else {
580-
structuredViolation = v
581-
}
582-
}
583-
require.NotNil(t, stringViolation, "expected a string violation")
584-
assert.Equal(t, "simple string violation", stringViolation.Violation)
585-
assert.Nil(t, stringViolation.RawFinding)
586-
587-
require.NotNil(t, structuredViolation, "expected a structured violation")
588-
assert.Equal(t, "Found vulnerability CVE-2024-5678", structuredViolation.Violation)
589-
assert.Equal(t, "CVE-2024-5678", structuredViolation.RawFinding["external_id"])
575+
v := result.Violations[0]
576+
require.NotNil(t, v.RawFinding)
577+
assert.Equal(t, "Found vulnerability CVE-2024-5678", v.Violation)
578+
assert.Equal(t, "CVE-2024-5678", v.RawFinding["external_id"])
579+
},
580+
},
581+
{
582+
name: "deprecated structured objects in violations still work",
583+
fixture: "testfiles/deprecated_structured_violations.rego",
584+
input: `{"vulnerabilities": [{"id": "CVE-2024-1234", "severity": "CRITICAL", "purl": "pkg:golang/example.com/lib@v1.0.0"}]}`,
585+
wantViolations: 1,
586+
checkFn: func(t *testing.T, result *engine.EvaluationResult) {
587+
t.Helper()
588+
v := result.Violations[0]
589+
assert.Equal(t, "Found vulnerability CVE-2024-1234 (CRITICAL)", v.Violation)
590+
assert.NotNil(t, v.RawFinding)
591+
assert.Equal(t, "CVE-2024-1234", v.RawFinding["external_id"])
590592
},
591593
},
592594
{
593-
name: "no violations with structured policy",
595+
name: "no findings with structured policy",
594596
fixture: "testfiles/structured_violations.rego",
595597
input: `{"vulnerabilities": []}`,
596598
wantViolations: 0,
597599
},
598600
{
599-
name: "multiple structured violations",
601+
name: "multiple structured findings",
600602
fixture: "testfiles/structured_violations.rego",
601603
input: `{"vulnerabilities": [{"id": "CVE-2024-1", "severity": "HIGH", "purl": "pkg:npm/foo@1.0"}, {"id": "CVE-2024-2", "severity": "LOW", "purl": "pkg:npm/bar@2.0"}]}`,
602604
wantViolations: 2,

0 commit comments

Comments
 (0)