Skip to content

Commit

Permalink
feat: add enhanced search
Browse files Browse the repository at this point in the history
  • Loading branch information
sergargar committed Oct 25, 2024
1 parent 159b95c commit d556a3e
Show file tree
Hide file tree
Showing 4 changed files with 182 additions and 36 deletions.
7 changes: 5 additions & 2 deletions docs/tutorials/gcp/organization.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
# GCP Organization

By default, Prowler will scan all the Google Cloud projects that the authenticated user has access to.
By default, Prowler scans all Google Cloud projects accessible to the authenticated user.

If you want to scan only projects from a specific organization, you can use the `--organization-id` argument.
To limit the scan to projects within a specific Google Cloud organization, use the `--organization-id` option with the GCP organization ID:

```console
prowler gcp --organization-id organization-id
```

???+ note
With this option, Prowler retrieves all projects within the specified organization, including those organized in folders and nested subfolders. This ensures that every project under the organization’s hierarchy is scanned, providing full visibility across the entire organization.
11 changes: 11 additions & 0 deletions prowler/providers/gcp/exceptions/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ class GCPBaseException(ProwlerException):
"message": "Provider does not match with the expected project_id",
"remediation": "Check the provider and ensure it matches the expected project_id.",
},
(3009, "GCPCloudAssetAPINotUsedError"): {
"message": "Cloud Asset API not used",
"remediation": "Enable the Cloud Asset API for the project.",
},
}

def __init__(self, code, file=None, original_exception=None, message=None):
Expand Down Expand Up @@ -126,3 +130,10 @@ def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
3008, file=file, original_exception=original_exception, message=message
)


class GCPCloudAssetAPINotUsedError(GCPBaseException):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(

Check warning on line 137 in prowler/providers/gcp/exceptions/exceptions.py

View check run for this annotation

Codecov / codecov/patch

prowler/providers/gcp/exceptions/exceptions.py#L137

Added line #L137 was not covered by tests
3009, file=file, original_exception=original_exception, message=message
)
114 changes: 81 additions & 33 deletions prowler/providers/gcp/gcp_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from prowler.providers.common.models import Audit_Metadata, Connection
from prowler.providers.common.provider import Provider
from prowler.providers.gcp.exceptions.exceptions import (
GCPCloudAssetAPINotUsedError,
GCPCloudResourceManagerAPINotUsedError,
GCPGetProjectError,
GCPHTTPError,
Expand Down Expand Up @@ -444,47 +445,87 @@ def get_projects(
try:
projects = {}

service = discovery.build(
"cloudresourcemanager", "v1", credentials=credentials
)
if organization_id:

Check warning on line 448 in prowler/providers/gcp/gcp_provider.py

View check run for this annotation

Codecov / codecov/patch

prowler/providers/gcp/gcp_provider.py#L448

Added line #L448 was not covered by tests
request = service.projects().list(
filter=f'parent.type:"organization" parent.id:"{organization_id}"'
# Initialize Cloud Asset Inventory API for recursive project retrieval
asset_service = discovery.build(

Check warning on line 450 in prowler/providers/gcp/gcp_provider.py

View check run for this annotation

Codecov / codecov/patch

prowler/providers/gcp/gcp_provider.py#L450

Added line #L450 was not covered by tests
"cloudasset", "v1", credentials=credentials
)
# Set the scope to the specified organization and filter for projects
scope = f"organizations/{organization_id}"
request = asset_service.assets().list(

Check warning on line 455 in prowler/providers/gcp/gcp_provider.py

View check run for this annotation

Codecov / codecov/patch

prowler/providers/gcp/gcp_provider.py#L454-L455

Added lines #L454 - L455 were not covered by tests
parent=scope,
assetTypes=["cloudresourcemanager.googleapis.com/Project"],
contentType="RESOURCE",
)
else:
request = service.projects().list()

while request is not None:
response = request.execute()

for project in response.get("projects", []):
labels = {}
for key, value in project.get("labels", {}).items():
labels[key] = value

project_id = project["projectId"]
gcp_project = GCPProject(
number=project["projectNumber"],
id=project_id,
name=project.get("name", project_id),
lifecycle_state=project["lifecycleState"],
labels=labels,
)

if (
"parent" in project
and "type" in project["parent"]
and project["parent"]["type"] == "organization"
):
organization_id = project["parent"]["id"]
while request is not None:
response = request.execute()

Check warning on line 462 in prowler/providers/gcp/gcp_provider.py

View check run for this annotation

Codecov / codecov/patch

prowler/providers/gcp/gcp_provider.py#L461-L462

Added lines #L461 - L462 were not covered by tests

for asset in response.get("assets", []):

Check warning on line 464 in prowler/providers/gcp/gcp_provider.py

View check run for this annotation

Codecov / codecov/patch

prowler/providers/gcp/gcp_provider.py#L464

Added line #L464 was not covered by tests
# Extract labels and other project details
labels = {

Check warning on line 466 in prowler/providers/gcp/gcp_provider.py

View check run for this annotation

Codecov / codecov/patch

prowler/providers/gcp/gcp_provider.py#L466

Added line #L466 was not covered by tests
k: v
for k, v in asset["resource"]["data"]
.get("labels", {})
.items()
}
project_id = asset["resource"]["data"]["projectId"]
gcp_project = GCPProject(

Check warning on line 473 in prowler/providers/gcp/gcp_provider.py

View check run for this annotation

Codecov / codecov/patch

prowler/providers/gcp/gcp_provider.py#L472-L473

Added lines #L472 - L473 were not covered by tests
number=asset["resource"]["data"]["projectNumber"],
id=project_id,
name=asset["resource"]["data"].get("name", project_id),
lifecycle_state=asset["resource"]["data"].get(
"lifecycleState"
),
labels=labels,
)
gcp_project.organization = GCPOrganization(
id=organization_id, name=f"organizations/{organization_id}"
)

projects[project_id] = gcp_project
request = service.projects().list_next(
previous_request=request, previous_response=response
projects[project_id] = gcp_project

Check warning on line 486 in prowler/providers/gcp/gcp_provider.py

View check run for this annotation

Codecov / codecov/patch

prowler/providers/gcp/gcp_provider.py#L486

Added line #L486 was not covered by tests

request = asset_service.assets().list_next(

Check warning on line 488 in prowler/providers/gcp/gcp_provider.py

View check run for this annotation

Codecov / codecov/patch

prowler/providers/gcp/gcp_provider.py#L488

Added line #L488 was not covered by tests
previous_request=request, previous_response=response
)

else:
# Initialize Cloud Resource Manager API for simple project listing
service = discovery.build(

Check warning on line 494 in prowler/providers/gcp/gcp_provider.py

View check run for this annotation

Codecov / codecov/patch

prowler/providers/gcp/gcp_provider.py#L494

Added line #L494 was not covered by tests
"cloudresourcemanager", "v1", credentials=credentials
)
request = service.projects().list()

Check warning on line 497 in prowler/providers/gcp/gcp_provider.py

View check run for this annotation

Codecov / codecov/patch

prowler/providers/gcp/gcp_provider.py#L497

Added line #L497 was not covered by tests

while request is not None:
response = request.execute()

Check warning on line 500 in prowler/providers/gcp/gcp_provider.py

View check run for this annotation

Codecov / codecov/patch

prowler/providers/gcp/gcp_provider.py#L499-L500

Added lines #L499 - L500 were not covered by tests

for project in response.get("projects", []):

Check warning on line 502 in prowler/providers/gcp/gcp_provider.py

View check run for this annotation

Codecov / codecov/patch

prowler/providers/gcp/gcp_provider.py#L502

Added line #L502 was not covered by tests
# Extract labels and other project details
labels = {k: v for k, v in project.get("labels", {}).items()}
project_id = project["projectId"]
gcp_project = GCPProject(

Check warning on line 506 in prowler/providers/gcp/gcp_provider.py

View check run for this annotation

Codecov / codecov/patch

prowler/providers/gcp/gcp_provider.py#L504-L506

Added lines #L504 - L506 were not covered by tests
number=project["projectNumber"],
id=project_id,
name=project.get("name", project_id),
lifecycle_state=project["lifecycleState"],
labels=labels,
)

# Set organization if present in the project metadata
if (

Check warning on line 515 in prowler/providers/gcp/gcp_provider.py

View check run for this annotation

Codecov / codecov/patch

prowler/providers/gcp/gcp_provider.py#L515

Added line #L515 was not covered by tests
"parent" in project
and project["parent"].get("type") == "organization"
):
parent_org_id = project["parent"]["id"]
gcp_project.organization = GCPOrganization(

Check warning on line 520 in prowler/providers/gcp/gcp_provider.py

View check run for this annotation

Codecov / codecov/patch

prowler/providers/gcp/gcp_provider.py#L519-L520

Added lines #L519 - L520 were not covered by tests
id=parent_org_id, name=f"organizations/{parent_org_id}"
)

projects[project_id] = gcp_project

Check warning on line 524 in prowler/providers/gcp/gcp_provider.py

View check run for this annotation

Codecov / codecov/patch

prowler/providers/gcp/gcp_provider.py#L524

Added line #L524 was not covered by tests

request = service.projects().list_next(

Check warning on line 526 in prowler/providers/gcp/gcp_provider.py

View check run for this annotation

Codecov / codecov/patch

prowler/providers/gcp/gcp_provider.py#L526

Added line #L526 was not covered by tests
previous_request=request, previous_response=response
)

except HttpError as http_error:
if "Cloud Resource Manager API has not been used" in str(http_error):
Expand All @@ -494,6 +535,13 @@ def get_projects(
raise GCPCloudResourceManagerAPINotUsedError(
file=__file__, original_exception=http_error
)
elif "Cloud Asset API has not been used" in str(http_error):
logger.critical(

Check warning on line 539 in prowler/providers/gcp/gcp_provider.py

View check run for this annotation

Codecov / codecov/patch

prowler/providers/gcp/gcp_provider.py#L538-L539

Added lines #L538 - L539 were not covered by tests
"Cloud Asset API has not been used before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/cloudasset.googleapis.com/ then retry."
)
raise GCPCloudAssetAPINotUsedError(

Check warning on line 542 in prowler/providers/gcp/gcp_provider.py

View check run for this annotation

Codecov / codecov/patch

prowler/providers/gcp/gcp_provider.py#L542

Added line #L542 was not covered by tests
file=__file__, original_exception=http_error
)
else:
logger.error(
f"{http_error.__class__.__name__}[{http_error.__traceback__.tb_lineno}]: {http_error}"
Expand Down
86 changes: 85 additions & 1 deletion tests/providers/gcp/gcp_provider_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
GCPTestConnectionError,
)
from prowler.providers.gcp.gcp_provider import GcpProvider
from prowler.providers.gcp.models import GCPIdentityInfo, GCPProject
from prowler.providers.gcp.models import GCPIdentityInfo, GCPOrganization, GCPProject


class TestGCPProvider:
Expand Down Expand Up @@ -87,6 +87,7 @@ def test_is_project_matching(self):
arguments = Namespace()
arguments.project_id = []
arguments.excluded_project_id = []
arguments.organization_id = None
arguments.list_project_id = False
arguments.credentials_file = ""
arguments.impersonate_service_account = ""
Expand Down Expand Up @@ -131,6 +132,7 @@ def test_is_project_matching(self):
return_value=mocked_service,
):
gcp_provider = GcpProvider(
arguments.organization_id,
arguments.project_id,
arguments.excluded_project_id,
arguments.credentials_file,
Expand Down Expand Up @@ -168,6 +170,7 @@ def test_setup_session_with_credentials_file_no_impersonate(self):
arguments = Namespace()
arguments.project_id = []
arguments.excluded_project_id = []
arguments.organization_id = None
arguments.list_project_id = False
arguments.credentials_file = "test_credentials_file"
arguments.impersonate_service_account = ""
Expand Down Expand Up @@ -206,6 +209,7 @@ def test_setup_session_with_credentials_file_no_impersonate(self):
return_value=mocked_service,
):
gcp_provider = GcpProvider(
arguments.organization_id,
arguments.project_id,
arguments.excluded_project_id,
arguments.credentials_file,
Expand All @@ -230,6 +234,7 @@ def test_setup_session_with_credentials_file_and_impersonate(self):
arguments = Namespace()
arguments.project_id = []
arguments.excluded_project_id = []
arguments.organization_id = None
arguments.list_project_id = False
arguments.credentials_file = "test_credentials_file"
arguments.impersonate_service_account = "test-impersonate-service-account"
Expand Down Expand Up @@ -268,6 +273,7 @@ def test_setup_session_with_credentials_file_and_impersonate(self):
return_value=mocked_service,
):
gcp_provider = GcpProvider(
arguments.organization_id,
arguments.project_id,
arguments.excluded_project_id,
arguments.credentials_file,
Expand All @@ -291,6 +297,78 @@ def test_setup_session_with_credentials_file_and_impersonate(self):
== "test-impersonate-service-account"
)

def test_setup_session_with_organization_id(self):
mocked_credentials = MagicMock()

mocked_credentials.refresh.return_value = None
mocked_credentials._service_account_email = "test-service-account-email"

arguments = Namespace()
arguments.project_id = []
arguments.excluded_project_id = []
arguments.organization_id = "test-organization-id"
arguments.list_project_id = False
arguments.credentials_file = "test_credentials_file"
arguments.impersonate_service_account = ""
arguments.config_file = default_config_file_path
arguments.fixer_config = default_fixer_config_file_path

projects = {
"test-project": GCPProject(
number="55555555",
id="project/55555555",
name="test-project",
labels={"test": "value"},
lifecycle_state="",
organization=GCPOrganization(
id="test-organization-id",
name="test-organization",
display_name="Test Organization",
),
)
}

mocked_service = MagicMock()

mocked_service.projects.list.return_value = MagicMock(
execute=MagicMock(return_value={"projects": projects})
)
with patch(
"prowler.providers.gcp.gcp_provider.GcpProvider.get_projects",
return_value=projects,
), patch(
"prowler.providers.gcp.gcp_provider.GcpProvider.update_projects_with_organizations",
return_value=None,
), patch(
"os.path.abspath",
return_value="test_credentials_file",
), patch(
"prowler.providers.gcp.gcp_provider.default",
return_value=(mocked_credentials, MagicMock()),
), patch(
"prowler.providers.gcp.gcp_provider.discovery.build",
return_value=mocked_service,
):
gcp_provider = GcpProvider(
arguments.organization_id,
arguments.project_id,
arguments.excluded_project_id,
arguments.credentials_file,
arguments.impersonate_service_account,
arguments.list_project_id,
arguments.config_file,
arguments.fixer_config,
client_id=None,
client_secret=None,
refresh_token=None,
)
assert environ["GOOGLE_APPLICATION_CREDENTIALS"] == "test_credentials_file"
assert gcp_provider.session is not None
assert (
gcp_provider.projects["test-project"].organization.id
== "test-organization-id"
)

def test_print_credentials_default_options(self, capsys):
mocked_credentials = MagicMock()

Expand All @@ -300,6 +378,7 @@ def test_print_credentials_default_options(self, capsys):
arguments = Namespace()
arguments.project_id = []
arguments.excluded_project_id = []
arguments.organization_id = None
arguments.list_project_id = False
arguments.credentials_file = "test_credentials_file"
arguments.impersonate_service_account = ""
Expand Down Expand Up @@ -338,6 +417,7 @@ def test_print_credentials_default_options(self, capsys):
return_value=mocked_service,
):
gcp_provider = GcpProvider(
arguments.organization_id,
arguments.project_id,
arguments.excluded_project_id,
arguments.credentials_file,
Expand Down Expand Up @@ -369,6 +449,7 @@ def test_print_credentials_impersonated_service_account(self, capsys):
arguments = Namespace()
arguments.project_id = []
arguments.excluded_project_id = []
arguments.organization_id = None
arguments.list_project_id = False
arguments.credentials_file = "test_credentials_file"
arguments.impersonate_service_account = "test-impersonate-service-account"
Expand Down Expand Up @@ -407,6 +488,7 @@ def test_print_credentials_impersonated_service_account(self, capsys):
return_value=mocked_service,
):
gcp_provider = GcpProvider(
arguments.organization_id,
arguments.project_id,
arguments.excluded_project_id,
arguments.credentials_file,
Expand Down Expand Up @@ -438,6 +520,7 @@ def test_print_credentials_excluded_project_ids(self, capsys):
arguments = Namespace()
arguments.project_id = []
arguments.excluded_project_id = ["test-excluded-project"]
arguments.organization_id = None
arguments.list_project_id = False
arguments.credentials_file = "test_credentials_file"
arguments.impersonate_service_account = ""
Expand Down Expand Up @@ -484,6 +567,7 @@ def test_print_credentials_excluded_project_ids(self, capsys):
return_value=mocked_service,
):
gcp_provider = GcpProvider(
arguments.organization_id,
arguments.project_id,
arguments.excluded_project_id,
arguments.credentials_file,
Expand Down

0 comments on commit d556a3e

Please sign in to comment.