Skip to content
13 changes: 2 additions & 11 deletions app/cli/cmd/attestation_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,8 @@ func hasGatingViolation(violations []*action.PolicyViolation) bool {
}

func validatePolicyEnforcement(status *action.AttestationStatusResult, bypassPolicyCheck bool) error {
// Block if any of the policies has been configured as a gate. Entries
// Block if any of the policies acts as a gate. Organization and contract
// enforcement are already resolved into each evaluation's gate. Entries
// flagged Suppress are excluded from the gate count but remain in
// eval.Violations for CAS storage and ingestion (audit trail preserved).
for _, evaluations := range status.PolicyEvaluations {
Expand All @@ -215,15 +216,5 @@ func validatePolicyEnforcement(status *action.AttestationStatusResult, bypassPol
}
}

// Do a final check in case the operator has configured the attestation
// to be blocked on any policy violation.
if status.MustBlockOnPolicyViolations {
Comment thread
migmartri marked this conversation as resolved.
Resolved
if bypassPolicyCheck {
logger.Warn().Msg(exceptionBypassPolicyCheck)
return nil
}

}

return nil
}
4 changes: 3 additions & 1 deletion app/cli/cmd/attestation_push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ func TestValidatePolicyEnforcement(t *testing.T) {
require.Equal(t, "cdx-fresh", gateErr.PolicyName)
})

t.Run("does not block when strategy is enforced and policy is explicitly not gated", func(t *testing.T) {
// Enforcement is resolved into each evaluation's gate when the policy is
// evaluated, so only the per-evaluation gate decides here.
t.Run("blocking depends on the evaluation gate, not on the strategy flag", func(t *testing.T) {
status := &action.AttestationStatusResult{
PolicyEvaluations: map[string][]*action.PolicyEvaluation{
"materials": {
Expand Down
13 changes: 8 additions & 5 deletions app/cli/cmd/attestation_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,16 @@ func attestationStatusTableOutput(status *action.AttestationStatusResult, w io.W
gt.AppendRow(table.Row{"Timestamp Authority", status.TimestampAuthority})
}

var blockingColor text.Color
var blockingText = action.PolicyViolationBlockingStrategyAdvisory
// Only show the strategy when it is enforced. Showing "ADVISORY" is misleading
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
// when individual policies are configured as gates.
if status.MustBlockOnPolicyViolations {
blockingColor = text.FgHiYellow
blockingText = action.PolicyViolationBlockingStrategyEnforced
strategy := fmt.Sprintf("%s (by %s)", action.PolicyViolationBlockingStrategyEnforced, status.PolicyEnforcementSource)
gt.AppendRow(table.Row{"Policy violation strategy", text.FgHiYellow.Sprint(strategy)})
}

if len(status.IgnoredGateOptOuts) > 0 {
gt.AppendRow(table.Row{"Ignored gate: false", text.FgHiYellow.Sprintf("%s (enforcement cannot be relaxed)", strings.Join(status.IgnoredGateOptOuts, ", "))})
}
gt.AppendRow(table.Row{"Policy violation strategy", blockingColor.Sprint(blockingText)})

evs := status.PolicyEvaluations[chainloop.AttPolicyEvaluation]
if len(evs) > 0 {
Expand Down
64 changes: 63 additions & 1 deletion app/cli/cmd/attestation_status_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// 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 @@ -15,12 +15,74 @@
package cmd

import (
"bytes"
"testing"
"time"

"github.com/chainloop-dev/chainloop/app/cli/pkg/action"
attestationv1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestAttestationStatusPolicyEnforcementRows(t *testing.T) {
const (
strategyRowLabel = "Policy violation strategy"
ignoredRowLabel = "Ignored gate: false"
)

testCases := []struct {
name string
status *action.AttestationStatusResult
wantContain []string
wantAbsent []string
}{
{
name: "advisory strategy is hidden",
status: &action.AttestationStatusResult{},
wantAbsent: []string{strategyRowLabel, action.PolicyViolationBlockingStrategyAdvisory, ignoredRowLabel},
},
{
name: "enforced strategy names its source",
status: &action.AttestationStatusResult{
MustBlockOnPolicyViolations: true,
PolicyEnforcementSource: attestationv1.PolicyEnforcementSourceOrganization,
},
wantContain: []string{strategyRowLabel, "ENFORCED (by organization)"},
wantAbsent: []string{ignoredRowLabel},
},
{
name: "ignored gate opt-outs are listed",
status: &action.AttestationStatusResult{
MustBlockOnPolicyViolations: true,
PolicyEnforcementSource: attestationv1.PolicyEnforcementSourceContract,
IgnoredGateOptOuts: []string{"sbom-banned-licenses", "slsa-checks"},
},
wantContain: []string{ignoredRowLabel, "sbom-banned-licenses, slsa-checks (enforcement cannot be relaxed)"},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
now := time.Now()
tc.status.InitializedAt = &now
tc.status.WorkflowMeta = &action.AttestationStatusWorkflowMeta{}
tc.status.RunnerContext = &action.AttestationResultRunnerContext{}

var buf bytes.Buffer
require.NoError(t, fullStatusTableWithWriter(tc.status, &buf))
got := buf.String()

for _, want := range tc.wantContain {
assert.Contains(t, got, want)
}
for _, absent := range tc.wantAbsent {
assert.NotContains(t, got, absent)
}
})
}
}

func TestVersionStringAttestation(t *testing.T) {
testCases := []struct {
name string
Expand Down
30 changes: 20 additions & 10 deletions app/cli/cmd/workflow_workflow_run_describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,16 +164,7 @@ func workflowRunDescribeTableOutput(run *action.WorkflowRunItemFull) error {
}
}

gt.AppendRow(table.Row{"Policies violation strategy", att.PolicyEvaluationStatus.Strategy})
if att.PolicyEvaluationStatus.Blocked {
gt.AppendRow(table.Row{"Run Blocked", att.PolicyEvaluationStatus.Blocked})
}
if att.PolicyEvaluationStatus.HasGatedViolations {
gt.AppendRow(table.Row{"Run Gated", text.Colors{text.FgHiRed}.Sprint(att.PolicyEvaluationStatus.HasGatedViolations)})
}
if att.PolicyEvaluationStatus.Strategy == action.PolicyViolationBlockingStrategyEnforced {
gt.AppendRow(table.Row{"Policy enforcement bypassed", att.PolicyEvaluationStatus.Bypassed})
}
appendPolicyStrategyRows(att.PolicyEvaluationStatus, gt)

appendPolicySection(att, gt, flagDebug)

Expand All @@ -188,6 +179,25 @@ func workflowRunDescribeTableOutput(run *action.WorkflowRunItemFull) error {
return nil
}

// appendPolicyStrategyRows adds the policy enforcement rows of a run.
// Only show the strategy when it is enforced. Showing "ADVISORY" is misleading
// when individual policies are configured as gates.
func appendPolicyStrategyRows(status *action.PolicyEvaluationStatus, gt table.Writer) {
enforced := status.Strategy == action.PolicyViolationBlockingStrategyEnforced
if enforced {
gt.AppendRow(table.Row{"Policies violation strategy", status.Strategy})
}
if status.Blocked {
gt.AppendRow(table.Row{"Run Blocked", status.Blocked})
}
if status.HasGatedViolations {
gt.AppendRow(table.Row{"Run Gated", text.Colors{text.FgHiRed}.Sprint(status.HasGatedViolations)})
}
if enforced {
gt.AppendRow(table.Row{"Policy enforcement bypassed", status.Bypassed})
}
}

func predicateV1Table(att *action.WorkflowRunAttestationItem) {
// Materials
materials := att.Materials
Expand Down
43 changes: 43 additions & 0 deletions app/cli/cmd/workflow_workflow_run_describe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -414,3 +414,46 @@ func TestAppendPolicySection(t *testing.T) {
})
}
}

func TestAppendPolicyStrategyRows(t *testing.T) {
const strategyRowLabel = "Policies violation strategy"

tests := []struct {
name string
status *action.PolicyEvaluationStatus
wantContain []string
wantAbsent []string
}{
{
name: "enforced strategy is shown",
status: &action.PolicyEvaluationStatus{Strategy: action.PolicyViolationBlockingStrategyEnforced, Blocked: true},
wantContain: []string{strategyRowLabel, action.PolicyViolationBlockingStrategyEnforced, "Run Blocked", "Policy enforcement bypassed"},
},
{
name: "advisory strategy is hidden",
status: &action.PolicyEvaluationStatus{Strategy: action.PolicyViolationBlockingStrategyAdvisory},
wantAbsent: []string{strategyRowLabel, action.PolicyViolationBlockingStrategyAdvisory, "Policy enforcement bypassed"},
},
{
name: "gated violations show without an enforced strategy",
status: &action.PolicyEvaluationStatus{Strategy: action.PolicyViolationBlockingStrategyAdvisory, HasGatedViolations: true},
wantContain: []string{"Run Gated"},
wantAbsent: []string{strategyRowLabel},
},
}

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

for _, want := range tc.wantContain {
assert.Contains(t, got, want)
}
for _, absent := range tc.wantAbsent {
assert.NotContains(t, got, absent)
}
})
}
}
18 changes: 18 additions & 0 deletions app/cli/pkg/action/attestation_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"fmt"
"slices"
"strconv"
"strings"

"github.com/chainloop-dev/chainloop/app/cli/internal/token"
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
Expand Down Expand Up @@ -333,6 +334,11 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun
return "", err
}

// gate: false can not relax organization or contract enforcement (R-006)
if msg := ignoredGateOptOutsWarning(action.c.CraftingState.CraftingState); msg != "" {
action.Logger.Warn().Msg(msg)
}

// Load the env variables both the system populated and the user predefined ones
if err := action.c.ResolveEnvVars(ctx, attestationID); err != nil {
if action.dryRun {
Expand Down Expand Up @@ -384,6 +390,18 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun

// enrichContractMaterials augments a V1 contract schema with the materials
// declared by its attached policy groups.
// ignoredGateOptOutsWarning returns a message that names the `gate: false`
// settings that have no effect, or an empty string when there are none.
func ignoredGateOptOutsWarning(state *clientAPI.CraftingState) string {
ignored := state.IgnoredGateOptOuts()
if len(ignored) == 0 {
return ""
}

return fmt.Sprintf("gate: false has no effect on %s because policies are enforced by the %s, and enforcement cannot be relaxed",
strings.Join(ignored, ", "), state.PolicyEnforcementSource())
}

func enrichContractMaterials(ctx context.Context, schema *v1.CraftingSchema, client pb.AttestationServiceClient, logger *zerolog.Logger) error {
merged, err := mergePolicyGroupMaterials(ctx, schema.GetPolicyGroups(), schema.GetMaterials(), client, logger)
if err != nil {
Expand Down
37 changes: 37 additions & 0 deletions app/cli/pkg/action/attestation_init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"

pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
Expand Down Expand Up @@ -425,3 +426,39 @@ func setupGitHubPREnv(t *testing.T) {
t.Setenv("RUNNER_NAME", "chainloop-runner")
t.Setenv("RUNNER_OS", "linux")
}

func TestIgnoredGateOptOutsWarning(t *testing.T) {
gateFalse := []*v1.PolicyAttachment{{Policy: &v1.PolicyAttachment_Ref{Ref: "sbom-banned-licenses"}, Gate: proto.Bool(false)}}

testCases := []struct {
name string
enforced bool
contractGate bool
want string
}{
{name: "no warning when nothing enforces"},
{
name: "organization",
enforced: true,
want: "gate: false has no effect on spec.policies.gate, sbom-banned-licenses because policies are enforced by the organization, and enforcement cannot be relaxed",
},
{
name: "contract",
enforced: true,
contractGate: true,
want: "gate: false has no effect on sbom-banned-licenses because policies are enforced by the contract, and enforcement cannot be relaxed",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
state := &craftingv1.CraftingState{
Attestation: &craftingv1.Attestation{BlockOnPolicyViolation: tc.enforced},
Schema: &craftingv1.CraftingState_SchemaV2{SchemaV2: &v1.CraftingSchemaV2{
Spec: &v1.CraftingSchemaV2Spec{Policies: &v1.Policies{Gate: proto.Bool(tc.contractGate), Materials: gateFalse}},
}},
}
assert.Equal(t, tc.want, ignoredGateOptOutsWarning(state))
})
}
}
11 changes: 9 additions & 2 deletions app/cli/pkg/action/attestation_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,13 @@ type AttestationStatusResult struct {
PolicyEvaluations map[string][]*PolicyEvaluation `json:"policy_evaluations,omitempty"`
HasPolicyViolations bool `json:"has_policy_violations"`
MustBlockOnPolicyViolations bool `json:"must_block_on_policy_violations"`
TimestampAuthority string `json:"timestamp_authority"`
AttestationViewURL string `json:"attestation_view_url"`
// Level that enforces all policies: organization or contract
PolicyEnforcementSource string `json:"policy_enforcement_source,omitempty"`
// Contract policies and policy groups with `gate: false` that have no effect
// because the organization or the contract enforces policies
IgnoredGateOptOuts []string `json:"ignored_gate_opt_outs,omitempty"`
TimestampAuthority string `json:"timestamp_authority"`
AttestationViewURL string `json:"attestation_view_url"`
// This might only be set if the attestation is pushed
Digest string `json:"digest"`
// This is the human readable output of the attestation status
Expand Down Expand Up @@ -126,6 +131,8 @@ func (action *AttestationStatus) Run(ctx context.Context, attestationID string)
Annotations: pbAnnotationsToAction(c.CraftingState.GetAnnotations()),
IsPushed: action.isPushed,
MustBlockOnPolicyViolations: att.GetBlockOnPolicyViolation(),
PolicyEnforcementSource: c.CraftingState.PolicyEnforcementSource(),
IgnoredGateOptOuts: c.CraftingState.IgnoredGateOptOuts(),
TimestampAuthority: att.GetSigningOptions().GetTimestampAuthorityUrl(),
}

Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading