Skip to content

Commit f0c5d57

Browse files
fix: IVPOL required has no effect (kyverno#16853)
1 parent 09111f1 commit f0c5d57

13 files changed

Lines changed: 450 additions & 27 deletions

File tree

pkg/cel/libs/imageverify/impl.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ type ivfuncs struct {
4545
ivCache imageverifycache.Client
4646
authOpts []remote.Option
4747
nameOpts []name.Option
48+
verifications *ImageVerificationResults
4849
}
4950

5051
func ImageVerifyCELFuncs(
@@ -54,6 +55,7 @@ func ImageVerifyCELFuncs(
5455
lister corev1listers.SecretLister,
5556
ivCache imageverifycache.Client,
5657
adapter types.Adapter,
58+
verifications *ImageVerificationResults,
5759
) (*ivfuncs, error) {
5860
if ivpol == nil {
5961
return nil, fmt.Errorf("nil image verification policy")
@@ -88,6 +90,7 @@ func ImageVerifyCELFuncs(
8890
ivCache: ivCache,
8991
nameOpts: nameOpts,
9092
authOpts: authOpts[:],
93+
verifications: verifications,
9194
}, nil
9295
}
9396

@@ -135,6 +138,7 @@ func (f *ivfuncs) verify_image_signature_string_stringarray(image ref.Val, attes
135138
f.logger.Error(err, "error occurred during image verify cache get", "image", image)
136139
} else if found {
137140
f.logger.V(4).Info("image signature verification cache hit", "image", image, "policy", f.policy.GetName())
141+
f.verifications.Record(image, true)
138142
return f.NativeToValue(len(attestors))
139143
}
140144
}
@@ -178,6 +182,9 @@ func (f *ivfuncs) verify_image_signature_string_stringarray(image ref.Val, attes
178182
f.logger.Error(err, "error occurred during image verify cache set", "image", image)
179183
}
180184
}
185+
if len(attestors) > 0 {
186+
f.verifications.Record(image, count > 0)
187+
}
181188
return f.NativeToValue(count)
182189
}
183190
}
@@ -208,6 +215,7 @@ func (f *ivfuncs) verify_image_attestations_string_string_stringarray(args ...re
208215
f.logger.Error(err, "error occurred during image verify cache get", "image", image)
209216
} else if found {
210217
f.logger.V(4).Info("image attestation verification cache hit", "image", image, "policy", f.policy.GetName())
218+
f.verifications.Record(image, true)
211219
return f.NativeToValue(len(attestors))
212220
}
213221
}
@@ -257,6 +265,9 @@ func (f *ivfuncs) verify_image_attestations_string_string_stringarray(args ...re
257265
f.logger.Error(err, "error occurred during image verify cache set", "image", image)
258266
}
259267
}
268+
if len(attestors) > 0 {
269+
f.verifications.Record(image, count > 0)
270+
}
260271
return f.NativeToValue(count)
261272
}
262273
}

pkg/cel/libs/imageverify/impl_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ func Test_impl_verify_image_signature_string_stringarray(t *testing.T) {
6868

6969
options := []cel.EnvOption{
7070
cel.Variable("attestors", cel.MapType(cel.StringType, cel.DynType)),
71-
Lib(nil, imgCtx, ivpol, nil, logr.Discard(), nil),
71+
Lib(nil, imgCtx, ivpol, nil, logr.Discard(), nil, NewImageVerificationResults()),
7272
}
7373
env, err := cel.NewEnv(options...)
7474
assert.NoError(t, err)
@@ -106,7 +106,7 @@ func Test_impl_verify_image_attestations_string_string_stringarray(t *testing.T)
106106

107107
options := []cel.EnvOption{
108108
cel.Variable("attestors", cel.MapType(cel.StringType, cel.DynType)),
109-
Lib(nil, imgCtx, ivpol, nil, logr.Discard(), nil),
109+
Lib(nil, imgCtx, ivpol, nil, logr.Discard(), nil, NewImageVerificationResults()),
110110
}
111111
env, err := cel.NewEnv(options...)
112112
assert.NoError(t, err)

pkg/cel/libs/imageverify/lib.go

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,32 @@ import (
1616
const libraryName = "kyverno.imageverify"
1717

1818
type lib struct {
19-
logger logr.Logger
20-
version *version.Version
21-
imgCtx imagedataloader.ImageContext
22-
ivpol policiesv1beta1.ImageValidatingPolicyLike
23-
lister corev1listers.SecretLister
24-
ivCache imageverifycache.Client
19+
logger logr.Logger
20+
version *version.Version
21+
imgCtx imagedataloader.ImageContext
22+
ivpol policiesv1beta1.ImageValidatingPolicyLike
23+
lister corev1listers.SecretLister
24+
ivCache imageverifycache.Client
25+
verifications *ImageVerificationResults
2526
}
2627

2728
func Latest() *version.Version {
2829
return versions.KyvernoLatest
2930
}
3031

31-
func Lib(v *version.Version, imgCtx imagedataloader.ImageContext, ivpol policiesv1beta1.ImageValidatingPolicyLike, lister corev1listers.SecretLister, logger logr.Logger, ivCache imageverifycache.Client) cel.EnvOption {
32+
// Lib builds the image verification CEL library. The verification results are shared
33+
// with the caller, which reads them back after evaluation to enforce
34+
// validationConfigurations.required; pass nil when that enforcement is not needed.
35+
func Lib(v *version.Version, imgCtx imagedataloader.ImageContext, ivpol policiesv1beta1.ImageValidatingPolicyLike, lister corev1listers.SecretLister, logger logr.Logger, ivCache imageverifycache.Client, verifications *ImageVerificationResults) cel.EnvOption {
3236
// create the cel lib env option
3337
return cel.Lib(&lib{
34-
logger: logger,
35-
version: v,
36-
imgCtx: imgCtx,
37-
ivpol: ivpol,
38-
lister: lister,
39-
ivCache: ivCache,
38+
logger: logger,
39+
version: v,
40+
imgCtx: imgCtx,
41+
ivpol: ivpol,
42+
lister: lister,
43+
ivCache: ivCache,
44+
verifications: verifications,
4045
})
4146
}
4247

@@ -59,7 +64,7 @@ func (*lib) ProgramOptions() []cel.ProgramOption {
5964
}
6065

6166
func (c *lib) extendEnv(env *cel.Env) (*cel.Env, error) {
62-
impl, err := ImageVerifyCELFuncs(c.logger, c.imgCtx, c.ivpol, c.lister, c.ivCache, env.CELTypeAdapter())
67+
impl, err := ImageVerifyCELFuncs(c.logger, c.imgCtx, c.ivpol, c.lister, c.ivCache, env.CELTypeAdapter(), c.verifications)
6368
if err != nil {
6469
return nil, err
6570
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package imageverify
2+
3+
import "sync"
4+
5+
// ImageVerificationResults records, per image, whether a real cryptographic
6+
// signature or attestation check succeeded. IVPOL analogue of CPOL's
7+
// ImageVerificationMetadata (pkg/engine/api/imageverifymetadata.go).
8+
//
9+
// required can't trust a CEL expression's return value (that's exactly what it
10+
// exists to distrust) so verification functions record the real outcome here
11+
// instead, for EnforceRequired to read back.
12+
//
13+
// One instance is shared by every policy compiled for the same admission request,
14+
// so a wildcard required policy can see verifications done by other policies in
15+
// that request. It's bound into the CEL environment at compile time, so a compiled
16+
// policy belongs to that request and must not be cached/reused across requests.
17+
//
18+
// Only verification functions write to it (not exposed to CEL), and Record is
19+
// monotonic so a later no-op check can't clear an earlier genuine verification.
20+
// Both success and failure are recorded, to tell "never checked" apart from
21+
// "checked and failed".
22+
type ImageVerificationResults struct {
23+
mu sync.RWMutex
24+
verified map[string]bool
25+
}
26+
27+
func NewImageVerificationResults() *ImageVerificationResults {
28+
return &ImageVerificationResults{verified: make(map[string]bool)}
29+
}
30+
31+
// Record notes the outcome of a verification attempt for image. Callers must only
32+
// report success when a cryptographic check genuinely passed, not when a CEL
33+
// expression evaluated to true.
34+
func (r *ImageVerificationResults) Record(image string, verified bool) {
35+
if r == nil {
36+
return
37+
}
38+
r.mu.Lock()
39+
defer r.mu.Unlock()
40+
if r.verified == nil {
41+
r.verified = make(map[string]bool)
42+
}
43+
r.verified[image] = r.verified[image] || verified
44+
}
45+
46+
// Status reports whether image passed verification, and whether it was checked at
47+
// all. The two are separate because an image no policy tried to verify is a
48+
// different failure from one that was checked and rejected, and the caller
49+
// reports them with different messages.
50+
func (r *ImageVerificationResults) Status(image string) (verified bool, attempted bool) {
51+
if r == nil {
52+
return false, false
53+
}
54+
r.mu.RLock()
55+
defer r.mu.RUnlock()
56+
v, ok := r.verified[image]
57+
return v, ok
58+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package imageverify
2+
3+
import (
4+
"sync"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestImageVerificationResults_StatusDistinguishesNeverCheckedFromFailed(t *testing.T) {
11+
t.Parallel()
12+
l := NewImageVerificationResults()
13+
14+
// never recorded
15+
verified, attempted := l.Status("ghcr.io/kyverno/unseen:latest")
16+
assert.False(t, verified)
17+
assert.False(t, attempted, "an image no expression checked must not look like a failed check")
18+
19+
l.Record("ghcr.io/kyverno/failed:latest", false)
20+
verified, attempted = l.Status("ghcr.io/kyverno/failed:latest")
21+
assert.False(t, verified)
22+
assert.True(t, attempted, "a failed check must be distinguishable from no check at all")
23+
24+
l.Record("ghcr.io/kyverno/ok:latest", true)
25+
verified, attempted = l.Status("ghcr.io/kyverno/ok:latest")
26+
assert.True(t, verified)
27+
assert.True(t, attempted)
28+
}
29+
30+
// A policy's expressions all share one set of results, so an image verified by one
31+
// expression must stay verified when a later expression checks it against a
32+
// different attestor and fails. Without this the verdict would depend on the
33+
// order the expressions happen to be written in.
34+
func TestImageVerificationResults_RecordIsMonotonic(t *testing.T) {
35+
t.Parallel()
36+
const image = "ghcr.io/kyverno/test-verify-image:signed"
37+
38+
t.Run("success then failure", func(t *testing.T) {
39+
t.Parallel()
40+
l := NewImageVerificationResults()
41+
l.Record(image, true)
42+
l.Record(image, false)
43+
verified, _ := l.Status(image)
44+
assert.True(t, verified, "a later failed check must not clear an earlier genuine verification")
45+
})
46+
47+
t.Run("failure then success", func(t *testing.T) {
48+
t.Parallel()
49+
l := NewImageVerificationResults()
50+
l.Record(image, false)
51+
l.Record(image, true)
52+
verified, _ := l.Status(image)
53+
assert.True(t, verified)
54+
})
55+
}
56+
57+
// The results are shared by every policy in an admission request, so they must be
58+
// safe to write from more than one goroutine even though policies are evaluated
59+
// sequentially today.
60+
func TestImageVerificationResults_ConcurrentRecordAndStatus(t *testing.T) {
61+
t.Parallel()
62+
const image = "ghcr.io/kyverno/test-verify-image:signed"
63+
l := NewImageVerificationResults()
64+
65+
var wg sync.WaitGroup
66+
for i := range 50 {
67+
wg.Add(2)
68+
go func() {
69+
defer wg.Done()
70+
l.Record(image, i%2 == 0)
71+
}()
72+
go func() {
73+
defer wg.Done()
74+
l.Status(image)
75+
}()
76+
}
77+
wg.Wait()
78+
79+
verified, attempted := l.Status(image)
80+
assert.True(t, verified)
81+
assert.True(t, attempted)
82+
}
83+
84+
// The results are optional at the API boundary, so every method has to tolerate a
85+
// nil receiver rather than panicking inside a CEL function.
86+
func TestImageVerificationResults_NilReceiverIsSafe(t *testing.T) {
87+
t.Parallel()
88+
var l *ImageVerificationResults
89+
assert.NotPanics(t, func() {
90+
l.Record("ghcr.io/kyverno/test-verify-image:signed", true)
91+
verified, attempted := l.Status("ghcr.io/kyverno/test-verify-image:signed")
92+
assert.False(t, verified)
93+
assert.False(t, attempted)
94+
})
95+
}

pkg/cel/policies/ivpol/engine/engine.go

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/kyverno/kyverno/pkg/admissionpolicy"
1010
"github.com/kyverno/kyverno/pkg/cel/engine"
1111
"github.com/kyverno/kyverno/pkg/cel/libs"
12+
"github.com/kyverno/kyverno/pkg/cel/libs/imageverify"
1213
"github.com/kyverno/kyverno/pkg/cel/matching"
1314
"github.com/kyverno/kyverno/pkg/config"
1415
engineapi "github.com/kyverno/kyverno/pkg/engine/api"
@@ -245,7 +246,9 @@ func (e *engineImpl) handleMutation(
245246
if !matches {
246247
continue
247248
}
248-
compiled, errList := c.Compile(ivpol.Policy, ivpol.Exceptions)
249+
// digest mutation performs no verification, so it takes no part in the
250+
// request-scoped verification results
251+
compiled, errList := c.Compile(ivpol.Policy, ivpol.Exceptions, nil)
249252
if errList != nil {
250253
// compile errors are surfaced by the validating webhook, skip mutation
251254
continue
@@ -369,14 +372,18 @@ func (e *engineImpl) evaluatePolicies(
369372
return nil, err
370373
}
371374
c := eval.NewCompiler(ictx, e.lister, requestResource, e.ivCache)
375+
// shared by every policy compiled below, so required sees cross-policy evidence
376+
verifications := imageverify.NewImageVerificationResults()
377+
// resolved after the loop: evidence may come from a policy evaluated later
378+
var pendingRequired []pendingRequiredCheck
372379
for _, ivpol := range policies {
373380
response := eval.ImageVerifyPolicyResponse{
374381
Policy: ivpol.Policy,
375382
Actions: ivpol.Actions,
376383
Exceptions: ivpol.Exceptions,
377384
}
378385
startTime := time.Now()
379-
compiled, errList := c.Compile(ivpol.Policy, ivpol.Exceptions)
386+
compiled, errList := c.Compile(ivpol.Policy, ivpol.Exceptions, verifications)
380387
if errList != nil {
381388
response.Result = *engineapi.RuleError("evaluation", engineapi.ImageVerify, "failed to compile policy", errList.ToAggregate(), nil)
382389
response.Result = response.Result.WithStats(engineapi.NewExecutionStats(startTime, time.Now()))
@@ -414,12 +421,48 @@ func (e *engineImpl) evaluatePolicies(
414421
response.Result = *engineapi.RuleError(ruleName, engineapi.ImageVerify, "error", result.Error, nil)
415422
} else if result.Result {
416423
response.Result = *engineapi.RulePass(ruleName, engineapi.ImageVerify, "success", result.AuditAnnotations)
424+
pendingRequired = append(pendingRequired, pendingRequiredCheck{
425+
name: ivpol.Policy.GetName(),
426+
compiled: compiled,
427+
images: result.MatchedImages,
428+
auditAnnotations: result.AuditAnnotations,
429+
startTime: startTime,
430+
})
417431
} else {
418432
response.Result = *engineapi.RuleFail(ruleName, engineapi.ImageVerify, result.Message, result.AuditAnnotations)
419433
}
420434
}
421435
response.Result = response.Result.WithStats(engineapi.NewExecutionStats(startTime, time.Now()))
422436
responses[ivpol.Policy.GetName()] = response
423437
}
438+
enforceRequired(pendingRequired, responses)
424439
return responses, nil
425440
}
441+
442+
// pendingRequiredCheck defers a passing policy's required check until every
443+
// policy in the request has contributed its verifications.
444+
type pendingRequiredCheck struct {
445+
name string
446+
compiled eval.CompiledPolicy
447+
images []string
448+
auditAnnotations map[string]string
449+
startTime time.Time
450+
}
451+
452+
// enforceRequired turns a policy that passed its validations into a failure when
453+
// one of the images it matched was never verified, by any policy in the request.
454+
func enforceRequired(checks []pendingRequiredCheck, responses map[string]eval.ImageVerifyPolicyResponse) {
455+
for _, check := range checks {
456+
err := check.compiled.EnforceRequired(check.images)
457+
if err == nil {
458+
continue
459+
}
460+
response, ok := responses[check.name]
461+
if !ok {
462+
continue
463+
}
464+
result := *engineapi.RuleFail(check.name, engineapi.ImageVerify, err.Error(), check.auditAnnotations)
465+
response.Result = result.WithStats(engineapi.NewExecutionStats(check.startTime, time.Now()))
466+
responses[check.name] = response
467+
}
468+
}

0 commit comments

Comments
 (0)