mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-06-09 21:04:53 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6df80a4890 | |||
| a769e37615 | |||
| 9d2a8d9108 | |||
| e05519ff9f | |||
| 67b26072f8 | |||
| 2222082631 | |||
| 8b0cb4b981 | |||
| 9422eff8ab | |||
| e3c4368d32 | |||
| 2a641b39c8 | |||
| 02b713572b |
+2
-1
@@ -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
@@ -43,7 +43,7 @@ dependencies = [
|
||||
"defusedxml==0.7.1",
|
||||
"gunicorn==23.0.0",
|
||||
"lxml==6.1.0",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.29",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (==1.3.0)",
|
||||
"sentry-sdk[django] (==2.56.0)",
|
||||
|
||||
Generated
+53
-3
@@ -4410,8 +4410,8 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.27.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#0abbb7fc590eaf7de6ed354dd5a217bca261d2b0" }
|
||||
version = "5.29.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.29#a769e3761532d9332cb64078ef09ebf7ffb15292" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -4484,9 +4484,13 @@ dependencies = [
|
||||
{ name = "pygithub" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "pytz" },
|
||||
{ name = "scaleway" },
|
||||
{ name = "schema" },
|
||||
{ name = "shodan" },
|
||||
{ name = "slack-sdk" },
|
||||
{ name = "stackit-core" },
|
||||
{ name = "stackit-iaas" },
|
||||
{ name = "stackit-resourcemanager" },
|
||||
{ name = "tabulate" },
|
||||
{ name = "tzlocal" },
|
||||
{ name = "uuid6" },
|
||||
@@ -4590,7 +4594,7 @@ requires-dist = [
|
||||
{ name = "matplotlib", specifier = "==3.10.8" },
|
||||
{ name = "neo4j", specifier = "==6.1.0" },
|
||||
{ name = "openai", specifier = "==1.109.1" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.29" },
|
||||
{ name = "psycopg2-binary", specifier = "==2.9.9" },
|
||||
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
|
||||
{ name = "reportlab", specifier = "==4.4.10" },
|
||||
@@ -5526,6 +5530,52 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stackit-core"
|
||||
version = "0.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "requests" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/90/20f9ec7387eec4067cfd3d29055d0e2b5e1e0322c601a7f48125fd8ea35f/stackit_core-0.2.0.tar.gz", hash = "sha256:b8af91877cdb060d6969a303d8cf20bc0b33b345afd91f679c44a987381e2d47", size = 8987, upload-time = "2025-06-12T08:24:45.251Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/b4/7b53187ce68956870d864ccb9ccfb68066c9df9de1c9568fd2feb03c4504/stackit_core-0.2.0-py3-none-any.whl", hash = "sha256:04632fc6742790d08ddfcb7f2313e04d1254827397a80250f838a2f81b92645b", size = 10240, upload-time = "2025-06-12T08:24:44.214Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stackit-iaas"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "requests" },
|
||||
{ name = "stackit-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/07/24e65278300d5c3cb19cb1660bff924c80812cf8aad3e715f826bae5aa80/stackit_iaas-1.4.0.tar.gz", hash = "sha256:93523b23442350c7ebefd9129485c4c2a539f694a9c36a0f8edfaba9862057ea", size = 116236, upload-time = "2026-05-13T09:43:15.996Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/08/51/2201164d7bfacf47539888c735f10f6320c188252384957aa1b23121a210/stackit_iaas-1.4.0-py3-none-any.whl", hash = "sha256:3f4a32321b57ac238f73e5d660c6428186b92cc0425c1f0783ba801e377149d9", size = 316588, upload-time = "2026-05-13T09:43:14.943Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stackit-resourcemanager"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "requests" },
|
||||
{ name = "stackit-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/2d/f458f18e48ed2b1c83df52cff7dbdfd5dd904fb2980ffd9385876e47bbd9/stackit_resourcemanager-0.8.0.tar.gz", hash = "sha256:f44542beab4130857f5a7f465cf02defeef657bdf63c1beeb3102f0ba3c003fe", size = 33943, upload-time = "2026-05-13T09:43:08.667Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/9c/38a74d0f7a89b4320f6d2366fb660638bda8860daa08748b12c713d84381/stackit_resourcemanager-0.8.0-py3-none-any.whl", hash = "sha256:dd04bb8353d041a137c4dcba190beabded7acfaff1bc98b218fce20a99389ebc", size = 81288, upload-time = "2026-05-13T09:43:07.81Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "statsd"
|
||||
version = "4.0.1"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+4
-6
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-3
@@ -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": {
|
||||
|
||||
+1
-2
@@ -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": {
|
||||
|
||||
+1
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
+2
-2
@@ -2,11 +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
|
||||
|
||||
- New Scan Jobs view with specific In Progress, Completed, Scheduled tabs [(#11258)](https://github.com/prowler-cloud/prowler/pull/11258)
|
||||
- Restyle `Scan Jobs` view with specific In Progress, Completed, Scheduled tabs [(#11258)](https://github.com/prowler-cloud/prowler/pull/11258)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ComponentProps } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||
import { SCAN_JOBS_TAB } from "@/types";
|
||||
|
||||
import { LaunchStep } from "./launch-step";
|
||||
|
||||
@@ -81,5 +82,9 @@ describe("LaunchStep", () => {
|
||||
title: "Scan Launched",
|
||||
}),
|
||||
);
|
||||
const toastPayload = toastMock.mock.calls[0]?.[0];
|
||||
expect(toastPayload.action.props.children.props.href).toBe(
|
||||
`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon";
|
||||
import { ToastAction, useToast } from "@/components/ui";
|
||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||
import { SCAN_JOBS_TAB } from "@/types";
|
||||
import { TREE_ITEM_STATUS } from "@/types/tree";
|
||||
|
||||
import {
|
||||
@@ -81,7 +82,7 @@ export function LaunchStep({
|
||||
: "Single scan launched successfully.",
|
||||
action: (
|
||||
<ToastAction altText="Go to scans" asChild>
|
||||
<Link href="/scans">Go to scans</Link>
|
||||
<Link href={`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`}>Go to scans</Link>
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
|
||||
import { CliImportBanner } from "./cli-import-banner";
|
||||
|
||||
const STORAGE_KEY = "prowler:cli-import-banner-dismissed";
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
get length() {
|
||||
return Object.keys(store).length;
|
||||
},
|
||||
key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
describe("CliImportBanner", () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the banner when not dismissed", () => {
|
||||
render(<CliImportBanner />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Import findings from Prowler CLI/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a link to the documentation", () => {
|
||||
render(<CliImportBanner />);
|
||||
|
||||
const link = screen.getByRole("link", { name: "Learn more" });
|
||||
|
||||
expect(link).toHaveAttribute("href", DOCS_URLS.FINDINGS_INGESTION);
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("does not render when previously dismissed", () => {
|
||||
localStorageMock.setItem(STORAGE_KEY, "true");
|
||||
|
||||
const { container } = render(<CliImportBanner />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("dismisses the banner and persists to localStorage on close", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<CliImportBanner />);
|
||||
|
||||
const closeButton = screen.getByRole("button", { name: "Close" });
|
||||
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(
|
||||
screen.queryByText(/Import findings from Prowler CLI/),
|
||||
).not.toBeInTheDocument();
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(STORAGE_KEY, "true");
|
||||
});
|
||||
|
||||
it("renders with role='alert'", () => {
|
||||
render(<CliImportBanner />);
|
||||
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Upload } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Alert, AlertTitle } from "@/components/shadcn";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STORAGE_KEY = "prowler:cli-import-banner-dismissed";
|
||||
|
||||
export const CliImportBanner = ({ className }: { className?: string }) => {
|
||||
const [isVisible, setIsVisible] = useState<boolean | null>(null);
|
||||
|
||||
useMountEffect(() => {
|
||||
const isDismissed = localStorage.getItem(STORAGE_KEY) === "true";
|
||||
setIsVisible(!isDismissed);
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
if (isVisible === null || !isVisible) return null;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
variant="info"
|
||||
onClose={handleClose}
|
||||
className={cn("animate-fade-in", className)}
|
||||
>
|
||||
<Upload />
|
||||
<AlertTitle>
|
||||
Import findings from Prowler CLI —{" "}
|
||||
<Link
|
||||
href={DOCS_URLS.FINDINGS_INGESTION}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-normal underline underline-offset-2"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./auto-refresh";
|
||||
export * from "./cli-import-banner";
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SCAN_JOBS_TAB } from "@/types";
|
||||
|
||||
import { ScansFilterBar } from "./scans-filter-bar";
|
||||
|
||||
vi.mock("@/components/filters/provider-account-selectors", () => ({
|
||||
ProviderAccountSelectors: () => <div>Provider account selectors</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Select: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectItem: ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
}) => <div data-value={value}>{children}</div>,
|
||||
SelectTrigger: ({ children, ...props }: React.ComponentProps<"button">) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
SelectValue: ({ placeholder }: { placeholder: string }) => (
|
||||
<span>{placeholder}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
providers: [],
|
||||
scheduleType: "all",
|
||||
scanStatus: "all",
|
||||
showStatusFilter: false,
|
||||
onScheduleTypeChange: vi.fn(),
|
||||
onScanStatusChange: vi.fn(),
|
||||
};
|
||||
|
||||
describe("ScansFilterBar", () => {
|
||||
it("hides the type filter on the scheduled tab", () => {
|
||||
// Given
|
||||
render(
|
||||
<ScansFilterBar {...defaultProps} activeTab={SCAN_JOBS_TAB.SCHEDULED} />,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /all types/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Provider account selectors")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the type filter outside the scheduled tab", () => {
|
||||
// Given
|
||||
render(
|
||||
<ScansFilterBar {...defaultProps} activeTab={SCAN_JOBS_TAB.COMPLETED} />,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("button", { name: /all types/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn";
|
||||
import type { ScanJobsTab } from "@/types";
|
||||
import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
import {
|
||||
@@ -40,6 +40,7 @@ export function ScansFilterBar({
|
||||
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
|
||||
const triggerFilterOptions = getScanTriggerFilterOptions(isCloudEnvironment);
|
||||
const statusFilterOptions = getScanStatusFilterOptions(activeTab);
|
||||
const showScheduleTypeFilter = activeTab !== SCAN_JOBS_TAB.SCHEDULED;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -52,18 +53,20 @@ export function ScansFilterBar({
|
||||
accountSelectorClassName={filterItemClass}
|
||||
/>
|
||||
|
||||
<Select value={scheduleType} onValueChange={onScheduleTypeChange}>
|
||||
<SelectTrigger aria-label="All Types" className={filterItemClass}>
|
||||
<SelectValue placeholder="All Types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{triggerFilterOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{showScheduleTypeFilter && (
|
||||
<Select value={scheduleType} onValueChange={onScheduleTypeChange}>
|
||||
<SelectTrigger aria-label="All Types" className={filterItemClass}>
|
||||
<SelectValue placeholder="All Types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{triggerFilterOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{showStatusFilter && (
|
||||
<Select value={scanStatus} onValueChange={onScanStatusChange}>
|
||||
|
||||
@@ -16,6 +16,32 @@ const { scansFilterBarSpy } = vi.hoisted(() => ({
|
||||
scansFilterBarSpy: vi.fn(),
|
||||
}));
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
get length() {
|
||||
return Object.keys(store).length;
|
||||
},
|
||||
key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/scans",
|
||||
useRouter: () => ({
|
||||
@@ -120,6 +146,7 @@ describe("ScansPageShell", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
localStorageMock.clear();
|
||||
searchParamsValue.current = "";
|
||||
useScansStore.getState().closeLaunchScanModal();
|
||||
});
|
||||
@@ -191,6 +218,36 @@ describe("ScansPageShell", () => {
|
||||
expect(screen.getByRole("combobox", { name: /all types/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it("shows the CLI import banner in Cloud", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
|
||||
render(
|
||||
<ScansPageShell providers={providers} hasManageScansPermission>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
/import findings from prowler cli/i,
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /learn more/i })).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings",
|
||||
);
|
||||
});
|
||||
|
||||
it("hides the CLI import banner outside Cloud", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
|
||||
render(
|
||||
<ScansPageShell providers={providers} hasManageScansPermission>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps launch scan with filters and mutelist with tabs", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
|
||||
@@ -332,4 +389,22 @@ describe("ScansPageShell", () => {
|
||||
expect(calledUrl).toContain("tab=active");
|
||||
expect(calledUrl).not.toContain("filter%5Bstate__in%5D");
|
||||
});
|
||||
|
||||
it("clears type filter when switching to scheduled scans", async () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
searchParamsValue.current = "tab=completed&filter%5Btrigger%5D=manual";
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ScansPageShell providers={providers} hasManageScansPermission>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: /scheduled/i }));
|
||||
|
||||
const calledUrl = pushMock.mock.calls.at(-1)?.[0] as string;
|
||||
expect(calledUrl).toContain("tab=scheduled");
|
||||
expect(calledUrl).not.toContain("filter%5Btrigger%5D");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useScansStore } from "@/store";
|
||||
import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
import { CliImportBanner } from "./cli-import-banner";
|
||||
import { LaunchScanModal } from "./launch-scan-modal";
|
||||
import { ScansFilterBar } from "./scans-filter-bar";
|
||||
import { useScansFilters } from "./use-scans-filters";
|
||||
@@ -53,6 +54,7 @@ export function ScansPageShell({
|
||||
const hasConnectedProviders = providers.some(
|
||||
(provider) => provider.attributes.connection.connected === true,
|
||||
);
|
||||
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
|
||||
const launchDisabled = !hasManageScansPermission || !hasConnectedProviders;
|
||||
const launchOpen = isLaunchScanModalOpen || urlLaunchOpen;
|
||||
|
||||
@@ -104,6 +106,8 @@ export function ScansPageShell({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isCloudEnvironment && <CliImportBanner />}
|
||||
|
||||
<Tabs
|
||||
value={filters.activeTab}
|
||||
onValueChange={filters.setTab}
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ScansProvidersEmptyState } from "./scans-providers-empty-state";
|
||||
|
||||
const { replaceMock, searchParamsValue } = vi.hoisted(() => ({
|
||||
replaceMock: vi.fn(),
|
||||
searchParamsValue: { current: "" },
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/scans",
|
||||
useRouter: () => ({
|
||||
replace: replaceMock,
|
||||
}),
|
||||
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/wizard", () => ({
|
||||
ProviderWizardModal: ({ open }: { open: boolean }) =>
|
||||
open ? <div role="dialog">Provider wizard</div> : null,
|
||||
@@ -14,6 +27,11 @@ vi.mock("./no-providers-connected", () => ({
|
||||
}));
|
||||
|
||||
describe("ScansProvidersEmptyState", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
searchParamsValue.current = "";
|
||||
});
|
||||
|
||||
it("shows the add provider message and opens the provider wizard", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -28,6 +46,25 @@ describe("ScansProvidersEmptyState", () => {
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
|
||||
});
|
||||
|
||||
it("clears the launch scan URL intent before opening the provider wizard", async () => {
|
||||
// Given
|
||||
searchParamsValue.current = "tab=completed&launchScan=true";
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ScansProvidersEmptyState thereIsNoProviders />);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open add provider modal/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(replaceMock).toHaveBeenCalledWith("/scans?tab=completed", {
|
||||
scroll: false,
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
|
||||
});
|
||||
|
||||
it("shows the no connected providers message", () => {
|
||||
render(<ScansProvidersEmptyState thereIsNoProviders={false} />);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ProviderWizardModal } from "@/components/providers/wizard";
|
||||
import { LAUNCH_SCAN_SEARCH_PARAM } from "@/lib/scans-navigation";
|
||||
|
||||
import { NoProvidersAdded } from "./no-providers-added";
|
||||
import { NoProvidersConnected } from "./no-providers-connected";
|
||||
@@ -14,12 +16,28 @@ interface ScansProvidersEmptyStateProps {
|
||||
export function ScansProvidersEmptyState({
|
||||
thereIsNoProviders,
|
||||
}: ScansProvidersEmptyStateProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
|
||||
|
||||
const openProviderWizard = () => {
|
||||
if (searchParams.has(LAUNCH_SCAN_SEARCH_PARAM)) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete(LAUNCH_SCAN_SEARCH_PARAM);
|
||||
const query = params.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, {
|
||||
scroll: false,
|
||||
});
|
||||
}
|
||||
|
||||
setIsProviderWizardOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{thereIsNoProviders ? (
|
||||
<NoProvidersAdded onOpenWizard={() => setIsProviderWizardOpen(true)} />
|
||||
<NoProvidersAdded onOpenWizard={openProviderWizard} />
|
||||
) : (
|
||||
<NoProvidersConnected />
|
||||
)}
|
||||
|
||||
@@ -94,6 +94,18 @@ describe("scans.utils", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes trigger filters from scheduled scans", () => {
|
||||
expect(
|
||||
getScanJobsUserFilters({
|
||||
tab: "scheduled",
|
||||
"filter[trigger]": "manual",
|
||||
"filter[provider_uid]": "123456789012",
|
||||
}),
|
||||
).toEqual({
|
||||
"filter[provider_uid]": "123456789012",
|
||||
});
|
||||
});
|
||||
|
||||
it("formats scan labels and durations for table display", () => {
|
||||
expect(getScanAlias(makeScan(""))).toBe("-");
|
||||
expect(getScanAlias(makeScan("Daily scheduled scan", "scheduled"))).toBe(
|
||||
|
||||
@@ -77,8 +77,14 @@ function isSearchParamValue(value: unknown): value is string | string[] {
|
||||
export function getScanJobsUserFilters(
|
||||
searchParams: SearchParamsProps,
|
||||
): Record<string, string | string[]> {
|
||||
const tab = getScanJobsTab(searchParams.tab);
|
||||
|
||||
return Object.entries(searchParams).reduce<Record<string, string | string[]>>(
|
||||
(filters, [key, value]) => {
|
||||
if (tab === SCAN_JOBS_TAB.SCHEDULED && key === "filter[trigger]") {
|
||||
return filters;
|
||||
}
|
||||
|
||||
if (
|
||||
key.startsWith("filter[") &&
|
||||
!isScanStateFilterKey(key) &&
|
||||
|
||||
@@ -81,6 +81,17 @@ const makeCompletedScan = (): ScanProps => ({
|
||||
},
|
||||
});
|
||||
|
||||
const makeScheduledScan = (): ScanProps => ({
|
||||
...makeCompletedScan(),
|
||||
attributes: {
|
||||
...makeCompletedScan().attributes,
|
||||
trigger: "scheduled",
|
||||
state: "scheduled",
|
||||
scheduled_at: "2026-01-01T10:00:00Z",
|
||||
next_scan_at: "2026-01-02T10:00:00Z",
|
||||
},
|
||||
});
|
||||
|
||||
const renderCell = (
|
||||
columnId: string,
|
||||
scan: ScanProps,
|
||||
@@ -137,7 +148,6 @@ describe("getScanJobsColumns", () => {
|
||||
"account",
|
||||
"scanInfo",
|
||||
"scanSchedule",
|
||||
"nextScan",
|
||||
"actions",
|
||||
]);
|
||||
});
|
||||
@@ -163,4 +173,19 @@ describe("getScanJobsColumns", () => {
|
||||
|
||||
expect(screen.getByText("1 min 13 sec")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels the completed scan schedule column as Type", () => {
|
||||
renderHeader(SCAN_JOBS_TAB.COMPLETED, "scanSchedule");
|
||||
|
||||
expect(screen.getByText("Type")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Schedule")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the scheduled column without repeating the scheduled label in each row", () => {
|
||||
renderHeader(SCAN_JOBS_TAB.SCHEDULED, "scanSchedule");
|
||||
renderCell("scanSchedule", makeScheduledScan(), SCAN_JOBS_TAB.SCHEDULED);
|
||||
|
||||
expect(screen.getByText("Schedule")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Scheduled")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,13 +39,25 @@ const scanInfoColumn: ColumnDef<ScanProps> = {
|
||||
cell: ({ row }) => <ScanInfoCell scan={row.original} />,
|
||||
};
|
||||
|
||||
const scanScheduleColumn: ColumnDef<ScanProps> = {
|
||||
const getScanScheduleColumn = (title: string): ColumnDef<ScanProps> => ({
|
||||
id: "scanSchedule",
|
||||
accessorFn: (row) => row.attributes.trigger,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Schedule" param="trigger" />
|
||||
<DataTableColumnHeader column={column} title={title} param="trigger" />
|
||||
),
|
||||
cell: ({ row }) => <ScheduleCell scan={row.original} />,
|
||||
});
|
||||
|
||||
const scheduledScanScheduleColumn: ColumnDef<ScanProps> = {
|
||||
id: "scanSchedule",
|
||||
accessorFn: (row) => row.attributes.scheduled_at,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Schedule" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<DateWithTime dateTime={row.original.attributes.scheduled_at} showTime />
|
||||
),
|
||||
enableSorting: false,
|
||||
};
|
||||
|
||||
const resourcesColumn: ColumnDef<ScanProps> = {
|
||||
@@ -86,7 +98,7 @@ const activeColumns = (): ColumnDef<ScanProps>[] => [
|
||||
cell: ({ row }) => <ProgressCell scan={row.original} />,
|
||||
enableSorting: false,
|
||||
},
|
||||
scanScheduleColumn,
|
||||
getScanScheduleColumn("Schedule"),
|
||||
{
|
||||
id: "launched",
|
||||
header: ({ column }) => (
|
||||
@@ -118,7 +130,7 @@ const completedColumns = (): ColumnDef<ScanProps>[] => [
|
||||
cell: ({ row }) => <StatusBadge status={row.original.attributes.state} />,
|
||||
enableSorting: false,
|
||||
},
|
||||
scanScheduleColumn,
|
||||
getScanScheduleColumn("Type"),
|
||||
{
|
||||
id: "scanDate",
|
||||
accessorFn: (row) => row.attributes.completed_at,
|
||||
@@ -139,7 +151,7 @@ const completedColumns = (): ColumnDef<ScanProps>[] => [
|
||||
const scheduledColumns = (): ColumnDef<ScanProps>[] => [
|
||||
accountColumn,
|
||||
scanInfoColumn,
|
||||
scanScheduleColumn,
|
||||
scheduledScanScheduleColumn,
|
||||
/*
|
||||
* TODO: Restore this column when the API exposes the last completed scan date for this schedule.
|
||||
* {
|
||||
@@ -153,20 +165,6 @@ const scheduledColumns = (): ColumnDef<ScanProps>[] => [
|
||||
* enableSorting: false,
|
||||
* },
|
||||
*/
|
||||
{
|
||||
id: "nextScan",
|
||||
accessorFn: (row) => row.attributes.next_scan_at,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title="Next Run"
|
||||
param="next_scan_at"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<DateWithTime dateTime={row.original.attributes.next_scan_at} />
|
||||
),
|
||||
},
|
||||
actionsColumn,
|
||||
];
|
||||
|
||||
|
||||
@@ -46,13 +46,19 @@ export function useScansFilters(): UseScansFiltersReturn {
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const setTab = (tab: string) =>
|
||||
updateParams({
|
||||
const setTab = (tab: string) => {
|
||||
const isScheduledTab = tab === SCAN_JOBS_TAB.SCHEDULED;
|
||||
const updates: Record<string, string | null> = {
|
||||
tab,
|
||||
sort: null,
|
||||
"filter[state]": null,
|
||||
"filter[state__in]": null,
|
||||
});
|
||||
};
|
||||
|
||||
if (isScheduledTab) updates["filter[trigger]"] = null;
|
||||
|
||||
updateParams(updates);
|
||||
};
|
||||
|
||||
const setScheduleType = (value: string) =>
|
||||
updateParams({ "filter[trigger]": value });
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import MainLayout from "./main-layout";
|
||||
|
||||
vi.mock("@/hooks/use-sidebar", () => ({
|
||||
useSidebar: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-store", () => ({
|
||||
useStore: () => ({
|
||||
getOpenState: () => true,
|
||||
settings: { disabled: false },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../sidebar/sidebar", () => ({
|
||||
Sidebar: () => <aside data-testid="sidebar" />,
|
||||
}));
|
||||
|
||||
describe("MainLayout", () => {
|
||||
it("renders subdued background glows for side-nav contrast", () => {
|
||||
render(
|
||||
<MainLayout>
|
||||
<div>Page content</div>
|
||||
</MainLayout>,
|
||||
);
|
||||
|
||||
const topGlow =
|
||||
screen.getByTestId("sidebar").previousElementSibling
|
||||
?.previousElementSibling;
|
||||
const bottomGlow = screen.getByTestId("sidebar").previousElementSibling;
|
||||
|
||||
expect(topGlow).toHaveClass("h-[120%]", "w-[160%]", "opacity-[7%]");
|
||||
expect(bottomGlow).toHaveClass("h-[50%]", "w-[50%]", "opacity-[7%]");
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ export default function MainLayout({
|
||||
<div className="relative flex h-dvh items-center justify-center overflow-hidden">
|
||||
{/* Top-left gradient halo */}
|
||||
<div
|
||||
className="pointer-events-none fixed top-0 left-0 z-0 size-[600px] opacity-20 blur-3xl"
|
||||
className="pointer-events-none fixed top-0 left-0 z-0 h-[120%] w-[160%] opacity-[7%] blur-3xl"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #31E59F 0%, #60E0EC 100%)",
|
||||
transform: "translate(-50%, -50%)",
|
||||
@@ -26,7 +26,7 @@ export default function MainLayout({
|
||||
|
||||
{/* Bottom-right gradient halo */}
|
||||
<div
|
||||
className="pointer-events-none fixed right-0 bottom-0 z-0 size-[600px] opacity-20 blur-3xl"
|
||||
className="pointer-events-none fixed right-0 bottom-0 z-0 h-[50%] w-[50%] opacity-[7%] blur-3xl"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #31E59F 0%, #60E0EC 100%)",
|
||||
transform: "translate(50%, 50%)",
|
||||
|
||||
@@ -4,6 +4,8 @@ import { IntegrationType } from "../types/integrations";
|
||||
export const DOCS_URLS = {
|
||||
FINDINGS_ANALYSIS:
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app#step-8:-analyze-the-findings",
|
||||
FINDINGS_INGESTION:
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings",
|
||||
AWS_ORGANIZATIONS:
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations",
|
||||
ALERTS: "https://docs.prowler.com/user-guide/tutorials/prowler-app-alerts",
|
||||
|
||||
@@ -171,9 +171,13 @@ export class ScansPage extends BasePage {
|
||||
// 1. The provider exists in the table (by account ID/UID)
|
||||
// 2. The scan name field contains "scheduled scan"
|
||||
|
||||
// Scheduled scans live in the Scheduled tab; /scans defaults to Completed,
|
||||
// which would show an empty-state card (no table) in a fresh environment.
|
||||
await this.gotoTab("scheduled");
|
||||
// Adding a provider first creates a scheduled-trigger scan in the
|
||||
// AVAILABLE state (the immediate first run), which the tabbed view lists
|
||||
// under "In Progress" — not "Scheduled" (that tab only shows state=scheduled,
|
||||
// a later run created asynchronously). /scans defaults to Completed, which
|
||||
// would show an empty-state card (no table) in a fresh environment, so
|
||||
// navigate to the active tab where this row actually appears.
|
||||
await this.gotoTab("active");
|
||||
await this.verifyPageLoaded();
|
||||
|
||||
// Find a row that contains the account ID (provider UID in Provider column)
|
||||
|
||||
Reference in New Issue
Block a user