Skip to content

Commit ad9c230

Browse files
committed
feat(policies): surface suppressed violations and finding data on describe API
Spec 061. The CAS-stored PolicyEvaluationBundle preserves every violation with full structured finding data, but the cpAPI PolicyViolation message only carried subject+message — assessment context died at the CP→client proto boundary. Extends the wire format and the renderer so the audit-trail view is available on workflow workflow-run describe: - PolicyViolation: adds bool suppress and a oneof finding mirroring the shape on attestation.v1.PolicyEvaluation.Violation. - PolicyStatusSummary: adds int32 suppressed counter so the UI can render a "Suppressed (N)" badge without partitioning client-side. - v02 renderer: groupEvaluations/renderEvaluation now take an includeSuppressed flag — the predicate path keeps PR #3105 semantics (filtered, no finding), while the CAS bundle path preserves suppressed entries and finding pointers. Gate counters always exclude suppressed. - ProvenancePredicateV02: adds PolicySuppressedCount alongside the existing skipped/passed counters. - CP service mapper and CLI action layer propagate the new fields. - policiesTable renders a distinct dim "Suppressed (N):" sub-section per evaluation with assessment status/scope pulled from the structured finding when available — applies uniformly across attestation status / add / workflow workflow-run describe. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev> Chainloop-Trace-Sessions: 5bd2a917-fb7b-400c-9772-60ba6af6c9af, b66717f5-626e-4c20-8d33-59c129b5885d
1 parent 195f278 commit ad9c230

16 files changed

Lines changed: 628 additions & 99 deletions

app/cli/cmd/workflow_workflow_run_describe.go

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626

2727
"github.com/chainloop-dev/chainloop/app/cli/cmd/output"
2828
"github.com/chainloop-dev/chainloop/app/cli/pkg/action"
29+
attv1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
2930
"github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop"
3031
"github.com/jedib0t/go-pretty/v6/table"
3132
"github.com/jedib0t/go-pretty/v6/text"
@@ -253,14 +254,17 @@ func policiesTable(evs []*action.PolicyEvaluation, mt table.Writer, debugMode bo
253254
for _, ev := range evs {
254255
msg := ""
255256

256-
// Suppressed violations stay in the CAS-stored bundle for audit but
257-
// don't surface here — the terminal output mirrors what the gate counts.
258-
violations := make([]string, 0, len(ev.Violations))
257+
// Partition: active violations count toward the gate; suppressed
258+
// entries are kept in the CAS bundle for audit and shown separately
259+
// so operators can see policy decisions without losing context.
260+
var active []string
261+
var suppressed []*action.PolicyViolation
259262
for _, v := range ev.Violations {
260263
if v.Suppress {
264+
suppressed = append(suppressed, v)
261265
continue
262266
}
263-
violations = append(violations, v.Message)
267+
active = append(active, v.Message)
264268
}
265269

266270
switch {
@@ -274,22 +278,26 @@ func policiesTable(evs []*action.PolicyEvaluation, mt table.Writer, debugMode bo
274278
default:
275279
msg = text.Colors{text.FgHiYellow}.Sprint("the policy was skipped in all execution paths")
276280
}
277-
case len(violations) == 0:
281+
case len(active) == 0:
278282
msg = text.Colors{text.FgHiGreen}.Sprint("Ok")
279283
default:
280284
color := text.Colors{text.FgHiRed}
281285
var prefix = ""
282286
// For multiple violations, we want to indent the list
283-
if len(violations) > 1 {
287+
if len(active) > 1 {
284288
prefix = "\n - "
285289
}
286290

287291
// Color the violations text before joining
288-
for i, v := range violations {
289-
violations[i] = color.Sprint(v)
292+
for i, v := range active {
293+
active[i] = color.Sprint(v)
290294
}
291295

292-
msg = prefix + strings.Join(violations, prefix)
296+
msg = prefix + strings.Join(active, prefix)
297+
}
298+
299+
if s := renderSuppressed(suppressed); s != "" {
300+
msg = msg + "\n" + s
293301
}
294302

295303
name := ev.Name
@@ -300,6 +308,72 @@ func policiesTable(evs []*action.PolicyEvaluation, mt table.Writer, debugMode bo
300308
}
301309
}
302310

311+
// renderSuppressed formats a "Suppressed (N)" sub-section listing entries the
312+
// policy excluded from the gate. Each line shows the violation message plus,
313+
// when a structured finding with an assessment is available, the
314+
// precedence-resolved status and scope (e.g. "NOT_AFFECTED, PROJECT") so
315+
// operators can audit suppression decisions without downloading the bundle.
316+
func renderSuppressed(suppressed []*action.PolicyViolation) string {
317+
if len(suppressed) == 0 {
318+
return ""
319+
}
320+
dim := text.Colors{text.FgHiYellow}
321+
lines := make([]string, 0, len(suppressed))
322+
for _, v := range suppressed {
323+
line := v.Message
324+
if a := suppressedAssessment(v); a != "" {
325+
line = fmt.Sprintf("%s — %s", line, a)
326+
}
327+
lines = append(lines, " - "+line)
328+
}
329+
header := dim.Sprintf("Suppressed (%d):", len(suppressed))
330+
return header + "\n" + dim.Sprint(strings.Join(lines, "\n"))
331+
}
332+
333+
// suppressedAssessment extracts the effective assessment status and scope
334+
// from whichever structured finding is attached to the violation, if any.
335+
// Returns the empty string when no assessment is available (unstructured
336+
// policy, or finding without an assessment annotation).
337+
func suppressedAssessment(v *action.PolicyViolation) string {
338+
switch {
339+
case v.Vulnerability != nil && v.Vulnerability.Assessment != nil:
340+
return prettyAssessment(v.Vulnerability.Assessment.GetEffectiveStatus(), assessmentScopes(v.Vulnerability.Assessment.GetAssessments()))
341+
case v.Sast != nil && v.Sast.Assessment != nil:
342+
return prettyAssessment(v.Sast.Assessment.GetEffectiveStatus(), assessmentScopes(v.Sast.Assessment.GetAssessments()))
343+
case v.LicenseViolation != nil && v.LicenseViolation.Assessment != nil:
344+
return prettyAssessment(v.LicenseViolation.Assessment.GetEffectiveStatus(), assessmentScopes(v.LicenseViolation.Assessment.GetAssessments()))
345+
}
346+
return ""
347+
}
348+
349+
func prettyAssessment(status string, scopes []string) string {
350+
s := strings.TrimPrefix(status, "ASSESSMENT_STATUS_")
351+
if s == "" {
352+
return ""
353+
}
354+
if len(scopes) == 0 {
355+
return s
356+
}
357+
return fmt.Sprintf("%s, %s scope", s, strings.Join(scopes, "/"))
358+
}
359+
360+
func assessmentScopes(in []*attv1.PolicyAssessment) []string {
361+
scopes := make([]string, 0, len(in))
362+
seen := make(map[string]struct{}, len(in))
363+
for _, a := range in {
364+
scope := strings.TrimPrefix(a.GetScope(), "ASSESSMENT_SCOPE_")
365+
if scope == "" {
366+
continue
367+
}
368+
if _, dup := seen[scope]; dup {
369+
continue
370+
}
371+
seen[scope] = struct{}{}
372+
scopes = append(scopes, scope)
373+
}
374+
return scopes
375+
}
376+
303377
func encodeAttestationOutput(run *action.WorkflowRunItemFull, writer io.Writer) error {
304378
// Try to encode as a table or json
305379
err := output.EncodeOutput(flagOutputFormat, run, workflowRunDescribeTableOutput)

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{

app/cli/pkg/action/workflow_run_describe.go

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"unicode/utf8"
2727

2828
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
29+
attv1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
2930
"github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop"
3031
"github.com/chainloop-dev/chainloop/pkg/attestation/verifier"
3132
intoto "github.com/in-toto/attestation/go/v1"
@@ -119,6 +120,12 @@ type PolicyViolation struct {
119120
// keeping it in the CAS-stored bundle (audit trail preserved). Set by
120121
// the policy author via the rego "suppress" key.
121122
Suppress bool `json:"suppress,omitempty"`
123+
// Structured finding data from the policy, present when the policy
124+
// declared finding_type. Mirrors the oneof on the wire — exactly one
125+
// pointer is set per violation, or none for unstructured policies.
126+
Vulnerability *attv1.PolicyVulnerabilityFinding `json:"vulnerability,omitempty"`
127+
Sast *attv1.PolicySASTFinding `json:"sast,omitempty"`
128+
LicenseViolation *attv1.PolicyLicenseViolationFinding `json:"license_violation,omitempty"`
122129
}
123130

124131
type PolicyReference struct {
@@ -296,10 +303,20 @@ func policyEvaluationPBToAction(in *pb.PolicyEvaluation) *PolicyEvaluation {
296303
}
297304
violations := make([]*PolicyViolation, 0, len(in.Violations))
298305
for _, v := range in.Violations {
299-
violations = append(violations, &PolicyViolation{
300-
Subject: v.Subject,
301-
Message: v.Message,
302-
})
306+
out := &PolicyViolation{
307+
Subject: v.Subject,
308+
Message: v.Message,
309+
Suppress: v.GetSuppress(),
310+
}
311+
switch f := v.GetFinding().(type) {
312+
case *pb.PolicyViolation_Vulnerability:
313+
out.Vulnerability = f.Vulnerability
314+
case *pb.PolicyViolation_Sast:
315+
out.Sast = f.Sast
316+
case *pb.PolicyViolation_LicenseViolation:
317+
out.LicenseViolation = f.LicenseViolation
318+
}
319+
violations = append(violations, out)
303320
}
304321
return &PolicyEvaluation{
305322
Name: in.Name,

0 commit comments

Comments
 (0)