Compare commits

...

14 Commits

Author SHA1 Message Date
pedrooot 70db6f3d74 fix(deps): bump dulwich to 1.2.6 to fix GHSA-897w-fcg9-f6xj 2026-06-02 11:00:39 +02:00
Hugo Pereira Brito 1b17304c4a docs(installation): add PowerShell commands for Prowler App install (#11413) 2026-06-02 09:17:40 +01:00
Prowler Bot c2cef99b33 chore(release): Bump versions to v5.30.0 (#11418)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-06-01 18:37:51 +02:00
Alejandro Bailo a769e37615 fix(ui): restore scheduled scan column (#11411) 2026-06-01 14:34:58 +02:00
Alejandro Bailo 9d2a8d9108 fix(ui): improve background glow contrast (#11409) 2026-06-01 14:25:23 +02:00
Alejandro Bailo e05519ff9f fix(ui): refine scans tabs and provider launch flow (#11407) 2026-06-01 12:34:11 +02:00
Pedro Martín 67b26072f8 docs(installation): add info about updating prowler (#11404) 2026-06-01 11:15:07 +02:00
lydiavilchez 2222082631 fix(googleworkspace): update metadata urls to point to official documentation (#11405) 2026-06-01 10:52:32 +02:00
Pepe Fagoaga 8b0cb4b981 chore: fix SDK changelog for v5.29 (#11392) 2026-05-29 18:23:36 +02:00
Pepe Fagoaga 9422eff8ab chore: changelog v5.29.0 (#11390)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-29 17:29:52 +02:00
Br1an e3c4368d32 fix(azure): pass authority to credentials for sovereign clouds (#10284)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2026-05-29 15:17:41 +02:00
OokaToru 2a641b39c8 chore(s3): deprecate s3_bucket_default_encryption check (#11230)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-05-29 14:41:52 +02:00
Alejandro Bailo 02b713572b test(ui): find scheduled scan e2e row in In Progress tab (#11385) 2026-05-29 10:55:16 +02:00
Alejandro Bailo 74251350bc feat(ui): add new scan jobs view (#11258) 2026-05-28 19:20:39 +02:00
202 changed files with 6291 additions and 2012 deletions
+1 -1
View File
@@ -145,7 +145,7 @@ SENTRY_RELEASE=local
NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.29.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.30.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+12
View File
@@ -153,6 +153,8 @@ Prowler App offers flexible installation methods tailored to various environment
#### Commands
_macOS/Linux:_
``` console
VERSION=$(curl -s https://api.github.com/repos/prowler-cloud/prowler/releases/latest | jq -r .tag_name)
curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/docker-compose.yml"
@@ -161,6 +163,16 @@ curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${V
docker compose up -d
```
_Windows PowerShell:_
``` powershell
$VERSION = (Invoke-RestMethod -Uri "https://api.github.com/repos/prowler-cloud/prowler/releases/latest").tag_name
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/docker-compose.yml" -OutFile "docker-compose.yml"
# Environment variables can be customized in the .env file. Using default values in production environments is not recommended.
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/.env" -OutFile ".env"
docker compose up -d
```
> [!WARNING]
> 🔒 For a secure setup, the API auto-generates a unique key pair, `DJANGO_TOKEN_SIGNING_KEY` and `DJANGO_TOKEN_VERIFYING_KEY`, and stores it in `~/.config/prowler-api` (non-container) or the bound Docker volume in `_data/api` (container). Never commit or reuse static/default keys. To rotate keys, delete the stored key files and restart the API.
+2 -1
View File
@@ -2,11 +2,12 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.30.0] (Prowler UNRELEASED)
## [1.30.0] (Prowler v5.29.0)
### 🔄 Changed
- Scan finding ingestion: bulk-resolve `Resource`/`ResourceTag` rows, replace per-mapping `SELECT FOR UPDATE` with deferred `ResourceTagMapping.bulk_create(ignore_conflicts=True)`, wrap each micro-batch in a single `rls_transaction`, and raise `SCAN_DB_BATCH_SIZE` to 1000 [(#11249)](https://github.com/prowler-cloud/prowler/pull/11249)
- Faster `GET /api/v1/finding-groups/latest` aggregation on tenants where one recent scan holds most findings [(#11380)](https://github.com/prowler-cloud/prowler/pull/11380)
---
+1 -1
View File
@@ -68,7 +68,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.30.0"
version = "1.31.0"
[tool.uv]
# Transitive pins matching master to avoid silent drift; bump deliberately.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.30.0
version: 1.31.0
description: |-
Prowler API specification.
Generated
+1 -1
View File
@@ -4494,7 +4494,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.30.0"
version = "1.31.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -20,7 +20,8 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai
_Commands_:
```bash
<CodeGroup>
```bash macOS/Linux
VERSION=$(curl -s https://api.github.com/repos/prowler-cloud/prowler/releases/latest | jq -r .tag_name)
curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/docker-compose.yml"
# Environment variables can be customized in the .env file. Using default values in production environments is not recommended.
@@ -28,6 +29,15 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai
docker compose up -d
```
```powershell Windows PowerShell
$VERSION = (Invoke-RestMethod -Uri "https://api.github.com/repos/prowler-cloud/prowler/releases/latest").tag_name
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/docker-compose.yml" -OutFile "docker-compose.yml"
# Environment variables can be customized in the .env file. Using default values in production environments is not recommended.
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/.env" -OutFile ".env"
docker compose up -d
```
</CodeGroup>
<Callout icon="lock" iconType="regular" color="#e74c3c">
For a secure setup, the API auto-generates a unique key pair, `DJANGO_TOKEN_SIGNING_KEY` and `DJANGO_TOKEN_VERIFYING_KEY`, and stores it in `~/.config/prowler-api` (non-container) or the bound Docker volume in `_data/api` (container). Never commit or reuse static/default keys. To rotate keys, delete the stored key files and restart the API.
</Callout>
@@ -118,8 +128,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.28.0"
PROWLER_API_VERSION="5.28.0"
PROWLER_UI_VERSION="5.29.0"
PROWLER_API_VERSION="5.29.0"
```
<Note>
@@ -40,12 +40,6 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i
pip install prowler
prowler -v
```
To upgrade Prowler to the latest version:
``` bash
pip install --upgrade prowler
```
</Tab>
<Tab title="Docker">
_Requirements_:
@@ -170,6 +164,68 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i
</Tab>
</Tabs>
## Updating Prowler CLI
Upgrade Prowler CLI to the latest release using the same method chosen for installation:
<Tabs>
<Tab title="pipx">
```bash
pipx upgrade prowler
prowler -v
```
</Tab>
<Tab title="pip">
```bash
pip install --upgrade prowler
prowler -v
```
</Tab>
<Tab title="Docker">
Pull the desired image tag to fetch the latest version:
```bash
docker pull toniblyx/prowler:latest
```
<Note>
Replace `latest` with a specific release tag (for example, `stable` or `<x.y.z>`) to pin a version. Refer to the [Container Versions](#container-versions) section for the full list of available tags.
</Note>
</Tab>
<Tab title="GitHub">
Pull the latest changes and sync the environment:
```bash
cd prowler
git pull
uv sync
uv run python prowler-cli.py -v
```
<Note>
To upgrade to a specific release, check out the corresponding tag before syncing: `git checkout <x.y.z>`.
</Note>
</Tab>
<Tab title="Brew">
```bash
brew upgrade prowler
prowler -v
```
</Tab>
<Tab title="CloudShell">
Both AWS CloudShell and Azure CloudShell install Prowler with `pipx`, so the upgrade command is the same:
```bash
pipx upgrade prowler
prowler -v
```
</Tab>
</Tabs>
<Note>
To install a specific version instead of the latest release, pin it explicitly. For example, with `pipx`: `pipx install prowler==<x.y.z>`, or with `pip`: `pip install prowler==<x.y.z>`. The available releases are listed in the [Releases GitHub section](https://github.com/prowler-cloud/prowler/releases).
</Note>
## Container Versions
The available versions of Prowler CLI are the following:
@@ -141,6 +141,45 @@ Choose one of the following installation methods:
---
## Updating Prowler MCP Server
When running Prowler MCP Server locally ("Option 2: Run Locally"), upgrade to the latest version using the same method chosen for installation. The hosted server (`https://mcp.prowler.com/mcp`) is always kept up to date by Prowler and requires no action.
<Tabs>
<Tab title="Docker">
Pull the latest image and restart the container:
```bash
docker pull prowlercloud/prowler-mcp
```
<Note>
Recreate any running container after pulling the new image so the updated version takes effect.
</Note>
</Tab>
<Tab title="From Source">
Pull the latest changes and sync the dependencies:
```bash
cd prowler/mcp_server
git pull
uv sync
uv run prowler-mcp --help
```
</Tab>
<Tab title="Build Docker Image">
Pull the latest source and rebuild the image:
```bash
cd prowler/mcp_server
git pull
docker build -t prowler-mcp .
```
</Tab>
</Tabs>
---
## Command Line Options
The Prowler MCP Server supports the following command-line arguments:
+14
View File
@@ -17,6 +17,20 @@ enforce key strength in our own auth code, so this advisory does not apply.
Re-evaluate when a non-disputed advisory or upstream fix lands.
"""
[[IgnoredVulns]]
id = "GHSA-897w-fcg9-f6xj"
ignoreUntil = 2026-09-01T00:00:00Z
reason = """
Temporary suppression for api/uv.lock only. The SDK (root pyproject.toml) is
already bumped to dulwich==1.2.6, which fixes this advisory (patched in 1.2.5).
api/uv.lock resolves dulwich transitively through `prowler @ git+...@master`,
which still pins dulwich==0.23.0 at the locked commit, so api cannot upgrade
until the SDK fix lands on master and api/uv.lock is regenerated against the
new commit. The advisory is also Windows-only (arbitrary file write via
NTFS-hostile tree entries); the API runs in Linux containers. Remove this entry
once api/uv.lock is refreshed and no longer resolves dulwich 0.23.0.
"""
[[IgnoredVulns]]
id = "PYSEC-2026-89"
ignoreUntil = 2026-08-20T00:00:00Z
+9 -3
View File
@@ -2,26 +2,32 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.29.0] (Prowler UNRELEASED)
## [5.29.0] (Prowler v5.29.0)
### 🚀 Added
- `application` service for Okta provider with `application_admin_console_session_idle_timeout_15min`, `application_admin_console_mfa_required`, `application_admin_console_phishing_resistant_authentication`, `application_dashboard_mfa_required`, `application_dashboard_phishing_resistant_authentication`, and `application_authentication_policy_network_zone_enforced` checks [(#11358)](https://github.com/prowler-cloud/prowler/pull/11358)
- AWS AI Security Framework compliance for AWS provider [(#11353)](https://github.com/prowler-cloud/prowler/pull/11353)
- `storage_account_public_network_access_disabled` check for Azure provider and remapped the Azure CIS "Public Network Access is Disabled" requirements to it [(#11334)](https://github.com/prowler-cloud/prowler/pull/11334)
- StackIT provider now authenticates with a service account key, either as a file path (`--stackit-service-account-key-path` / `STACKIT_SERVICE_ACCOUNT_KEY_PATH`) or as inline JSON content (`--stackit-service-account-key` / `STACKIT_SERVICE_ACCOUNT_KEY`, intended for CI/CD with a secret manager); the StackIT SDK refreshes access tokens internally, replacing the short-lived `STACKIT_API_TOKEN` flow [(#9237)](https://github.com/prowler-cloud/prowler/pull/9237)
- StackIT provider with service account key authentication [(#9237)](https://github.com/prowler-cloud/prowler/pull/9237)
- 8 Rules service checks for Google Workspace provider using the Cloud Identity Policy API [(#11379)](https://github.com/prowler-cloud/prowler/pull/11379)
- 12 Security service checks for Google Workspace provider using the Cloud Identity Policy API [(#11356)](https://github.com/prowler-cloud/prowler/pull/11356)
### ⚠️ Deprecated
- `s3_bucket_default_encryption` check for AWS provider since SSE-S3 is automatically applied to all S3 buckets by AWS as of January 5, 2023 and can no longer be disabled [(#11230)](https://github.com/prowler-cloud/prowler/pull/11230)
### 🐞 Fixed
- Broken documentation URLs in Google Workspace check metadata [(#11405)](https://github.com/prowler-cloud/prowler/pull/11405)
- ENS RD 311/2022 (AWS) compliance mapping: `vpc_different_regions` was uncorrectly mapped under the `mp.com.4` family (Network segregation). That check is now mapped to a new `op.cont.2.aws.vpc.1` requirement under the Continuity of Service control [(#11372)](https://github.com/prowler-cloud/prowler/pull/11372)
- Compliance CSV row count now matches the UI per requirement by sourcing rows from the framework JSON's `requirement.Checks` instead of the stale `finding.compliance` snapshot [(#11370)](https://github.com/prowler-cloud/prowler/pull/11370)
- OpenStack provider exception codes moved from the `10000-10999` range, shared with the AlibabaCloud provider, to the free `17000-17999` range to keep error codes unambiguous [(#11382)](https://github.com/prowler-cloud/prowler/pull/11382)
- Azure provider authentication against sovereign clouds (`AzureChinaCloud`, `AzureUSGovernment`) [(#10284)](https://github.com/prowler-cloud/prowler/pull/10284)
---
## [5.28.1] (Prowler 5.28.1)
## [5.28.1] (Prowler v5.28.1)
### 🐞 Fixed
+1 -1
View File
@@ -48,7 +48,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.29.0"
prowler_version = "5.30.0"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
@@ -1,7 +1,7 @@
{
"Provider": "aws",
"CheckID": "s3_bucket_default_encryption",
"CheckTitle": "S3 bucket has default server-side encryption (SSE) enabled",
"CheckTitle": "[DEPRECATED] S3 bucket has default server-side encryption (SSE) enabled",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
@@ -14,13 +14,11 @@
"Severity": "medium",
"ResourceType": "AwsS3Bucket",
"ResourceGroup": "storage",
"Description": "**Amazon S3 buckets** have a default **server-side encryption** setting that automatically encrypts new objects using `SSE-S3` or `SSE-KMS`. This evaluates whether a bucket has a default encryption configuration defined.",
"Description": "[DEPRECATED] **Amazon S3 buckets** have a default **server-side encryption** setting that automatically encrypts new objects using `SSE-S3` or `SSE-KMS`. This evaluates whether a bucket has a default encryption configuration defined.",
"Risk": "Without default encryption, older objects may remain unencrypted and new uploads won't be forced to use `SSE-KMS`. This reduces confidentiality and governance by limiting key audit logs, rotation, and cross-account controls, and increases exposure if data is copied, replicated, or accessed outside intended paths.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.amazonaws.cn/en_us/AmazonS3/latest/userguide/bucket-encryption.html",
"https://aws.amazon.com/blogs/security/how-to-prevent-uploads-of-unencrypted-objects-to-amazon-s3/",
"https://docs.aws.amazon.com/us_en/AmazonS3/latest/userguide/default-encryption-faq.html"
"https://docs.aws.amazon.com/AmazonS3/latest/userguide/default-encryption-faq.html"
],
"Remediation": {
"Code": {
@@ -39,5 +37,5 @@
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
"Notes": "This check is being deprecated since AWS automatically applies SSE-S3 to every S3 bucket (both new buckets and previously-unencrypted existing buckets) as of January 5, 2023, and encryption can no longer be disabled. For SSE-KMS validation, use `s3_bucket_kms_encryption` instead."
}
+50 -9
View File
@@ -241,7 +241,10 @@ class AzureProvider(Provider):
azure_credentials = None
if tenant_id and client_id and client_secret:
azure_credentials = self.validate_static_credentials(
tenant_id=tenant_id, client_id=client_id, client_secret=client_secret
tenant_id=tenant_id,
client_id=client_id,
client_secret=client_secret,
region_config=self._region_config,
)
# Set up the Azure session
@@ -410,6 +413,9 @@ class AzureProvider(Provider):
authority=config["authority"],
base_url=config["base_url"],
credential_scopes=config["credential_scopes"],
graph_host=config["graph_host"],
graph_scope=config["graph_scope"],
logs_endpoint=config["logs_endpoint"],
)
except ArgumentTypeError as validation_error:
logger.error(
@@ -507,6 +513,7 @@ class AzureProvider(Provider):
tenant_id=azure_credentials["tenant_id"],
client_id=azure_credentials["client_id"],
client_secret=azure_credentials["client_secret"],
authority=region_config.authority,
)
return credentials
except ClientAuthenticationError as error:
@@ -579,7 +586,10 @@ class AzureProvider(Provider):
)
else:
try:
credentials = InteractiveBrowserCredential(tenant_id=tenant_id)
credentials = InteractiveBrowserCredential(
tenant_id=tenant_id,
authority=region_config.authority,
)
except Exception as error:
logger.critical(
"Failed to retrieve azure credentials using browser authentication"
@@ -662,6 +672,7 @@ class AzureProvider(Provider):
tenant_id=tenant_id,
client_id=client_id,
client_secret=client_secret,
region_config=region_config,
)
# Set up the Azure session
@@ -675,7 +686,11 @@ class AzureProvider(Provider):
region_config,
)
# Create a SubscriptionClient
subscription_client = SubscriptionClient(credentials)
subscription_client = SubscriptionClient(
credentials,
base_url=region_config.base_url,
credential_scopes=region_config.credential_scopes,
)
# Get info from the subscriptions
available_subscriptions = []
@@ -1039,7 +1054,11 @@ class AzureProvider(Provider):
}
"""
credentials = self.session
subscription_client = SubscriptionClient(credentials)
subscription_client = SubscriptionClient(
credentials,
base_url=self.region_config.base_url,
credential_scopes=self.region_config.credential_scopes,
)
locations = {}
for subscription_id, display_name in self._identity.subscriptions.items():
@@ -1084,7 +1103,10 @@ class AzureProvider(Provider):
@staticmethod
def validate_static_credentials(
tenant_id: str = None, client_id: str = None, client_secret: str = None
tenant_id: str = None,
client_id: str = None,
client_secret: str = None,
region_config: AzureRegionConfig = None,
) -> dict:
"""
Validates the static credentials for the Azure provider.
@@ -1093,6 +1115,9 @@ class AzureProvider(Provider):
tenant_id (str): The Azure Active Directory tenant ID.
client_id (str): The Azure client ID.
client_secret (str): The Azure client secret.
region_config (AzureRegionConfig): The region configuration used to
build the per-cloud login endpoint and Graph scope. Defaults to
the public-cloud configuration when not provided.
Raises:
AzureNotValidTenantIdError: If the provided Azure Tenant ID is not valid.
@@ -1129,8 +1154,13 @@ class AzureProvider(Provider):
message="The provided Azure Client Secret is not valid.",
)
if region_config is None:
region_config = AzureProvider.setup_region_config("AzureCloud")
try:
AzureProvider.verify_client(tenant_id, client_id, client_secret)
AzureProvider.verify_client(
tenant_id, client_id, client_secret, region_config
)
return {
"tenant_id": tenant_id,
"client_id": client_id,
@@ -1162,7 +1192,9 @@ class AzureProvider(Provider):
)
@staticmethod
def verify_client(tenant_id, client_id, client_secret) -> None:
def verify_client(
tenant_id, client_id, client_secret, region_config: AzureRegionConfig = None
) -> None:
"""
Verifies the Azure client credentials using the specified tenant ID, client ID, and client secret.
@@ -1170,6 +1202,9 @@ class AzureProvider(Provider):
tenant_id (str): The Azure Active Directory tenant ID.
client_id (str): The Azure client ID.
client_secret (str): The Azure client secret.
region_config (AzureRegionConfig): The region configuration used to
build the per-cloud login endpoint and Graph scope. Defaults to
the public-cloud configuration when not provided.
Raises:
AzureNotValidTenantIdError: If the provided Azure Tenant ID is not valid.
@@ -1179,7 +1214,13 @@ class AzureProvider(Provider):
Returns:
None
"""
url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
if region_config is None:
region_config = AzureProvider.setup_region_config("AzureCloud")
# `authority` is None for the public cloud and a bare host (e.g.
# `login.chinacloudapi.cn`) for sovereign clouds, mirroring the
# `AzureAuthorityHosts` constants used by azure-identity.
login_endpoint = region_config.authority or "login.microsoftonline.com"
url = f"https://{login_endpoint}/{tenant_id}/oauth2/v2.0/token"
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
@@ -1188,7 +1229,7 @@ class AzureProvider(Provider):
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": "https://graph.microsoft.com/.default",
"scope": region_config.graph_scope,
}
response = requests.post(url, headers=headers, data=data).json()
if "access_token" not in response.keys() and "error_codes" in response.keys():
@@ -4,6 +4,18 @@ AZURE_CHINA_CLOUD = "https://management.chinacloudapi.cn"
AZURE_US_GOV_CLOUD = "https://management.usgovcloudapi.net"
AZURE_GENERIC_CLOUD = "https://management.azure.com"
AZURE_GENERIC_GRAPH_HOST = "https://graph.microsoft.com"
AZURE_CHINA_GRAPH_HOST = "https://microsoftgraph.chinacloudapi.cn"
AZURE_US_GOV_GRAPH_HOST = "https://graph.microsoft.us"
AZURE_GENERIC_GRAPH_SCOPE = f"{AZURE_GENERIC_GRAPH_HOST}/.default"
AZURE_CHINA_GRAPH_SCOPE = f"{AZURE_CHINA_GRAPH_HOST}/.default"
AZURE_US_GOV_GRAPH_SCOPE = f"{AZURE_US_GOV_GRAPH_HOST}/.default"
AZURE_GENERIC_LOGS_ENDPOINT = "https://api.loganalytics.io"
AZURE_CHINA_LOGS_ENDPOINT = "https://api.loganalytics.azure.cn"
AZURE_US_GOV_LOGS_ENDPOINT = "https://api.loganalytics.us"
def get_regions_config(region):
allowed_regions = {
@@ -11,16 +23,25 @@ def get_regions_config(region):
"authority": None,
"base_url": AZURE_GENERIC_CLOUD,
"credential_scopes": [AZURE_GENERIC_CLOUD + "/.default"],
"graph_host": AZURE_GENERIC_GRAPH_HOST,
"graph_scope": AZURE_GENERIC_GRAPH_SCOPE,
"logs_endpoint": AZURE_GENERIC_LOGS_ENDPOINT,
},
"AzureChinaCloud": {
"authority": AzureAuthorityHosts.AZURE_CHINA,
"base_url": AZURE_CHINA_CLOUD,
"credential_scopes": [AZURE_CHINA_CLOUD + "/.default"],
"graph_host": AZURE_CHINA_GRAPH_HOST,
"graph_scope": AZURE_CHINA_GRAPH_SCOPE,
"logs_endpoint": AZURE_CHINA_LOGS_ENDPOINT,
},
"AzureUSGovernment": {
"authority": AzureAuthorityHosts.AZURE_GOVERNMENT,
"base_url": AZURE_US_GOV_CLOUD,
"credential_scopes": [AZURE_US_GOV_CLOUD + "/.default"],
"graph_host": AZURE_US_GOV_GRAPH_HOST,
"graph_scope": AZURE_US_GOV_GRAPH_SCOPE,
"logs_endpoint": AZURE_US_GOV_LOGS_ENDPOINT,
},
}
return allowed_regions[region]
+30 -2
View File
@@ -1,5 +1,11 @@
from concurrent.futures import ThreadPoolExecutor, as_completed
from kiota_authentication_azure.azure_identity_authentication_provider import (
AzureIdentityAuthenticationProvider,
)
from msgraph.graph_request_adapter import GraphRequestAdapter
from msgraph_core import GraphClientFactory
from prowler.lib.logger import logger
from prowler.providers.azure.azure_provider import AzureProvider
@@ -47,10 +53,32 @@ class AzureService:
clients = {}
try:
if "GraphServiceClient" in str(service):
clients.update({identity.tenant_domain: service(credentials=session)})
# GraphServiceClient(credentials, scopes=...) only customises the
# OAuth scope; the underlying httpx client's base URL stays at
# graph.microsoft.com. For sovereign clouds we must also point
# the HTTP transport at the per-cloud host, which is done by
# building a custom GraphRequestAdapter with a NationalClouds
# base URL.
auth_provider = AzureIdentityAuthenticationProvider(
session, scopes=[region_config.graph_scope]
)
http_client = GraphClientFactory.create_with_default_middleware(
host=region_config.graph_host
)
request_adapter = GraphRequestAdapter(auth_provider, client=http_client)
clients.update(
{identity.tenant_domain: service(request_adapter=request_adapter)}
)
elif "LogsQueryClient" in str(service):
for subscription_id, display_name in identity.subscriptions.items():
clients.update({subscription_id: service(credential=session)})
clients.update(
{
subscription_id: service(
credential=session,
endpoint=region_config.logs_endpoint,
)
}
)
else:
for subscription_id, display_name in identity.subscriptions.items():
clients.update(
+3
View File
@@ -20,6 +20,9 @@ class AzureRegionConfig(BaseModel):
authority: Optional[str] = None
base_url: str = ""
credential_scopes: list = []
graph_host: str = "https://graph.microsoft.com"
graph_scope: str = "https://graph.microsoft.com/.default"
logs_endpoint: str = "https://api.loganalytics.io"
class AzureSubscription(BaseModel):
@@ -13,8 +13,8 @@
"Risk": "When external Google Groups access is enabled, users can access and participate in groups created **outside the organization**, potentially exposing them to **phishing, social engineering, or data leakage** through unmanaged external group communications.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/181865",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/users/advanced/turn-on-or-off-additional-google-services",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,9 +13,8 @@
"Risk": "Without external invitation warnings, users may unintentionally include **external guests** in internal meetings, exposing **confidential meeting details**, agendas, and internal attendee lists to unauthorized parties. This is a common vector for inadvertent data leakage through everyday calendar actions.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/6329284",
"https://knowledge.workspace.google.com/admin/calendar/set-google-calendar-sharing-options",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/calendar/allow-external-invitations-in-google-calendar-events",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,9 +13,8 @@
"Risk": "Overly permissive external sharing of primary calendars exposes **sensitive meeting metadata** — titles, attendees, locations, and descriptions — to users outside the organization. This increases the risk of **information disclosure**, **social engineering**, and **targeted phishing** based on insights into organizational activities.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60765",
"https://knowledge.workspace.google.com/admin/calendar/set-google-calendar-sharing-options",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,9 +13,8 @@
"Risk": "Overly permissive external sharing of secondary calendars exposes **project-specific or team-specific event details** to users outside the organization. Because secondary calendars often hold more targeted activities (e.g., product launches, internal reviews), unrestricted external sharing increases the risk of **information disclosure** and **competitive intelligence leakage**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60765",
"https://knowledge.workspace.google.com/admin/calendar/set-google-calendar-sharing-options",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Unrestricted Chat app installation allows **unvetted third-party applications** to access user data including conversation content and organizational information. An attacker could distribute a malicious Chat app to **exfiltrate confidential data** or establish **persistent access** to internal communications.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/6089179",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/apps/manage-the-marketplace-app-allowlist-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Enabled external file sharing allows users to send files containing **confidential information** to external parties through Chat. This creates a **data leakage** channel that bypasses DLP controls, particularly dangerous for organizations handling **regulated data** such as PII, PHI, or financial records.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9540647",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/chat/set-up-chat-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Unrestricted external messaging allows users to communicate freely with **any external party**, increasing the risk of **data exfiltration** through conversation content and **social engineering attacks** from untrusted domains targeting internal users.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9540647",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/chat/set-up-chat-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Unrestricted external spaces allow users to add **anyone from any domain** to persistent group conversations. This increases the risk of **confidential information exposure** in shared spaces and enables **unauthorized external access** to ongoing organizational discussions.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9540647",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/chat/set-up-chat-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Exposed webhook URLs allow **unauthorized content injection** into Chat spaces. Attackers can send **fraudulent or misleading messages** that appear to come from trusted services, creating a vector for **social engineering** and **phishing** within internal communications.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/6089179",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/apps/manage-the-marketplace-app-allowlist-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Unrestricted internal file sharing in Chat allows files with **sensitive information** to be distributed freely without passing through approved channels. This undermines **data governance** and **audit trail** requirements, making it harder to track data movement within the organization.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9540647",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/chat/set-up-chat-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/users/prebuilt-administrator-roles",
"https://support.google.com/a/answer/9011373"
"https://knowledge.workspace.google.com/admin/users/security-best-practices-for-administrator-accounts"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/users/prebuilt-administrator-roles",
"https://support.google.com/a/answer/9011373"
"https://knowledge.workspace.google.com/admin/users/security-best-practices-for-administrator-accounts"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "If Access Checker suggests broader audiences or public visibility, users may **inadvertently widen access** to a file beyond the people they intended to share with. This is a common cause of unintentional internal or external over-sharing.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60781",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "When Drive for desktop is enabled, organizational files are **synchronized to local devices** and remain accessible if the device is lost, stolen, or compromised. Because Drive for desktop bypasses the central offline-access controls, this channel is a frequently overlooked path for sensitive data to leave organization-managed environments.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7491144",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/set-up-drive-for-desktop-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without external sharing warnings, users may unintentionally share **sensitive documents** with external recipients who are not entitled to the data. This is a common vector for inadvertent leakage of intellectual property, personally identifiable information, and confidential business data through routine Drive sharing.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60781",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "If external users can move files from internal shared drives into shared drives owned by another organization, the organization **loses authoritative control** over its own data. This is a frequently overlooked path for unintentional or malicious data exfiltration through shared drive collaboration.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60781",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Allowing users to publish Drive files to the web creates a path for **unbounded data exposure**. Sensitive documents, intellectual property, customer data, or internal communications can be made publicly accessible — and indexed by search engines — with a single click, often unintentionally.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60781",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "When users cannot create shared drives, they store collaborative content in their personal **My Drive** instead. When that user account is deleted, the data is also deleted, leading to **unintentional data loss** of organizationally significant information. Allowing shared drive creation makes data survivable across account lifecycle events.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7212025",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://support.google.com/a/users/answer/7212025",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "When viewers and commenters can download, print, or copy shared drive files, they can **bulk-extract sensitive content** — including intellectual property, personally identifiable information, and confidential business documents — using nothing more than read access. This is one of the most direct paths to data exfiltration through Drive.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7662202",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-shared-drives-as-an-admin",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "If shared drive managers can override organizational defaults, **unauthorized data exposure** can occur when a manager intentionally or accidentally weakens a shared drive's security posture (for example, allowing external members or enabling download for viewers).",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7662202",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-shared-drives-as-an-admin",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "If non-members can be added to files inside a shared drive, the **drive's membership becomes meaningless** as a security control. Sensitive content scoped to a specific team can be silently extended to users who were never granted access to the drive itself, leading to unintended information disclosure.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7662202",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-shared-drives-as-an-admin",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "When external sharing is unrestricted, users can share organizational content with **any external Google account**, including untrusted or unknown parties. Restricting sharing to allowlisted domains drastically reduces the surface area for accidental and malicious data exfiltration through Drive.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60781",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Allowlisted domains are still external. Users may not realize that even an allowlisted recipient is outside the organization, leading to **unintentional disclosure of sensitive content** to legitimate but external collaborators. A warning prompt at share time mitigates that without preventing the sharing itself.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60781",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection against anomalous attachment types, users may receive **emails with unusual file formats** that are designed to bypass standard security filters. Attackers may use **uncommon file extensions or MIME types** to deliver malware that evades signature-based detection.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7676854",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/set-up-rules-to-detect-harmful-attachments",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "With auto-forwarding enabled, an attacker who gains control of a user account can create **forwarding rules to exfiltrate** all incoming email to an external address. This can persist undetected and provide the attacker with continuous access to sensitive communications even after the account is recovered.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/2491924",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/let-users-automatically-forward-their-own-gmail-emails",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without comprehensive mail storage, messages sent through other Google services (Calendar, Drive, etc.) may not be stored in Gmail and therefore **not subject to Vault retention policies**. This creates gaps in **compliance coverage**, **eDiscovery**, and **audit trails** that could violate regulatory requirements.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/3547347",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/set-up-comprehensive-mail-storage",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection against domain spoofing based on similar domain names, users may receive **phishing emails from lookalike domains** (e.g., examp1e.com instead of example.com) that appear legitimate. This enables **credential theft, malware delivery, and business email compromise** attacks.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9157861",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection against employee name spoofing, users may receive **emails that appear to come from colleagues or executives** but are actually from external attackers. This enables **business email compromise (BEC)**, **wire fraud**, and **social engineering attacks** that exploit trust relationships.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9157861",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection against encrypted attachments from untrusted senders, users may receive **password-protected archives containing malware** that bypass standard content scanning. Attackers commonly use encrypted attachments to evade detection and deliver **ransomware, trojans, or other malicious payloads**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7676854",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/set-up-rules-to-detect-harmful-attachments",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without enhanced pre-delivery scanning, some **sophisticated phishing and malware** messages may pass through standard filters and be delivered to users. The additional scanning layer catches threats that the first-pass filters miss, reducing the organization's exposure to **zero-day phishing campaigns** and **targeted attacks**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7380368",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/security/help-prevent-phishing-with-pre-delivery-message-scanning",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without external image scanning, attackers can use **linked images to track email opens**, deliver **exploit payloads via image rendering vulnerabilities**, or use images as part of sophisticated **phishing schemes** that mimic legitimate communications.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7676854",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection of groups from domain-spoofing emails, attackers can send **spoofed messages to group mailboxes** that appear to originate from the organization. Since groups distribute to many recipients, a single spoofed email can enable **mass phishing, social engineering, or misinformation** campaigns across the organization.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9157861",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection against inbound domain spoofing, users may receive **emails that appear to come from their own organization** but are sent by external attackers. This enables **internal impersonation**, **phishing**, and **business email compromise** attacks that exploit trust in internal communications.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9157861",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "If users can delegate access to their mailbox, an attacker who compromises one account could silently delegate access to maintain persistent email surveillance. This also increases the risk of **insider threats** and **data exfiltration** through shared mailbox access.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7223765",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/let-users-delegate-access-to-a-gmail-account",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "With per-user outbound gateways enabled, users can route outbound email through **external SMTP servers**, bypassing organizational **email security controls**, **DLP policies**, and **audit logging**. This creates an unmonitored channel for data exfiltration and policy circumvention.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/176652",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/allow-per-user-outbound-gateways",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "With POP and IMAP enabled, users can access email through **legacy clients** that rely on simple password authentication, bypassing **multifactor authentication** and other modern security controls. This significantly increases the risk of **credential-based account compromise**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/105694",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/sync/turn-pop-and-imap-on-or-off-for-users",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection against script-bearing attachments from untrusted senders, users may receive **files containing malicious scripts** that can execute harmful code when opened. Attackers commonly use script attachments to deliver **malware, backdoors, or credential stealers**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7676854",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/set-up-rules-to-detect-harmful-attachments",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without shortened URL scanning, attackers can use **URL shortening services** to hide malicious destinations in phishing emails. Users cannot visually verify where the link leads, increasing the success rate of **phishing and credential harvesting** attacks.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7676854",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection against unauthenticated emails, users may receive **spoofed or forged messages** that fail SPF and DKIM checks but are still delivered normally. This enables **phishing**, **spam**, and **impersonation attacks** that exploit the lack of sender verification.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9157861",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without untrusted link warnings, users may click on **phishing links** or links to **malware distribution sites** without any warning. This significantly increases the success rate of **social engineering attacks** targeting the organization.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7676854",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Allowing any user to create groups with external members or incoming email from outside increases the risk of **unauthorized data sharing**, **spam delivery**, and **shadow IT** groups that bypass organizational controls.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/10308022",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/groups/what-you-get-with-groups-for-business",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Allowing external access to groups exposes **group names, descriptions, and membership** to anyone outside the organization, increasing the risk of **information disclosure** and enabling external parties to identify targets for **social engineering attacks**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/10308022",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/groups/what-you-get-with-groups-for-business",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Allowing all organization users or anyone to view group conversations can lead to **information disclosure** of sensitive discussions, internal decisions, and confidential data shared within groups.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/10308022",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/groups/what-you-get-with-groups-for-business",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Allowing unrestricted Marketplace app installation exposes the organization to **unvetted third-party applications** that may request broad OAuth scopes, potentially gaining access to **sensitive organizational data** including emails, documents, and calendar events without proper security review.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/6089179",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/apps/manage-the-marketplace-app-allowlist-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/security/protect-your-business-with-2-step-verification",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/security/protect-your-business-with-2-step-verification",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/security/protect-users-with-the-advanced-protection-program",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/apps/control-which-apps-access-google-workspace-data",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/security/about-dlp",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/apps/control-which-apps-access-google-workspace-data",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/apps/control-access-to-less-secure-apps",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/security/protect-google-workspace-accounts-with-security-challenges",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/users/enforce-and-monitor-password-requirements-for-users",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/security/set-session-length-for-google-services",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/users/allow-super-administrators-to-recover-their-password",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/users/set-up-password-recovery-for-users",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "When Google Sites is enabled, users can create websites that may **inadvertently expose internal information** to external parties. These sites can be difficult to track and manage, creating potential **data leakage vectors** outside the organization's standard content management controls.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/182442",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/users/advanced/turn-a-service-on-or-off-for-google-workspace-users",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
+2 -2
View File
@@ -73,7 +73,7 @@ dependencies = [
"dash-bootstrap-components==2.0.3",
"defusedxml==0.7.1",
"detect-secrets==1.5.0",
"dulwich==0.23.0",
"dulwich==1.2.6",
"google-api-python-client==2.163.0",
"google-auth-httplib2==0.2.0",
"jsonschema==4.23.0",
@@ -123,7 +123,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.13"
version = "5.29.0"
version = "5.30.0"
[project.scripts]
prowler = "prowler.__main__:prowler"
@@ -725,6 +725,300 @@ class TestAzureProviderSetupIdentitySubscriptions:
}
class TestAzureProviderSovereignCloudSupport:
"""Sovereign-cloud authentication coverage across AzureCloud,
AzureChinaCloud and AzureUSGovernment for every authentication code path
Prowler exposes. Pinned to issue #8425."""
REGION_CASES = [
(
"AzureCloud",
None,
"https://management.azure.com",
["https://management.azure.com/.default"],
"https://graph.microsoft.com/.default",
"https://api.loganalytics.io",
"login.microsoftonline.com",
),
(
"AzureChinaCloud",
"login.chinacloudapi.cn",
"https://management.chinacloudapi.cn",
["https://management.chinacloudapi.cn/.default"],
"https://microsoftgraph.chinacloudapi.cn/.default",
"https://api.loganalytics.azure.cn",
"login.chinacloudapi.cn",
),
(
"AzureUSGovernment",
"login.microsoftonline.us",
"https://management.usgovcloudapi.net",
["https://management.usgovcloudapi.net/.default"],
"https://graph.microsoft.us/.default",
"https://api.loganalytics.us",
"login.microsoftonline.us",
),
]
@pytest.mark.parametrize(
"region,authority,base_url,credential_scopes,graph_scope,logs_endpoint,_login_endpoint",
REGION_CASES,
)
def test_setup_region_config_per_cloud(
self,
region,
authority,
base_url,
credential_scopes,
graph_scope,
logs_endpoint,
_login_endpoint,
):
config = AzureProvider.setup_region_config(region)
# graph_host mirrors graph_scope without the `/.default` suffix; we
# derive it here to avoid threading a separate parameter through every
# parametrized test in this class.
expected_graph_host = graph_scope.removesuffix("/.default")
assert config == AzureRegionConfig(
name=region,
authority=authority,
base_url=base_url,
credential_scopes=credential_scopes,
graph_host=expected_graph_host,
graph_scope=graph_scope,
logs_endpoint=logs_endpoint,
)
@pytest.mark.parametrize(
"region,authority,_base_url,_credential_scopes,_graph_scope,_logs_endpoint,_login_endpoint",
REGION_CASES,
)
def test_setup_session_static_credentials_passes_authority(
self,
region,
authority,
_base_url,
_credential_scopes,
_graph_scope,
_logs_endpoint,
_login_endpoint,
):
with patch(
"prowler.providers.azure.azure_provider.ClientSecretCredential"
) as mock_client_secret_credential:
azure_credentials = {
"tenant_id": str(uuid4()),
"client_id": str(uuid4()),
"client_secret": "fake-secret-value",
}
region_config = AzureProvider.setup_region_config(region)
AzureProvider.setup_session(
az_cli_auth=False,
sp_env_auth=False,
browser_auth=False,
managed_identity_auth=False,
tenant_id=azure_credentials["tenant_id"],
azure_credentials=azure_credentials,
region_config=region_config,
)
mock_client_secret_credential.assert_called_once_with(
tenant_id=azure_credentials["tenant_id"],
client_id=azure_credentials["client_id"],
client_secret=azure_credentials["client_secret"],
authority=authority,
)
@pytest.mark.parametrize(
"region,authority,_base_url,_credential_scopes,_graph_scope,_logs_endpoint,_login_endpoint",
REGION_CASES,
)
def test_setup_session_browser_auth_passes_authority(
self,
region,
authority,
_base_url,
_credential_scopes,
_graph_scope,
_logs_endpoint,
_login_endpoint,
):
with patch(
"prowler.providers.azure.azure_provider.InteractiveBrowserCredential"
) as mock_interactive_browser_credential:
tenant_id = str(uuid4())
region_config = AzureProvider.setup_region_config(region)
AzureProvider.setup_session(
az_cli_auth=False,
sp_env_auth=False,
browser_auth=True,
managed_identity_auth=False,
tenant_id=tenant_id,
azure_credentials=None,
region_config=region_config,
)
mock_interactive_browser_credential.assert_called_once_with(
tenant_id=tenant_id,
authority=authority,
)
@pytest.mark.parametrize(
"region,authority,_base_url,_credential_scopes,_graph_scope,_logs_endpoint,_login_endpoint",
REGION_CASES,
)
def test_setup_session_default_credential_passes_authority(
self,
region,
authority,
_base_url,
_credential_scopes,
_graph_scope,
_logs_endpoint,
_login_endpoint,
):
with patch(
"prowler.providers.azure.azure_provider.DefaultAzureCredential"
) as mock_default_credential:
region_config = AzureProvider.setup_region_config(region)
AzureProvider.setup_session(
az_cli_auth=True,
sp_env_auth=False,
browser_auth=False,
managed_identity_auth=False,
tenant_id=None,
azure_credentials=None,
region_config=region_config,
)
_, called_kwargs = mock_default_credential.call_args
assert called_kwargs["authority"] == authority
assert called_kwargs["exclude_cli_credential"] is False
assert called_kwargs["exclude_environment_credential"] is True
assert called_kwargs["exclude_managed_identity_credential"] is True
@pytest.mark.parametrize(
"region,_authority,_base_url,_credential_scopes,graph_scope,_logs_endpoint,login_endpoint",
REGION_CASES,
)
def test_verify_client_uses_per_cloud_endpoints(
self,
region,
_authority,
_base_url,
_credential_scopes,
graph_scope,
_logs_endpoint,
login_endpoint,
):
tenant_id = str(uuid4())
client_id = str(uuid4())
client_secret = "fake-secret"
region_config = AzureProvider.setup_region_config(region)
with patch("prowler.providers.azure.azure_provider.requests.post") as mock_post:
mock_post.return_value = MagicMock()
mock_post.return_value.json.return_value = {"access_token": "fake-token"}
AzureProvider.verify_client(
tenant_id, client_id, client_secret, region_config
)
mock_post.assert_called_once()
args, kwargs = mock_post.call_args
assert args[0] == (
f"https://{login_endpoint}/{tenant_id}/oauth2/v2.0/token"
)
assert kwargs["data"]["scope"] == graph_scope
assert kwargs["data"]["client_id"] == client_id
assert kwargs["data"]["client_secret"] == client_secret
@pytest.mark.parametrize(
"region,_authority,base_url,credential_scopes,_graph_scope,_logs_endpoint,_login_endpoint",
REGION_CASES,
)
def test_test_connection_passes_base_url_to_subscription_client(
self,
region,
_authority,
base_url,
credential_scopes,
_graph_scope,
_logs_endpoint,
_login_endpoint,
):
subscription_client_instance = MagicMock()
subscription_client_instance.subscriptions = MagicMock()
subscription_client_instance.subscriptions.list = MagicMock(return_value=[])
subscription_client_class = MagicMock(return_value=subscription_client_instance)
with (
patch(
"prowler.providers.azure.azure_provider.AzureProvider.setup_session"
) as mock_setup_session,
patch(
"prowler.providers.azure.azure_provider.SubscriptionClient",
subscription_client_class,
),
):
mock_setup_session.return_value = MagicMock()
AzureProvider.test_connection(
az_cli_auth=True,
region=region,
raise_on_exception=False,
)
subscription_client_class.assert_called_once()
_, kwargs = subscription_client_class.call_args
assert kwargs["base_url"] == base_url
assert kwargs["credential_scopes"] == credential_scopes
@pytest.mark.parametrize(
"region,_authority,base_url,credential_scopes,_graph_scope,_logs_endpoint,_login_endpoint",
REGION_CASES,
)
def test_get_locations_passes_base_url_to_subscription_client(
self,
region,
_authority,
base_url,
credential_scopes,
_graph_scope,
_logs_endpoint,
_login_endpoint,
):
subscription_client_instance = MagicMock()
subscription_client_instance.subscriptions = MagicMock()
subscription_client_instance.subscriptions.list_locations = MagicMock(
return_value=[]
)
subscription_client_class = MagicMock(return_value=subscription_client_instance)
with (
patch.object(AzureProvider, "__init__", return_value=None),
patch(
"prowler.providers.azure.azure_provider.SubscriptionClient",
subscription_client_class,
),
):
azure_provider = AzureProvider()
azure_provider._session = MagicMock()
azure_provider._region_config = AzureProvider.setup_region_config(region)
azure_provider._identity = AzureIdentityInfo(subscriptions={})
azure_provider.get_locations()
subscription_client_class.assert_called_once()
_, kwargs = subscription_client_class.call_args
assert kwargs["base_url"] == base_url
assert kwargs["credential_scopes"] == credential_scopes
class TestAzureProviderSetupIdentityEventLoop:
"""Regression for the Celery worker scenario where
asyncio.get_event_loop() raised "There is no current event loop in
@@ -2,8 +2,17 @@ from azure.identity import AzureAuthorityHosts
from prowler.providers.azure.lib.regions.regions import (
AZURE_CHINA_CLOUD,
AZURE_CHINA_GRAPH_HOST,
AZURE_CHINA_GRAPH_SCOPE,
AZURE_CHINA_LOGS_ENDPOINT,
AZURE_GENERIC_CLOUD,
AZURE_GENERIC_GRAPH_HOST,
AZURE_GENERIC_GRAPH_SCOPE,
AZURE_GENERIC_LOGS_ENDPOINT,
AZURE_US_GOV_CLOUD,
AZURE_US_GOV_GRAPH_HOST,
AZURE_US_GOV_GRAPH_SCOPE,
AZURE_US_GOV_LOGS_ENDPOINT,
get_regions_config,
)
@@ -20,16 +29,25 @@ class Test_azure_regions:
"authority": None,
"base_url": AZURE_GENERIC_CLOUD,
"credential_scopes": [AZURE_GENERIC_CLOUD + "/.default"],
"graph_host": AZURE_GENERIC_GRAPH_HOST,
"graph_scope": AZURE_GENERIC_GRAPH_SCOPE,
"logs_endpoint": AZURE_GENERIC_LOGS_ENDPOINT,
},
"AzureChinaCloud": {
"authority": AzureAuthorityHosts.AZURE_CHINA,
"base_url": AZURE_CHINA_CLOUD,
"credential_scopes": [AZURE_CHINA_CLOUD + "/.default"],
"graph_host": AZURE_CHINA_GRAPH_HOST,
"graph_scope": AZURE_CHINA_GRAPH_SCOPE,
"logs_endpoint": AZURE_CHINA_LOGS_ENDPOINT,
},
"AzureUSGovernment": {
"authority": AzureAuthorityHosts.AZURE_GOVERNMENT,
"base_url": AZURE_US_GOV_CLOUD,
"credential_scopes": [AZURE_US_GOV_CLOUD + "/.default"],
"graph_host": AZURE_US_GOV_GRAPH_HOST,
"graph_scope": AZURE_US_GOV_GRAPH_SCOPE,
"logs_endpoint": AZURE_US_GOV_LOGS_ENDPOINT,
},
}
@@ -0,0 +1,108 @@
from unittest.mock import MagicMock, patch
import pytest
from prowler.providers.azure.lib.service.service import AzureService
from prowler.providers.azure.models import AzureIdentityInfo, AzureRegionConfig
REGION_CASES = [
(
"AzureCloud",
"https://graph.microsoft.com",
"https://graph.microsoft.com/.default",
"https://api.loganalytics.io",
),
(
"AzureChinaCloud",
"https://microsoftgraph.chinacloudapi.cn",
"https://microsoftgraph.chinacloudapi.cn/.default",
"https://api.loganalytics.azure.cn",
),
(
"AzureUSGovernment",
"https://graph.microsoft.us",
"https://graph.microsoft.us/.default",
"https://api.loganalytics.us",
),
]
def _identity_and_session():
identity = AzureIdentityInfo(
tenant_domain="tenant.onmicrosoft.com",
subscriptions={"sub-1": "Subscription 1"},
)
session = MagicMock()
return identity, session
class TestAzureServiceSovereignClouds:
"""Cover __set_clients__ kwargs for the Graph and Logs clients across the
three sovereign clouds — these are the two service slots in service.py
that historically defaulted to public-cloud endpoints."""
@pytest.mark.parametrize(
"_region,graph_host,graph_scope,_logs_endpoint",
REGION_CASES,
)
def test_set_clients_graph_uses_per_cloud_host_scope_and_adapter(
self, _region, graph_host, graph_scope, _logs_endpoint
):
graph_service = MagicMock()
graph_service.__str__ = MagicMock(return_value="GraphServiceClient")
region_config = AzureRegionConfig(
graph_host=graph_host,
graph_scope=graph_scope,
logs_endpoint=_logs_endpoint,
)
identity, session = _identity_and_session()
with (
patch.object(AzureService, "__init__", return_value=None),
patch(
"prowler.providers.azure.lib.service.service.AzureIdentityAuthenticationProvider"
) as mock_auth_provider_cls,
patch(
"prowler.providers.azure.lib.service.service.GraphClientFactory"
) as mock_factory,
patch(
"prowler.providers.azure.lib.service.service.GraphRequestAdapter"
) as mock_adapter_cls,
):
service = AzureService.__new__(AzureService)
service.__set_clients__(identity, session, graph_service, region_config)
mock_auth_provider_cls.assert_called_once_with(session, scopes=[graph_scope])
mock_factory.create_with_default_middleware.assert_called_once_with(
host=graph_host
)
mock_adapter_cls.assert_called_once_with(
mock_auth_provider_cls.return_value,
client=mock_factory.create_with_default_middleware.return_value,
)
graph_service.assert_called_once_with(
request_adapter=mock_adapter_cls.return_value
)
@pytest.mark.parametrize(
"_region,_graph_host,_graph_scope,logs_endpoint",
REGION_CASES,
)
def test_set_clients_logs_passes_per_cloud_endpoint(
self, _region, _graph_host, _graph_scope, logs_endpoint
):
logs_service = MagicMock()
logs_service.__str__ = MagicMock(return_value="LogsQueryClient")
region_config = AzureRegionConfig(
graph_host=_graph_host,
graph_scope=_graph_scope,
logs_endpoint=logs_endpoint,
)
identity, session = _identity_and_session()
with patch.object(AzureService, "__init__", return_value=None):
service = AzureService.__new__(AzureService)
service.__set_clients__(identity, session, logs_service, region_config)
logs_service.assert_called_once_with(credential=session, endpoint=logs_endpoint)
+5 -1
View File
@@ -2,7 +2,11 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.29.0] (Prowler UNRELEASED)
## [1.29.0] (Prowler v5.29.0)
### 🚀 Added
- Restyle `Scan Jobs` view with specific In Progress, Completed, Scheduled tabs [(#11258)](https://github.com/prowler-cloud/prowler/pull/11258)
### 🔄 Changed
+2
View File
@@ -1,5 +1,6 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders, getErrorMessage } from "@/lib";
@@ -140,6 +141,7 @@ export const scanOnDemand = async (formData: FormData) => {
const result = await handleApiResponse(response, "/scans");
if (result?.data?.id) {
addScanOperation("start", result.data.id);
revalidatePath("/scans");
}
return result;
} catch (error) {
+1
View File
@@ -1,2 +1,3 @@
export * from "./poll";
export * from "./task.adapter";
export * from "./tasks";
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { getScanErrorDetails } from "./task.adapter";
describe("getScanErrorDetails", () => {
it("returns null when response is not a record", () => {
expect(getScanErrorDetails(null)).toBeNull();
expect(getScanErrorDetails("oops")).toBeNull();
expect(getScanErrorDetails(undefined)).toBeNull();
});
it("returns null when data is missing", () => {
expect(getScanErrorDetails({})).toBeNull();
});
it("returns null when attributes.result is missing", () => {
expect(getScanErrorDetails({ data: { attributes: {} } })).toBeNull();
});
it("returns null when result has no recognizable fields", () => {
expect(
getScanErrorDetails({ data: { attributes: { result: {} } } }),
).toBeNull();
});
it("parses an error with only an exc_type", () => {
const details = getScanErrorDetails({
data: { attributes: { result: { exc_type: "BotoCoreError" } } },
});
expect(details).toEqual({
type: "BotoCoreError",
messages: ["-"],
module: undefined,
copyValue: "ErrorType: BotoCoreError\nError: -",
});
});
it("joins multiple exc_message entries in copyValue", () => {
const details = getScanErrorDetails({
data: {
attributes: {
result: {
exc_type: "ScanError",
exc_message: ["Failed to connect", "Retry exhausted"],
exc_module: "scan.runner",
},
},
},
});
expect(details).toEqual({
type: "ScanError",
messages: ["Failed to connect", "Retry exhausted"],
module: "scan.runner",
copyValue:
"ErrorType: ScanError\nError: Failed to connect\nRetry exhausted",
});
});
it("filters non-string entries out of exc_message", () => {
const details = getScanErrorDetails({
data: {
attributes: {
result: {
exc_type: "ScanError",
exc_message: ["valid", 42, null, " ", " trimmed "],
},
},
},
});
expect(details?.messages).toEqual(["valid", "trimmed"]);
});
it("returns null when only whitespace fields are present", () => {
const details = getScanErrorDetails({
data: {
attributes: {
result: {
exc_type: " ",
exc_message: [""],
exc_module: "",
},
},
},
});
expect(details).toBeNull();
});
});
+55
View File
@@ -0,0 +1,55 @@
export interface ScanErrorDetails {
type: string;
messages: string[];
module?: string;
copyValue: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() !== ""
? value.trim()
: undefined;
}
function getStringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim())
.filter((item) => item !== "");
}
export function buildScanErrorDetails(
result: unknown,
): ScanErrorDetails | null {
if (!isRecord(result)) return null;
const type = getString(result.exc_type) ?? "-";
const messages = getStringList(result.exc_message);
const module = getString(result.exc_module);
if (type === "-" && messages.length === 0 && !module) return null;
const errorText = messages.length > 0 ? messages.join("\n") : "-";
return {
type,
messages: messages.length > 0 ? messages : ["-"],
module,
copyValue: `ErrorType: ${type}\nError: ${errorText}`,
};
}
export function getScanErrorDetails(
taskResponse: unknown,
): ScanErrorDetails | null {
if (!isRecord(taskResponse) || !isRecord(taskResponse.data)) return null;
if (!isRecord(taskResponse.data.attributes)) return null;
return buildScanErrorDetails(taskResponse.data.attributes.result);
}
@@ -1,9 +1,11 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { AccountsSelector } from "./accounts-selector";
const multiSelectContentSpy = vi.fn();
const multiSelectSpy = vi.fn();
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(),
@@ -35,9 +37,25 @@ vi.mock("@/components/icons/providers-badge", () => ({
}));
vi.mock("@/components/shadcn/select/multiselect", () => ({
MultiSelect: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
MultiSelect: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) => {
multiSelectSpy({ open });
return (
<div data-open={String(open)}>
<button type="button" onClick={() => onOpenChange?.(true)}>
Open selector
</button>
{children}
</div>
);
},
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
@@ -56,16 +74,26 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
},
MultiSelectItem: ({
children,
disabled,
value,
keywords,
onSelect,
}: {
children: React.ReactNode;
disabled?: boolean;
value: string;
keywords?: string[];
onSelect?: (value: string) => void;
}) => (
<div data-value={value} data-keywords={keywords?.join("|")}>
<button
type="button"
data-value={value}
data-keywords={keywords?.join("|")}
data-disabled={disabled ? "true" : "false"}
onClick={() => onSelect?.(value)}
>
{children}
</div>
</button>
),
}));
@@ -114,8 +142,8 @@ describe("AccountsSelector", () => {
render(<AccountsSelector providers={providers} />);
expect(multiSelectContentSpy).toHaveBeenCalledWith({
placeholder: "Search accounts...",
emptyMessage: "No accounts found.",
placeholder: "Search Providers...",
emptyMessage: "No Providers found.",
});
expect(screen.getByText("Production AWS")).toBeInTheDocument();
});
@@ -140,12 +168,56 @@ describe("AccountsSelector", () => {
).toHaveAttribute("data-keywords", expect.stringContaining("123456789012"));
});
it("can use provider UID values for pages whose API filters by provider_uid__in", () => {
render(
<AccountsSelector providers={providers} filterKey="provider_uid__in" />,
);
expect(
screen.getByText("Production AWS").closest("[data-value]"),
).toHaveAttribute("data-value", "123456789012");
});
it("disables select all when every account is already shown", () => {
render(<AccountsSelector providers={providers} />);
expect(
screen.getByRole("option", { name: /select all accounts/i }),
screen.getByRole("option", { name: /select all Providers/i }),
).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("All selected")).toBeInTheDocument();
});
it("marks configured account values as disabled", () => {
render(
<AccountsSelector
providers={providers}
disabledValues={["provider-1"]}
/>,
);
expect(
screen.getByText("Production AWS").closest("[data-value]"),
).toHaveAttribute("data-disabled", "true");
expect(screen.getByText("Disconnected")).toBeInTheDocument();
});
it("can close the dropdown after selecting a launch-scan provider", async () => {
const user = userEvent.setup();
render(
<AccountsSelector
providers={providers}
closeOnSelect
onBatchChange={vi.fn()}
selectedValues={[]}
/>,
);
await user.click(screen.getByRole("button", { name: /open selector/i }));
expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: true });
await user.click(screen.getByRole("button", { name: /production aws/i }));
expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: false });
});
});
@@ -1,7 +1,7 @@
"use client";
import { useSearchParams } from "next/navigation";
import { ReactNode } from "react";
import { ReactNode, useState } from "react";
import {
AlibabaCloudProviderBadge,
@@ -21,6 +21,7 @@ import {
OracleCloudProviderBadge,
VercelProviderBadge,
} from "@/components/icons/providers-badge";
import { Badge } from "@/components/shadcn";
import {
MultiSelect,
MultiSelectContent,
@@ -36,6 +37,14 @@ import {
type ProviderType,
} from "@/types/providers";
const ACCOUNT_SELECTOR_FILTER = {
PROVIDER_ID: "provider_id__in",
PROVIDER_UID: "provider_uid__in",
} as const;
type AccountSelectorFilter =
(typeof ACCOUNT_SELECTOR_FILTER)[keyof typeof ACCOUNT_SELECTOR_FILTER];
const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
aws: <AWSProviderBadge width={18} height={18} />,
azure: <AzureProviderBadge width={18} height={18} />,
@@ -59,12 +68,10 @@ const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
interface AccountsSelectorBaseProps {
providers: ProviderProps[];
search?: MultiSelectSearchProp;
/**
* Currently selected provider types (from the pending ProviderTypeSelector state).
* Used only for contextual description/empty-state messaging does NOT narrow
* the list of available accounts, which remains independent of provider selection.
*/
selectedProviderTypes?: string[];
filterKey?: AccountSelectorFilter;
id?: string;
disabledValues?: string[];
closeOnSelect?: boolean;
}
/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */
@@ -98,74 +105,79 @@ export function AccountsSelector({
providers,
onBatchChange,
selectedValues,
selectedProviderTypes,
filterKey = ACCOUNT_SELECTOR_FILTER.PROVIDER_ID,
id = "accounts-selector",
disabledValues = [],
search = {
placeholder: "Search accounts...",
emptyMessage: "No accounts found.",
placeholder: "Search Providers...",
emptyMessage: "No Providers found.",
},
closeOnSelect = false,
}: AccountsSelectorProps) {
const searchParams = useSearchParams();
const { navigateWithParams } = useUrlFilters();
const [selectorOpen, setSelectorOpen] = useState(false);
const filterKey = "filter[provider_id__in]";
const current = searchParams.get(filterKey) || "";
const labelId = `${id}-label`;
const urlFilterKey = `filter[${filterKey}]`;
const current = searchParams.get(urlFilterKey) || "";
const urlSelectedIds = current ? current.split(",").filter(Boolean) : [];
// In batch mode, use the parent-controlled pending values; otherwise, use URL state.
const selectedIds = onBatchChange ? selectedValues : urlSelectedIds;
const visibleProviders = providers;
// .filter((p) => p.attributes.connection?.connected)
const getProviderValue = (provider: ProviderProps) =>
filterKey === ACCOUNT_SELECTOR_FILTER.PROVIDER_UID
? provider.attributes.uid
: provider.id;
const disabledValuesSet = new Set(disabledValues);
// In batch mode, use the parent-controlled pending values; otherwise, use URL state.
const selectedIds = (onBatchChange ? selectedValues : urlSelectedIds).filter(
(id) => !disabledValuesSet.has(id),
);
const handleMultiValueChange = (ids: string[]) => {
const enabledIds = ids.filter((id) => !disabledValuesSet.has(id));
if (onBatchChange) {
onBatchChange("provider_id__in", ids);
onBatchChange(filterKey, enabledIds);
if (closeOnSelect) setSelectorOpen(false);
return;
}
navigateWithParams((params) => {
params.delete(filterKey);
params.delete(urlFilterKey);
if (ids.length > 0) {
params.set(filterKey, ids.join(","));
if (enabledIds.length > 0) {
params.set(urlFilterKey, enabledIds.join(","));
}
});
if (closeOnSelect) setSelectorOpen(false);
};
const selectedLabel = () => {
if (selectedIds.length === 0) return null;
if (selectedIds.length === 1) {
const p = providers.find((pr) => pr.id === selectedIds[0]);
const p = providers.find((pr) => getProviderValue(pr) === selectedIds[0]);
const name = p ? p.attributes.alias || p.attributes.uid : selectedIds[0];
return <span className="truncate">{name}</span>;
}
return (
<span className="truncate">{selectedIds.length} accounts selected</span>
<span className="truncate">{selectedIds.length} Providers selected</span>
);
};
// Build a contextual description based on currently selected provider types.
// This is purely for user guidance (aria label + empty state) and does NOT
// narrow the list of available accounts — all providers remain selectable.
const filterDescription =
selectedProviderTypes && selectedProviderTypes.length > 0
? `Accounts for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}`
: "All connected provider accounts";
return (
<div className="relative">
<label
htmlFor="accounts-selector"
className="sr-only"
id="accounts-label"
>
Filter by provider account. {filterDescription}. Select one or more
accounts to view findings.
<label htmlFor={id} className="sr-only" id={labelId}>
Filter by Provider. Select one or more Providers to filter results.
</label>
<MultiSelect values={selectedIds} onValuesChange={handleMultiValueChange}>
<MultiSelectTrigger
id="accounts-selector"
aria-labelledby="accounts-label"
>
{selectedLabel() || <MultiSelectValue placeholder="All accounts" />}
<MultiSelect
values={selectedIds}
onValuesChange={handleMultiValueChange}
open={closeOnSelect ? selectorOpen : undefined}
onOpenChange={closeOnSelect ? setSelectorOpen : undefined}
>
<MultiSelectTrigger id={id} aria-labelledby={labelId}>
{selectedLabel() || <MultiSelectValue placeholder="All Providers" />}
</MultiSelectTrigger>
<MultiSelectContent search={search}>
{visibleProviders.length > 0 ? (
@@ -174,7 +186,7 @@ export function AccountsSelector({
role="option"
aria-selected={selectedIds.length === 0}
aria-disabled={selectedIds.length === 0}
aria-label="Select all accounts (clears current selection to show all)"
aria-label="Select all Providers (clears current selection to show all)"
tabIndex={0}
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50"
onClick={() => {
@@ -192,7 +204,8 @@ export function AccountsSelector({
{selectedIds.length === 0 ? "All selected" : "Select All"}
</div>
{visibleProviders.map((p) => {
const id = p.id;
const value = getProviderValue(p);
const isDisabled = disabledValuesSet.has(value);
const displayName = p.attributes.alias || p.attributes.uid;
const providerType = p.attributes.provider as ProviderType;
const icon = PROVIDER_ICON[providerType];
@@ -205,23 +218,28 @@ export function AccountsSelector({
].filter(Boolean);
return (
<MultiSelectItem
key={id}
value={id}
key={p.id}
value={value}
badgeLabel={displayName}
keywords={searchKeywords}
aria-label={`${displayName} account (${providerType.toUpperCase()})`}
disabled={isDisabled}
aria-label={`${displayName} Provider (${providerType.toUpperCase()})`}
onSelect={() => {
if (closeOnSelect) setSelectorOpen(false);
}}
>
<span aria-hidden="true">{icon}</span>
<span className="truncate">{displayName}</span>
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate">{displayName}</span>
{isDisabled && <Badge variant="tag">Disconnected</Badge>}
</span>
</MultiSelectItem>
);
})}
</>
) : (
<div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
{selectedProviderTypes && selectedProviderTypes.length > 0
? `No accounts available for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}`
: "No connected accounts available"}
No connected Providers available
</div>
)}
</MultiSelectContent>
@@ -114,8 +114,8 @@ describe("ProviderTypeSelector", () => {
render(<ProviderTypeSelector providers={providers} />);
expect(multiSelectContentSpy).toHaveBeenCalledWith({
placeholder: "Search providers...",
emptyMessage: "No providers found.",
placeholder: "Search Provider Types...",
emptyMessage: "No Provider Types found.",
});
expect(screen.getByText("Amazon Web Services")).toBeInTheDocument();
});
@@ -141,7 +141,7 @@ describe("ProviderTypeSelector", () => {
render(<ProviderTypeSelector providers={providers} />);
expect(
screen.getByRole("option", { name: /select all providers/i }),
screen.getByRole("option", { name: /select all Provider Types/i }),
).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("All selected")).toBeInTheDocument();
});
@@ -210,8 +210,8 @@ export const ProviderTypeSelector = ({
onBatchChange,
selectedValues,
search = {
placeholder: "Search providers...",
emptyMessage: "No providers found.",
placeholder: "Search Provider Types...",
emptyMessage: "No Provider Types found.",
},
}: ProviderTypeSelectorProps) => {
const searchParams = useSearchParams();
@@ -274,7 +274,7 @@ export const ProviderTypeSelector = ({
}
return (
<span className="min-w-0 truncate">
{selectedTypes.length} providers selected
{selectedTypes.length} Provider Types selected
</span>
);
};
@@ -286,7 +286,8 @@ export const ProviderTypeSelector = ({
className="sr-only"
id="provider-type-label"
>
Filter by provider type. Select one or more providers to view findings.
Filter by Provider Type. Select one or more Provider Types to view
findings.
</label>
<MultiSelect
values={selectedTypes}
@@ -296,7 +297,9 @@ export const ProviderTypeSelector = ({
id="provider-type-selector"
aria-labelledby="provider-type-label"
>
{selectedLabel() || <MultiSelectValue placeholder="All providers" />}
{selectedLabel() || (
<MultiSelectValue placeholder="All Provider Types" />
)}
</MultiSelectTrigger>
<MultiSelectContent search={search}>
{availableTypes.length > 0 ? (
@@ -305,7 +308,7 @@ export const ProviderTypeSelector = ({
role="option"
aria-selected={selectedTypes.length === 0}
aria-disabled={selectedTypes.length === 0}
aria-label="Select all providers (clears current selection to show all)"
aria-label="Select all Provider Types (clears current selection to show all)"
tabIndex={0}
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50"
onClick={() => {
@@ -328,7 +331,7 @@ export const ProviderTypeSelector = ({
value={providerType}
badgeLabel={PROVIDER_DATA[providerType].label}
keywords={[providerType, PROVIDER_DATA[providerType].label]}
aria-label={`${PROVIDER_DATA[providerType].label} provider`}
aria-label={`${PROVIDER_DATA[providerType].label} Provider Type`}
>
<span aria-hidden="true">{renderIcon(providerType)}</span>
<span>{PROVIDER_DATA[providerType].label}</span>
@@ -337,7 +340,7 @@ export const ProviderTypeSelector = ({
</>
) : (
<div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
No connected providers available
No connected Provider Types available
</div>
)}
</MultiSelectContent>
@@ -3,7 +3,7 @@ import {
getFindingsBySeverity,
SeverityByProviderType,
} from "@/actions/overview";
import { getProviders } from "@/actions/providers";
import { getAllProviders } from "@/actions/providers";
import { SankeyChart } from "@/components/graphs/sankey-chart";
import { SearchParamsProps } from "@/types";
@@ -20,7 +20,7 @@ export async function RiskPipelineViewSSR({
const providerIdFilter = filters["filter[provider_id__in]"];
// Fetch providers list to know account types
const providersListResponse = await getProviders({ pageSize: 200 });
const providersListResponse = await getAllProviders();
const allProviders = providersListResponse?.data || [];
// Build severityByProviderType based on filters
@@ -4,7 +4,7 @@ import {
adaptToRiskPlotData,
getProvidersRiskData,
} from "@/actions/overview/risk-plot";
import { getProviders } from "@/actions/providers";
import { getAllProviders } from "@/actions/providers";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../_lib/filter-params";
@@ -21,7 +21,7 @@ export async function RiskPlotSSR({
const providerIdFilter = filters["filter[provider_id__in]"];
// Fetch all providers
const providersListResponse = await getProviders({ pageSize: 200 });
const providersListResponse = await getAllProviders();
const allProviders = providersListResponse?.data || [];
// Filter providers based on search params
@@ -518,7 +518,7 @@ describe("AlertFormModal", () => {
expect(alertEditGrid).toHaveClass("xl:grid-cols-3", "2xl:grid-cols-3");
expect(alertEditGrid).not.toHaveClass("xl:grid-cols-4", "2xl:grid-cols-5");
expect(screen.getAllByText("Amazon Web Services")[0]).toBeVisible();
expect(screen.getByText("All accounts")).toBeVisible();
expect(screen.getByText("All Providers")).toBeVisible();
expect(within(filterControls).getByText("All Delta")).toBeVisible();
expect(within(filterControls).getByText("All Resource Type")).toBeVisible();
expect(
@@ -547,7 +547,9 @@ describe("AlertFormModal", () => {
});
// When
await user.click(screen.getByLabelText(/provider type/i));
await user.click(
screen.getByRole("combobox", { name: /filter by Provider Type/i }),
);
const providerOptions = await screen.findAllByText("Google Cloud Platform");
const visibleProviderOption = providerOptions.at(-1);
expect(visibleProviderOption).toBeDefined();
@@ -597,7 +599,9 @@ describe("AlertFormModal", () => {
});
// When
await user.click(screen.getByLabelText(/provider type/i));
await user.click(
screen.getByRole("combobox", { name: /filter by Provider Type/i }),
);
const providerOptions = await screen.findAllByText("Google Cloud Platform");
const visibleProviderOption = providerOptions.at(-1);
expect(visibleProviderOption).toBeDefined();
+2 -2
View File
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { getLatestMetadataInfo } from "@/actions/findings";
import { getProviders } from "@/actions/providers";
import { getAllProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { getAlert, listAlerts } from "@/app/(prowler)/alerts/_actions";
import { AlertsManager } from "@/app/(prowler)/alerts/_components/alerts-manager";
@@ -58,7 +58,7 @@ export default async function AlertsPage({ searchParams }: AlertsPageProps) {
const [result, providersData, scansData, metadataInfoData, editResult] =
await Promise.all([
listAlerts(toAlertsSearchParams(resolvedSearchParams)),
getProviders({ pageSize: 50 }),
getAllProviders(),
getScans({ pageSize: 50 }),
getLatestMetadataInfo({}),
editAlertId ? getAlert(editAlertId) : Promise.resolve(null),
+2 -2
View File
@@ -6,7 +6,7 @@ import {
getLatestFindingGroups,
} from "@/actions/finding-groups";
import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings";
import { getProviders } from "@/actions/providers";
import { getAllProviders } from "@/actions/providers";
import { getScan, getScans } from "@/actions/scans";
import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components";
import { FindingsFilters } from "@/components/findings/findings-filters";
@@ -37,7 +37,7 @@ export default async function Findings({
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
const [providersData, scansData] = await Promise.all([
getProviders({ pageSize: 50 }),
getAllProviders(),
getScans({ pageSize: 50 }),
]);
@@ -1,7 +1,7 @@
import React from "react";
import { getIntegrations } from "@/actions/integrations";
import { getProviders } from "@/actions/providers";
import { getAllProviders } from "@/actions/providers";
import { S3IntegrationsManager } from "@/components/integrations/s3/s3-integrations-manager";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
@@ -47,7 +47,7 @@ export default async function S3Integrations({
const [integrations, providers] = await Promise.all([
getIntegrations(urlSearchParams),
getProviders({ pageSize: 100 }),
getAllProviders(),
]);
const s3Integrations = integrations?.data || [];
@@ -1,7 +1,7 @@
import React from "react";
import { getIntegrations } from "@/actions/integrations";
import { getProviders } from "@/actions/providers";
import { getAllProviders } from "@/actions/providers";
import { SecurityHubIntegrationsManager } from "@/components/integrations/security-hub/security-hub-integrations-manager";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
@@ -45,7 +45,7 @@ export default async function SecurityHubIntegrations({
const [integrations, providers] = await Promise.all([
getIntegrations(urlSearchParams),
getProviders({ pageSize: 100 }),
getAllProviders(),
]);
const securityHubIntegrations = integrations?.data || [];
+4 -6
View File
@@ -1,11 +1,10 @@
import { Suspense } from "react";
import { getProviders } from "@/actions/providers";
import { getAllProviders } from "@/actions/providers";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import { ContentLayout } from "@/components/ui";
import { SearchParamsProps } from "@/types";
import { AccountsSelector } from "./_overview/_components/accounts-selector";
import { ProviderTypeSelector } from "./_overview/_components/provider-type-selector";
import {
AttackSurfaceSkeleton,
AttackSurfaceSSR,
@@ -39,13 +38,12 @@ export default async function Home({
searchParams: Promise<SearchParamsProps>;
}) {
const resolvedSearchParams = await searchParams;
const providersData = await getProviders({ page: 1, pageSize: 200 });
const providersData = await getAllProviders();
return (
<ContentLayout title="Overview" icon="lucide:square-chart-gantt">
<div className="xxl:grid-cols-4 mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<ProviderTypeSelector providers={providersData?.data ?? []} />
<AccountsSelector providers={providersData?.data ?? []} />
<ProviderAccountSelectors providers={providersData?.data ?? []} />
</div>
<div className="flex flex-col gap-6 xl:flex-row xl:flex-wrap xl:items-stretch">
+9
View File
@@ -34,4 +34,13 @@ describe("providers page", () => {
expect(source).toContain("size: 160");
expect(source).toContain("size: 140");
});
it("keeps the CLI import banner gated by the Cloud environment", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const pagePath = path.join(currentDir, "page.tsx");
const source = readFileSync(pagePath, "utf8");
expect(source).toContain("NEXT_PUBLIC_IS_CLOUD_ENV");
expect(source).toContain("{isCloudEnvironment && <CliImportBanner");
});
});
+3
View File
@@ -2,6 +2,7 @@ import { Suspense } from "react";
import { ProvidersAccountsView } from "@/components/providers";
import { SkeletonTableProviders } from "@/components/providers/table";
import { CliImportBanner } from "@/components/scans";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { ContentLayout } from "@/components/ui";
import { FilterTransitionWrapper } from "@/contexts";
@@ -19,6 +20,7 @@ export default async function Providers({
}) {
const resolvedSearchParams = await searchParams;
const activeTab = getProviderTab(resolvedSearchParams.tab);
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
// Exclude `tab` from the Suspense key so switching tabs doesn't re-suspend
const { tab: _, ...paramsWithoutTab } = resolvedSearchParams || {};
@@ -26,6 +28,7 @@ export default async function Providers({
return (
<ContentLayout title="Providers" icon="lucide:cloud-cog">
{isCloudEnvironment && <CliImportBanner className="mb-6" />}
<FilterTransitionWrapper>
<ProviderPageTabs
activeTab={activeTab}
@@ -2,7 +2,7 @@ import {
getProviderGroupInfoById,
getProviderGroups,
} from "@/actions/manage-groups/manage-groups";
import { getProviders } from "@/actions/providers";
import { getAllProviders } from "@/actions/providers";
import { getRoles } from "@/actions/roles";
import { AddGroupForm, EditGroupForm } from "@/components/manage-groups/forms";
import { ColumnGroups } from "@/components/manage-groups/table";
@@ -19,7 +19,7 @@ export const ProviderGroupsContent = async ({
// Fetch all data in parallel
const [providersResponse, rolesResponse, providerGroupsData, editGroupData] =
await Promise.all([
getProviders({ pageSize: 50 }),
getAllProviders(),
getRoles({}),
fetchGroupsTableData(searchParams),
providerGroupId && !Array.isArray(providerGroupId)
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
const providersActionsMock = vi.hoisted(() => ({
getProviders: vi.fn(),
getAllProviders: vi.fn(),
}));
const organizationsActionsMock = vi.hoisted(() => ({
@@ -619,6 +620,7 @@ describe("loadProvidersAccountsViewData", () => {
it("does not call organizations endpoints in OSS", async () => {
// Given
providersActionsMock.getProviders.mockResolvedValue(providersResponse);
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
scansActionsMock.getScans.mockResolvedValue({ data: [] });
// When
@@ -662,6 +664,7 @@ describe("loadProvidersAccountsViewData", () => {
},
})),
});
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
organizationsActionsMock.listOrganizationsSafe.mockResolvedValue({
data: [
{
@@ -724,6 +727,7 @@ describe("loadProvidersAccountsViewData", () => {
it("falls back to empty cloud grouping data when organizations endpoints fail", async () => {
// Given
providersActionsMock.getProviders.mockResolvedValue(providersResponse);
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
organizationsActionsMock.listOrganizationsSafe.mockResolvedValue({
data: [],
});
@@ -2,7 +2,7 @@ import {
listOrganizationsSafe,
listOrganizationUnitsSafe,
} from "@/actions/organizations/organizations";
import { getProviders } from "@/actions/providers";
import { getAllProviders, getProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import {
extractFiltersAndQuery,
@@ -467,7 +467,7 @@ export async function loadProvidersAccountsViewData({
),
// Unfiltered fetch for ProviderTypeSelector — only needs distinct types;
// TODO: Replace with a dedicated lightweight endpoint when available.
resolveActionResult(getProviders({ pageSize: 500 })),
resolveActionResult(getAllProviders()),
// Fetch active scheduled scans to determine daily schedule per provider
resolveActionResult(
getScans({

Some files were not shown because too many files have changed in this diff Show More