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
56 changes: 30 additions & 26 deletions app/cli/cmd/workflow_workflow_run_describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,31 +299,44 @@ 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.
// usual per-policy rows or, when the server declined to inline the evaluations,
// as a notice explaining why. Either way it closes with the bundle in the CAS
// backend, which holds the full set of evaluations the table only summarizes.
func appendPolicySection(att *action.WorkflowRunAttestationItem, gt table.Writer, debugMode bool) {
if notice := policyEvaluationsRefNotice(att.PolicyEvaluationsRef, att.PolicyEvaluationStatus); notice != nil {
ref := att.PolicyEvaluationsRef

// A missing reference means the evaluations, if any, came inline: there is
// no bundle to point at.
if ref == nil || ref.Inlined {
if evs := att.PolicyEvaluations[chainloop.AttPolicyEvaluation]; len(evs) > 0 {
gt.AppendRow(table.Row{"Policies", "------"})
policiesTable(evs, gt, debugMode)
}
} else {
gt.AppendRow(table.Row{"Policies", "------"})
for _, line := range notice {
for _, line := range policyEvaluationsRefNotice(ref, att.PolicyEvaluationStatus) {
gt.AppendRow(table.Row{"", line})
}

return
}

evs := att.PolicyEvaluations[chainloop.AttPolicyEvaluation]
if len(evs) == 0 {
appendPolicyEvaluationsBundleRow(ref, gt)
}

// appendPolicyEvaluationsBundleRow points at the policy-evaluation bundle
// whenever there is a digest to point at, including when the server could not
// read it: the caller may well have access the control plane lacked.
func appendPolicyEvaluationsBundleRow(ref *action.PolicyEvaluationsRef, gt table.Writer) {
if ref == nil || ref.Digest == "" {
return
}

gt.AppendRow(table.Row{"Policies", "------"})
policiesTable(evs, gt, debugMode)
gt.AppendRow(table.Row{"Policy evaluations bundle", downloadPolicyEvaluationsHint(ref)})
}

// 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.
// is.
func policyEvaluationsRefNotice(ref *action.PolicyEvaluationsRef, status *action.PolicyEvaluationStatus) []string {
if ref == nil {
return nil
Expand All @@ -339,29 +352,19 @@ func policyEvaluationsRefNotice(ref *action.PolicyEvaluationsRef, status *action

// 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
return []string{fmt.Sprintf("policy evaluations %s", reason)}
}

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
return []string{fmt.Sprintf("%s - %s", counters, reason)}
}

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

// violationSummary builds a single-line description of a violation using the
Expand Down Expand Up @@ -512,10 +515,11 @@ func encodeAttestationOutput(run *action.WorkflowRunItemFull, writer io.Writer)
if err != nil {
return fmt.Errorf("unmarshaling attestation: %w", err)
}

return output.EncodeProtoJSON(&bundle)
} else {
return output.EncodeJSON(run.Attestation.Envelope)
}

return output.EncodeJSON(run.Attestation.Envelope)
case formatPayloadPAE:
return encodePAE(run, writer)
default:
Expand Down
76 changes: 62 additions & 14 deletions app/cli/cmd/workflow_workflow_run_describe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,11 @@ func (s *workflowRunDescribeSuite) TestOutputTypePayload() {

const (
testRefDigest = "sha256:abc123"
testRefDownloadHint = "inspect with: chainloop artifact download --digest " + testRefDigest
testRefDownloadHint = "chainloop artifact download --digest " + testRefDigest
policiesRowLabel = "Policies"
bundleRowLabel = "Policy evaluations bundle"
testPolicyName = "strong-acl"
testViolationMsg = "weak ACL"
)

func TestPolicyEvaluationsRefNotice(t *testing.T) {
Expand All @@ -237,7 +240,6 @@ func TestPolicyEvaluationsRefNotice(t *testing.T) {
status: &action.PolicyEvaluationStatus{Total: 12, Violated: 134112, Suppressed: 86321},
want: []string{
"12 evaluations, 134112 violations (86321 suppressed) - too large to include inline (64M)",
testRefDownloadHint,
},
},
{
Expand All @@ -250,7 +252,6 @@ func TestPolicyEvaluationsRefNotice(t *testing.T) {
status: &action.PolicyEvaluationStatus{Total: 2, Violated: 40},
want: []string{
"2 evaluations, 40 violations - too large to include inline (3M)",
testRefDownloadHint,
},
},
{
Expand All @@ -262,11 +263,10 @@ func TestPolicyEvaluationsRefNotice(t *testing.T) {
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",
name: "unavailable bundle reports why it could not be read",
ref: &action.PolicyEvaluationsRef{
Digest: testRefDigest,
Reason: action.PolicyEvaluationsRefReasonUnavailable,
Expand All @@ -285,7 +285,6 @@ func TestPolicyEvaluationsRefNotice(t *testing.T) {
},
want: []string{
"policy evaluations too large to include inline (1K)",
testRefDownloadHint,
},
},
}
Expand All @@ -298,24 +297,39 @@ func TestPolicyEvaluationsRefNotice(t *testing.T) {
}

func TestAppendPolicySection(t *testing.T) {
inlinedEvaluations := map[string][]*action.PolicyEvaluation{
chainloop.AttPolicyEvaluation: {
{Name: testPolicyName, Violations: []*action.PolicyViolation{{Message: testViolationMsg}}},
},
}

tests := []struct {
name string
attestation *action.WorkflowRunAttestationItem
wantContain []string
wantAbsent []string
}{
{
name: "inlined evaluations are rendered as policy rows",
name: "inlined evaluations are rendered as policy rows alongside the bundle",
attestation: &action.WorkflowRunAttestationItem{
PolicyEvaluations: map[string][]*action.PolicyEvaluation{
chainloop.AttPolicyEvaluation: {
{Name: "strong-acl", Violations: []*action.PolicyViolation{{Message: "weak ACL"}}},
},
PolicyEvaluations: inlinedEvaluations,
PolicyEvaluationStatus: &action.PolicyEvaluationStatus{Total: 1, Violated: 1},
PolicyEvaluationsRef: &action.PolicyEvaluationsRef{
Digest: testRefDigest,
SizeBytes: 2048,
Inlined: true,
},
},
wantContain: []string{policiesRowLabel, testPolicyName, testViolationMsg, bundleRowLabel, testRefDownloadHint},
},
{
name: "evaluations without a bundle render the policy rows alone",
attestation: &action.WorkflowRunAttestationItem{
PolicyEvaluations: inlinedEvaluations,
PolicyEvaluationStatus: &action.PolicyEvaluationStatus{Total: 1, Violated: 1},
},
wantContain: []string{policiesRowLabel, "strong-acl", "weak ACL"},
wantAbsent: []string{"artifact download"},
wantContain: []string{policiesRowLabel, testPolicyName, testViolationMsg},
wantAbsent: []string{"artifact download", bundleRowLabel},
},
{
name: "an oversized bundle is rendered as a notice instead",
Expand All @@ -327,7 +341,30 @@ func TestAppendPolicySection(t *testing.T) {
Reason: action.PolicyEvaluationsRefReasonTooLarge,
},
},
wantContain: []string{policiesRowLabel, "134112 violations", "too large", "artifact download --digest " + testRefDigest},
wantContain: []string{policiesRowLabel, "134112 violations", "too large", bundleRowLabel, testRefDownloadHint},
wantAbsent: []string{testPolicyName},
},
{
name: "an unavailable bundle still offers the download when the digest is known",
attestation: &action.WorkflowRunAttestationItem{
PolicyEvaluationStatus: &action.PolicyEvaluationStatus{Total: 3, Violated: 7},
PolicyEvaluationsRef: &action.PolicyEvaluationsRef{
Digest: testRefDigest,
Reason: action.PolicyEvaluationsRefReasonUnavailable,
},
},
wantContain: []string{policiesRowLabel, "could not be retrieved", bundleRowLabel, testRefDownloadHint},
},
{
name: "a reference without a digest cannot offer a download",
attestation: &action.WorkflowRunAttestationItem{
PolicyEvaluationStatus: &action.PolicyEvaluationStatus{Total: 3, Violated: 7},
PolicyEvaluationsRef: &action.PolicyEvaluationsRef{
Reason: action.PolicyEvaluationsRefReasonUnavailable,
},
},
wantContain: []string{policiesRowLabel, "could not be retrieved"},
wantAbsent: []string{"artifact download", bundleRowLabel},
},
{
name: "no policies and no reference renders nothing",
Expand All @@ -336,6 +373,17 @@ func TestAppendPolicySection(t *testing.T) {
},
wantAbsent: []string{"Policies"},
},
{
name: "a bundle with no attestation-level policies still points at it",
attestation: &action.WorkflowRunAttestationItem{
PolicyEvaluationStatus: &action.PolicyEvaluationStatus{Total: 1},
PolicyEvaluationsRef: &action.PolicyEvaluationsRef{
Digest: testRefDigest,
Inlined: true,
},
},
wantContain: []string{bundleRowLabel, testRefDownloadHint},
},
}

for _, tc := range tests {
Expand Down
17 changes: 11 additions & 6 deletions app/cli/pkg/action/attestation_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,9 +343,11 @@ func decodeEnvelope(rawEnvelope []byte) (*dsse.Envelope, error) {
}

// uploadPolicyEvaluationsBundle serializes policy evaluations as a protobuf bundle,
// uploads to CAS, and returns a ResourceDescriptor referencing the uploaded object.
// uploads to CAS, and returns a reference to the uploaded object. The reference
// records the size of the uploaded bytes so readers can decide whether to fetch
// the bundle without asking the CAS how big it is.
// Returns (nil, nil) when there are no evaluations or no uploader.
func uploadPolicyEvaluationsBundle(ctx context.Context, evaluations []*v1.PolicyEvaluation, uploader casclient.Uploader) (*intoto.ResourceDescriptor, error) {
func uploadPolicyEvaluationsBundle(ctx context.Context, evaluations []*v1.PolicyEvaluation, uploader casclient.Uploader) (*crChainloop.PolicyEvaluationsRef, error) {
if len(evaluations) == 0 || uploader == nil {
return nil, nil
}
Expand All @@ -364,9 +366,12 @@ func uploadPolicyEvaluationsBundle(ctx context.Context, evaluations []*v1.Policy
return nil, fmt.Errorf("uploading policy evaluation bundle: %w", err)
}

return &intoto.ResourceDescriptor{
Name: "policy-evaluations",
Digest: map[string]string{"sha256": hexDigest},
MediaType: crChainloop.PolicyEvaluationsBundleMediaType,
return &crChainloop.PolicyEvaluationsRef{
ResourceDescriptor: &intoto.ResourceDescriptor{
Name: "policy-evaluations",
Digest: map[string]string{"sha256": hexDigest},
MediaType: crChainloop.PolicyEvaluationsBundleMediaType,
},
SizeBytes: int64(len(data)),
}, nil
}
4 changes: 4 additions & 0 deletions app/cli/pkg/action/attestation_push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ func TestUploadPolicyEvaluationsBundle(t *testing.T) {
require.NoError(t, err)
expectedDigest := fmt.Sprintf("%x", sha256.Sum256(data))
assert.Equal(t, expectedDigest, ref.Digest["sha256"])

// The recorded size is what the reader gates the inlining cap on,
// so it has to be the size of the very bytes that were uploaded.
assert.Equal(t, int64(len(data)), ref.GetSizeBytes())
})
}
}
39 changes: 27 additions & 12 deletions app/cli/pkg/action/workflow_run_describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +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.
// Where the policy-evaluation bundle lives in the CAS backend, set whenever
// the attestation carries one. When Inlined is false the evaluations above
// are empty and must be fetched from there instead; PolicyEvaluationStatus
// stays complete either way.
PolicyEvaluationsRef *PolicyEvaluationsRef `json:"policy_evaluations_ref,omitempty"`
// URL to view the attestation in the UI
AttestationViewURL string `json:"attestation_view_url"`
Expand Down Expand Up @@ -112,7 +113,7 @@ type Annotation struct {
}

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

const (
Expand All @@ -123,13 +124,16 @@ const (
)

// PolicyEvaluationsRef points at a policy-evaluation bundle stored in a CAS
// backend, returned in place of the evaluations themselves.
// backend. It is returned both alongside the evaluations and, when they could
// not be inlined, in their place.
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"`
Reason PolicyEvaluationsRefReason `json:"reason,omitempty"`
// Whether the evaluations decoded from this bundle are also in the response
Inlined bool `json:"inlined"`
}

type PolicyEvaluation struct {
Expand Down Expand Up @@ -339,25 +343,36 @@ 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.
// pbPolicyEvaluationsRefToAction maps the reference to the policy-evaluation
// bundle. A reason the client does not recognize is reported as unavailable
// unless the server also inlined the evaluations, which is the more
// conservative rendering: it never promises that a download would succeed, and
// never invents a failure the server did not report.
func pbPolicyEvaluationsRefToAction(in *pb.PolicyEvaluationsRef) *PolicyEvaluationsRef {
if in == nil {
return nil
}

reason := PolicyEvaluationsRefReasonUnavailable
if in.GetReason() == pb.PolicyEvaluationsRef_REASON_TOO_LARGE {
var reason PolicyEvaluationsRefReason
switch in.GetReason() {
case pb.PolicyEvaluationsRef_REASON_TOO_LARGE:
reason = PolicyEvaluationsRefReasonTooLarge
case pb.PolicyEvaluationsRef_REASON_UNAVAILABLE:
reason = PolicyEvaluationsRefReasonUnavailable
default:
// An unrecognized reason next to inlined evaluations is no failure at
// all; on its own it is one we cannot name.
if !in.GetInlined() {
reason = PolicyEvaluationsRefReasonUnavailable
}
}

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

Expand Down
Loading
Loading