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+
297423func encodeAttestationOutput (run * action.WorkflowRunItemFull , writer io.Writer ) error {
298424 // Try to encode as a table or json
299425 err := output .EncodeOutput (flagOutputFormat , run , workflowRunDescribeTableOutput )
0 commit comments