Skip to content

Commit cfb77f2

Browse files
committed
feat(policies): suppress hint on Violation skips the gate while keeping CAS data
Adds `bool suppress` on PolicyEvaluation.Violation. Policies opt in by emitting `"suppress": true` on a structured finding (typically derived from `assessment.effective_status == NOT_AFFECTED`). The engine reads the bool generically; the gate filter excludes suppressed entries from the count while the full violations list still flows to CAS and ingestion (last_seen_at refreshes, audit trail preserved). Touches: proto field, engine extraction in engineEvaluationsToAPIViolations, gate filter in validatePolicyEnforcement, action-layer round-trip on attestation_status, tests for all three. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
1 parent e089c20 commit cfb77f2

11 files changed

Lines changed: 193 additions & 12 deletions

File tree

app/cli/cmd/attestation_push.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,11 +186,25 @@ func (e *GateError) Error() string {
186186
return fmt.Sprintf("the policy %q is configured as a gate and has violations", e.PolicyName)
187187
}
188188

189+
// hasGatingViolation reports whether any of the supplied violations should
190+
// count toward the policy gate. Entries flagged Suppress (typically because
191+
// an assessment renders the finding NOT_AFFECTED) are excluded.
192+
func hasGatingViolation(violations []*action.PolicyViolation) bool {
193+
for _, v := range violations {
194+
if !v.Suppress {
195+
return true
196+
}
197+
}
198+
return false
199+
}
200+
189201
func validatePolicyEnforcement(status *action.AttestationStatusResult, bypassPolicyCheck bool) error {
190-
// Block if any of the policies has been configured as a gate.
202+
// Block if any of the policies has been configured as a gate. Entries
203+
// flagged Suppress are excluded from the gate count but remain in
204+
// eval.Violations for CAS storage and ingestion (audit trail preserved).
191205
for _, evaluations := range status.PolicyEvaluations {
192206
for _, eval := range evaluations {
193-
if len(eval.Violations) > 0 && eval.Gate {
207+
if eval.Gate && hasGatingViolation(eval.Violations) {
194208
if bypassPolicyCheck {
195209
logger.Warn().Msg(exceptionBypassPolicyCheck)
196210
continue

app/cli/cmd/attestation_push_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,51 @@ func TestValidatePolicyEnforcement(t *testing.T) {
8888
err := validatePolicyEnforcement(status, false)
8989
require.NoError(t, err)
9090
})
91+
92+
t.Run("does not block when every violation on a gated policy is suppressed", func(t *testing.T) {
93+
status := &action.AttestationStatusResult{
94+
PolicyEvaluations: map[string][]*action.PolicyEvaluation{
95+
"materials": {
96+
{
97+
Name: "cdx-fresh",
98+
Gate: true,
99+
Violations: []*action.PolicyViolation{
100+
{Message: "CVE-2024-1 NOT_AFFECTED", Suppress: true},
101+
{Message: "CVE-2024-2 NOT_AFFECTED", Suppress: true},
102+
},
103+
},
104+
},
105+
},
106+
HasPolicyViolations: true,
107+
MustBlockOnPolicyViolations: false,
108+
}
109+
110+
err := validatePolicyEnforcement(status, false)
111+
require.NoError(t, err)
112+
})
113+
114+
t.Run("blocks when at least one violation on a gated policy is not suppressed", func(t *testing.T) {
115+
status := &action.AttestationStatusResult{
116+
PolicyEvaluations: map[string][]*action.PolicyEvaluation{
117+
"materials": {
118+
{
119+
Name: "cdx-fresh",
120+
Gate: true,
121+
Violations: []*action.PolicyViolation{
122+
{Message: "CVE-2024-1 NOT_AFFECTED", Suppress: true},
123+
{Message: "CVE-2024-2 active", Suppress: false},
124+
},
125+
},
126+
},
127+
},
128+
HasPolicyViolations: true,
129+
MustBlockOnPolicyViolations: false,
130+
}
131+
132+
err := validatePolicyEnforcement(status, false)
133+
require.Error(t, err)
134+
var gateErr *GateError
135+
require.ErrorAs(t, err, &gateErr)
136+
require.Equal(t, "cdx-fresh", gateErr.PolicyName)
137+
})
91138
}

app/cli/pkg/action/attestation_status.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -367,8 +367,9 @@ func policyEvaluationStateToActionForStatus(in *v1.PolicyEvaluation) *PolicyEval
367367
violations := make([]*PolicyViolation, 0, len(in.Violations))
368368
for _, v := range in.Violations {
369369
violations = append(violations, &PolicyViolation{
370-
Subject: v.Subject,
371-
Message: v.Message,
370+
Subject: v.Subject,
371+
Message: v.Message,
372+
Suppress: v.GetSuppress(),
372373
})
373374
}
374375

app/cli/pkg/action/workflow_run_describe.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ type PolicyEvaluation struct {
115115
type PolicyViolation struct {
116116
Subject string `json:"subject"`
117117
Message string `json:"message"`
118+
// Suppress, when true, excludes this entry from the gate count while
119+
// keeping it in the CAS-stored bundle (audit trail preserved). Set by
120+
// the policy author via the rego "suppress" key.
121+
Suppress bool `json:"suppress,omitempty"`
118122
}
119123

120124
type PolicyReference struct {

app/controlplane/api/gen/frontend/attestation/v1/crafting_state.ts

Lines changed: 32 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.Violation.jsonschema.json

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.Violation.schema.json

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/attestation/crafter/api/attestation/v1/crafting_state.pb.go

Lines changed: 18 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/attestation/crafter/api/attestation/v1/crafting_state.proto

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,13 @@ message PolicyEvaluation {
284284
PolicySASTFinding sast = 4;
285285
PolicyLicenseViolationFinding license_violation = 5;
286286
}
287+
288+
// Suppression hint set by the policy. When true the gate count
289+
// excludes this entry, but it is still stored in CAS and ingested
290+
// (audit trail preserved). Set by the policy author (typically based
291+
// on `assessment.effective_status`) — the engine reads the bool
292+
// without interpreting why.
293+
bool suppress = 6;
287294
}
288295

289296
message Reference {

pkg/policies/policies.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,16 @@ func splitArgs(s string) []string {
787787
return result
788788
}
789789

790+
// extractSuppress pulls the optional "suppress" boolean from a structured
791+
// rego finding. Returns false for plain-string violations or when absent.
792+
func extractSuppress(raw map[string]any) bool {
793+
if raw == nil {
794+
return false
795+
}
796+
v, _ := raw["suppress"].(bool)
797+
return v
798+
}
799+
790800
func engineEvaluationsToAPIViolations(results []*engine.EvaluationResult, findingType string) ([]*v12.PolicyEvaluation_Violation, []string, error) {
791801
res := make([]*v12.PolicyEvaluation_Violation, 0)
792802
var warnings []string
@@ -796,8 +806,9 @@ func engineEvaluationsToAPIViolations(results []*engine.EvaluationResult, findin
796806
for _, r := range results {
797807
for _, v := range r.Violations {
798808
apiV := &v12.PolicyEvaluation_Violation{
799-
Subject: v.Subject,
800-
Message: v.Violation,
809+
Subject: v.Subject,
810+
Message: v.Violation,
811+
Suppress: extractSuppress(v.RawFinding),
801812
}
802813

803814
hasStructuredData := v.RawFinding != nil

0 commit comments

Comments
 (0)