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] = &copyFinding
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}

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.