Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 68 additions & 5 deletions app/cli/cmd/workflow_workflow_run_describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"strings"
"time"

"code.cloudfoundry.org/bytefmt"
"github.com/chainloop-dev/chainloop/app/cli/cmd/output"
"github.com/chainloop-dev/chainloop/app/cli/pkg/action"
attv1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
Expand Down Expand Up @@ -174,11 +175,7 @@ func workflowRunDescribeTableOutput(run *action.WorkflowRunItemFull) error {
gt.AppendRow(table.Row{"Policy enforcement bypassed", att.PolicyEvaluationStatus.Bypassed})
}

evs := att.PolicyEvaluations[chainloop.AttPolicyEvaluation]
if len(evs) > 0 {
gt.AppendRow(table.Row{"Policies", "------"})
policiesTable(evs, gt, flagDebug)
}
appendPolicySection(att, gt, flagDebug)

if run.Attestation.AttestationViewURL != "" {
gt.AppendRow(table.Row{"Attestation View URL", run.Attestation.AttestationViewURL})
Expand Down Expand Up @@ -301,6 +298,72 @@ func policiesTable(evs []*action.PolicyEvaluation, mt table.Writer, debugMode bo
}
}

// appendPolicySection renders the attestation-level policies, either as the
// usual per-policy rows or, when the server declined to inline the
// evaluations, as a notice pointing at the bundle in the CAS backend.
func appendPolicySection(att *action.WorkflowRunAttestationItem, gt table.Writer, debugMode bool) {
if notice := policyEvaluationsRefNotice(att.PolicyEvaluationsRef, att.PolicyEvaluationStatus); notice != nil {
gt.AppendRow(table.Row{"Policies", "------"})
for _, line := range notice {
gt.AppendRow(table.Row{"", line})
}

return
}

evs := att.PolicyEvaluations[chainloop.AttPolicyEvaluation]
if len(evs) == 0 {
return
}

gt.AppendRow(table.Row{"Policies", "------"})
policiesTable(evs, gt, debugMode)
}

// policyEvaluationsRefNotice renders the lines shown in place of the policy
// table when the server returned a reference instead of the evaluations. It
// leads with the counters, which stay accurate no matter how large the bundle
// is, and only offers a download when the bundle is known to be there.
func policyEvaluationsRefNotice(ref *action.PolicyEvaluationsRef, status *action.PolicyEvaluationStatus) []string {
if ref == nil {
return nil
}

reason := "could not be retrieved from the CAS backend"
if ref.Reason == action.PolicyEvaluationsRefReasonTooLarge {
reason = "too large to include inline"
if ref.SizeBytes > 0 {
reason = fmt.Sprintf("%s (%s)", reason, bytefmt.ByteSize(uint64(ref.SizeBytes)))
}
}

// Without counters there is nothing to summarize, so name the subject instead.
if status == nil {
lines := []string{fmt.Sprintf("policy evaluations %s", reason)}
if ref.Reason == action.PolicyEvaluationsRefReasonTooLarge {
lines = append(lines, downloadPolicyEvaluationsHint(ref))
}

return lines
}

counters := fmt.Sprintf("%d evaluations, %d violations", status.Total, status.Violated)
if status.Suppressed > 0 {
counters = fmt.Sprintf("%s (%d suppressed)", counters, status.Suppressed)
}

lines := []string{fmt.Sprintf("%s - %s", counters, reason)}
if ref.Reason == action.PolicyEvaluationsRefReasonTooLarge {
lines = append(lines, downloadPolicyEvaluationsHint(ref))
}

return lines
}

func downloadPolicyEvaluationsHint(ref *action.PolicyEvaluationsRef) string {
return fmt.Sprintf("inspect with: chainloop artifact download --digest %s", ref.Digest)
}

// violationSummary builds a single-line description of a violation using the
// structured finding when present (CVE id + severity + package + fix info,
// or SAST rule + location, or license + component). Falls back to the first
Expand Down
147 changes: 146 additions & 1 deletion app/cli/cmd/workflow_workflow_run_describe_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2024 The Chainloop Authors.
// Copyright 2024-2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand All @@ -22,6 +22,8 @@ import (

"github.com/chainloop-dev/chainloop/app/cli/pkg/action"
attv1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
"github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/secure-systems-lab/go-securesystemslib/dsse"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
Expand Down Expand Up @@ -208,3 +210,146 @@ func (s *workflowRunDescribeSuite) TestOutputTypePayload() {
s.Require().NoError(err)
s.Equal(expected, buf.String())
}

const (
testRefDigest = "sha256:abc123"
testRefDownloadHint = "inspect with: chainloop artifact download --digest " + testRefDigest
policiesRowLabel = "Policies"
)

func TestPolicyEvaluationsRefNotice(t *testing.T) {
tests := []struct {
name string
ref *action.PolicyEvaluationsRef
status *action.PolicyEvaluationStatus
want []string
}{
{
name: "no reference produces no notice",
},
{
name: "oversized bundle reports counters, size and how to fetch it",
ref: &action.PolicyEvaluationsRef{
Digest: testRefDigest,
SizeBytes: 64 * 1024 * 1024,
Reason: action.PolicyEvaluationsRefReasonTooLarge,
},
status: &action.PolicyEvaluationStatus{Total: 12, Violated: 134112, Suppressed: 86321},
want: []string{
"12 evaluations, 134112 violations (86321 suppressed) - too large to include inline (64M)",
testRefDownloadHint,
},
},
{
name: "oversized bundle with nothing suppressed omits the suppressed count",
ref: &action.PolicyEvaluationsRef{
Digest: testRefDigest,
SizeBytes: 3 * 1024 * 1024,
Reason: action.PolicyEvaluationsRefReasonTooLarge,
},
status: &action.PolicyEvaluationStatus{Total: 2, Violated: 40},
want: []string{
"2 evaluations, 40 violations - too large to include inline (3M)",
testRefDownloadHint,
},
},
{
name: "unknown size omits the size",
ref: &action.PolicyEvaluationsRef{
Digest: testRefDigest,
Reason: action.PolicyEvaluationsRefReasonTooLarge,
},
status: &action.PolicyEvaluationStatus{Total: 2, Violated: 40},
want: []string{
"2 evaluations, 40 violations - too large to include inline",
testRefDownloadHint,
},
},
{
name: "unavailable bundle does not suggest a download",
ref: &action.PolicyEvaluationsRef{
Digest: testRefDigest,
Reason: action.PolicyEvaluationsRefReasonUnavailable,
},
status: &action.PolicyEvaluationStatus{Total: 3, Violated: 7},
want: []string{
"3 evaluations, 7 violations - could not be retrieved from the CAS backend",
},
},
{
name: "missing status still reports the reason",
ref: &action.PolicyEvaluationsRef{
Digest: testRefDigest,
SizeBytes: 1024,
Reason: action.PolicyEvaluationsRefReasonTooLarge,
},
want: []string{
"policy evaluations too large to include inline (1K)",
testRefDownloadHint,
},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, policyEvaluationsRefNotice(tc.ref, tc.status))
})
}
}

func TestAppendPolicySection(t *testing.T) {
tests := []struct {
name string
attestation *action.WorkflowRunAttestationItem
wantContain []string
wantAbsent []string
}{
{
name: "inlined evaluations are rendered as policy rows",
attestation: &action.WorkflowRunAttestationItem{
PolicyEvaluations: map[string][]*action.PolicyEvaluation{
chainloop.AttPolicyEvaluation: {
{Name: "strong-acl", Violations: []*action.PolicyViolation{{Message: "weak ACL"}}},
},
},
PolicyEvaluationStatus: &action.PolicyEvaluationStatus{Total: 1, Violated: 1},
},
wantContain: []string{policiesRowLabel, "strong-acl", "weak ACL"},
wantAbsent: []string{"artifact download"},
},
{
name: "an oversized bundle is rendered as a notice instead",
attestation: &action.WorkflowRunAttestationItem{
PolicyEvaluationStatus: &action.PolicyEvaluationStatus{Total: 12, Violated: 134112},
PolicyEvaluationsRef: &action.PolicyEvaluationsRef{
Digest: testRefDigest,
SizeBytes: 64 * 1024 * 1024,
Reason: action.PolicyEvaluationsRefReasonTooLarge,
},
},
wantContain: []string{policiesRowLabel, "134112 violations", "too large", "artifact download --digest " + testRefDigest},
},
{
name: "no policies and no reference renders nothing",
attestation: &action.WorkflowRunAttestationItem{
PolicyEvaluationStatus: &action.PolicyEvaluationStatus{},
},
wantAbsent: []string{"Policies"},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tw := table.NewWriter()
appendPolicySection(tc.attestation, tw, false)
got := tw.Render()

for _, want := range tc.wantContain {
assert.Contains(t, got, want)
}
for _, absent := range tc.wantAbsent {
assert.NotContains(t, got, absent)
}
})
}
}
74 changes: 73 additions & 1 deletion app/cli/pkg/action/workflow_run_describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ type WorkflowRunAttestationItem struct {
PolicyEvaluations map[string][]*PolicyEvaluation `json:"policy_evaluations,omitempty"`
// Policy evaluation status
PolicyEvaluationStatus *PolicyEvaluationStatus `json:"policy_evaluation_status,omitempty"`
// Set when the evaluations were not inlined above and must be fetched from
// the CAS backend instead. PolicyEvaluations is empty in that case, while
// PolicyEvaluationStatus stays complete.
PolicyEvaluationsRef *PolicyEvaluationsRef `json:"policy_evaluations_ref,omitempty"`
// URL to view the attestation in the UI
AttestationViewURL string `json:"attestation_view_url"`
}
Expand All @@ -73,6 +77,15 @@ type PolicyEvaluationStatus struct {
Blocked bool `json:"blocked"`
HasViolations bool `json:"has_violations"`
HasGatedViolations bool `json:"has_gated_violations"`
// Canonical, server-computed status and counters. Status is empty for
// runs that predate the server materializing the summary.
Status string `json:"status,omitempty"`
Total int `json:"total"`
Passed int `json:"passed"`
Skipped int `json:"skipped"`
Violated int `json:"violated"`
Suppressed int `json:"suppressed"`
HasGates bool `json:"has_gates"`
}

type Material struct {
Expand All @@ -98,6 +111,27 @@ type Annotation struct {
Value string `json:"value"`
}

// PolicyEvaluationsRefReason explains why the evaluations were not included
// in the response.
type PolicyEvaluationsRefReason string

const (
// The bundle is larger than the server is willing to inline
PolicyEvaluationsRefReasonTooLarge PolicyEvaluationsRefReason = "TOO_LARGE"
// The bundle could not be resolved from the CAS backend
PolicyEvaluationsRefReasonUnavailable PolicyEvaluationsRefReason = "UNAVAILABLE"
)

// PolicyEvaluationsRef points at a policy-evaluation bundle stored in a CAS
// backend, returned in place of the evaluations themselves.
type PolicyEvaluationsRef struct {
Digest string `json:"digest"`
// Size of the bundle in bytes, zero when it could not be determined
SizeBytes int64 `json:"size_bytes,omitempty"`
MediaType string `json:"media_type,omitempty"`
Reason PolicyEvaluationsRefReason `json:"reason"`
}

type PolicyEvaluation struct {
Name string `json:"name"`
MaterialName string `json:"material_name,omitempty"`
Expand Down Expand Up @@ -237,6 +271,14 @@ func (action *WorkflowRunDescribe) Run(ctx context.Context, opts *WorkflowRunDes
}

policyEvaluationStatus := att.GetPolicyEvaluationStatus()
summary := policyEvaluationStatus.GetSummary()

// Left empty for runs that predate the server materializing the summary,
// so consumers can tell "no policies" apart from "not reported".
var summaryStatus string
if summary != nil {
summaryStatus = summary.GetStatus().String()
}

var attestationViewURL string
baseUIDashboardURL := fetchUIDashboardURL(ctx, action.cfg.CPConnection)
Expand All @@ -259,8 +301,16 @@ func (action *WorkflowRunDescribe) Run(ctx context.Context, opts *WorkflowRunDes
Blocked: policyEvaluationStatus.Blocked,
HasViolations: policyEvaluationStatus.HasViolations,
HasGatedViolations: policyEvaluationStatus.HasGatedViolations,
Status: summaryStatus,
Total: int(summary.GetTotal()),
Passed: int(summary.GetPassed()),
Skipped: int(summary.GetSkipped()),
Violated: int(summary.GetViolated()),
Suppressed: int(summary.GetSuppressed()),
HasGates: summary.GetHasGates(),
},
AttestationViewURL: attestationViewURL,
PolicyEvaluationsRef: pbPolicyEvaluationsRefToAction(att.GetPolicyEvaluationsRef()),
AttestationViewURL: attestationViewURL,
}

return item, nil
Expand Down Expand Up @@ -289,6 +339,28 @@ func trustedRootPbToVerifier(resp *pb.GetTrustedRootResponse) (*verifier.Trusted
return tr, nil
}

// pbPolicyEvaluationsRefToAction maps the reference the server returns when it
// declines to inline the evaluations. An unspecified reason is treated as
// unavailable, which is the more conservative rendering: it does not promise
// the caller that a download would succeed.
func pbPolicyEvaluationsRefToAction(in *pb.PolicyEvaluationsRef) *PolicyEvaluationsRef {
if in == nil {
return nil
}

reason := PolicyEvaluationsRefReasonUnavailable
if in.GetReason() == pb.PolicyEvaluationsRef_REASON_TOO_LARGE {
reason = PolicyEvaluationsRefReasonTooLarge
}

return &PolicyEvaluationsRef{
Digest: in.GetDigest(),
SizeBytes: in.GetSizeBytes(),
MediaType: in.GetMediaType(),
Reason: reason,
}
}

func policyEvaluationPBToAction(in *pb.PolicyEvaluation) *PolicyEvaluation {
var pr *PolicyReference
if in.PolicyReference != nil {
Expand Down
Loading
Loading