Cloud Security scripts
Cloud Security
The cloud security scripts and packages I've written, with their source shown directly below โ no need to open GitHub to read them.
Kubernetes RBAC privilege-escalation auditor
GoRead-onlyGo test suite (48 cases)
auditor.go Go ยท 779 lines
cloud-security/scripts/k8s-rbac/auditor.go
1// Package k8srbac is a bounded static RBAC-risk heuristic for educational
2// Role/ClusterRole fixtures.
3//
4// It validates roleRef API group and kind, distinguishes RoleBinding versus
5// ClusterRoleBinding authorization scope for ordinary API resources, and
6// models selected Kubernetes special authorization verbs (bind, escalate,
7// impersonate) separately from ordinary resource scope. TokenRequest detection
8// is limited to the core serviceaccounts/token subresource.
9//
10// It is not a full effective-permissions analysis and does not evaluate
11// SubjectAccessReview, SelfSubjectRulesReview, aggregation, admission
12// policies, or live API discovery. Unknown/custom resources are not assigned
13// a fabricated scope.
14package k8srbac
15
16import (
17 "fmt"
18 "sort"
19 "strings"
20)
21
22const rbacAPIGroup = "rbac.authorization.k8s.io"
23
24// ResourceScope classifies a built-in resource for binding-effective analysis.
25type ResourceScope int
26
27const (
28 // ResourceScopeUnknown means the analyzer does not claim namespaced or
29 // cluster scope for the resource (typically custom/unknown APIs).
30 ResourceScopeUnknown ResourceScope = iota
31 // ResourceScopeNamespaced resources can take effect through a RoleBinding.
32 ResourceScopeNamespaced
33 // ResourceScopeCluster resources take effect only through ClusterRoleBinding
34 // (or equivalent cluster-wide authorization), not through a namespaced RoleBinding.
35 ResourceScopeCluster
36)
37
38// RoleKey uniquely identifies a Role or ClusterRole.
39type RoleKey struct {
40 Kind string // "Role" or "ClusterRole"
41 Namespace string // empty for ClusterRole
42 Name string
43}
44
45// RoleRef mirrors rbac RoleRef used by bindings.
46type RoleRef struct {
47 APIGroup string
48 Kind string
49 Name string
50}
51
52// PolicyRule mirrors a subset of rbac.authorization.k8s.io PolicyRule.
53type PolicyRule struct {
54 APIGroups []string
55 Resources []string
56 ResourceNames []string
57 Verbs []string
58}
59
60// RoleLike is a Role or ClusterRole fixture.
61type RoleLike struct {
62 Kind string // Role or ClusterRole
63 Name string
64 Namespace string
65 Rules []PolicyRule
66}
67
68// BindingLike is a RoleBinding or ClusterRoleBinding fixture.
69type BindingLike struct {
70 Kind string // RoleBinding or ClusterRoleBinding
71 Name string
72 Namespace string // RoleBinding namespace; empty for ClusterRoleBinding
73 RoleRef RoleRef
74 Subjects []string
75}
76
77// Finding is one heuristic risk observation.
78type Finding struct {
79 Code string
80 Severity string
81 Description string
82 RoleKey RoleKey
83 BindingKind string
84 BindingName string
85 BindingScope string // namespace or "cluster"
86 Subjects []string
87 ResourceNames []string
88}
89
90// Audit resolves bindings against roles and returns heuristic risk findings.
91// Roles are indexed by RoleKey so identically named Roles in different
92// namespaces are never conflated. Unbound roles are not scored; findings are
93// produced from bindings (plus invalid/unresolved binding diagnostics).
94func Audit(roles []RoleLike, bindings []BindingLike) []Finding {
95 roleIndex := indexRoles(roles)
96
97 var findings []Finding
98 for _, binding := range bindings {
99 subjects := unique(binding.Subjects)
100 scope := bindingScope(binding)
101 role, roleKey, diag := resolveBinding(binding, roleIndex)
102 if diag != nil {
103 diag.Subjects = subjects
104 diag.BindingKind = binding.Kind
105 diag.BindingName = binding.Name
106 diag.BindingScope = scope
107 findings = append(findings, *diag)
108 continue
109 }
110 for _, rule := range role.Rules {
111 findings = append(findings, evaluateRule(roleKey, binding, scope, subjects, rule)...)
112 }
113 }
114 return mergeFindings(findings)
115}
116
117func indexRoles(roles []RoleLike) map[RoleKey]RoleLike {
118 out := make(map[RoleKey]RoleLike, len(roles))
119 for _, role := range roles {
120 key := roleKeyFor(role)
121 out[key] = role
122 }
123 return out
124}
125
126func roleKeyFor(role RoleLike) RoleKey {
127 kind := role.Kind
128 if kind == "" {
129 if role.Namespace == "" {
130 kind = "ClusterRole"
131 } else {
132 kind = "Role"
133 }
134 }
135 ns := role.Namespace
136 if kind == "ClusterRole" {
137 ns = ""
138 }
139 return RoleKey{Kind: kind, Namespace: ns, Name: role.Name}
140}
141
142func bindingScope(binding BindingLike) string {
143 if binding.Kind == "ClusterRoleBinding" {
144 return "cluster"
145 }
146 return binding.Namespace
147}
148
149func invalidBindingFinding(key RoleKey, description string) *Finding {
150 return &Finding{
151 Code: "invalid-binding",
152 Severity: "high",
153 Description: description,
154 RoleKey: key,
155 }
156}
157
158func resolveBinding(binding BindingLike, roles map[RoleKey]RoleLike) (RoleLike, RoleKey, *Finding) {
159 ref := binding.RoleRef
160 refKey := RoleKey{Kind: ref.Kind, Name: ref.Name}
161
162 switch binding.Kind {
163 case "ClusterRoleBinding":
164 if binding.Namespace != "" {
165 return RoleLike{}, refKey, invalidBindingFinding(refKey, "ClusterRoleBinding must not declare a namespace")
166 }
167 if ref.APIGroup != rbacAPIGroup {
168 return RoleLike{}, refKey, invalidBindingFinding(refKey, "roleRef.apiGroup must be rbac.authorization.k8s.io")
169 }
170 if ref.Kind == "" {
171 return RoleLike{}, refKey, invalidBindingFinding(refKey, "ClusterRoleBinding roleRef.kind must be ClusterRole")
172 }
173 if ref.Kind != "ClusterRole" {
174 if ref.Kind == "Role" {
175 return RoleLike{}, RoleKey{Kind: "Role", Name: ref.Name}, invalidBindingFinding(
176 RoleKey{Kind: "Role", Name: ref.Name},
177 "ClusterRoleBinding roleRef.kind must be ClusterRole",
178 )
179 }
180 return RoleLike{}, refKey, invalidBindingFinding(refKey, "ClusterRoleBinding roleRef.kind must be ClusterRole")
181 }
182 key := RoleKey{Kind: "ClusterRole", Name: ref.Name}
183 role, ok := roles[key]
184 if !ok {
185 return RoleLike{}, key, unresolvedFinding(key)
186 }
187 return role, key, nil
188
189 case "RoleBinding":
190 if binding.Namespace == "" {
191 return RoleLike{}, refKey, invalidBindingFinding(refKey, "RoleBinding requires a namespace")
192 }
193 if ref.APIGroup != rbacAPIGroup {
194 return RoleLike{}, refKey, invalidBindingFinding(refKey, "roleRef.apiGroup must be rbac.authorization.k8s.io")
195 }
196 switch ref.Kind {
197 case "Role":
198 key := RoleKey{Kind: "Role", Namespace: binding.Namespace, Name: ref.Name}
199 role, ok := roles[key]
200 if !ok {
201 return RoleLike{}, key, unresolvedFinding(key)
202 }
203 return role, key, nil
204 case "ClusterRole":
205 key := RoleKey{Kind: "ClusterRole", Name: ref.Name}
206 role, ok := roles[key]
207 if !ok {
208 return RoleLike{}, key, unresolvedFinding(key)
209 }
210 return role, key, nil
211 case "":
212 return RoleLike{}, refKey, invalidBindingFinding(refKey, "RoleBinding roleRef.kind must be Role or ClusterRole")
213 default:
214 return RoleLike{}, refKey, invalidBindingFinding(refKey, "RoleBinding roleRef.kind must be Role or ClusterRole")
215 }
216
217 default:
218 return RoleLike{}, refKey, invalidBindingFinding(refKey, fmt.Sprintf("unsupported binding kind %q", binding.Kind))
219 }
220}
221
222func unresolvedFinding(key RoleKey) *Finding {
223 return &Finding{
224 Code: "unresolved-role-ref",
225 Severity: "medium",
226 Description: fmt.Sprintf("binding references %s %q that was not provided in the role set", key.Kind, key.Name),
227 RoleKey: key,
228 }
229}
230
231// knownResourceScope returns the analyzer's bounded classification for a
232// built-in resource name (without subresource). Unknown APIs remain Unknown.
233func knownResourceScope(apiGroup, resource string) ResourceScope {
234 if resource == "" || resource == "*" {
235 return ResourceScopeUnknown
236 }
237 switch apiGroup {
238 case "", "core":
239 switch resource {
240 case "pods", "secrets", "serviceaccounts", "configmaps", "services",
241 "endpoints", "events", "limitranges", "resourcequotas",
242 "replicationcontrollers", "persistentvolumeclaims":
243 return ResourceScopeNamespaced
244 case "nodes", "namespaces", "persistentvolumes", "componentstatuses":
245 return ResourceScopeCluster
246 case "users", "groups", "userextras":
247 // Impersonation targets are not namespaced RoleBinding-effective.
248 return ResourceScopeCluster
249 }
250 case "apps", "batch", "extensions":
251 switch resource {
252 case "deployments", "statefulsets", "daemonsets", "replicasets",
253 "jobs", "cronjobs":
254 return ResourceScopeNamespaced
255 }
256 case "rbac.authorization.k8s.io":
257 switch resource {
258 case "roles", "rolebindings":
259 return ResourceScopeNamespaced
260 case "clusterroles", "clusterrolebindings":
261 return ResourceScopeCluster
262 }
263 case "certificates.k8s.io":
264 switch resource {
265 case "certificatesigningrequests", "signers":
266 return ResourceScopeCluster
267 }
268 case "authentication.k8s.io":
269 switch resource {
270 case "tokenreviews":
271 return ResourceScopeCluster
272 }
273 case "authorization.k8s.io":
274 switch resource {
275 case "subjectaccessreviews", "selfsubjectaccessreviews",
276 "selfsubjectrulesreviews", "localsubjectaccessreviews":
277 // LocalSubjectAccessReviews are namespaced; the others are cluster-scoped.
278 if resource == "localsubjectaccessreviews" {
279 return ResourceScopeNamespaced
280 }
281 return ResourceScopeCluster
282 }
283 case "storage.k8s.io":
284 switch resource {
285 case "storageclasses", "csidrivers", "csinodes":
286 return ResourceScopeCluster
287 case "volumeattachments":
288 return ResourceScopeCluster
289 }
290 case "admissionregistration.k8s.io":
291 switch resource {
292 case "validatingwebhookconfigurations", "mutatingwebhookconfigurations",
293 "validatingadmissionpolicies", "validatingadmissionpolicybindings",
294 "mutatingadmissionpolicies", "mutatingadmissionpolicybindings":
295 return ResourceScopeCluster
296 }
297 }
298 return ResourceScopeUnknown
299}
300
301// permissionEffective reports whether an ordinary API resource permission can
302// take effect through the binding under this bounded model. Special
303// authorization verbs (bind, escalate, impersonate) use dedicated helpers.
304func permissionEffective(binding BindingLike, apiGroup, resourceName string) bool {
305 if binding.Kind == "ClusterRoleBinding" {
306 return true
307 }
308 // Namespaced RoleBinding: only known namespaced resources are treated as effective.
309 switch knownResourceScope(apiGroup, resourceName) {
310 case ResourceScopeNamespaced:
311 return true
312 case ResourceScopeCluster, ResourceScopeUnknown:
313 return false
314 default:
315 return false
316 }
317}
318
319// specialRBACPermissionEffective models Kubernetes special bind/escalate checks
320// against roles and clusterroles. RoleBindings and ClusterRoleBindings are not
321// valid special-verb targets.
322func specialRBACPermissionEffective(binding BindingLike, resource, verb string) bool {
323 switch verb {
324 case "bind":
325 switch resource {
326 case "roles", "clusterroles":
327 return binding.Kind == "RoleBinding" || binding.Kind == "ClusterRoleBinding"
328 }
329 case "escalate":
330 switch resource {
331 case "roles":
332 return binding.Kind == "RoleBinding" || binding.Kind == "ClusterRoleBinding"
333 case "clusterroles":
334 // Escalating ClusterRole definitions is cluster-scoped.
335 return binding.Kind == "ClusterRoleBinding"
336 }
337 }
338 return false
339}
340
341// impersonationEffective reports whether identity impersonation can take effect
342// through the binding. Impersonation is cluster-scoped identity authorization.
343func impersonationEffective(binding BindingLike) bool {
344 return binding.Kind == "ClusterRoleBinding"
345}
346
347func impersonationTargetAPIGroup(resource string) (string, bool) {
348 switch resource {
349 case "users", "groups", "serviceaccounts":
350 return "", true
351 case "userextras":
352 return "authentication.k8s.io", true
353 default:
354 return "", false
355 }
356}
357
358func specialRBACVerbs(verbs []string) []string {
359 if contains(verbs, "*") {
360 return []string{"bind", "escalate"}
361 }
362 var out []string
363 if contains(verbs, "bind") {
364 out = append(out, "bind")
365 }
366 if contains(verbs, "escalate") {
367 out = append(out, "escalate")
368 }
369 return out
370}
371
372func specialRBACDescription(binding BindingLike, verb, resource, scope string) string {
373 switch verb {
374 case "bind":
375 switch resource {
376 case "roles":
377 if binding.Kind == "RoleBinding" {
378 return fmt.Sprintf("bind on roles can permit binding selected Roles through RoleBindings in namespace %q", scope)
379 }
380 return "bind on roles can permit binding selected Roles cluster-wide"
381 case "clusterroles":
382 if binding.Kind == "RoleBinding" {
383 return fmt.Sprintf("bind on clusterroles can permit binding selected ClusterRoles through RoleBindings in namespace %q", scope)
384 }
385 return "bind on clusterroles can permit binding selected ClusterRoles cluster-wide"
386 }
387 case "escalate":
388 switch resource {
389 case "roles":
390 if binding.Kind == "RoleBinding" {
391 return fmt.Sprintf("escalate on roles can permit creating or modifying Roles beyond the caller's currently held permissions in namespace %q", scope)
392 }
393 return "escalate on roles can permit creating or modifying Roles beyond the caller's currently held permissions"
394 case "clusterroles":
395 return "escalate on clusterroles can permit cluster-wide privilege escalation"
396 }
397 }
398 return fmt.Sprintf("%s on %s expands RBAC authority", verb, resource)
399}
400
401func evaluateRule(roleKey RoleKey, binding BindingLike, scope string, subjects []string, rule PolicyRule) []Finding {
402 var findings []Finding
403 appendFinding := func(code, severity, description, resourceNamesNote string) {
404 desc := description
405 resourceNames := append([]string(nil), rule.ResourceNames...)
406 if len(resourceNames) > 0 {
407 note := resourceNamesNote
408 if note == "" {
409 note = "named constraints do not make the permission safe by themselves"
410 }
411 desc = fmt.Sprintf("%s; constrained to resourceNames %v; %s", description, resourceNames, note)
412 }
413 findings = append(findings, Finding{
414 Code: code,
415 Severity: severity,
416 Description: desc,
417 RoleKey: roleKey,
418 BindingKind: binding.Kind,
419 BindingName: binding.Name,
420 BindingScope: scope,
421 Subjects: append([]string(nil), subjects...),
422 ResourceNames: resourceNames,
423 })
424 }
425 addOrdinary := func(code, severity, description, apiGroup, resourceName string) {
426 if !permissionEffective(binding, apiGroup, resourceName) {
427 return
428 }
429 appendFinding(code, severity, description, "named constraints do not make the permission safe by themselves")
430 }
431
432 if isAllResourcesAllVerbs(rule) {
433 // Wildcard grant is still meaningful inside a RoleBinding namespace, but
434 // must not be described as granting cluster-scoped resources.
435 desc := "apiGroups/resources/verbs wildcards grant unrestricted authorization within the binding scope for resources that can take effect there"
436 if binding.Kind == "RoleBinding" {
437 desc = "apiGroups/resources/verbs wildcards grant unrestricted authorization for namespaced resources within the RoleBinding namespace; known cluster-scoped resources are not treated as effective through this binding"
438 }
439 addOrdinary("all-resources-all-verbs", "critical", desc, "", "pods")
440 }
441
442 apiGroups := rule.APIGroups
443 if len(apiGroups) == 0 {
444 apiGroups = []string{""}
445 }
446
447 for _, group := range apiGroups {
448 for _, resource := range expandResources(rule.Resources) {
449 // TokenRequest is core serviceaccounts/token only.
450 if groupMatches(group, "") &&
451 resourceMatches(resource, "serviceaccounts", "token") &&
452 hasAnyVerb(rule.Verbs, "create", "*") {
453 addOrdinary("sa-token-create", "high", "create on serviceaccounts/token can mint TokenRequest-bound service-account tokens", "", "serviceaccounts")
454 }
455 if groupMatches(group, "certificates.k8s.io") &&
456 resourceMatches(resource, "certificatesigningrequests", "approval") &&
457 hasAnyVerb(rule.Verbs, "update", "patch", "*") {
458 addOrdinary("csr-approval", "high", "update/patch on certificatesigningrequests/approval can approve certificate requests", "certificates.k8s.io", "certificatesigningrequests")
459 }
460 if groupMatches(group, "certificates.k8s.io") &&
461 resourceMatches(resource, "signers", "") &&
462 hasAnyVerb(rule.Verbs, "approve", "*") {
463 addOrdinary("csr-signer-approve", "high", "approve on certificatesigningrequests signers grants signer approval authority", "certificates.k8s.io", "signers")
464 }
465 if groupMatches(group, "authentication.k8s.io") &&
466 resourceMatches(resource, "tokenreviews", "") &&
467 hasAnyVerb(rule.Verbs, "create", "*") {
468 addOrdinary("tokenreview-oracle", "medium", "create on tokenreviews is a token-authentication oracle capability; it does not mint service-account tokens", "authentication.k8s.io", "tokenreviews")
469 }
470
471 if hasAnyVerb(rule.Verbs, "impersonate", "*") && impersonationEffective(binding) {
472 var targets []string
473 switch {
474 case resource.name == "*":
475 targets = []string{"users", "groups", "serviceaccounts", "userextras"}
476 case resource.subresource == "" && (resource.name == "users" || resource.name == "groups" ||
477 resource.name == "serviceaccounts" || resource.name == "userextras"):
478 targets = []string{resource.name}
479 }
480 for _, target := range targets {
481 wantGroup, ok := impersonationTargetAPIGroup(target)
482 if !ok || !groupMatches(group, wantGroup) {
483 continue
484 }
485 appendFinding("impersonate", "high",
486 fmt.Sprintf("impersonate on %s expands identity authority", target),
487 "named constraints do not make the permission safe by themselves")
488 }
489 }
490
491 if groupMatches(group, "rbac.authorization.k8s.io") {
492 candidates := []string{"roles", "clusterroles"}
493 if resource.name != "*" {
494 if resource.subresource != "" {
495 candidates = nil
496 } else {
497 candidates = []string{resource.name}
498 }
499 }
500 for _, candidate := range candidates {
501 if candidate != "roles" && candidate != "clusterroles" {
502 continue
503 }
504 for _, verb := range specialRBACVerbs(rule.Verbs) {
505 if !specialRBACPermissionEffective(binding, candidate, verb) {
506 continue
507 }
508 code := "rbac-bind"
509 if verb == "escalate" {
510 code = "rbac-escalate"
511 }
512 note := "named constraints do not make the permission safe by themselves"
513 if verb == "bind" {
514 note = "the restriction narrows which roles may be bound but does not make the permission safe by itself"
515 }
516 appendFinding(code, "high", specialRBACDescription(binding, verb, candidate, scope), note)
517 }
518 }
519 }
520
521 if groupMatches(group, "apps", "batch", "extensions") &&
522 isWorkloadController(resource.name) &&
523 hasAnyVerb(rule.Verbs, "create", "update", "patch", "*") {
524 name := resource.name
525 effective := name
526 if name == "*" {
527 name = "workload controllers"
528 effective = "deployments"
529 }
530 api := group
531 if api == "*" {
532 api = "apps"
533 }
534 addOrdinary("workload-controller-mutate", "medium", fmt.Sprintf("mutate %s; review pod templates and service-account bindings", name), api, effective)
535 }
536 if groupMatches(group, "") &&
537 resourceMatches(resource, "pods", "") &&
538 hasAnyVerb(rule.Verbs, "create", "*") {
539 addOrdinary("pod-create", "medium", "create pods is a heuristic risk when combined with privileged service accounts or permissive admission; not proof of breakout alone", "", "pods")
540 }
541 if groupMatches(group, "") &&
542 (resourceMatches(resource, "pods", "exec") || resourceMatches(resource, "pods", "attach") || resourceMatches(resource, "pods", "portforward")) &&
543 hasAnyVerb(rule.Verbs, "create", "get", "update", "*") {
544 sub := resource.subresource
545 if resource.name == "*" || sub == "" {
546 sub = "exec|attach|portforward"
547 }
548 addOrdinary("pod-interactive", "high", fmt.Sprintf("pods/%s enables interactive access to running workloads", sub), "", "pods")
549 }
550 if groupMatches(group, "") &&
551 resourceMatches(resource, "secrets", "") &&
552 hasAnyVerb(rule.Verbs, "get", "list", "watch", "*") {
553 addOrdinary("secrets-read", "high", "read access to secrets can expose credentials mounted or stored as Secret objects", "", "secrets")
554 }
555 if groupMatches(group, "") &&
556 resourceMatches(resource, "nodes", "proxy") &&
557 hasAnyVerb(rule.Verbs, "create", "get", "update", "*") {
558 addOrdinary("nodes-proxy", "high", "nodes/proxy can reach kubelet APIs and bypass some network controls", "", "nodes")
559 }
560 if groupMatches(group, "admissionregistration.k8s.io") &&
561 (resourceMatches(resource, "validatingwebhookconfigurations", "") ||
562 resourceMatches(resource, "mutatingwebhookconfigurations", "")) &&
563 hasAnyVerb(rule.Verbs, "create", "update", "patch", "delete", "*") {
564 name := resource.name
565 effective := name
566 if name == "*" {
567 name = "admission webhook configurations"
568 effective = "validatingwebhookconfigurations"
569 }
570 addOrdinary("admission-policy-mutate", "high", fmt.Sprintf("mutation of %s can weaken or bypass admission controls", name), "admissionregistration.k8s.io", effective)
571 }
572 }
573 }
574
575 findings = append(findings, evaluateCustomPolicyRules(roleKey, binding, scope, subjects, rule)...)
576 return findings
577}
578
579func evaluateCustomPolicyRules(roleKey RoleKey, binding BindingLike, scope string, subjects []string, rule PolicyRule) []Finding {
580 if binding.Kind != "ClusterRoleBinding" {
581 return nil
582 }
583 var findings []Finding
584 apiGroups := rule.APIGroups
585 if len(apiGroups) == 0 {
586 return nil
587 }
588 for _, group := range apiGroups {
589 if !groupMatches(group, "kyverno.io", "constraints.gatekeeper.sh", "templates.gatekeeper.sh", "policy") {
590 continue
591 }
592 if !hasAnyVerb(rule.Verbs, "create", "update", "patch", "delete", "*") {
593 continue
594 }
595 for _, resource := range expandResources(rule.Resources) {
596 name := resource.name
597 if name == "" || name == "*" {
598 name = "policy resources"
599 }
600 desc := fmt.Sprintf("mutation of %s can weaken or bypass admission controls", name)
601 resourceNames := append([]string(nil), rule.ResourceNames...)
602 if len(resourceNames) > 0 {
603 desc = fmt.Sprintf(
604 "%s; constrained to resourceNames %v (named constraints do not make the permission safe by themselves)",
605 desc,
606 resourceNames,
607 )
608 }
609 findings = append(findings, Finding{
610 Code: "admission-policy-mutate",
611 Severity: "high",
612 Description: desc,
613 RoleKey: roleKey,
614 BindingKind: binding.Kind,
615 BindingName: binding.Name,
616 BindingScope: scope,
617 Subjects: append([]string(nil), subjects...),
618 ResourceNames: resourceNames,
619 })
620 break
621 }
622 }
623 return findings
624}
625
626func isAllResourcesAllVerbs(rule PolicyRule) bool {
627 return contains(rule.APIGroups, "*") && contains(rule.Resources, "*") && contains(rule.Verbs, "*")
628}
629
630type resourceRef struct {
631 name string
632 subresource string
633}
634
635func expandResources(resources []string) []resourceRef {
636 if len(resources) == 0 {
637 return nil
638 }
639 var out []resourceRef
640 for _, item := range resources {
641 if item == "*" {
642 out = append(out, resourceRef{name: "*"})
643 continue
644 }
645 name, sub, ok := strings.Cut(item, "/")
646 if ok {
647 out = append(out, resourceRef{name: name, subresource: sub})
648 } else {
649 out = append(out, resourceRef{name: item})
650 }
651 }
652 return out
653}
654
655func groupMatches(actual string, allowed ...string) bool {
656 if actual == "*" {
657 return true
658 }
659 for _, item := range allowed {
660 if item == "*" || item == actual {
661 return true
662 }
663 }
664 return false
665}
666
667// resourceMatches treats resources:["*"] as covering every resource and
668// subresource. A bare resource (no slash) never implies a subresource.
669func resourceMatches(ref resourceRef, wantName, wantSub string) bool {
670 if ref.name == "*" {
671 return true
672 }
673 if ref.name != wantName {
674 return false
675 }
676 if wantSub == "" {
677 return ref.subresource == ""
678 }
679 return ref.subresource == wantSub || ref.subresource == "*"
680}
681
682func hasAnyVerb(verbs []string, wanted ...string) bool {
683 for _, verb := range verbs {
684 if verb == "*" {
685 return true
686 }
687 for _, want := range wanted {
688 if verb == want {
689 return true
690 }
691 }
692 }
693 return false
694}
695
696func isWorkloadController(name string) bool {
697 switch name {
698 case "deployments", "statefulsets", "daemonsets", "replicasets", "jobs", "cronjobs", "*":
699 return true
700 default:
701 return false
702 }
703}
704
705func contains(values []string, want string) bool {
706 for _, value := range values {
707 if value == want {
708 return true
709 }
710 }
711 return false
712}
713
714func unique(values []string) []string {
715 seen := map[string]struct{}{}
716 var out []string
717 for _, value := range values {
718 if _, ok := seen[value]; ok {
719 continue
720 }
721 seen[value] = struct{}{}
722 out = append(out, value)
723 }
724 return out
725}
726
727type findingKey struct {
728 Code string
729 RoleKind string
730 RoleNS string
731 RoleName string
732 BindingKind string
733 BindingName string
734 BindingScope string
735}
736
737func mergeFindings(in []Finding) []Finding {
738 order := make([]findingKey, 0, len(in))
739 byKey := map[findingKey]*Finding{}
740 for _, finding := range in {
741 key := findingKey{
742 Code: finding.Code,
743 RoleKind: finding.RoleKey.Kind,
744 RoleNS: finding.RoleKey.Namespace,
745 RoleName: finding.RoleKey.Name,
746 BindingKind: finding.BindingKind,
747 BindingName: finding.BindingName,
748 BindingScope: finding.BindingScope,
749 }
750 if existing, ok := byKey[key]; ok {
751 existing.Subjects = unique(append(existing.Subjects, finding.Subjects...))
752 existing.ResourceNames = unique(append(existing.ResourceNames, finding.ResourceNames...))
753 continue
754 }
755 copyFinding := finding
756 copyFinding.Subjects = unique(append([]string(nil), finding.Subjects...))
757 copyFinding.ResourceNames = unique(append([]string(nil), finding.ResourceNames...))
758 byKey[key] = ©Finding
759 order = append(order, key)
760 }
761 out := make([]Finding, 0, len(order))
762 for _, key := range order {
763 f := *byKey[key]
764 sort.Strings(f.Subjects)
765 sort.Strings(f.ResourceNames)
766 out = append(out, f)
767 }
768 return out
769}
770
771// HasCode reports whether any finding uses the code.
772func HasCode(findings []Finding, code string) bool {
773 for _, finding := range findings {
774 if finding.Code == code {
775 return true
776 }
777 }
778 return false
779}
1package k8srbac_test
2
3import (
4 "strings"
5 "testing"
6
7 k8srbac "github.com/jasonachkar/cybersecurity-writeups/cloud-security/scripts/k8s-rbac"
8)
9
10func bindRole(ns, bindingName, roleName string, subjects ...string) k8srbac.BindingLike {
11 return k8srbac.BindingLike{
12 Kind: "RoleBinding",
13 Name: bindingName,
14 Namespace: ns,
15 RoleRef: k8srbac.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "Role", Name: roleName},
16 Subjects: subjects,
17 }
18}
19
20func bindClusterRoleViaRoleBinding(ns, bindingName, clusterRoleName string, subjects ...string) k8srbac.BindingLike {
21 return k8srbac.BindingLike{
22 Kind: "RoleBinding",
23 Name: bindingName,
24 Namespace: ns,
25 RoleRef: k8srbac.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "ClusterRole", Name: clusterRoleName},
26 Subjects: subjects,
27 }
28}
29
30func bindClusterRole(bindingName, clusterRoleName string, subjects ...string) k8srbac.BindingLike {
31 return k8srbac.BindingLike{
32 Kind: "ClusterRoleBinding",
33 Name: bindingName,
34 RoleRef: k8srbac.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "ClusterRole", Name: clusterRoleName},
35 Subjects: subjects,
36 }
37}
38
39func findingsWithCode(findings []k8srbac.Finding, code string) []k8srbac.Finding {
40 var out []k8srbac.Finding
41 for _, finding := range findings {
42 if finding.Code == code {
43 out = append(out, finding)
44 }
45 }
46 return out
47}
48
49func TestWildcardAPIGroupResourcesAndVerbs(t *testing.T) {
50 roles := []k8srbac.RoleLike{{
51 Kind: "ClusterRole",
52 Name: "superuser",
53 Rules: []k8srbac.PolicyRule{{
54 APIGroups: []string{"*"},
55 Resources: []string{"*"},
56 Verbs: []string{"*"},
57 }},
58 }}
59 bindings := []k8srbac.BindingLike{bindClusterRole("bind", "superuser", "system:serviceaccount:kube-system:admin")}
60 findings := k8srbac.Audit(roles, bindings)
61
62 if !k8srbac.HasCode(findings, "all-resources-all-verbs") {
63 t.Fatalf("expected all-resources-all-verbs, got %#v", findings)
64 }
65 // Derived risk classes should also evaluate under full wildcards.
66 for _, code := range []string{"sa-token-create", "secrets-read", "pod-interactive", "rbac-bind", "rbac-escalate", "impersonate"} {
67 if !k8srbac.HasCode(findings, code) {
68 t.Fatalf("expected derived risk %s under wildcards, got %#v", code, findings)
69 }
70 }
71 got := findingsWithCode(findings, "all-resources-all-verbs")
72 if got[0].Severity != "critical" {
73 t.Fatalf("severity = %q, want critical", got[0].Severity)
74 }
75}
76
77func TestSameRoleNameDifferentNamespaces(t *testing.T) {
78 roles := []k8srbac.RoleLike{
79 {
80 Kind: "Role",
81 Name: "reader",
82 Namespace: "alpha",
83 Rules: []k8srbac.PolicyRule{{
84 APIGroups: []string{""},
85 Resources: []string{"secrets"},
86 Verbs: []string{"get"},
87 }},
88 },
89 {
90 Kind: "Role",
91 Name: "reader",
92 Namespace: "beta",
93 Rules: []k8srbac.PolicyRule{{
94 APIGroups: []string{""},
95 Resources: []string{"pods"},
96 Verbs: []string{"create"},
97 }},
98 },
99 }
100 bindings := []k8srbac.BindingLike{
101 bindRole("alpha", "a", "reader", "system:serviceaccount:alpha:sa"),
102 bindRole("beta", "b", "reader", "system:serviceaccount:beta:sa"),
103 }
104 findings := k8srbac.Audit(roles, bindings)
105
106 secretFindings := findingsWithCode(findings, "secrets-read")
107 if len(secretFindings) != 1 {
108 t.Fatalf("secrets-read findings = %#v", secretFindings)
109 }
110 if secretFindings[0].RoleKey.Namespace != "alpha" || secretFindings[0].BindingScope != "alpha" {
111 t.Fatalf("secrets finding keyed wrong: %#v", secretFindings[0])
112 }
113
114 podFindings := findingsWithCode(findings, "pod-create")
115 if len(podFindings) != 1 {
116 t.Fatalf("pod-create findings = %#v", podFindings)
117 }
118 if podFindings[0].RoleKey.Namespace != "beta" || podFindings[0].BindingScope != "beta" {
119 t.Fatalf("pod finding keyed wrong: %#v", podFindings[0])
120 }
121}
122
123func TestRoleBindingReferencesRole(t *testing.T) {
124 roles := []k8srbac.RoleLike{{
125 Kind: "Role",
126 Name: "secret-reader",
127 Namespace: "app",
128 Rules: []k8srbac.PolicyRule{{
129 APIGroups: []string{""},
130 Resources: []string{"secrets"},
131 Verbs: []string{"get"},
132 }},
133 }}
134 bindings := []k8srbac.BindingLike{
135 bindRole("app", "bind", "secret-reader", "system:serviceaccount:app:worker"),
136 }
137 findings := k8srbac.Audit(roles, bindings)
138 got := findingsWithCode(findings, "secrets-read")
139 if len(got) != 1 {
140 t.Fatalf("expected one secrets-read finding, got %#v", findings)
141 }
142 if got[0].RoleKey != (k8srbac.RoleKey{Kind: "Role", Namespace: "app", Name: "secret-reader"}) {
143 t.Fatalf("RoleKey = %#v", got[0].RoleKey)
144 }
145 if got[0].BindingKind != "RoleBinding" || got[0].BindingScope != "app" {
146 t.Fatalf("binding metadata = %#v", got[0])
147 }
148}
149
150func TestRoleBindingReferencesClusterRole(t *testing.T) {
151 roles := []k8srbac.RoleLike{{
152 Kind: "ClusterRole",
153 Name: "secret-reader",
154 Rules: []k8srbac.PolicyRule{{
155 APIGroups: []string{""},
156 Resources: []string{"secrets"},
157 Verbs: []string{"list"},
158 }},
159 }}
160 bindings := []k8srbac.BindingLike{
161 bindClusterRoleViaRoleBinding("team", "bind", "secret-reader", "system:serviceaccount:team:worker"),
162 }
163 findings := k8srbac.Audit(roles, bindings)
164 got := findingsWithCode(findings, "secrets-read")
165 if len(got) != 1 {
166 t.Fatalf("expected secrets-read, got %#v", findings)
167 }
168 if got[0].RoleKey.Kind != "ClusterRole" || got[0].RoleKey.Namespace != "" {
169 t.Fatalf("RoleKey = %#v", got[0].RoleKey)
170 }
171 if got[0].BindingScope != "team" {
172 t.Fatalf("BindingScope = %q, want team (namespaced grant of cluster role)", got[0].BindingScope)
173 }
174}
175
176func TestClusterRoleBindingReferencesClusterRole(t *testing.T) {
177 roles := []k8srbac.RoleLike{{
178 Kind: "ClusterRole",
179 Name: "node-proxy",
180 Rules: []k8srbac.PolicyRule{{
181 APIGroups: []string{""},
182 Resources: []string{"nodes/proxy"},
183 Verbs: []string{"get"},
184 }},
185 }}
186 bindings := []k8srbac.BindingLike{
187 bindClusterRole("bind", "node-proxy", "system:serviceaccount:kube-system:proxy"),
188 }
189 findings := k8srbac.Audit(roles, bindings)
190 got := findingsWithCode(findings, "nodes-proxy")
191 if len(got) != 1 {
192 t.Fatalf("expected nodes-proxy, got %#v", findings)
193 }
194 if got[0].BindingKind != "ClusterRoleBinding" || got[0].BindingScope != "cluster" {
195 t.Fatalf("binding metadata = %#v", got[0])
196 }
197}
198
199func TestInvalidClusterRoleBindingToRole(t *testing.T) {
200 roles := []k8srbac.RoleLike{{
201 Kind: "Role",
202 Name: "local",
203 Namespace: "app",
204 Rules: []k8srbac.PolicyRule{{
205 APIGroups: []string{""},
206 Resources: []string{"secrets"},
207 Verbs: []string{"get"},
208 }},
209 }}
210 bindings := []k8srbac.BindingLike{{
211 Kind: "ClusterRoleBinding",
212 Name: "bad",
213 RoleRef: k8srbac.RoleRef{
214 APIGroup: "rbac.authorization.k8s.io",
215 Kind: "Role",
216 Name: "local",
217 },
218 Subjects: []string{"user:attacker"},
219 }}
220 findings := k8srbac.Audit(roles, bindings)
221 if !k8srbac.HasCode(findings, "invalid-binding") {
222 t.Fatalf("expected invalid-binding, got %#v", findings)
223 }
224 if k8srbac.HasCode(findings, "secrets-read") {
225 t.Fatal("invalid ClusterRoleBindingโRole must not evaluate Role rules")
226 }
227}
228
229func TestNamespaceLimitedClusterRoleViaRoleBinding(t *testing.T) {
230 roles := []k8srbac.RoleLike{{
231 Kind: "ClusterRole",
232 Name: "pod-creator",
233 Rules: []k8srbac.PolicyRule{{
234 APIGroups: []string{""},
235 Resources: []string{"pods"},
236 Verbs: []string{"create"},
237 }},
238 }}
239 bindings := []k8srbac.BindingLike{
240 bindClusterRoleViaRoleBinding("payments", "ns-grant", "pod-creator", "system:serviceaccount:payments:ci"),
241 }
242 findings := k8srbac.Audit(roles, bindings)
243 got := findingsWithCode(findings, "pod-create")
244 if len(got) != 1 {
245 t.Fatalf("expected pod-create, got %#v", findings)
246 }
247 if got[0].BindingScope != "payments" {
248 t.Fatalf("BindingScope = %q, want payments", got[0].BindingScope)
249 }
250 if got[0].RoleKey.Kind != "ClusterRole" {
251 t.Fatalf("RoleKey.Kind = %q, want ClusterRole", got[0].RoleKey.Kind)
252 }
253}
254
255func TestResourceNamesPreserved(t *testing.T) {
256 roles := []k8srbac.RoleLike{{
257 Kind: "ClusterRole",
258 Name: "signer",
259 Rules: []k8srbac.PolicyRule{{
260 APIGroups: []string{"certificates.k8s.io"},
261 Resources: []string{"signers"},
262 ResourceNames: []string{"kubernetes.io/kubelet-serving"},
263 Verbs: []string{"approve"},
264 }},
265 }}
266 bindings := []k8srbac.BindingLike{
267 bindClusterRole("bind", "signer", "system:serviceaccount:kube-system:csr"),
268 }
269 findings := k8srbac.Audit(roles, bindings)
270 got := findingsWithCode(findings, "csr-signer-approve")
271 if len(got) != 1 {
272 t.Fatalf("expected csr-signer-approve, got %#v", findings)
273 }
274 if len(got[0].ResourceNames) != 1 || got[0].ResourceNames[0] != "kubernetes.io/kubelet-serving" {
275 t.Fatalf("ResourceNames = %#v", got[0].ResourceNames)
276 }
277 if !strings.Contains(got[0].Description, "resourceNames") {
278 t.Fatalf("description must mention resourceNames constraint: %q", got[0].Description)
279 }
280 if strings.Contains(strings.ToLower(got[0].Description), "safe") &&
281 !strings.Contains(got[0].Description, "do not make the permission safe") {
282 t.Fatalf("must not claim resourceNames makes permission safe: %q", got[0].Description)
283 }
284}
285
286func TestUnresolvedRoleReferences(t *testing.T) {
287 bindings := []k8srbac.BindingLike{
288 bindRole("app", "missing-role", "no-such-role", "system:serviceaccount:app:sa"),
289 bindClusterRole("missing-cr", "no-such-clusterrole", "user:admin"),
290 }
291 findings := k8srbac.Audit(nil, bindings)
292 unresolved := findingsWithCode(findings, "unresolved-role-ref")
293 if len(unresolved) != 2 {
294 t.Fatalf("expected 2 unresolved-role-ref findings, got %#v", findings)
295 }
296}
297
298func TestDuplicateBindingsSubjectDedup(t *testing.T) {
299 roles := []k8srbac.RoleLike{{
300 Kind: "Role",
301 Name: "secret-reader",
302 Namespace: "app",
303 Rules: []k8srbac.PolicyRule{{
304 APIGroups: []string{""},
305 Resources: []string{"secrets"},
306 Verbs: []string{"get"},
307 }},
308 }}
309 bindings := []k8srbac.BindingLike{
310 {
311 Kind: "RoleBinding",
312 Name: "bind",
313 Namespace: "app",
314 RoleRef: k8srbac.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "Role", Name: "secret-reader"},
315 Subjects: []string{"user:a", "user:a", "user:b"},
316 },
317 {
318 Kind: "RoleBinding",
319 Name: "bind",
320 Namespace: "app",
321 RoleRef: k8srbac.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "Role", Name: "secret-reader"},
322 Subjects: []string{"user:b", "user:c"},
323 },
324 }
325 findings := k8srbac.Audit(roles, bindings)
326 got := findingsWithCode(findings, "secrets-read")
327 if len(got) != 1 {
328 t.Fatalf("expected merged secrets-read finding, got %#v", findings)
329 }
330 if len(got[0].Subjects) != 3 {
331 t.Fatalf("subjects = %#v, want 3 unique", got[0].Subjects)
332 }
333 joined := strings.Join(got[0].Subjects, ",")
334 if joined != "user:a,user:b,user:c" {
335 t.Fatalf("subjects = %q", joined)
336 }
337}
338
339func TestRiskClasses(t *testing.T) {
340 tests := []struct {
341 name string
342 role k8srbac.RoleLike
343 want string
344 notWant string
345 }{
346 {
347 name: "serviceaccounts token create",
348 role: k8srbac.RoleLike{Kind: "Role", Name: "r", Namespace: "ns", Rules: []k8srbac.PolicyRule{{
349 APIGroups: []string{""},
350 Resources: []string{"serviceaccounts/token"},
351 Verbs: []string{"create"},
352 }}},
353 want: "sa-token-create",
354 },
355 {
356 name: "CSR approval subresource",
357 role: k8srbac.RoleLike{Kind: "ClusterRole", Name: "r", Rules: []k8srbac.PolicyRule{{
358 APIGroups: []string{"certificates.k8s.io"},
359 Resources: []string{"certificatesigningrequests/approval"},
360 Verbs: []string{"update"},
361 }}},
362 want: "csr-approval",
363 },
364 {
365 name: "CSR create alone is not approval",
366 role: k8srbac.RoleLike{Kind: "ClusterRole", Name: "r", Rules: []k8srbac.PolicyRule{{
367 APIGroups: []string{"certificates.k8s.io"},
368 Resources: []string{"certificatesigningrequests"},
369 Verbs: []string{"create", "update", "patch"},
370 }}},
371 notWant: "csr-approval",
372 },
373 {
374 name: "signer approve",
375 role: k8srbac.RoleLike{Kind: "ClusterRole", Name: "r", Rules: []k8srbac.PolicyRule{{
376 APIGroups: []string{"certificates.k8s.io"},
377 Resources: []string{"signers"},
378 ResourceNames: []string{"kubernetes.io/kubelet-serving"},
379 Verbs: []string{"approve"},
380 }}},
381 want: "csr-signer-approve",
382 },
383 {
384 name: "tokenreviews is oracle not minting",
385 role: k8srbac.RoleLike{Kind: "ClusterRole", Name: "r", Rules: []k8srbac.PolicyRule{{
386 APIGroups: []string{"authentication.k8s.io"},
387 Resources: []string{"tokenreviews"},
388 Verbs: []string{"create"},
389 }}},
390 want: "tokenreview-oracle",
391 },
392 {
393 name: "impersonate users",
394 role: k8srbac.RoleLike{Kind: "ClusterRole", Name: "r", Rules: []k8srbac.PolicyRule{{
395 APIGroups: []string{""},
396 Resources: []string{"users"},
397 Verbs: []string{"impersonate"},
398 }}},
399 want: "impersonate",
400 },
401 {
402 name: "bind escalate",
403 role: k8srbac.RoleLike{Kind: "ClusterRole", Name: "r", Rules: []k8srbac.PolicyRule{{
404 APIGroups: []string{"rbac.authorization.k8s.io"},
405 Resources: []string{"clusterroles"},
406 Verbs: []string{"bind", "escalate"},
407 }}},
408 want: "rbac-bind",
409 },
410 {
411 name: "escalate clusterroles",
412 role: k8srbac.RoleLike{Kind: "ClusterRole", Name: "r", Rules: []k8srbac.PolicyRule{{
413 APIGroups: []string{"rbac.authorization.k8s.io"},
414 Resources: []string{"clusterroles"},
415 Verbs: []string{"escalate"},
416 }}},
417 want: "rbac-escalate",
418 },
419 {
420 name: "workload controller mutate",
421 role: k8srbac.RoleLike{Kind: "Role", Name: "r", Namespace: "ns", Rules: []k8srbac.PolicyRule{{
422 APIGroups: []string{"apps"},
423 Resources: []string{"deployments"},
424 Verbs: []string{"create", "patch"},
425 }}},
426 want: "workload-controller-mutate",
427 },
428 {
429 name: "pod create heuristic",
430 role: k8srbac.RoleLike{Kind: "Role", Name: "r", Namespace: "ns", Rules: []k8srbac.PolicyRule{{
431 APIGroups: []string{""},
432 Resources: []string{"pods"},
433 Verbs: []string{"create"},
434 }}},
435 want: "pod-create",
436 },
437 {
438 name: "pods exec",
439 role: k8srbac.RoleLike{Kind: "Role", Name: "r", Namespace: "ns", Rules: []k8srbac.PolicyRule{{
440 APIGroups: []string{""},
441 Resources: []string{"pods/exec"},
442 Verbs: []string{"create"},
443 }}},
444 want: "pod-interactive",
445 },
446 {
447 name: "bare pods get is not interactive",
448 role: k8srbac.RoleLike{Kind: "Role", Name: "r", Namespace: "ns", Rules: []k8srbac.PolicyRule{{
449 APIGroups: []string{""},
450 Resources: []string{"pods"},
451 Verbs: []string{"get", "list"},
452 }}},
453 notWant: "pod-interactive",
454 },
455 {
456 name: "secrets read",
457 role: k8srbac.RoleLike{Kind: "Role", Name: "r", Namespace: "ns", Rules: []k8srbac.PolicyRule{{
458 APIGroups: []string{""},
459 Resources: []string{"secrets"},
460 Verbs: []string{"get", "list"},
461 }}},
462 want: "secrets-read",
463 },
464 {
465 name: "nodes proxy",
466 role: k8srbac.RoleLike{Kind: "ClusterRole", Name: "r", Rules: []k8srbac.PolicyRule{{
467 APIGroups: []string{""},
468 Resources: []string{"nodes/proxy"},
469 Verbs: []string{"get", "create"},
470 }}},
471 want: "nodes-proxy",
472 },
473 {
474 name: "admission webhook mutate",
475 role: k8srbac.RoleLike{Kind: "ClusterRole", Name: "r", Rules: []k8srbac.PolicyRule{{
476 APIGroups: []string{"admissionregistration.k8s.io"},
477 Resources: []string{"mutatingwebhookconfigurations"},
478 Verbs: []string{"update"},
479 }}},
480 want: "admission-policy-mutate",
481 },
482 {
483 name: "wildcard resources with read verbs",
484 role: k8srbac.RoleLike{Kind: "Role", Name: "r", Namespace: "ns", Rules: []k8srbac.PolicyRule{{
485 APIGroups: []string{""},
486 Resources: []string{"*"},
487 Verbs: []string{"get"},
488 }}},
489 want: "secrets-read",
490 },
491 }
492
493 for _, tt := range tests {
494 t.Run(tt.name, func(t *testing.T) {
495 var binding k8srbac.BindingLike
496 if tt.role.Kind == "ClusterRole" || tt.role.Namespace == "" {
497 tt.role.Kind = "ClusterRole"
498 tt.role.Namespace = ""
499 binding = bindClusterRole("b", tt.role.Name, "user:test")
500 } else {
501 binding = bindRole(tt.role.Namespace, "b", tt.role.Name, "user:test")
502 }
503 findings := k8srbac.Audit([]k8srbac.RoleLike{tt.role}, []k8srbac.BindingLike{binding})
504 if tt.want != "" && !k8srbac.HasCode(findings, tt.want) {
505 t.Fatalf("missing %s in %#v", tt.want, findings)
506 }
507 if tt.notWant != "" && k8srbac.HasCode(findings, tt.notWant) {
508 t.Fatalf("unexpected %s in %#v", tt.notWant, findings)
509 }
510 })
511 }
512}
513
514func assertHas(t *testing.T, findings []k8srbac.Finding, code string) {
515 t.Helper()
516 if !k8srbac.HasCode(findings, code) {
517 t.Fatalf("missing %s in %#v", code, findings)
518 }
519}
520
521func assertNotHas(t *testing.T, findings []k8srbac.Finding, codes ...string) {
522 t.Helper()
523 for _, code := range codes {
524 if k8srbac.HasCode(findings, code) {
525 t.Fatalf("unexpected false-positive %s in %#v", code, findings)
526 }
527 }
528}
529
530func TestBindingValidation(t *testing.T) {
531 secretRole := k8srbac.RoleLike{
532 Kind: "ClusterRole", Name: "secret-reader",
533 Rules: []k8srbac.PolicyRule{{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}},
534 }
535 namespacedRole := k8srbac.RoleLike{
536 Kind: "Role", Name: "secret-reader", Namespace: "app",
537 Rules: []k8srbac.PolicyRule{{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}},
538 }
539
540 t.Run("RoleBinding Role resolves", func(t *testing.T) {
541 findings := k8srbac.Audit([]k8srbac.RoleLike{namespacedRole}, []k8srbac.BindingLike{
542 bindRole("app", "ok", "secret-reader", "user:a"),
543 })
544 assertHas(t, findings, "secrets-read")
545 assertNotHas(t, findings, "invalid-binding")
546 })
547 t.Run("RoleBinding ClusterRole resolves", func(t *testing.T) {
548 findings := k8srbac.Audit([]k8srbac.RoleLike{secretRole}, []k8srbac.BindingLike{
549 bindClusterRoleViaRoleBinding("app", "ok", "secret-reader", "user:a"),
550 })
551 assertHas(t, findings, "secrets-read")
552 assertNotHas(t, findings, "invalid-binding")
553 })
554 t.Run("ClusterRoleBinding ClusterRole resolves", func(t *testing.T) {
555 findings := k8srbac.Audit([]k8srbac.RoleLike{secretRole}, []k8srbac.BindingLike{
556 bindClusterRole("ok", "secret-reader", "user:a"),
557 })
558 assertHas(t, findings, "secrets-read")
559 assertNotHas(t, findings, "invalid-binding")
560 })
561
562 cases := []struct {
563 name string
564 roles []k8srbac.RoleLike
565 binding k8srbac.BindingLike
566 wantMsg string
567 }{
568 {
569 name: "wrong apiGroup",
570 roles: []k8srbac.RoleLike{secretRole},
571 binding: k8srbac.BindingLike{
572 Kind: "ClusterRoleBinding", Name: "bad",
573 RoleRef: k8srbac.RoleRef{APIGroup: "rbac.authorization.k8s.io.wrong", Kind: "ClusterRole", Name: "secret-reader"},
574 Subjects: []string{"user:a"},
575 },
576 wantMsg: "roleRef.apiGroup must be rbac.authorization.k8s.io",
577 },
578 {
579 name: "empty apiGroup",
580 roles: []k8srbac.RoleLike{secretRole},
581 binding: k8srbac.BindingLike{
582 Kind: "RoleBinding", Name: "bad", Namespace: "app",
583 RoleRef: k8srbac.RoleRef{APIGroup: "", Kind: "ClusterRole", Name: "secret-reader"},
584 Subjects: []string{"user:a"},
585 },
586 wantMsg: "roleRef.apiGroup must be rbac.authorization.k8s.io",
587 },
588 {
589 name: "RoleBinding empty kind",
590 roles: []k8srbac.RoleLike{secretRole},
591 binding: k8srbac.BindingLike{
592 Kind: "RoleBinding", Name: "bad", Namespace: "app",
593 RoleRef: k8srbac.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "", Name: "secret-reader"},
594 Subjects: []string{"user:a"},
595 },
596 wantMsg: "RoleBinding roleRef.kind must be Role or ClusterRole",
597 },
598 {
599 name: "RoleBinding unknown kind",
600 roles: []k8srbac.RoleLike{secretRole},
601 binding: k8srbac.BindingLike{
602 Kind: "RoleBinding", Name: "bad", Namespace: "app",
603 RoleRef: k8srbac.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "ServiceAccount", Name: "secret-reader"},
604 Subjects: []string{"user:a"},
605 },
606 wantMsg: "RoleBinding roleRef.kind must be Role or ClusterRole",
607 },
608 {
609 name: "ClusterRoleBinding references Role",
610 roles: []k8srbac.RoleLike{namespacedRole},
611 binding: k8srbac.BindingLike{
612 Kind: "ClusterRoleBinding", Name: "bad",
613 RoleRef: k8srbac.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "Role", Name: "secret-reader"},
614 Subjects: []string{"user:a"},
615 },
616 wantMsg: "ClusterRoleBinding roleRef.kind must be ClusterRole",
617 },
618 {
619 name: "ClusterRoleBinding empty kind",
620 roles: []k8srbac.RoleLike{secretRole},
621 binding: k8srbac.BindingLike{
622 Kind: "ClusterRoleBinding", Name: "bad",
623 RoleRef: k8srbac.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "", Name: "secret-reader"},
624 Subjects: []string{"user:a"},
625 },
626 wantMsg: "ClusterRoleBinding roleRef.kind must be ClusterRole",
627 },
628 {
629 name: "ClusterRoleBinding unknown kind",
630 roles: []k8srbac.RoleLike{secretRole},
631 binding: k8srbac.BindingLike{
632 Kind: "ClusterRoleBinding", Name: "bad",
633 RoleRef: k8srbac.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "Pod", Name: "secret-reader"},
634 Subjects: []string{"user:a"},
635 },
636 wantMsg: "ClusterRoleBinding roleRef.kind must be ClusterRole",
637 },
638 {
639 name: "RoleBinding missing namespace",
640 roles: []k8srbac.RoleLike{secretRole},
641 binding: k8srbac.BindingLike{
642 Kind: "RoleBinding", Name: "bad", Namespace: "",
643 RoleRef: k8srbac.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "ClusterRole", Name: "secret-reader"},
644 Subjects: []string{"user:a"},
645 },
646 wantMsg: "RoleBinding requires a namespace",
647 },
648 {
649 name: "ClusterRoleBinding with namespace",
650 roles: []k8srbac.RoleLike{secretRole},
651 binding: k8srbac.BindingLike{
652 Kind: "ClusterRoleBinding", Name: "bad", Namespace: "app",
653 RoleRef: k8srbac.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "ClusterRole", Name: "secret-reader"},
654 Subjects: []string{"user:a"},
655 },
656 wantMsg: "ClusterRoleBinding must not declare a namespace",
657 },
658 }
659
660 for _, tt := range cases {
661 t.Run(tt.name, func(t *testing.T) {
662 findings := k8srbac.Audit(tt.roles, []k8srbac.BindingLike{tt.binding})
663 got := findingsWithCode(findings, "invalid-binding")
664 if len(got) != 1 {
665 t.Fatalf("expected invalid-binding, got %#v", findings)
666 }
667 if !strings.Contains(got[0].Description, tt.wantMsg) {
668 t.Fatalf("description = %q, want substring %q", got[0].Description, tt.wantMsg)
669 }
670 assertNotHas(t, findings, "secrets-read")
671 })
672 }
673}
674
675func TestRoleBindingDoesNotActivateClusterScopedRisks(t *testing.T) {
676 tests := []struct {
677 name string
678 rules []k8srbac.PolicyRule
679 notWant []string
680 want []string
681 }{
682 {
683 name: "nodes/proxy",
684 rules: []k8srbac.PolicyRule{{APIGroups: []string{""}, Resources: []string{"nodes/proxy"}, Verbs: []string{"get", "create"}}},
685 notWant: []string{"nodes-proxy"},
686 },
687 {
688 name: "tokenreviews",
689 rules: []k8srbac.PolicyRule{{APIGroups: []string{"authentication.k8s.io"}, Resources: []string{"tokenreviews"}, Verbs: []string{"create"}}},
690 notWant: []string{"tokenreview-oracle"},
691 },
692 {
693 name: "csr approval",
694 rules: []k8srbac.PolicyRule{{APIGroups: []string{"certificates.k8s.io"}, Resources: []string{"certificatesigningrequests/approval"}, Verbs: []string{"update"}}},
695 notWant: []string{"csr-approval"},
696 },
697 {
698 name: "signers",
699 rules: []k8srbac.PolicyRule{{APIGroups: []string{"certificates.k8s.io"}, Resources: []string{"signers"}, Verbs: []string{"approve"}}},
700 notWant: []string{"csr-signer-approve"},
701 },
702 {
703 name: "clusterroles bind is effective; escalate is not",
704 rules: []k8srbac.PolicyRule{{
705 APIGroups: []string{"rbac.authorization.k8s.io"},
706 Resources: []string{"clusterroles"},
707 Verbs: []string{"bind", "escalate"},
708 }},
709 want: []string{"rbac-bind"},
710 notWant: []string{"rbac-escalate"},
711 },
712 {
713 name: "admission webhooks",
714 rules: []k8srbac.PolicyRule{{APIGroups: []string{"admissionregistration.k8s.io"}, Resources: []string{"validatingwebhookconfigurations"}, Verbs: []string{"update"}}},
715 notWant: []string{"admission-policy-mutate"},
716 },
717 {
718 name: "secrets still effective",
719 rules: []k8srbac.PolicyRule{{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}},
720 want: []string{"secrets-read"},
721 notWant: []string{"nodes-proxy", "tokenreview-oracle"},
722 },
723 {
724 name: "pods/exec still effective",
725 rules: []k8srbac.PolicyRule{{APIGroups: []string{""}, Resources: []string{"pods/exec"}, Verbs: []string{"create"}}},
726 want: []string{"pod-interactive"},
727 },
728 {
729 name: "serviceaccounts/token still effective",
730 rules: []k8srbac.PolicyRule{{APIGroups: []string{""}, Resources: []string{"serviceaccounts/token"}, Verbs: []string{"create"}}},
731 want: []string{"sa-token-create"},
732 },
733 }
734
735 for _, tt := range tests {
736 t.Run(tt.name, func(t *testing.T) {
737 roles := []k8srbac.RoleLike{{Kind: "ClusterRole", Name: "cr", Rules: tt.rules}}
738 bindings := []k8srbac.BindingLike{
739 bindClusterRoleViaRoleBinding("team", "ns-bind", "cr", "system:serviceaccount:team:sa"),
740 }
741 findings := k8srbac.Audit(roles, bindings)
742 for _, code := range tt.want {
743 assertHas(t, findings, code)
744 }
745 assertNotHas(t, findings, tt.notWant...)
746 })
747 }
748}
749
750func TestClusterRoleBindingActivatesClusterScopedRisks(t *testing.T) {
751 roles := []k8srbac.RoleLike{{
752 Kind: "ClusterRole",
753 Name: "dangerous",
754 Rules: []k8srbac.PolicyRule{
755 {APIGroups: []string{""}, Resources: []string{"nodes/proxy"}, Verbs: []string{"get"}},
756 {APIGroups: []string{"authentication.k8s.io"}, Resources: []string{"tokenreviews"}, Verbs: []string{"create"}},
757 {APIGroups: []string{"certificates.k8s.io"}, Resources: []string{"certificatesigningrequests/approval"}, Verbs: []string{"update"}},
758 {APIGroups: []string{"certificates.k8s.io"}, Resources: []string{"signers"}, Verbs: []string{"approve"}},
759 {APIGroups: []string{"rbac.authorization.k8s.io"}, Resources: []string{"clusterroles"}, Verbs: []string{"bind"}},
760 {APIGroups: []string{"admissionregistration.k8s.io"}, Resources: []string{"mutatingwebhookconfigurations"}, Verbs: []string{"update"}},
761 {APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}},
762 },
763 }}
764 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{
765 bindClusterRole("cluster-bind", "dangerous", "user:admin"),
766 })
767 for _, code := range []string{
768 "nodes-proxy", "tokenreview-oracle", "csr-approval", "csr-signer-approve",
769 "rbac-bind", "admission-policy-mutate", "secrets-read",
770 } {
771 assertHas(t, findings, code)
772 }
773}
774
775func TestNamespacedWildcardOmitsClusterScopedFindings(t *testing.T) {
776 roles := []k8srbac.RoleLike{{
777 Kind: "ClusterRole",
778 Name: "wildcard",
779 Rules: []k8srbac.PolicyRule{{
780 APIGroups: []string{"*"},
781 Resources: []string{"*"},
782 Verbs: []string{"*"},
783 }},
784 }}
785 nsFindings := k8srbac.Audit(roles, []k8srbac.BindingLike{
786 bindClusterRoleViaRoleBinding("app", "ns-wild", "wildcard", "system:serviceaccount:app:sa"),
787 })
788 assertHas(t, nsFindings, "all-resources-all-verbs")
789 assertHas(t, nsFindings, "secrets-read")
790 assertHas(t, nsFindings, "pod-interactive")
791 assertHas(t, nsFindings, "sa-token-create")
792 assertHas(t, nsFindings, "pod-create")
793 assertHas(t, nsFindings, "workload-controller-mutate")
794 // Special verbs: bind on roles/clusterroles and escalate on roles remain effective.
795 assertHas(t, nsFindings, "rbac-bind")
796 assertHas(t, nsFindings, "rbac-escalate")
797 assertNotHas(t, nsFindings,
798 "nodes-proxy", "tokenreview-oracle", "csr-approval", "csr-signer-approve",
799 "admission-policy-mutate", "impersonate",
800 )
801
802 clusterFindings := k8srbac.Audit(roles, []k8srbac.BindingLike{
803 bindClusterRole("cluster-wild", "wildcard", "user:admin"),
804 })
805 assertHas(t, clusterFindings, "all-resources-all-verbs")
806 assertHas(t, clusterFindings, "secrets-read")
807 assertHas(t, clusterFindings, "nodes-proxy")
808 assertHas(t, clusterFindings, "tokenreview-oracle")
809 assertHas(t, clusterFindings, "csr-approval")
810 assertHas(t, clusterFindings, "admission-policy-mutate")
811 assertHas(t, clusterFindings, "rbac-bind")
812 assertHas(t, clusterFindings, "rbac-escalate")
813 assertHas(t, clusterFindings, "impersonate")
814}
815
816func TestSpecialBindSemantics(t *testing.T) {
817 t.Run("RoleBinding bind on clusterroles is effective in namespace", func(t *testing.T) {
818 roles := []k8srbac.RoleLike{{
819 Kind: "ClusterRole", Name: "binder",
820 Rules: []k8srbac.PolicyRule{{
821 APIGroups: []string{"rbac.authorization.k8s.io"},
822 Resources: []string{"clusterroles"},
823 Verbs: []string{"bind"},
824 }},
825 }}
826 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{
827 bindClusterRoleViaRoleBinding("payments", "ns-bind", "binder", "system:serviceaccount:payments:sa"),
828 })
829 got := findingsWithCode(findings, "rbac-bind")
830 if len(got) != 1 {
831 t.Fatalf("expected rbac-bind, got %#v", findings)
832 }
833 if got[0].BindingKind != "RoleBinding" || got[0].BindingScope != "payments" {
834 t.Fatalf("binding metadata = %#v", got[0])
835 }
836 if !strings.Contains(got[0].Description, "bind on clusterroles") {
837 t.Fatalf("description = %q", got[0].Description)
838 }
839 assertNotHas(t, findings, "rbac-escalate")
840 })
841
842 t.Run("RoleBinding bind on roles is effective", func(t *testing.T) {
843 roles := []k8srbac.RoleLike{{
844 Kind: "Role", Name: "binder", Namespace: "app",
845 Rules: []k8srbac.PolicyRule{{
846 APIGroups: []string{"rbac.authorization.k8s.io"},
847 Resources: []string{"roles"},
848 Verbs: []string{"bind"},
849 }},
850 }}
851 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{
852 bindRole("app", "b", "binder", "user:a"),
853 })
854 assertHas(t, findings, "rbac-bind")
855 got := findingsWithCode(findings, "rbac-bind")
856 if got[0].BindingScope != "app" {
857 t.Fatalf("BindingScope = %q", got[0].BindingScope)
858 }
859 })
860
861 t.Run("ClusterRoleBinding bind on clusterroles is effective", func(t *testing.T) {
862 roles := []k8srbac.RoleLike{{
863 Kind: "ClusterRole", Name: "binder",
864 Rules: []k8srbac.PolicyRule{{
865 APIGroups: []string{"rbac.authorization.k8s.io"},
866 Resources: []string{"clusterroles"},
867 Verbs: []string{"bind"},
868 }},
869 }}
870 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{
871 bindClusterRole("b", "binder", "user:a"),
872 })
873 got := findingsWithCode(findings, "rbac-bind")
874 if len(got) != 1 || got[0].BindingKind != "ClusterRoleBinding" || got[0].BindingScope != "cluster" {
875 t.Fatalf("unexpected finding %#v", findings)
876 }
877 })
878
879 t.Run("bind on rolebindings is not a special binding risk", func(t *testing.T) {
880 roles := []k8srbac.RoleLike{{
881 Kind: "ClusterRole", Name: "binder",
882 Rules: []k8srbac.PolicyRule{{
883 APIGroups: []string{"rbac.authorization.k8s.io"},
884 Resources: []string{"rolebindings"},
885 Verbs: []string{"bind"},
886 }},
887 }}
888 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{bindClusterRole("b", "binder", "user:a")})
889 assertNotHas(t, findings, "rbac-bind", "rbac-escalate")
890 })
891
892 t.Run("bind on clusterrolebindings is not a special binding risk", func(t *testing.T) {
893 roles := []k8srbac.RoleLike{{
894 Kind: "ClusterRole", Name: "binder",
895 Rules: []k8srbac.PolicyRule{{
896 APIGroups: []string{"rbac.authorization.k8s.io"},
897 Resources: []string{"clusterrolebindings"},
898 Verbs: []string{"bind"},
899 }},
900 }}
901 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{bindClusterRole("b", "binder", "user:a")})
902 assertNotHas(t, findings, "rbac-bind", "rbac-escalate")
903 })
904
905 t.Run("resourceNames retained for bind on clusterroles", func(t *testing.T) {
906 roles := []k8srbac.RoleLike{{
907 Kind: "ClusterRole", Name: "binder",
908 Rules: []k8srbac.PolicyRule{{
909 APIGroups: []string{"rbac.authorization.k8s.io"},
910 Resources: []string{"clusterroles"},
911 ResourceNames: []string{"view", "edit"},
912 Verbs: []string{"bind"},
913 }},
914 }}
915 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{
916 bindClusterRoleViaRoleBinding("payments", "b", "binder", "user:a"),
917 })
918 got := findingsWithCode(findings, "rbac-bind")
919 if len(got) != 1 {
920 t.Fatalf("expected rbac-bind, got %#v", findings)
921 }
922 if len(got[0].ResourceNames) != 2 {
923 t.Fatalf("ResourceNames = %#v", got[0].ResourceNames)
924 }
925 if !strings.Contains(got[0].Description, "resourceNames") ||
926 !strings.Contains(got[0].Description, "does not make the permission safe") {
927 t.Fatalf("description = %q", got[0].Description)
928 }
929 })
930}
931
932func TestSpecialEscalateSemantics(t *testing.T) {
933 t.Run("RoleBinding escalate on roles is effective", func(t *testing.T) {
934 roles := []k8srbac.RoleLike{{
935 Kind: "Role", Name: "esc", Namespace: "app",
936 Rules: []k8srbac.PolicyRule{{
937 APIGroups: []string{"rbac.authorization.k8s.io"},
938 Resources: []string{"roles"},
939 Verbs: []string{"escalate"},
940 }},
941 }}
942 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{bindRole("app", "b", "esc", "user:a")})
943 assertHas(t, findings, "rbac-escalate")
944 assertNotHas(t, findings, "rbac-bind")
945 })
946
947 t.Run("RoleBinding escalate on clusterroles is not effective", func(t *testing.T) {
948 roles := []k8srbac.RoleLike{{
949 Kind: "ClusterRole", Name: "esc",
950 Rules: []k8srbac.PolicyRule{{
951 APIGroups: []string{"rbac.authorization.k8s.io"},
952 Resources: []string{"clusterroles"},
953 Verbs: []string{"escalate"},
954 }},
955 }}
956 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{
957 bindClusterRoleViaRoleBinding("app", "b", "esc", "user:a"),
958 })
959 assertNotHas(t, findings, "rbac-escalate", "rbac-bind")
960 })
961
962 t.Run("ClusterRoleBinding escalate on clusterroles is effective", func(t *testing.T) {
963 roles := []k8srbac.RoleLike{{
964 Kind: "ClusterRole", Name: "esc",
965 Rules: []k8srbac.PolicyRule{{
966 APIGroups: []string{"rbac.authorization.k8s.io"},
967 Resources: []string{"clusterroles"},
968 Verbs: []string{"escalate"},
969 }},
970 }}
971 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{bindClusterRole("b", "esc", "user:a")})
972 assertHas(t, findings, "rbac-escalate")
973 })
974
975 t.Run("escalate on rolebindings is not a special escalation risk", func(t *testing.T) {
976 roles := []k8srbac.RoleLike{{
977 Kind: "ClusterRole", Name: "esc",
978 Rules: []k8srbac.PolicyRule{{
979 APIGroups: []string{"rbac.authorization.k8s.io"},
980 Resources: []string{"rolebindings"},
981 Verbs: []string{"escalate"},
982 }},
983 }}
984 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{bindClusterRole("b", "esc", "user:a")})
985 assertNotHas(t, findings, "rbac-escalate", "rbac-bind")
986 })
987
988 t.Run("escalate on clusterrolebindings is not a special escalation risk", func(t *testing.T) {
989 roles := []k8srbac.RoleLike{{
990 Kind: "ClusterRole", Name: "esc",
991 Rules: []k8srbac.PolicyRule{{
992 APIGroups: []string{"rbac.authorization.k8s.io"},
993 Resources: []string{"clusterrolebindings"},
994 Verbs: []string{"escalate"},
995 }},
996 }}
997 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{bindClusterRole("b", "esc", "user:a")})
998 assertNotHas(t, findings, "rbac-escalate", "rbac-bind")
999 })
1000
1001 t.Run("RoleBinding bind+escalate on clusterroles reports only bind", func(t *testing.T) {
1002 roles := []k8srbac.RoleLike{{
1003 Kind: "ClusterRole", Name: "both",
1004 Rules: []k8srbac.PolicyRule{{
1005 APIGroups: []string{"rbac.authorization.k8s.io"},
1006 Resources: []string{"clusterroles"},
1007 Verbs: []string{"bind", "escalate"},
1008 }},
1009 }}
1010 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{
1011 bindClusterRoleViaRoleBinding("payments", "b", "both", "user:a"),
1012 })
1013 assertHas(t, findings, "rbac-bind")
1014 assertNotHas(t, findings, "rbac-escalate")
1015 got := findingsWithCode(findings, "rbac-bind")
1016 if strings.Contains(got[0].Description, "escalate") {
1017 t.Fatalf("bind finding must not use vague joined-verb wording: %q", got[0].Description)
1018 }
1019 })
1020}
1021
1022func TestImpersonationScope(t *testing.T) {
1023 targets := []struct {
1024 name string
1025 group string
1026 }{
1027 {"users", ""},
1028 {"groups", ""},
1029 {"serviceaccounts", ""},
1030 {"userextras", "authentication.k8s.io"},
1031 }
1032
1033 for _, target := range targets {
1034 t.Run("RoleBinding impersonate "+target.name+" suppressed", func(t *testing.T) {
1035 roles := []k8srbac.RoleLike{{
1036 Kind: "ClusterRole", Name: "imp",
1037 Rules: []k8srbac.PolicyRule{{
1038 APIGroups: []string{target.group},
1039 Resources: []string{target.name},
1040 Verbs: []string{"impersonate"},
1041 }},
1042 }}
1043 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{
1044 bindClusterRoleViaRoleBinding("app", "b", "imp", "user:a"),
1045 })
1046 assertNotHas(t, findings, "impersonate")
1047 })
1048
1049 t.Run("ClusterRoleBinding impersonate "+target.name+" effective", func(t *testing.T) {
1050 roles := []k8srbac.RoleLike{{
1051 Kind: "ClusterRole", Name: "imp",
1052 Rules: []k8srbac.PolicyRule{{
1053 APIGroups: []string{target.group},
1054 Resources: []string{target.name},
1055 Verbs: []string{"impersonate"},
1056 }},
1057 }}
1058 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{bindClusterRole("b", "imp", "user:a")})
1059 assertHas(t, findings, "impersonate")
1060 })
1061 }
1062
1063 t.Run("userextras under core API group rejected", func(t *testing.T) {
1064 roles := []k8srbac.RoleLike{{
1065 Kind: "ClusterRole", Name: "imp",
1066 Rules: []k8srbac.PolicyRule{{
1067 APIGroups: []string{""},
1068 Resources: []string{"userextras"},
1069 Verbs: []string{"impersonate"},
1070 }},
1071 }}
1072 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{bindClusterRole("b", "imp", "user:a")})
1073 assertNotHas(t, findings, "impersonate")
1074 })
1075
1076 t.Run("users under unrelated API group rejected", func(t *testing.T) {
1077 roles := []k8srbac.RoleLike{{
1078 Kind: "ClusterRole", Name: "imp",
1079 Rules: []k8srbac.PolicyRule{{
1080 APIGroups: []string{"example.com"},
1081 Resources: []string{"users"},
1082 Verbs: []string{"impersonate"},
1083 }},
1084 }}
1085 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{bindClusterRole("b", "imp", "user:a")})
1086 assertNotHas(t, findings, "impersonate")
1087 })
1088
1089 t.Run("namespaced wildcard RoleBinding does not produce impersonate", func(t *testing.T) {
1090 roles := []k8srbac.RoleLike{{
1091 Kind: "ClusterRole", Name: "wild",
1092 Rules: []k8srbac.PolicyRule{{
1093 APIGroups: []string{"*"},
1094 Resources: []string{"*"},
1095 Verbs: []string{"*"},
1096 }},
1097 }}
1098 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{
1099 bindClusterRoleViaRoleBinding("app", "b", "wild", "user:a"),
1100 })
1101 assertNotHas(t, findings, "impersonate")
1102 })
1103
1104 t.Run("ClusterRoleBinding wildcards produce impersonate", func(t *testing.T) {
1105 roles := []k8srbac.RoleLike{{
1106 Kind: "ClusterRole", Name: "wild",
1107 Rules: []k8srbac.PolicyRule{{
1108 APIGroups: []string{"*"},
1109 Resources: []string{"*"},
1110 Verbs: []string{"*"},
1111 }},
1112 }}
1113 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{bindClusterRole("b", "wild", "user:a")})
1114 assertHas(t, findings, "impersonate")
1115 })
1116}
1117
1118func TestTokenRequestAPIGroup(t *testing.T) {
1119 t.Run("core group serviceaccounts/token create", func(t *testing.T) {
1120 roles := []k8srbac.RoleLike{{
1121 Kind: "Role", Name: "tok", Namespace: "ns",
1122 Rules: []k8srbac.PolicyRule{{
1123 APIGroups: []string{""},
1124 Resources: []string{"serviceaccounts/token"},
1125 Verbs: []string{"create"},
1126 }},
1127 }}
1128 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{bindRole("ns", "b", "tok", "user:a")})
1129 assertHas(t, findings, "sa-token-create")
1130 })
1131
1132 t.Run("wildcard API group serviceaccounts/token create", func(t *testing.T) {
1133 roles := []k8srbac.RoleLike{{
1134 Kind: "Role", Name: "tok", Namespace: "ns",
1135 Rules: []k8srbac.PolicyRule{{
1136 APIGroups: []string{"*"},
1137 Resources: []string{"serviceaccounts/token"},
1138 Verbs: []string{"create"},
1139 }},
1140 }}
1141 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{bindRole("ns", "b", "tok", "user:a")})
1142 assertHas(t, findings, "sa-token-create")
1143 })
1144
1145 t.Run("authentication.k8s.io serviceaccounts/token does not mint", func(t *testing.T) {
1146 roles := []k8srbac.RoleLike{{
1147 Kind: "Role", Name: "tok", Namespace: "ns",
1148 Rules: []k8srbac.PolicyRule{{
1149 APIGroups: []string{"authentication.k8s.io"},
1150 Resources: []string{"serviceaccounts/token"},
1151 Verbs: []string{"create"},
1152 }},
1153 }}
1154 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{bindRole("ns", "b", "tok", "user:a")})
1155 assertNotHas(t, findings, "sa-token-create")
1156 })
1157
1158 t.Run("bare serviceaccounts create is not TokenRequest", func(t *testing.T) {
1159 roles := []k8srbac.RoleLike{{
1160 Kind: "Role", Name: "sa", Namespace: "ns",
1161 Rules: []k8srbac.PolicyRule{{
1162 APIGroups: []string{""},
1163 Resources: []string{"serviceaccounts"},
1164 Verbs: []string{"create"},
1165 }},
1166 }}
1167 findings := k8srbac.Audit(roles, []k8srbac.BindingLike{bindRole("ns", "b", "sa", "user:a")})
1168 assertNotHas(t, findings, "sa-token-create")
1169 })
1170
1171 t.Run("bare serviceaccounts impersonate follows impersonation rules", func(t *testing.T) {
1172 roles := []k8srbac.RoleLike{{
1173 Kind: "ClusterRole", Name: "imp",
1174 Rules: []k8srbac.PolicyRule{{
1175 APIGroups: []string{""},
1176 Resources: []string{"serviceaccounts"},
1177 Verbs: []string{"impersonate"},
1178 }},
1179 }}
1180 nsFindings := k8srbac.Audit(roles, []k8srbac.BindingLike{
1181 bindClusterRoleViaRoleBinding("app", "b", "imp", "user:a"),
1182 })
1183 assertNotHas(t, nsFindings, "impersonate", "sa-token-create")
1184
1185 clusterFindings := k8srbac.Audit(roles, []k8srbac.BindingLike{bindClusterRole("b", "imp", "user:a")})
1186 assertHas(t, clusterFindings, "impersonate")
1187 assertNotHas(t, clusterFindings, "sa-token-create")
1188 })
1189}
What it does
Checks exported Kubernetes RBAC objects for scope mismatches and special verbs that can enable privilege escalation.
Why it exists
The Kubernetes isolation research needed a repeatable answer to whether a binding grants more than it appears to grant.
Permissions and safety
None. It evaluates supplied in-memory objects and never calls the Kubernetes API.
Usage
go test ./cloud-security/scripts/k8s-rbac/...
What was tested
Forty-eight positive and negative Go test cases cover ordinary resources, special verbs, and unresolved bindings.
Limitations
- It is a bounded static heuristic, not a complete effective-permissions engine.
- It does not perform live API discovery or evaluate admission policy.