diff --git a/PROVIDERS.md b/PROVIDERS.md index e6237964..08718b1c 100644 --- a/PROVIDERS.md +++ b/PROVIDERS.md @@ -152,6 +152,7 @@ Google Cloud Platform supports **two discovery approaches** and **two authentica - `service_account_email` (string, required if short-lived): Target service account to impersonate - `source_credentials` (string, optional): Path to source credentials file (uses ADC if not provided) - `token_lifetime` (string, optional): Token lifetime in seconds (e.g., "3600s") or Go duration format (e.g., "1h"). Range: 1s to 3600s (1 hour). Default: "3600s" +- `project_ids` (list, optional): Comma-separated/list of project IDs to enumerate. When provided, Cloudlist skips discovery in every other accessible project, both for individual APIs and the organization-level Asset API. --- @@ -191,8 +192,13 @@ Google Cloud Platform supports **two discovery approaches** and **two authentica use_short_lived_credentials: true service_account_email: "asset-viewer-sa@project.iam.gserviceaccount.com" token_lifetime: "7200s" # 2 hours + project_ids: + - security-core + - shared-infra ``` +Add `project_ids` to either configuration style to limit enumeration strictly to the listed projects (Cloud Asset API requests are filtered too), which is helpful for large organizations or delegated-access service accounts. + **Required Organization-Level Roles:** 1. `roles/cloudasset.viewer` - Core Asset API access 2. `roles/resourcemanager.viewer` - List projects in organization diff --git a/internal/runner/runner.go b/internal/runner/runner.go index de4f6cac..a6b39866 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -6,10 +6,12 @@ import ( "os" "strconv" "strings" + "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" ) @@ -115,6 +117,8 @@ func (r *Runner) Enumerate() { continue } + sanitizePrivateIPs(instance, r.options.ExcludePrivate) + builder.Reset() if r.options.JSON { @@ -124,7 +128,7 @@ func (r *Runner) Enumerate() { } else { builder.Write(data) builder.WriteString("\n") - output.Write(builder.Bytes()) //nolint + writeOutputBytes(output, builder.Bytes()) if instance.DNSName != "" { hostsCount++ @@ -152,7 +156,7 @@ func (r *Runner) Enumerate() { hostsCount++ builder.WriteString(instance.DNSName) builder.WriteRune('\n') - output.WriteString(builder.String()) //nolint + writeOutputString(output, builder.String()) builder.Reset() gologger.Silent().Msgf("%s", instance.DNSName) } @@ -163,7 +167,7 @@ func (r *Runner) Enumerate() { ipCount++ builder.WriteString(instance.PublicIPv4) builder.WriteRune('\n') - output.WriteString(builder.String()) //nolint + writeOutputString(output, builder.String()) builder.Reset() gologger.Silent().Msgf("%s", instance.PublicIPv4) } @@ -171,7 +175,7 @@ func (r *Runner) Enumerate() { ipCount++ builder.WriteString(instance.PublicIPv6) builder.WriteRune('\n') - output.WriteString(builder.String()) //nolint + writeOutputString(output, builder.String()) builder.Reset() gologger.Silent().Msgf("%s", instance.PublicIPv6) } @@ -179,7 +183,7 @@ func (r *Runner) Enumerate() { ipCount++ builder.WriteString(instance.PrivateIpv4) builder.WriteRune('\n') - output.WriteString(builder.String()) //nolint + writeOutputString(output, builder.String()) builder.Reset() gologger.Silent().Msgf("%s", instance.PrivateIpv4) } @@ -187,7 +191,7 @@ func (r *Runner) Enumerate() { ipCount++ builder.WriteString(instance.PrivateIpv6) builder.WriteRune('\n') - output.WriteString(builder.String()) //nolint + writeOutputString(output, builder.String()) builder.Reset() gologger.Silent().Msgf("%s", instance.PrivateIpv6) } @@ -198,7 +202,7 @@ func (r *Runner) Enumerate() { hostsCount++ builder.WriteString(instance.DNSName) builder.WriteRune('\n') - output.WriteString(builder.String()) //nolint + writeOutputString(output, builder.String()) builder.Reset() gologger.Silent().Msgf("%s", instance.DNSName) } @@ -206,7 +210,7 @@ func (r *Runner) Enumerate() { ipCount++ builder.WriteString(instance.PublicIPv4) builder.WriteRune('\n') - output.WriteString(builder.String()) //nolint + writeOutputString(output, builder.String()) builder.Reset() gologger.Silent().Msgf("%s", instance.PublicIPv4) } @@ -214,7 +218,7 @@ func (r *Runner) Enumerate() { ipCount++ builder.WriteString(instance.PublicIPv6) builder.WriteRune('\n') - output.WriteString(builder.String()) //nolint + writeOutputString(output, builder.String()) builder.Reset() gologger.Silent().Msgf("%s", instance.PublicIPv6) } @@ -222,7 +226,7 @@ func (r *Runner) Enumerate() { ipCount++ builder.WriteString(instance.PrivateIpv4) builder.WriteRune('\n') - output.WriteString(builder.String()) //nolint + writeOutputString(output, builder.String()) builder.Reset() gologger.Silent().Msgf("%s", instance.PrivateIpv4) } @@ -230,7 +234,7 @@ func (r *Runner) Enumerate() { ipCount++ builder.WriteString(instance.PrivateIpv6) builder.WriteRune('\n') - output.WriteString(builder.String()) //nolint + writeOutputString(output, builder.String()) builder.Reset() gologger.Silent().Msgf("%s", instance.PrivateIpv6) } @@ -263,3 +267,55 @@ func Contains(s []string, e string) bool { } return false } + +func sanitizePrivateIPs(resource *schema.Resource, exclude bool) { + if !exclude || resource == nil { + return + } + resource.PrivateIpv4 = "" + resource.PrivateIpv6 = "" + + if isPrivateIP(resource.PublicIPv4) { + resource.PublicIPv4 = "" + } + if isPrivateIP(resource.PublicIPv6) { + resource.PublicIPv6 = "" + } +} + +var ( + ipValidatorOnce sync.Once + ipValidator *validate.Validator +) + +func isPrivateIP(ip string) bool { + if ip == "" { + return false + } + ipValidatorOnce.Do(func() { + var err error + ipValidator, err = validate.NewValidator() + if err != nil { + gologger.Warning().Msgf("could not initialize ip validator: %s", err) + } + }) + if ipValidator == nil { + return false + } + resourceType := ipValidator.Identify(ip) + return resourceType == validate.PrivateIPv4 || resourceType == validate.PrivateIPv6 +} + +func writeOutputString(output *os.File, data string) { + if output == nil || data == "" { + return + } + _, _ = output.WriteString(data) +} + +func writeOutputBytes(output *os.File, data []byte) { + if output == nil || len(data) == 0 { + return + } + _, _ = output.Write(data) +} diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go new file mode 100644 index 00000000..463e1c34 --- /dev/null +++ b/internal/runner/runner_test.go @@ -0,0 +1,58 @@ +package runner + +import ( + "testing" + + "github.com/projectdiscovery/cloudlist/pkg/schema" +) + +func TestSanitizePrivateIPs(t *testing.T) { + resource := &schema.Resource{ + PublicIPv4: "1.2.3.4", + PrivateIpv4: "10.0.0.5", + PrivateIpv6: "fd00::1", + PublicIPv6: "2606:4700:4700::1111", + DNSName: "example.internal", + } + + sanitizePrivateIPs(resource, true) + + if resource.PrivateIpv4 != "" || resource.PrivateIpv6 != "" { + t.Fatalf("expected private addresses to be cleared, got %q and %q", resource.PrivateIpv4, resource.PrivateIpv6) + } + + if resource.PublicIPv4 == "" || resource.PublicIPv6 == "" || resource.DNSName == "" { + t.Fatalf("public fields should remain untouched: %+v", resource) + } + + resource.PrivateIpv4 = "10.0.0.5" + resource.PrivateIpv6 = "fd00::1" + sanitizePrivateIPs(resource, false) + + if resource.PrivateIpv4 == "" || resource.PrivateIpv6 == "" { + t.Fatalf("expected private addresses to remain when exclusion disabled") + } + + sanitizePrivateIPs(nil, true) +} + +func TestSanitizePrivateIPsOnMisclassifiedFields(t *testing.T) { + resource := &schema.Resource{ + PublicIPv4: "10.10.0.5", + PublicIPv6: "fd00::5", + } + + sanitizePrivateIPs(resource, true) + + if resource.PublicIPv4 != "" || resource.PublicIPv6 != "" { + t.Fatalf("expected private addresses in public fields to be cleared, got ipv4=%q ipv6=%q", resource.PublicIPv4, resource.PublicIPv6) + } + + resource.PublicIPv4 = "8.8.8.8" + resource.PublicIPv6 = "2606:4700:4700::1111" + sanitizePrivateIPs(resource, true) + + if resource.PublicIPv4 == "" || resource.PublicIPv6 == "" { + t.Fatalf("expected public addresses to remain when exclusion enabled") + } +} diff --git a/pkg/providers/gcp/gcp.go b/pkg/providers/gcp/gcp.go index aa51e87b..54d2b0cf 100644 --- a/pkg/providers/gcp/gcp.go +++ b/pkg/providers/gcp/gcp.go @@ -49,6 +49,7 @@ type OrganizationProvider struct { assetClient *asset.Client services schema.ServiceMap projects []string + projectScope *projectScope extendedMetadata bool readTimeOffsetSeconds int compute *compute.Service // For extended metadata @@ -243,6 +244,8 @@ func newIndividualProvider(options schema.OptionBlock, id, JSONData string) (*Pr } provider.services = services + configuredProjects := getProjectIDsFromOptions(options) + // Extract short-lived credentials configuration useShortLived := false if val, ok := options.GetMetadata("use_short_lived_credentials"); ok { @@ -338,20 +341,31 @@ func newIndividualProvider(options schema.OptionBlock, id, JSONData string) (*Pr provider.run = cloudRunService } - projects := []string{} - manager, err := cloudresourcemanager.NewService(context.Background(), creds) - if err != nil { - return nil, errkit.Wrap(err, "could not list projects") - } - list := manager.Projects.List() - err = list.Pages(context.Background(), func(resp *cloudresourcemanager.ListProjectsResponse) error { - for _, project := range resp.Projects { - projects = append(projects, project.ProjectId) + projects := append([]string{}, configuredProjects...) + if len(projects) == 0 { + manager, err := cloudresourcemanager.NewService(context.Background(), creds) + if err != nil { + return nil, errkit.Wrap(err, "could not list projects") } - return nil - }) + list := manager.Projects.List() + err = list.Pages(context.Background(), func(resp *cloudresourcemanager.ListProjectsResponse) error { + for _, project := range resp.Projects { + projects = append(projects, project.ProjectId) + } + return nil + }) + if err != nil { + return nil, errkit.Wrap(err, "could not iterate projects") + } + } + if len(projects) == 0 { + return nil, errkit.New("no projects available for discovery") + } + if len(configuredProjects) > 0 { + gologger.Info().Msgf("Using %d configured GCP project(s) for provider %s", len(projects), id) + } provider.projects = projects - return provider, err + return provider, nil } // Name returns the name of the provider @@ -369,32 +383,65 @@ func (p *OrganizationProvider) Services() []string { return p.services.Keys() } -// Resources returns the provider resources using organization-level Cloud Asset Inventory API +// Resources returns the provider resources using project-scoped / organization-level Cloud Asset Inventory API func (p *OrganizationProvider) Resources(ctx context.Context) (*schema.Resources, error) { gologger.Info().Msgf("OrgProvider.Resources called with organization_id: '%s', projects: %v, services: %v", p.organizationID, p.projects, p.services.Keys()) - parent := "organizations/" + p.organizationID - gologger.Info().Msgf("Using organization-level discovery with parent: %s", parent) - finalResources := schema.NewResources() - // Use Cloud Asset Inventory API to get assets - if p.services.Has("all") { - gologger.Info().Msgf("Found 'all' service, starting comprehensive asset discovery") - allAssets, err := p.getAllAssets(ctx, parent) - if err != nil { - gologger.Warning().Msgf("Could not get all assets: %s", err) - } else { - finalResources.Merge(allAssets) + // Use per-project API calls when specific projects are configured + if p.projectScope != nil && len(p.projectScope.listIDs()) > 0 { + gologger.Info().Msgf("Using project-scoped discovery for %d configured projects", len(p.projectScope.listIDs())) + + for _, projectID := range p.projectScope.listIDs() { + parent := "projects/" + projectID + gologger.Info().Msgf("Fetching assets for project: %s", projectID) + + var projectResources *schema.Resources + var err error + // if projects has all, then get all assets + if p.services.Has("all") { + projectResources, err = p.getAllAssets(ctx, parent) + if err != nil { + gologger.Warning().Msgf("Could not get all assets for project %s: %s", projectID, err) + continue + } + } else { + projectResources = schema.NewResources() + for _, service := range p.services.Keys() { + assets, err := p.getAssetsForService(ctx, parent, service) + if err != nil { + gologger.Warning().Msgf("Could not get assets for service %s in project %s: %s", service, projectID, err) + } else { + projectResources.Merge(assets) + } + } + } + + finalResources.Merge(projectResources) } } else { - // Get assets for specific services - for _, service := range p.services.Keys() { - assets, err := p.getAssetsForService(ctx, parent, service) + // Fallback to organization-level discovery when no specific projects configured + parent := "organizations/" + p.organizationID + gologger.Info().Msgf("Using organization-level discovery with parent: %s", parent) + // Note: When using organization-level discovery, all assets are wanted maybe? + if p.services.Has("all") { + gologger.Info().Msgf("Found 'all' service, starting comprehensive asset discovery") + allAssets, err := p.getAllAssets(ctx, parent) if err != nil { - gologger.Warning().Msgf("Could not get assets for service %s: %s", service, err) + gologger.Warning().Msgf("Could not get all assets: %s", err) } else { - finalResources.Merge(assets) + finalResources.Merge(allAssets) + } + } else { + // Get assets for specific services + for _, service := range p.services.Keys() { + assets, err := p.getAssetsForService(ctx, parent, service) + if err != nil { + gologger.Warning().Msgf("Could not get assets for service %s: %s", service, err) + } else { + finalResources.Merge(assets) + } } } } @@ -468,6 +515,14 @@ func (p *OrganizationProvider) getAssetsForTypes(ctx context.Context, parent str return nil, err } + // Note: When using project-scoped API calls, client-side filtering is unnecessary + // as the API only returns assets from the specified scope (project or organization) + // For organization-level calls without project_ids config, all assets are wanted anyway + needsFiltering := strings.HasPrefix(parent, "organizations/") && p.projectScope != nil + if needsFiltering && !p.projectScope.allowsAsset(asset) { + continue + } + resource := p.parseAssetToResource(asset) if resource != nil { assetInfos = append(assetInfos, assetInfo{ @@ -528,6 +583,8 @@ func newOrganizationProvider(options schema.OptionBlock, id, JSONData, organizat organizationID: organizationID, } + configuredProjects := getProjectIDsFromOptions(options) + // Check for extended metadata flag if extendedMetadata, ok := options.GetMetadata("extended_metadata"); ok { provider.extendedMetadata = extendedMetadata == "true" @@ -663,15 +720,33 @@ func newOrganizationProvider(options schema.OptionBlock, id, JSONData, organizat if err != nil { return nil, errkit.Wrap(err, "could not create resource manager") } - list := manager.Projects.List() - err = list.Pages(context.Background(), func(resp *cloudresourcemanager.ListProjectsResponse) error { - for _, project := range resp.Projects { - projects = append(projects, project.ProjectId) + if len(configuredProjects) > 0 { + scope := newProjectScope(configuredProjects) + if scope == nil { + return nil, errkit.New("no valid project ids provided in configuration") + } + if err := scope.enrichWithProjectNumbers(context.Background(), manager); err != nil { + gologger.Warning().Msgf("Could not resolve configured project ids: %s", err) + } + projects = scope.listIDs() + provider.projectScope = scope + } else { + list := manager.Projects.List() + err = list.Pages(context.Background(), func(resp *cloudresourcemanager.ListProjectsResponse) error { + for _, project := range resp.Projects { + projects = append(projects, project.ProjectId) + } + return nil + }) + if err != nil { + return nil, errkit.Wrap(err, "could not list projects") } - return nil - }) - if err != nil { - return nil, errkit.Wrap(err, "could not list projects") + } + if len(projects) == 0 { + return nil, errkit.New("no projects available for organization discovery") + } + if len(configuredProjects) > 0 { + gologger.Info().Msgf("Restricting organization discovery to %d configured project(s)", len(projects)) } provider.projects = projects @@ -890,3 +965,219 @@ func (p *OrganizationProvider) Verify(ctx context.Context) error { } return nil } + +func getProjectIDsFromOptions(options schema.OptionBlock) []string { + raw, ok := options.GetMetadata("project_ids") + if !ok || strings.TrimSpace(raw) == "" { + return nil + } + return splitAndCleanProjectList(raw) +} + +func splitAndCleanProjectList(raw string) []string { + replacer := strings.NewReplacer("\n", ",", "\r", ",", ";", ",") + normalized := replacer.Replace(raw) + parts := strings.Split(normalized, ",") + result := make([]string, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + if len(result) == 0 { + return nil + } + return result +} + +type projectScope struct { + allowedIDs map[string]struct{} + allowedNumbers map[string]struct{} + orderedIDs []string +} + +func newProjectScope(projectIDs []string) *projectScope { + seen := make(map[string]struct{}, len(projectIDs)) + sanitized := make([]string, 0, len(projectIDs)) + for _, id := range projectIDs { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + sanitized = append(sanitized, trimmed) + } + if len(sanitized) == 0 { + return nil + } + scope := &projectScope{ + allowedIDs: make(map[string]struct{}, len(sanitized)), + allowedNumbers: make(map[string]struct{}), + orderedIDs: append([]string{}, sanitized...), + } + for _, id := range sanitized { + scope.allowedIDs[id] = struct{}{} + if isNumeric(id) { + scope.allowedNumbers[id] = struct{}{} + } + } + return scope +} + +func (ps *projectScope) listIDs() []string { + if ps == nil { + return nil + } + return append([]string{}, ps.orderedIDs...) +} + +func (ps *projectScope) enrichWithProjectNumbers(ctx context.Context, manager *cloudresourcemanager.Service) error { + if ps == nil || manager == nil { + return nil + } + var firstErr error + for idx, id := range ps.orderedIDs { + project, err := manager.Projects.Get(id).Context(ctx).Do() + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + if project.ProjectId != "" { + ps.allowedIDs[project.ProjectId] = struct{}{} + ps.orderedIDs[idx] = project.ProjectId + } + if project.ProjectNumber != 0 { + number := strconv.FormatInt(project.ProjectNumber, 10) + ps.allowedNumbers[number] = struct{}{} + } + } + return firstErr +} + +func (ps *projectScope) allowsAsset(asset *assetpb.Asset) bool { + if ps == nil { + return true + } + if ps.containsID(extractProjectIDFromAsset(asset)) { + return true + } + if ps.containsNumber(extractProjectNumberFromAsset(asset)) { + return true + } + return false +} + +func (ps *projectScope) containsID(id string) bool { + if id == "" { + return false + } + _, ok := ps.allowedIDs[id] + return ok +} + +func (ps *projectScope) containsNumber(number string) bool { + if number == "" { + return false + } + _, ok := ps.allowedNumbers[number] + return ok +} + +func extractProjectIDFromAsset(asset *assetpb.Asset) string { + if asset == nil { + return "" + } + if id := extractProjectToken(asset.GetName()); id != "" && id != "_" { + return id + } + if resource := asset.GetResource(); resource != nil { + if id := extractProjectToken(resource.Parent); id != "" && id != "_" { + return id + } + if data := resource.Data; data != nil { + for _, key := range []string{"projectId", "project", "project_id"} { + if field, ok := data.Fields[key]; ok { + if value := strings.TrimSpace(field.GetStringValue()); value != "" && value != "_" { + return value + } + } + } + } + } + return "" +} + +func extractProjectNumberFromAsset(asset *assetpb.Asset) string { + if asset == nil { + return "" + } + if resource := asset.GetResource(); resource != nil { + if number := extractNumericProjectToken(resource.Parent); number != "" { + return number + } + if data := resource.Data; data != nil { + if field, ok := data.Fields["projectNumber"]; ok { + if value := strings.TrimSpace(field.GetStringValue()); value != "" { + return value + } + } + } + } + for _, ancestor := range asset.Ancestors { + if number := extractNumericProjectToken(ancestor); number != "" { + return number + } + } + return "" +} + +func extractNumericProjectToken(value string) string { + token := extractProjectToken(value) + if token == "" { + return "" + } + if !isNumeric(token) { + return "" + } + return token +} + +func extractProjectToken(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + trimmed = strings.TrimPrefix(trimmed, "//") + index := strings.Index(trimmed, "projects/") + if index == -1 { + return "" + } + trimmed = trimmed[index+len("projects/"):] + parts := strings.Split(trimmed, "/") + if len(parts) == 0 { + return "" + } + return strings.TrimSpace(parts[0]) +} + +func isNumeric(value string) bool { + if value == "" { + return false + } + if _, err := strconv.ParseInt(value, 10, 64); err != nil { + return false + } + return true +} diff --git a/pkg/providers/gcp/project_scope_test.go b/pkg/providers/gcp/project_scope_test.go new file mode 100644 index 00000000..d505b222 --- /dev/null +++ b/pkg/providers/gcp/project_scope_test.go @@ -0,0 +1,47 @@ +package gcp + +import ( + "testing" + + assetpb "cloud.google.com/go/asset/apiv1/assetpb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSplitAndCleanProjectList(t *testing.T) { + input := "demo-project, prod-project, demo-project, ,\n qa-project" + expected := []string{"demo-project", "prod-project", "qa-project"} + + result := splitAndCleanProjectList(input) + + require.Equal(t, expected, result) +} + +func TestProjectScopeAllowsAssetByID(t *testing.T) { + scope := newProjectScope([]string{"demo-project", "prod-project"}) + require.NotNil(t, scope) + + allowedAsset := &assetpb.Asset{ + Name: "//compute.googleapis.com/projects/demo-project/zones/us-central1-a/instances/test-instance", + } + + blockedAsset := &assetpb.Asset{ + Name: "//compute.googleapis.com/projects/other-project/zones/us-central1-a/instances/test-instance", + } + + assert.True(t, scope.allowsAsset(allowedAsset)) + assert.False(t, scope.allowsAsset(blockedAsset)) +} + +func TestProjectScopeAllowsAssetByNumber(t *testing.T) { + scope := newProjectScope([]string{"123456789"}) + require.NotNil(t, scope) + + asset := &assetpb.Asset{ + Resource: &assetpb.Resource{ + Parent: "//cloudresourcemanager.googleapis.com/projects/123456789", + }, + } + + assert.True(t, scope.allowsAsset(asset)) +} diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go index 0c76d41e..14f31159 100644 --- a/pkg/schema/schema.go +++ b/pkg/schema/schema.go @@ -240,7 +240,7 @@ func (ob *OptionBlock) UnmarshalYAML(unmarshal func(interface{}) error) error { // Convert raw map to OptionBlock and handle special cases for key, value := range rawMap { switch key { - case "account_ids", "urls", "services": + case "account_ids", "urls", "services", "project_ids": if valueArr, ok := value.([]interface{}); ok { var strArr []string for _, v := range valueArr { diff --git a/pkg/schema/schema_test.go b/pkg/schema/schema_test.go new file mode 100644 index 00000000..3dd68082 --- /dev/null +++ b/pkg/schema/schema_test.go @@ -0,0 +1,26 @@ +package schema + +import ( + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" +) + +func TestOptionBlockParsesProjectIDs(t *testing.T) { + data := ` +- provider: gcp + project_ids: + - alpha + - beta + - alpha +` + var options Options + err := yaml.Unmarshal([]byte(data), &options) + require.NoError(t, err) + require.Len(t, options, 1) + + value, ok := options[0].GetMetadata("project_ids") + require.True(t, ok) + require.Equal(t, "alpha,beta,alpha", value) +} diff --git a/pkg/schema/validate/validate.go b/pkg/schema/validate/validate.go index bcf34583..cfd7cfd4 100644 --- a/pkg/schema/validate/validate.go +++ b/pkg/schema/validate/validate.go @@ -10,10 +10,11 @@ import ( var ipv4PrivateRanges = []string{ "0.0.0.0/8", // Current network (only valid as source address) "10.0.0.0/8", // Private network - "100.64.0.0/10", // Shared Address Space + "100.64.0.0/10", // Shared Address Space (CGNAT) "127.0.0.0/8", // Loopback "169.254.0.0/16", // Link-local (Also many cloud providers Metadata endpoint) - "172.16.0.0/12", // Private network + "172.16.0.0/12", // Private network (RFC 1918) + // "172.64.0.0/10", // Extended private range (cloud VPCs often use 172.x.x.x) "192.0.0.0/24", // IETF Protocol Assignments "192.0.2.0/24", // TEST-NET-1, documentation and examples "192.88.99.0/24", // IPv6 to IPv4 relay (includes 2002::/16)