Skip to content

Commit d5d9036

Browse files
authored
Merge pull request #746 from projectdiscovery/feat/gcp-exclude-project-ids
feat(gcp): exclude project ids
2 parents da6b66e + e576191 commit d5d9036

4 files changed

Lines changed: 136 additions & 1 deletion

File tree

PROVIDERS.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ Google Cloud Platform supports **two discovery approaches** and **two authentica
153153
- `source_credentials` (string, optional): Path to source credentials file (uses ADC if not provided)
154154
- `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"
155155
- `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.
156+
- `exclude_project_ids` (list, optional): Comma-separated/list of project IDs to exclude from organization-wide discovery. Requires `organization_id`. Mutually exclusive with `project_ids`. When provided, Cloudlist discovers all projects in the organization and skips the excluded ones, using per-project Asset API calls for the remaining projects.
156157

157158
---
158159

@@ -199,6 +200,20 @@ Google Cloud Platform supports **two discovery approaches** and **two authentica
199200

200201
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.
201202

203+
**Excluding Projects:**
204+
205+
```yaml
206+
- provider: gcp
207+
organization_id: "123456789012"
208+
gcp_service_account_key: '$GCP_SA_KEY'
209+
exclude_project_ids:
210+
- sandbox-project
211+
- legacy-app
212+
- test-environment
213+
```
214+
215+
Use `exclude_project_ids` to scan all projects in an organization except the listed ones. This is useful when only a few projects need to be excluded from a large organization.
216+
202217
**Required Organization-Level Roles:**
203218
1. `roles/cloudasset.viewer` - Core Asset API access
204219
2. `roles/resourcemanager.viewer` - List projects in organization

pkg/providers/gcp/gcp.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,18 @@ func New(options schema.OptionBlock) (schema.Provider, error) {
205205

206206
gologger.Info().Msgf("Creating GCP provider with id: %s", id)
207207

208+
// Validate exclude_project_ids constraints
209+
hasInclude := len(getProjectIDsFromOptions(options)) > 0
210+
hasExclude := len(getExcludeProjectIDsFromOptions(options)) > 0
211+
if hasInclude && hasExclude {
212+
return nil, errkit.New("project_ids and exclude_project_ids are mutually exclusive")
213+
}
214+
if hasExclude {
215+
if _, ok := options.GetMetadata("organization_id"); !ok {
216+
return nil, errkit.New("exclude_project_ids requires organization_id to be set")
217+
}
218+
}
219+
208220
// Note: gcp_service_account_key is optional
209221
// Authentication will fall back to Application Default Credentials (ADC) if not provided
210222
// This works for both traditional and short-lived credential modes
@@ -798,6 +810,46 @@ func newOrganizationProvider(options schema.OptionBlock, id, JSONData, organizat
798810
if len(projects) == 0 {
799811
gologger.Info().Msgf("No projects listed, will use organization-level Asset API discovery for org %s", organizationID)
800812
}
813+
814+
// Apply exclude filter if configured
815+
excludeIDs := getExcludeProjectIDsFromOptions(options)
816+
if len(excludeIDs) > 0 {
817+
if len(projects) == 0 {
818+
return nil, errkit.New("exclude_project_ids requires project listing to succeed, but no projects were discovered")
819+
}
820+
excludeScope := newProjectScope(excludeIDs)
821+
if excludeScope != nil {
822+
if manager != nil {
823+
if err := excludeScope.enrichWithProjectNumbers(context.Background(), manager); err != nil {
824+
gologger.Warning().Msgf("Could not resolve excluded project ids: %s", err)
825+
}
826+
}
827+
filtered := make([]string, 0, len(projects))
828+
for _, p := range projects {
829+
if !excludeScope.containsID(p) {
830+
filtered = append(filtered, p)
831+
}
832+
}
833+
matched := len(projects) - len(filtered)
834+
gologger.Info().Msgf("Excluded %d/%d project(s), %d remaining", matched, len(excludeIDs), len(filtered))
835+
projects = filtered
836+
}
837+
838+
if len(projects) == 0 {
839+
return nil, errkit.New("all projects were excluded, no projects remaining for discovery")
840+
}
841+
842+
// Build projectScope from remaining projects so Resources() uses per-project Asset API path
843+
scope := newProjectScope(projects)
844+
if scope != nil {
845+
if manager != nil {
846+
if err := scope.enrichWithProjectNumbers(context.Background(), manager); err != nil {
847+
gologger.Warning().Msgf("Could not resolve remaining project ids: %s", err)
848+
}
849+
}
850+
provider.projectScope = scope
851+
}
852+
}
801853
}
802854
provider.projects = projects
803855

@@ -1034,6 +1086,14 @@ func getProjectIDsFromOptions(options schema.OptionBlock) []string {
10341086
return splitAndCleanProjectList(raw)
10351087
}
10361088

1089+
func getExcludeProjectIDsFromOptions(options schema.OptionBlock) []string {
1090+
raw, ok := options.GetMetadata("exclude_project_ids")
1091+
if !ok || strings.TrimSpace(raw) == "" {
1092+
return nil
1093+
}
1094+
return splitAndCleanProjectList(raw)
1095+
}
1096+
10371097
func splitAndCleanProjectList(raw string) []string {
10381098
replacer := strings.NewReplacer("\n", ",", "\r", ",", ";", ",")
10391099
normalized := replacer.Replace(raw)

pkg/providers/gcp/project_scope_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"testing"
55

66
assetpb "cloud.google.com/go/asset/apiv1/assetpb"
7+
"github.com/projectdiscovery/cloudlist/pkg/schema"
78
"github.com/stretchr/testify/assert"
89
"github.com/stretchr/testify/require"
910
)
@@ -45,3 +46,62 @@ func TestProjectScopeAllowsAssetByNumber(t *testing.T) {
4546

4647
assert.True(t, scope.allowsAsset(asset))
4748
}
49+
50+
func TestGetExcludeProjectIDsFromOptions(t *testing.T) {
51+
t.Run("returns nil when not set", func(t *testing.T) {
52+
options := schema.OptionBlock{}
53+
result := getExcludeProjectIDsFromOptions(options)
54+
assert.Nil(t, result)
55+
})
56+
57+
t.Run("parses comma-separated list", func(t *testing.T) {
58+
options := schema.OptionBlock{"exclude_project_ids": "project-a,project-b,project-c"}
59+
result := getExcludeProjectIDsFromOptions(options)
60+
assert.Equal(t, []string{"project-a", "project-b", "project-c"}, result)
61+
})
62+
63+
t.Run("deduplicates entries", func(t *testing.T) {
64+
options := schema.OptionBlock{"exclude_project_ids": "project-a,project-a,project-b"}
65+
result := getExcludeProjectIDsFromOptions(options)
66+
assert.Equal(t, []string{"project-a", "project-b"}, result)
67+
})
68+
}
69+
70+
func TestExcludeProjectValidation(t *testing.T) {
71+
t.Run("errors when both include and exclude set", func(t *testing.T) {
72+
options := schema.OptionBlock{
73+
"provider": "gcp",
74+
"organization_id": "123456",
75+
"project_ids": "project-a",
76+
"exclude_project_ids": "project-b",
77+
}
78+
_, err := New(options)
79+
require.Error(t, err)
80+
assert.Contains(t, err.Error(), "mutually exclusive")
81+
})
82+
83+
t.Run("errors when exclude set without organization_id", func(t *testing.T) {
84+
options := schema.OptionBlock{
85+
"provider": "gcp",
86+
"exclude_project_ids": "project-a",
87+
}
88+
_, err := New(options)
89+
require.Error(t, err)
90+
assert.Contains(t, err.Error(), "requires organization_id")
91+
})
92+
}
93+
94+
func TestExcludeProjectFiltering(t *testing.T) {
95+
allProjects := []string{"project-a", "project-b", "project-c", "project-d"}
96+
excludeScope := newProjectScope([]string{"project-b", "project-d"})
97+
require.NotNil(t, excludeScope)
98+
99+
filtered := make([]string, 0, len(allProjects))
100+
for _, p := range allProjects {
101+
if !excludeScope.containsID(p) {
102+
filtered = append(filtered, p)
103+
}
104+
}
105+
106+
assert.Equal(t, []string{"project-a", "project-c"}, filtered)
107+
}

pkg/schema/schema.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ func (ob *OptionBlock) UnmarshalYAML(unmarshal func(interface{}) error) error {
240240
// Convert raw map to OptionBlock and handle special cases
241241
for key, value := range rawMap {
242242
switch key {
243-
case "account_ids", "exclude_account_ids", "urls", "services", "project_ids":
243+
case "account_ids", "exclude_account_ids", "urls", "services", "project_ids", "exclude_project_ids":
244244
if valueArr, ok := value.([]interface{}); ok {
245245
var strArr []string
246246
for _, v := range valueArr {

0 commit comments

Comments
 (0)