From c89a15f62764567897a7a73525f2d9422c1602c1 Mon Sep 17 00:00:00 2001 From: Yuval Avrahami Date: Tue, 30 Aug 2022 17:12:54 +0300 Subject: [PATCH] Discover protections infra, add support for identifying LegacyServiceAccountToken feature gates --- cmd/root.go | 1 + docs/policies.md | 6 +++- lib/list_secrets.rego | 19 ++++++++++++ lib/obtain_token_weak_ns.rego | 11 +++++-- ...crets.rego => retrieve_token_secrets.rego} | 3 +- lib/utils/builtins.rego | 7 +++++ pkg/collect/collect.go | 5 ++++ pkg/collect/discover_protections.go | 30 +++++++++++++++++++ pkg/collect/types.go | 18 ++++++----- 9 files changed, 86 insertions(+), 14 deletions(-) create mode 100644 lib/list_secrets.rego rename lib/{retrieve_secrets.rego => retrieve_token_secrets.rego} (96%) create mode 100644 pkg/collect/discover_protections.go diff --git a/cmd/root.go b/cmd/root.go index 8f17e98..e3a4dab 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -36,6 +36,7 @@ func init() { rootCmd.PersistentFlags().StringVarP(&outFile, "out-file", "o", "", "save results to file") rootCmd.PersistentFlags().BoolVarP(&loudMode, "loud", "l", false, "loud mode, print results regardless of -o") rootCmd.PersistentFlags().BoolVarP(&collectConfig.AllServiceAccounts, "all-serviceaccounts", "a", false, "collect data on all serviceAccounts, not only those assigned to a pod") + rootCmd.PersistentFlags().BoolVarP(&collectConfig.DiscoverProtections, "discover-protections", "w", false, "discover relevant control plane features gates and admission controller that protect against certain attacks, partly by emulating attacks via impersonation & dry-run write operations") rootCmd.PersistentFlags().BoolVar(&collectConfig.IgnoreControlPlane, "ignore-controlplane", false, "don't collect data on control plane nodes and pods. Identified by either the 'node-role.kubernetes.io/control-plane' or 'node-role.kubernetes.io/master' labels. ServiceAccounts will not be linked to control plane components") rootCmd.PersistentFlags().StringSliceVar(&collectConfig.NodeGroups, "node-groups", []string{"system:nodes"}, "treat nodes as part of these groups") rootCmd.PersistentFlags().StringVar(&collectConfig.NodeUser, "node-user", "", "user assigned to all nodes, default behaviour assumes nodes users are compatible with the NodeAuthorizer") diff --git a/docs/policies.md b/docs/policies.md index 1fb2e26..bef70de 100644 --- a/docs/policies.md +++ b/docs/policies.md @@ -74,6 +74,10 @@ The above options are implemented by a Rego [wrapper](../lib/utils/wrapper.rego) - Description: `SAs and nodes that can create or modify secrets in privileged namespaces can issue tokens for admin-equivalent SAs` - Severity: `Critical` - Violation types: `serviceAccounts, nodes` +### [list_secrets](../lib/list_secrets.rego) +- Description: `SAs and nodes that can list secrets cluster-wide may access confidential information, and in some cases serviceAccount tokens` +- Severity: `Low` +- Violation types: `serviceAccounts, nodes` ### [modify_node_status](../lib/modify_node_status.rego) - Description: `SAs and nodes that can modify nodes' status can set or remove labels to affect scheduling constraints enforced via nodeAffinity or nodeSelectors` - Severity: `Low` @@ -114,7 +118,7 @@ The above options are implemented by a Rego [wrapper](../lib/utils/wrapper.rego) - Description: `SAs and nodes that can update or patch pods or create pods/exec in unprivileged namespaces can execute code on existing pods` - Severity: `Medium` - Violation types: `serviceAccounts, nodes` -### [retrieve_secrets](../lib/retrieve_secrets.rego) +### [retrieve_token_secrets](../lib/retrieve_token_secrets.rego) - Description: `SAs and nodes that can retrieve secrets in privileged namespaces can obtain tokens of admin-equivalent SAs` - Severity: `Critical` - Violation types: `serviceAccounts, nodes` diff --git a/lib/list_secrets.rego b/lib/list_secrets.rego new file mode 100644 index 0000000..19601c0 --- /dev/null +++ b/lib/list_secrets.rego @@ -0,0 +1,19 @@ +package policy +import data.police_builtins as pb +import future.keywords.in + +describe[{"desc": desc, "severity": severity}] { + desc := "SAs and nodes that can list secrets cluster-wide may access confidential information, and in some cases serviceAccount tokens" + severity := "Low" +} +checkServiceAccounts := true +checkNodes := true + +evaluateRoles(roles, type) { + some role in roles + pb.notNamespaced(role) + some rule in role.rules + pb.valueOrWildcard(rule.resources, "secrets") + pb.valueOrWildcard(rule.verbs, "list") + pb.valueOrWildcard(rule.apiGroups, "") +} diff --git a/lib/obtain_token_weak_ns.rego b/lib/obtain_token_weak_ns.rego index 0409fda..5bd6889 100644 --- a/lib/obtain_token_weak_ns.rego +++ b/lib/obtain_token_weak_ns.rego @@ -37,9 +37,14 @@ ruleCanAcquireToken(rule) { # Create - mannualy create a token secret (issue_token_secrets) # Update & Patch - modfiy secret (issue_token_secrets), TODO: probably not exploitable if resourceNames is present? canAbuseSecretsForToken(verbs) { - "list" in verbs -} { - "get" in verbs + pb.legacyTokenSecrets + listOrGet(verbs) } { pb.createUpdatePatchOrWildcard(verbs) } + +listOrGet(verbs) { + "list" in verbs +} { + "get" in verbs +} \ No newline at end of file diff --git a/lib/retrieve_secrets.rego b/lib/retrieve_token_secrets.rego similarity index 96% rename from lib/retrieve_secrets.rego rename to lib/retrieve_token_secrets.rego index 41fbd37..77eebd3 100644 --- a/lib/retrieve_secrets.rego +++ b/lib/retrieve_token_secrets.rego @@ -10,6 +10,7 @@ checkServiceAccounts := true checkNodes := true evaluateRoles(roles, type) { + pb.legacyTokenSecrets some role in roles pb.affectsPrivNS(role) some rule in role.rules @@ -18,5 +19,3 @@ evaluateRoles(roles, type) { pb.valueOrWildcard(rule.apiGroups, "") not pb.hasKey(rule, "resourceNames") } - - diff --git a/lib/utils/builtins.rego b/lib/utils/builtins.rego index c0a1ee3..bd3bc81 100644 --- a/lib/utils/builtins.rego +++ b/lib/utils/builtins.rego @@ -172,4 +172,11 @@ equalNamespaceIfExist(obj, other) { } { not hasKey(obj, "namespace") not hasKey(other, "namespace") +} + +# Check whether LegacyTokenSecretsReducted is disabled +legacyTokenSecrets := true { + metadata := object.get(input, "metadata", {}) + features := object.get(metadata, "features", []) + not "LegacyTokenSecretsReducted" in features } \ No newline at end of file diff --git a/pkg/collect/collect.go b/pkg/collect/collect.go index 8d21d2f..f091065 100644 --- a/pkg/collect/collect.go +++ b/pkg/collect/collect.go @@ -28,6 +28,10 @@ func Collect(collectConfig CollectConfig) *CollectResult { return nil // error printed in BuildClusterDb } + if collectConfig.DiscoverProtections { + discoverRelevantControlPlaneFeatures(clientset, clusterDb, &metadata) + } + return &CollectResult{ Metadata: metadata, ServiceAccounts: rbacDb.ServiceAccounts, @@ -70,6 +74,7 @@ func getMetadata(clientset *kubernetes.Clientset, kubeConfig clientcmd.ClientCon ClusterName: rawConfig.Contexts[rawConfig.CurrentContext].Cluster, Platform: getPlatform(versionInfo.GitVersion), Version: versionInfo.GitVersion, + Features: []string{}, } } diff --git a/pkg/collect/discover_protections.go b/pkg/collect/discover_protections.go new file mode 100644 index 0000000..82e7ecb --- /dev/null +++ b/pkg/collect/discover_protections.go @@ -0,0 +1,30 @@ +package collect + +import ( + "k8s.io/client-go/kubernetes" +) + +// Discover control plane feature gates and admission controllers that protect against certain attacks, +// and populate the cluster's metadata with them for policies to consume. +// NOTE: Uses impersonation and dry-run write operations, which won't affect the cluster, but may be logged / audited on. +func discoverRelevantControlPlaneFeatures(clientset *kubernetes.Clientset, clusterDb *ClusterDb, metadata *ClusterMetadata) { + if legacyTokenSecretsReducted(clusterDb) { + metadata.Features = append(metadata.Features, "LegacyTokenSecretsReducted") + } +} + +// Best effort test for whether serviceAccount tokens are stored as secrets +func legacyTokenSecretsReducted(clusterDb *ClusterDb) bool { + for _, serviceAccount := range clusterDb.ServiceAccounts { + if serviceAccount.ObjectMeta.Namespace != "kube-system" { + continue + } + // Arbitrarily chose the replicaset-controller for testing + if serviceAccount.ObjectMeta.Name != "replicaset-controller" { + continue + } + // Return true if there are no auto-generated secrets for the serviceAccount + return len(serviceAccount.Secrets) == 0 + } + return false +} diff --git a/pkg/collect/types.go b/pkg/collect/types.go index b100420..8b21a7b 100644 --- a/pkg/collect/types.go +++ b/pkg/collect/types.go @@ -7,11 +7,12 @@ import ( // Configuration for Collect() type CollectConfig struct { - AllServiceAccounts bool - IgnoreControlPlane bool - NodeGroups []string - NodeUser string - Namespace string + AllServiceAccounts bool + IgnoreControlPlane bool + DiscoverProtections bool + NodeGroups []string + NodeUser string + Namespace string } // Outpot of Collect() @@ -42,9 +43,10 @@ type RbacDb struct { } type ClusterMetadata struct { - ClusterName string `json:"cluster"` - Platform string `json:"platform"` - Version string `json:"version"` + ClusterName string `json:"cluster"` + Platform string `json:"platform"` + Version string `json:"version"` + Features []string `json:"features"` } // RBAC info of a serviceAccount