From a5c3262fc619849ec1ebed5541f035b8b3d18d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Do=C4=9Fan=20Can=20Bak=C4=B1r?= Date: Fri, 9 Oct 2026 16:35:20 +0300 Subject: [PATCH] add auto-discovery mode --- PROVIDERS.md | 4 +-- README.md | 1 + internal/runner/autodiscovery.go | 43 ++++++++++++++++++++++ internal/runner/autodiscovery_test.go | 48 +++++++++++++++++++++++++ internal/runner/options.go | 2 ++ internal/runner/runner.go | 23 +++++++----- pkg/providers/k8s/kubernetes.go | 25 ++++++------- pkg/providers/k8s/kubernetes_test.go | 52 +++++++++++++++++++++++++++ 8 files changed, 173 insertions(+), 25 deletions(-) create mode 100644 internal/runner/autodiscovery.go create mode 100644 internal/runner/autodiscovery_test.go create mode 100644 pkg/providers/k8s/kubernetes_test.go diff --git a/PROVIDERS.md b/PROVIDERS.md index 861652db..d30a9dc2 100644 --- a/PROVIDERS.md +++ b/PROVIDERS.md @@ -588,7 +588,7 @@ References - ### Kubernetes -To integrate Kubernetes, utilize the configuration block outlined below. This block allows you to specify Kubernetes connection details either through a file path or directly by providing the encoded kubeconfig content. If both kubeconfig_file and kubeconfig_encoded are specified, the kubeconfig_encoded will take precedence. +To integrate Kubernetes, utilize the configuration block outlined below. This block allows you to specify Kubernetes connection details either through a file path or directly by providing the encoded kubeconfig content. If both kubeconfig_file and kubeconfig_encoded are specified, the kubeconfig_encoded will take precedence. If neither is specified, the kubeconfig is resolved like kubectl: `$KUBECONFIG`, then `~/.kube/config`, then the in-cluster service account. ```yaml @@ -596,7 +596,7 @@ To integrate Kubernetes, utilize the configuration block outlined below. This bl provider: kubernetes # User-defined identifier for filtering (optional). id: staging - # Path to the kubeconfig file. + # Path to the kubeconfig file (optional). kubeconfig_file: path/to/kubeconfig # Base64 encoded kubeconfig, $ cat $KUBECONFIG | base64 kubeconfig_encoded: diff --git a/README.md b/README.md index bc573f01..799d8ef5 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Flags: CONFIGURATION: -config string cloudlist flag config file (default "$HOME/.config/cloudlist/config.yaml") -pc, -provider-config string provider config file (default "$HOME/.config/cloudlist/provider-config.yaml") + -ad, -auto-discovery discover providers from environment credentials (aws, gcp, azure, kubernetes) instead of the provider config FILTERS: -p, -provider value display results for given providers (comma-separated) (default linode,fastly,heroku,terraform,digitalocean,consul,cloudflare,hetzner,nomad,do,scw,openstack,alibaba,aws,gcp,namecheap,kubernetes,azure, custom) diff --git a/internal/runner/autodiscovery.go b/internal/runner/autodiscovery.go new file mode 100644 index 00000000..0cf481ec --- /dev/null +++ b/internal/runner/autodiscovery.go @@ -0,0 +1,43 @@ +package runner + +import ( + "errors" + + "github.com/projectdiscovery/cloudlist/pkg/inventory" + "github.com/projectdiscovery/cloudlist/pkg/schema" + "github.com/projectdiscovery/gologger" +) + +// autoDiscoveryProviders resolve credentials without config through their SDK +// default chains: environment variables, CLI logins, instance metadata and +// in-cluster service accounts. +var autoDiscoveryProviders = []string{"aws", "gcp", "azure", "kubernetes"} + +func autoDiscoveryConfig() schema.Options { + config := make(schema.Options, 0, len(autoDiscoveryProviders)) + for _, provider := range autoDiscoveryProviders { + config = append(config, schema.OptionBlock{"provider": provider}) + } + return config +} + +func (r *Runner) newInventory(config schema.Options) (*inventory.Inventory, error) { + if !r.options.AutoDiscovery { + return inventory.New(config) + } + // A provider fails to initialize when the environment has no credentials + // for it, which is expected here, so skip it instead of aborting. + discovered := &inventory.Inventory{} + for _, block := range config { + inv, err := inventory.New(schema.Options{block}) + if err != nil { + gologger.Verbose().Msgf("Skipping %s: %s", block["provider"], err) + continue + } + discovered.Providers = append(discovered.Providers, inv.Providers...) + } + if len(discovered.Providers) == 0 { + return nil, errors.New("no provider credentials found in the environment") + } + return discovered, nil +} diff --git a/internal/runner/autodiscovery_test.go b/internal/runner/autodiscovery_test.go new file mode 100644 index 00000000..d2b78444 --- /dev/null +++ b/internal/runner/autodiscovery_test.go @@ -0,0 +1,48 @@ +package runner + +import ( + "os" + "path/filepath" + "testing" + + "github.com/projectdiscovery/cloudlist/pkg/schema" + "github.com/stretchr/testify/require" +) + +func TestNewInventorySkipsProvidersWithoutCredentials(t *testing.T) { + kubeconfig := filepath.Join(t.TempDir(), "config") + require.NoError(t, os.WriteFile(kubeconfig, []byte(`apiVersion: v1 +kind: Config +clusters: +- name: test + cluster: + server: https://k8s.example.test:6443 +contexts: +- name: test + context: + cluster: test +current-context: test +`), 0o600)) + r := &Runner{options: &Options{AutoDiscovery: true}} + + inv, err := r.newInventory(schema.Options{ + {"provider": "kubernetes", "kubeconfig_file": filepath.Join(t.TempDir(), "missing")}, + {"provider": "kubernetes", "kubeconfig_file": kubeconfig}, + }) + require.NoError(t, err) + require.Len(t, inv.Providers, 1) + + _, err = r.newInventory(schema.Options{ + {"provider": "kubernetes", "kubeconfig_file": filepath.Join(t.TempDir(), "missing")}, + }) + require.EqualError(t, err, "no provider credentials found in the environment") +} + +func TestNewInventoryFailsFastWithoutAutoDiscovery(t *testing.T) { + r := &Runner{options: &Options{}} + + _, err := r.newInventory(schema.Options{ + {"provider": "kubernetes", "kubeconfig_file": filepath.Join(t.TempDir(), "missing")}, + }) + require.Error(t, err) +} diff --git a/internal/runner/options.go b/internal/runner/options.go index 0c3bdc20..3d93efeb 100644 --- a/internal/runner/options.go +++ b/internal/runner/options.go @@ -36,6 +36,7 @@ type Options struct { ExtendedMetadata bool // ExtendedMetadata enables extended metadata for providers. ProviderConfig string // ProviderConfig is the location of the provider config file. DisableUpdateCheck bool // DisableUpdateCheck disable automatic update check + AutoDiscovery bool // AutoDiscovery discovers providers from environment credentials instead of the provider config. } var ( @@ -78,6 +79,7 @@ func ParseOptions() *Options { flagSet.CreateGroup("config", "Configuration", flagSet.StringVar(&options.Config, "config", defaultConfigLocation, "cloudlist flag config file"), flagSet.StringVarP(&options.ProviderConfig, "provider-config", "pc", defaultProviderConfigLocation, "provider config file"), + flagSet.BoolVarP(&options.AutoDiscovery, "auto-discovery", "ad", false, "discover providers from environment credentials (aws, gcp, azure, kubernetes) instead of the provider config"), ) flagSet.CreateGroup("filter", "Filters", flagSet.StringSliceVarP(&options.Providers, "provider", "p", nil, "display results for given providers (comma-separated) (default "+strings.Join(defaultProviders, ",")+")", goflags.CommaSeparatedStringSliceOptions), diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 35df9189..e67d3f5e 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -9,7 +9,6 @@ import ( "sync" jsoniter "github.com/json-iterator/go" - "github.com/projectdiscovery/cloudlist/pkg/inventory" "github.com/projectdiscovery/cloudlist/pkg/schema" "github.com/projectdiscovery/cloudlist/pkg/schema/validate" "github.com/projectdiscovery/gologger" @@ -24,14 +23,20 @@ type Runner struct { // New creates a new runner instance based on configuration options func New(options *Options) (*Runner, error) { - if options.ProviderConfig == "" { - options.ProviderConfig = defaultProviderConfigLocation - gologger.Print().Msgf("Using default provider config: %s\n", options.ProviderConfig) - } + var config schema.Options + if options.AutoDiscovery { + config = autoDiscoveryConfig() + } else { + if options.ProviderConfig == "" { + options.ProviderConfig = defaultProviderConfigLocation + gologger.Print().Msgf("Using default provider config: %s\n", options.ProviderConfig) + } - config, err := readProviderConfig(options.ProviderConfig) - if err != nil { - return nil, err + var err error + config, err = readProviderConfig(options.ProviderConfig) + if err != nil { + return nil, err + } } // CLI overrides config @@ -96,7 +101,7 @@ func (r *Runner) Enumerate() { } } - inventory, err := inventory.New(finalConfig) + inventory, err := r.newInventory(finalConfig) if err != nil { gologger.Fatal().Msgf("Could not create inventory: %s\n", err) } diff --git a/pkg/providers/k8s/kubernetes.go b/pkg/providers/k8s/kubernetes.go index e5ba2fef..42c9321c 100644 --- a/pkg/providers/k8s/kubernetes.go +++ b/pkg/providers/k8s/kubernetes.go @@ -32,12 +32,8 @@ const ( func New(options schema.OptionBlock) (*Provider, error) { id, _ := options.GetMetadata("id") - configFile, ok := options.GetMetadata(kubeconfig_file) + configFile, _ := options.GetMetadata(kubeconfig_file) configEncoded, strOk := options.GetMetadata(encodedKubeConfig) - - if !ok && !strOk { - return nil, errkit.New("no kubeconfig_file or kubeconfig_encoded provided") - } context, _ := options.GetMetadata("context") var kubeConfig *rest.Config @@ -111,19 +107,20 @@ func (p *Provider) Resources(ctx context.Context) (*schema.Resources, error) { return finalList, nil } +// buildConfigWithContext resolves the kubeconfig like kubectl when no path is +// given: $KUBECONFIG, then ~/.kube/config, then the in-cluster service account. func buildConfigWithContext(context string, kubeconfigPath string) (*rest.Config, error) { - if context == "" { - kubeConfig, err := clientcmd.BuildConfigFromFlags("", kubeconfigPath) - if err != nil { - return kubeConfig, errkit.Wrap(err, "could not read kubeconfig file") - } - return kubeConfig, nil - } - return clientcmd.NewNonInteractiveDeferredLoadingClientConfig( - &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfigPath}, + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + loadingRules.ExplicitPath = kubeconfigPath + kubeConfig, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + loadingRules, &clientcmd.ConfigOverrides{ CurrentContext: context, }).ClientConfig() + if err != nil { + return nil, errkit.Wrap(err, "could not read kubeconfig file") + } + return kubeConfig, nil } func buildConfigFromStr(contextName string, config []byte) (*rest.Config, error) { diff --git a/pkg/providers/k8s/kubernetes_test.go b/pkg/providers/k8s/kubernetes_test.go new file mode 100644 index 00000000..9725210d --- /dev/null +++ b/pkg/providers/k8s/kubernetes_test.go @@ -0,0 +1,52 @@ +package k8s + +import ( + "os" + "path/filepath" + "testing" + + "github.com/projectdiscovery/cloudlist/pkg/schema" + "github.com/stretchr/testify/require" +) + +const testKubeconfig = `apiVersion: v1 +kind: Config +clusters: +- name: test + cluster: + server: https://k8s.example.test:6443 +users: +- name: test + user: + token: test +contexts: +- name: test + context: + cluster: test + user: test +current-context: test +` + +func isolateKubeconfig(t *testing.T, kubeconfig string) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("KUBERNETES_SERVICE_HOST", "") + t.Setenv("KUBECONFIG", kubeconfig) +} + +func TestNewWithoutKubeconfigUsesEnvironment(t *testing.T) { + path := filepath.Join(t.TempDir(), "config") + require.NoError(t, os.WriteFile(path, []byte(testKubeconfig), 0o600)) + isolateKubeconfig(t, path) + + p, err := New(schema.OptionBlock{}) + require.NoError(t, err) + require.Equal(t, "k8s.example.test:6443", p.clientSet.CoreV1().RESTClient().Get().URL().Host) +} + +func TestNewWithoutKubeconfigFailsWhenNoneFound(t *testing.T) { + isolateKubeconfig(t, filepath.Join(t.TempDir(), "missing")) + + _, err := New(schema.OptionBlock{}) + require.Error(t, err) +}