Skip to content

Commit 29dce03

Browse files
authored
feat(policies): surface suppressed violations + finding data on describe API (#3105)
Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
1 parent 18a61ae commit 29dce03

28 files changed

Lines changed: 1221 additions & 165 deletions

app/cli/cmd/workflow_workflow_run_describe.go

Lines changed: 143 additions & 17 deletions
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.
@@ -20,12 +20,14 @@ import (
2020
"errors"
2121
"fmt"
2222
"io"
23+
"net/url"
2324
"os"
2425
"strings"
2526
"time"
2627

2728
"github.com/chainloop-dev/chainloop/app/cli/cmd/output"
2829
"github.com/chainloop-dev/chainloop/app/cli/pkg/action"
30+
attv1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
2931
"github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop"
3032
"github.com/jedib0t/go-pretty/v6/table"
3133
"github.com/jedib0t/go-pretty/v6/text"
@@ -221,7 +223,7 @@ func predicateV1Table(att *action.WorkflowRunAttestationItem) {
221223
if len(m.Annotations) > 0 {
222224
mt.AppendRow(table.Row{"Annotations", "------"})
223225
for _, a := range m.Annotations {
224-
mt.AppendRow(table.Row{"", fmt.Sprintf("%s: %s", a.Name, a.Value)})
226+
mt.AppendRow(table.Row{"", wrap.String(fmt.Sprintf("%s: %s", a.Name, a.Value), 100)})
225227
}
226228
}
227229
evs := att.PolicyEvaluations[m.Name]
@@ -266,24 +268,29 @@ func policiesTable(evs []*action.PolicyEvaluation, mt table.Writer, debugMode bo
266268
}
267269
case len(ev.Violations) == 0:
268270
msg = text.Colors{text.FgHiGreen}.Sprint("Ok")
269-
case len(ev.Violations) > 0:
270-
color := text.Colors{text.FgHiRed}
271-
var violations []string
272-
var prefix = ""
271+
default:
272+
hasActive := false
273+
lines := make([]string, 0, len(ev.Violations))
273274
for _, v := range ev.Violations {
274-
violations = append(violations, v.Message)
275-
}
276-
// For multiple violations, we want to indent the list
277-
if len(violations) > 1 {
278-
prefix = "\n - "
275+
color := text.FgHiRed
276+
if v.Suppress {
277+
color = text.FgHiYellow
278+
} else {
279+
hasActive = true
280+
}
281+
lines = append(lines, text.Colors{color}.Sprint(violationSummary(v)))
279282
}
280-
281-
// Color the violations text before joining
282-
for i, v := range violations {
283-
violations[i] = color.Sprint(v)
283+
// When every finding is suppressed the gate passed; lead with
284+
// "Ok" so the row carries the same signal as the no-violations
285+
// case rather than reading like a failure.
286+
switch {
287+
case !hasActive:
288+
msg = text.Colors{text.FgHiGreen}.Sprint("Ok") + "\n - " + strings.Join(lines, "\n - ")
289+
case len(lines) == 1:
290+
msg = lines[0]
291+
default:
292+
msg = "\n - " + strings.Join(lines, "\n - ")
284293
}
285-
286-
msg = prefix + strings.Join(violations, prefix)
287294
}
288295

289296
name := ev.Name
@@ -294,6 +301,125 @@ func policiesTable(evs []*action.PolicyEvaluation, mt table.Writer, debugMode bo
294301
}
295302
}
296303

304+
// violationSummary builds a single-line description of a violation using the
305+
// structured finding when present (CVE id + severity + package + fix info,
306+
// or SAST rule + location, or license + component). Falls back to the first
307+
// line of Message — vuln policies emit a multi-line markdown report there
308+
// which would otherwise break the row layout. The resolved assessment
309+
// status, if any, is appended inside the same severity-parens regardless
310+
// of suppression so AFFECTED / UNDER_INVESTIGATION / etc. surface on
311+
// active findings too.
312+
func violationSummary(v *action.PolicyViolation) string {
313+
statusTag := prettyAssessmentStatus(violationAssessment(v).GetEffectiveStatus())
314+
if statusTag == "" && v.Suppress {
315+
statusTag = "suppressed"
316+
}
317+
318+
switch {
319+
case v.Vulnerability != nil:
320+
f := v.Vulnerability
321+
head := f.GetExternalId()
322+
if tag := joinTag(f.GetSeverity(), statusTag); tag != "" {
323+
head = fmt.Sprintf("%s (%s)", head, tag)
324+
}
325+
if pkg := prettyPurl(f.GetPackagePurl()); pkg != "" {
326+
head += " " + pkg
327+
}
328+
if fix := f.GetFixedVersion(); fix != "" {
329+
head += " [fix: " + fix + "]"
330+
}
331+
return head
332+
case v.Sast != nil:
333+
f := v.Sast
334+
out := f.GetRuleId()
335+
if tag := joinTag(f.GetSeverity(), statusTag); tag != "" {
336+
out = fmt.Sprintf("%s (%s)", out, tag)
337+
}
338+
if loc := f.GetLocation(); loc != "" {
339+
if ln := f.GetLineNumber(); ln > 0 {
340+
out = fmt.Sprintf("%s at %s:%d", out, loc, ln)
341+
} else {
342+
out = fmt.Sprintf("%s at %s", out, loc)
343+
}
344+
}
345+
return out
346+
case v.LicenseViolation != nil:
347+
f := v.LicenseViolation
348+
out := f.GetLicenseId()
349+
if statusTag != "" {
350+
out = fmt.Sprintf("%s (%s)", out, statusTag)
351+
}
352+
if c := f.GetComponentName(); c != "" {
353+
if ver := f.GetComponentVersion(); ver != "" {
354+
out = fmt.Sprintf("%s — %s@%s", out, c, ver)
355+
} else {
356+
out = fmt.Sprintf("%s — %s", out, c)
357+
}
358+
}
359+
return out
360+
}
361+
head := strings.SplitN(strings.TrimSpace(v.Message), "\n", 2)[0]
362+
if head == "" {
363+
head = v.Subject
364+
}
365+
if statusTag != "" {
366+
head = fmt.Sprintf("%s (%s)", head, statusTag)
367+
}
368+
return head
369+
}
370+
371+
// joinTag combines severity and the assessment status into the
372+
// comma-separated parenthetical shown after the finding id.
373+
func joinTag(severity, status string) string {
374+
sev := strings.ToUpper(severity)
375+
switch {
376+
case sev != "" && status != "":
377+
return sev + ", " + status
378+
case sev != "":
379+
return sev
380+
default:
381+
return status
382+
}
383+
}
384+
385+
// violationAssessment returns the assessment attached to whichever structured
386+
// finding the violation carries, or nil. Centralizes the type dispatch.
387+
func violationAssessment(v *action.PolicyViolation) *attv1.PolicyAssessmentResult {
388+
switch {
389+
case v.Vulnerability != nil:
390+
return v.Vulnerability.GetAssessment()
391+
case v.Sast != nil:
392+
return v.Sast.GetAssessment()
393+
case v.LicenseViolation != nil:
394+
return v.LicenseViolation.GetAssessment()
395+
}
396+
return nil
397+
}
398+
399+
func prettyAssessmentStatus(s string) string {
400+
return strings.TrimPrefix(s, "ASSESSMENT_STATUS_")
401+
}
402+
403+
// prettyPurl shortens "pkg:type/[namespace/]name@version[?qualifiers][#sub]"
404+
// to "name@version" with percent-decoding applied — PURL requires `+` in
405+
// version strings to be encoded as %2B, which is unreadable in CLI output.
406+
func prettyPurl(purl string) string {
407+
if purl == "" {
408+
return ""
409+
}
410+
s := strings.TrimPrefix(purl, "pkg:")
411+
if i := strings.IndexAny(s, "?#"); i >= 0 {
412+
s = s[:i]
413+
}
414+
if i := strings.LastIndex(s, "/"); i >= 0 {
415+
s = s[i+1:]
416+
}
417+
if decoded, err := url.PathUnescape(s); err == nil {
418+
s = decoded
419+
}
420+
return s
421+
}
422+
297423
func encodeAttestationOutput(run *action.WorkflowRunItemFull, writer io.Writer) error {
298424
// Try to encode as a table or json
299425
err := output.EncodeOutput(flagOutputFormat, run, workflowRunDescribeTableOutput)

app/cli/cmd/workflow_workflow_run_describe_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ import (
2121
"testing"
2222

2323
"github.com/chainloop-dev/chainloop/app/cli/pkg/action"
24+
attv1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
2425
"github.com/secure-systems-lab/go-securesystemslib/dsse"
26+
"github.com/stretchr/testify/assert"
2527
"github.com/stretchr/testify/suite"
2628
)
2729

@@ -47,6 +49,154 @@ func (s *workflowRunDescribeSuite) SetupTest() {
4749
}
4850
}
4951

52+
func TestViolationSummary(t *testing.T) {
53+
tests := []struct {
54+
name string
55+
violation *action.PolicyViolation
56+
want string
57+
}{
58+
{
59+
name: "vulnerability with severity and package, no fix",
60+
violation: &action.PolicyViolation{
61+
Vulnerability: &attv1.PolicyVulnerabilityFinding{
62+
ExternalId: "CVE-2026-5450",
63+
Severity: "critical",
64+
PackagePurl: "pkg:deb/debian/libc6@2.41-12%2Bdeb13u2",
65+
},
66+
},
67+
want: "CVE-2026-5450 (CRITICAL) libc6@2.41-12+deb13u2",
68+
},
69+
{
70+
name: "vulnerability with fix version",
71+
violation: &action.PolicyViolation{
72+
Vulnerability: &attv1.PolicyVulnerabilityFinding{
73+
ExternalId: "CVE-2024-9999",
74+
Severity: "high",
75+
PackagePurl: "pkg:golang/example.com/lib@v1.0.0",
76+
FixedVersion: "1.0.1",
77+
},
78+
},
79+
want: "CVE-2024-9999 (HIGH) lib@v1.0.0 [fix: 1.0.1]",
80+
},
81+
{
82+
name: "active vulnerability surfaces assessment status (AFFECTED)",
83+
violation: &action.PolicyViolation{
84+
Vulnerability: &attv1.PolicyVulnerabilityFinding{
85+
ExternalId: "CVE-2024-1",
86+
Severity: "high",
87+
PackagePurl: "pkg:deb/debian/libc6@2.41",
88+
Assessment: &attv1.PolicyAssessmentResult{
89+
EffectiveStatus: "ASSESSMENT_STATUS_AFFECTED",
90+
},
91+
},
92+
},
93+
want: "CVE-2024-1 (HIGH, AFFECTED) libc6@2.41",
94+
},
95+
{
96+
name: "active vulnerability surfaces assessment status (UNDER_INVESTIGATION)",
97+
violation: &action.PolicyViolation{
98+
Vulnerability: &attv1.PolicyVulnerabilityFinding{
99+
ExternalId: "CVE-2024-2",
100+
Severity: "medium",
101+
PackagePurl: "pkg:deb/debian/libc6@2.41",
102+
Assessment: &attv1.PolicyAssessmentResult{
103+
EffectiveStatus: "ASSESSMENT_STATUS_UNDER_INVESTIGATION",
104+
},
105+
},
106+
},
107+
want: "CVE-2024-2 (MEDIUM, UNDER_INVESTIGATION) libc6@2.41",
108+
},
109+
{
110+
name: "suppressed vulnerability gets assessment status inline",
111+
violation: &action.PolicyViolation{
112+
Suppress: true,
113+
Vulnerability: &attv1.PolicyVulnerabilityFinding{
114+
ExternalId: "CVE-2018-XXXX",
115+
Severity: "low",
116+
PackagePurl: "pkg:deb/debian/libc6@2.41",
117+
Assessment: &attv1.PolicyAssessmentResult{
118+
EffectiveStatus: "ASSESSMENT_STATUS_NOT_AFFECTED",
119+
},
120+
},
121+
},
122+
want: "CVE-2018-XXXX (LOW, NOT_AFFECTED) libc6@2.41",
123+
},
124+
{
125+
name: "SAST with location and line number",
126+
violation: &action.PolicyViolation{
127+
Sast: &attv1.PolicySASTFinding{
128+
RuleId: "go-sec:G101",
129+
Severity: "medium",
130+
Location: "internal/auth.go",
131+
LineNumber: 42,
132+
},
133+
},
134+
want: "go-sec:G101 (MEDIUM) at internal/auth.go:42",
135+
},
136+
{
137+
name: "license violation with component and version",
138+
violation: &action.PolicyViolation{
139+
LicenseViolation: &attv1.PolicyLicenseViolationFinding{
140+
LicenseId: "GPL-3.0",
141+
ComponentName: "lodash",
142+
ComponentVersion: "4.17.21",
143+
},
144+
},
145+
want: "GPL-3.0 — lodash@4.17.21",
146+
},
147+
{
148+
name: "unstructured policy uses first line of message",
149+
violation: &action.PolicyViolation{
150+
Subject: "repo",
151+
Message: "missing VEX material",
152+
},
153+
want: "missing VEX material",
154+
},
155+
{
156+
name: "unstructured suppressed without assessment falls back to literal tag",
157+
violation: &action.PolicyViolation{
158+
Suppress: true,
159+
Subject: "repo",
160+
Message: "missing VEX material",
161+
},
162+
want: "missing VEX material (suppressed)",
163+
},
164+
{
165+
name: "unstructured multi-line message keeps only first line",
166+
violation: &action.PolicyViolation{
167+
Subject: "x",
168+
Message: "first line\nsecond line\nthird line",
169+
},
170+
want: "first line",
171+
},
172+
{
173+
name: "empty message falls back to subject",
174+
violation: &action.PolicyViolation{
175+
Subject: "missing-tag-annotation",
176+
Message: "",
177+
},
178+
want: "missing-tag-annotation",
179+
},
180+
{
181+
name: "vulnerability with purl qualifiers strips them",
182+
violation: &action.PolicyViolation{
183+
Vulnerability: &attv1.PolicyVulnerabilityFinding{
184+
ExternalId: "CVE-2024-3",
185+
Severity: "high",
186+
PackagePurl: "pkg:deb/debian/libc6@2.41?arch=amd64",
187+
},
188+
},
189+
want: "CVE-2024-3 (HIGH) libc6@2.41",
190+
},
191+
}
192+
193+
for _, tc := range tests {
194+
t.Run(tc.name, func(t *testing.T) {
195+
assert.Equal(t, tc.want, violationSummary(tc.violation))
196+
})
197+
}
198+
}
199+
50200
func (s *workflowRunDescribeSuite) TestOutputTypePayload() {
51201
flagOutputFormat = formatPayloadPAE
52202
expected := "DSSEv1 28 application/vnd.in-toto+json 5 hello"

app/cli/pkg/action/attestation_status.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -366,11 +366,20 @@ func policyEvaluationStateToActionForStatus(in *v1.PolicyEvaluation) *PolicyEval
366366

367367
violations := make([]*PolicyViolation, 0, len(in.Violations))
368368
for _, v := range in.Violations {
369-
violations = append(violations, &PolicyViolation{
369+
out := &PolicyViolation{
370370
Subject: v.Subject,
371371
Message: v.Message,
372372
Suppress: v.GetSuppress(),
373-
})
373+
}
374+
switch f := v.GetFinding().(type) {
375+
case *v1.PolicyEvaluation_Violation_Vulnerability:
376+
out.Vulnerability = f.Vulnerability
377+
case *v1.PolicyEvaluation_Violation_Sast:
378+
out.Sast = f.Sast
379+
case *v1.PolicyEvaluation_Violation_LicenseViolation:
380+
out.LicenseViolation = f.LicenseViolation
381+
}
382+
violations = append(violations, out)
374383
}
375384

376385
return &PolicyEvaluation{

0 commit comments

Comments
 (0)