feat(jira): merge master

This commit is contained in:
pedrooot
2025-09-04 09:11:06 +02:00
82 changed files with 4362 additions and 951 deletions
@@ -157,6 +157,22 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
# - name: Push README to Docker Hub (toniblyx)
# uses: peter-evans/dockerhub-description@432a30c9e07499fd01da9f8a49f0faf9e0ca5b77 # v4.0.2
# with:
# username: ${{ secrets.DOCKERHUB_USERNAME }}
# password: ${{ secrets.DOCKERHUB_TOKEN }}
# repository: ${{ env.DOCKER_HUB_REPOSITORY }}/${{ env.IMAGE_NAME }}
# readme-filepath: ./README.md
#
# - name: Push README to Docker Hub (prowlercloud)
# uses: peter-evans/dockerhub-description@432a30c9e07499fd01da9f8a49f0faf9e0ca5b77 # v4.0.2
# with:
# username: ${{ secrets.DOCKERHUB_USERNAME }}
# password: ${{ secrets.DOCKERHUB_TOKEN }}
# repository: ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}
# readme-filepath: ./README.md
dispatch-action:
needs: container-build-push
runs-on: ubuntu-latest
+17 -18
View File
@@ -19,19 +19,16 @@
<a href="https://goto.prowler.com/slack"><img alt="Slack Shield" src="https://img.shields.io/badge/slack-prowler-brightgreen.svg?logo=slack"></a>
<a href="https://pypi.org/project/prowler/"><img alt="Python Version" src="https://img.shields.io/pypi/v/prowler.svg"></a>
<a href="https://pypi.python.org/pypi/prowler/"><img alt="Python Version" src="https://img.shields.io/pypi/pyversions/prowler.svg"></a>
<a href="https://pypistats.org/packages/prowler"><img alt="PyPI Prowler Downloads" src="https://img.shields.io/pypi/dw/prowler.svg?label=prowler%20downloads"></a>
<a href="https://pypistats.org/packages/prowler"><img alt="PyPI Downloads" src="https://img.shields.io/pypi/dw/prowler.svg?label=downloads"></a>
<a href="https://hub.docker.com/r/toniblyx/prowler"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/toniblyx/prowler"></a>
<a href="https://hub.docker.com/r/toniblyx/prowler"><img alt="Docker" src="https://img.shields.io/docker/cloud/build/toniblyx/prowler"></a>
<a href="https://hub.docker.com/r/toniblyx/prowler"><img alt="Docker" src="https://img.shields.io/docker/image-size/toniblyx/prowler"></a>
<a href="https://gallery.ecr.aws/prowler-cloud/prowler"><img width="120" height=19" alt="AWS ECR Gallery" src="https://user-images.githubusercontent.com/3985464/151531396-b6535a68-c907-44eb-95a1-a09508178616.png"></a>
<a href="https://codecov.io/gh/prowler-cloud/prowler"><img src="https://codecov.io/gh/prowler-cloud/prowler/graph/badge.svg?token=OflBGsdpDl"/></a>
</p>
<p align="center">
<a href="https://github.com/prowler-cloud/prowler"><img alt="Repo size" src="https://img.shields.io/github/repo-size/prowler-cloud/prowler"></a>
<a href="https://github.com/prowler-cloud/prowler/issues"><img alt="Issues" src="https://img.shields.io/github/issues/prowler-cloud/prowler"></a>
<a href="https://github.com/prowler-cloud/prowler/releases"><img alt="Version" src="https://img.shields.io/github/v/release/prowler-cloud/prowler"></a>
<a href="https://github.com/prowler-cloud/prowler/releases"><img alt="Version" src="https://img.shields.io/github/v/release/prowler-cloud/prowler"></a>
<a href="https://github.com/prowler-cloud/prowler/releases"><img alt="Version" src="https://img.shields.io/github/release-date/prowler-cloud/prowler"></a>
<a href="https://github.com/prowler-cloud/prowler"><img alt="Contributors" src="https://img.shields.io/github/contributors-anon/prowler-cloud/prowler"></a>
<a href="https://github.com/prowler-cloud/prowler/issues"><img alt="Issues" src="https://img.shields.io/github/issues/prowler-cloud/prowler"></a>
<a href="https://github.com/prowler-cloud/prowler"><img alt="License" src="https://img.shields.io/github/license/prowler-cloud/prowler"></a>
<a href="https://twitter.com/ToniBlyx"><img alt="Twitter" src="https://img.shields.io/twitter/follow/toniblyx?style=social"></a>
<a href="https://twitter.com/prowlercloud"><img alt="Twitter" src="https://img.shields.io/twitter/follow/prowlercloud?style=social"></a>
@@ -55,15 +52,11 @@ Prowler includes hundreds of built-in controls to ensure compliance with standar
- **National Security Standards:** ENS (Spanish National Security Scheme)
- **Custom Security Frameworks:** Tailored to your needs
## Prowler CLI and Prowler Cloud
Prowler offers a Command Line Interface (CLI), known as Prowler Open Source, and an additional service built on top of it, called <a href="https://prowler.com">Prowler Cloud</a>.
## Prowler App
Prowler App is a web-based application that simplifies running Prowler across your cloud provider accounts. It provides a user-friendly interface to visualize the results and streamline your security assessments.
![Prowler App](docs/img/overview.png)
![Prowler App](docs/products/img/overview.png)
>For more details, refer to the [Prowler App Documentation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#prowler-app-installation)
@@ -80,13 +73,16 @@ prowler <provider>
```console
prowler dashboard
```
![Prowler Dashboard](docs/img/dashboard.png)
![Prowler Dashboard](docs/products/img/dashboard.png)
# Prowler at a Glance
> [!Tip]
> For the most accurate and up-to-date information about checks, services, frameworks, and categories, visit [**Prowler Hub**](https://hub.prowler.com).
| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/compliance/) | [Categories](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/misc/#categories) |
|---|---|---|---|---|
| AWS | 571 | 82 | 36 | 10 |
| AWS | 576 | 82 | 36 | 10 |
| GCP | 79 | 13 | 10 | 3 |
| Azure | 162 | 19 | 11 | 4 |
| Kubernetes | 83 | 7 | 5 | 7 |
@@ -97,11 +93,14 @@ prowler dashboard
> [!Note]
> The numbers in the table are updated periodically.
> [!Tip]
> For the most accurate and up-to-date information about checks, services, frameworks, and categories, visit [**Prowler Hub**](https://hub.prowler.com).
> [!Note]
> Use the following commands to list Prowler's available checks, services, compliance frameworks, and categories: `prowler <provider> --list-checks`, `prowler <provider> --list-services`, `prowler <provider> --list-compliance` and `prowler <provider> --list-categories`.
> Use the following commands to list Prowler's available checks, services, compliance frameworks, and categories:
> - `prowler <provider> --list-checks`
> - `prowler <provider> --list-services`
> - `prowler <provider> --list-compliance`
> - `prowler <provider> --list-categories`
# 💻 Installation
@@ -239,7 +238,7 @@ The following versions of Prowler CLI are available, depending on your requireme
The container images are available here:
- Prowler CLI:
- [DockerHub](https://hub.docker.com/r/toniblyx/prowler/tags)
- [DockerHub](https://hub.docker.com/r/prowlercloud/prowler/tags)
- [AWS Public ECR](https://gallery.ecr.aws/prowler-cloud/prowler)
- Prowler App:
- [DockerHub - Prowler UI](https://hub.docker.com/r/prowlercloud/prowler-ui/tags)
@@ -274,7 +273,7 @@ python prowler-cli.py -v
- **Prowler API**: A backend service, developed with Django REST Framework, responsible for running Prowler scans and storing the generated results.
- **Prowler SDK**: A Python SDK designed to extend the functionality of the Prowler CLI for advanced capabilities.
![Prowler App Architecture](docs/img/prowler-app-architecture.png)
![Prowler App Architecture](docs/products/img/prowler-app-architecture.png)
## Prowler CLI
+7
View File
@@ -2,6 +2,13 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.13.0] (Prowler UNRELEASED)
### Added
- Integration with JIRA, enabling sending findings to a JIRA project [(#8622)](https://github.com/prowler-cloud/prowler/pull/8622), [(#8637)](https://github.com/prowler-cloud/prowler/pull/8637)
---
## [1.12.0] (Prowler 5.11.0)
### Added
+1 -1
View File
@@ -39,7 +39,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.12.0"
version = "1.13.0"
[project.scripts]
celery = "src.backend.config.settings.celery"
+95
View File
@@ -0,0 +1,95 @@
def _pick_task_response_component(components):
schemas = components.get("schemas", {}) or {}
for candidate in ("TaskResponse",):
if candidate in schemas:
return candidate
return None
def _extract_task_example_from_components(components):
schemas = components.get("schemas", {}) or {}
candidate = "TaskResponse"
doc = schemas.get(candidate)
if isinstance(doc, dict) and "example" in doc:
return doc["example"]
res = schemas.get(candidate)
if isinstance(res, dict) and "example" in res:
example = res["example"]
return example if "data" in example else {"data": example}
# Fallback
return {
"data": {
"type": "tasks",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"attributes": {
"inserted_at": "2019-08-24T14:15:22Z",
"completed_at": "2019-08-24T14:15:22Z",
"name": "string",
"state": "available",
"result": None,
"task_args": None,
"metadata": None,
},
}
}
def attach_task_202_examples(result, generator, request, public): # noqa: F841
if not isinstance(result, dict):
return result
components = result.get("components", {}) or {}
task_resp_component = _pick_task_response_component(components)
task_example = _extract_task_example_from_components(components)
paths = result.get("paths", {}) or {}
for path_item in paths.values():
if not isinstance(path_item, dict):
continue
for method_obj in path_item.values():
if not isinstance(method_obj, dict):
continue
responses = method_obj.get("responses", {}) or {}
resp_202 = responses.get("202")
if not isinstance(resp_202, dict):
continue
content = resp_202.get("content", {}) or {}
jsonapi = content.get("application/vnd.api+json")
if not isinstance(jsonapi, dict):
continue
# Inject example if missing
if "examples" not in jsonapi and "example" not in jsonapi:
jsonapi["examples"] = {
"Task queued": {
"summary": "Task queued",
"value": task_example,
}
}
# Rewrite schema $ref if needed
if task_resp_component:
schema = jsonapi.get("schema")
must_replace = False
if not isinstance(schema, dict):
must_replace = True
else:
ref = schema.get("$ref")
if not ref:
must_replace = True
else:
current = ref.split("/")[-1]
if current != task_resp_component:
must_replace = True
if must_replace:
jsonapi["schema"] = {
"$ref": f"#/components/schemas/{task_resp_component}"
}
return result
File diff suppressed because it is too large Load Diff
+149
View File
@@ -502,3 +502,152 @@ class TestProwlerIntegrationConnectionTest:
assert integration.configuration["regions"]["eu-west-1"] is True
assert integration.configuration["regions"]["ap-south-1"] is False
integration.save.assert_called_once()
@patch("api.utils.rls_transaction")
@patch("api.utils.Jira")
def test_jira_connection_success_basic_auth(
self, mock_jira_class, mock_rls_transaction
):
integration = MagicMock()
integration.integration_type = Integration.IntegrationChoices.JIRA
integration.tenant_id = "test-tenant-id"
integration.credentials = {
"user_mail": "test@example.com",
"api_token": "test_api_token",
"domain": "example.atlassian.net",
}
integration.configuration = {}
# Mock successful JIRA connection with projects
mock_connection = MagicMock()
mock_connection.is_connected = True
mock_connection.error = None
mock_connection.projects = {"PROJ1": "Project 1", "PROJ2": "Project 2"}
mock_jira_class.test_connection.return_value = mock_connection
# Mock rls_transaction context manager
mock_rls_transaction.return_value.__enter__ = MagicMock()
mock_rls_transaction.return_value.__exit__ = MagicMock()
result = prowler_integration_connection_test(integration)
assert result.is_connected is True
assert result.error is None
# Verify JIRA connection was called with correct parameters including domain from credentials
mock_jira_class.test_connection.assert_called_once_with(
user_mail="test@example.com",
api_token="test_api_token",
domain="example.atlassian.net",
raise_on_exception=False,
)
# Verify rls_transaction was called with correct tenant_id
mock_rls_transaction.assert_called_once_with("test-tenant-id")
# Verify projects were saved to integration configuration
assert integration.configuration["projects"] == {
"PROJ1": "Project 1",
"PROJ2": "Project 2",
}
# Verify integration.save() was called
integration.save.assert_called_once()
@patch("api.utils.rls_transaction")
@patch("api.utils.Jira")
def test_jira_connection_failure_invalid_credentials(
self, mock_jira_class, mock_rls_transaction
):
integration = MagicMock()
integration.integration_type = Integration.IntegrationChoices.JIRA
integration.tenant_id = "test-tenant-id"
integration.credentials = {
"user_mail": "invalid@example.com",
"api_token": "invalid_token",
"domain": "invalid.atlassian.net",
}
integration.configuration = {}
# Mock failed JIRA connection
mock_connection = MagicMock()
mock_connection.is_connected = False
mock_connection.error = Exception("Authentication failed: Invalid credentials")
mock_connection.projects = {} # Empty projects when connection fails
mock_jira_class.test_connection.return_value = mock_connection
# Mock rls_transaction context manager
mock_rls_transaction.return_value.__enter__ = MagicMock()
mock_rls_transaction.return_value.__exit__ = MagicMock()
result = prowler_integration_connection_test(integration)
assert result.is_connected is False
assert "Authentication failed: Invalid credentials" in str(result.error)
# Verify JIRA connection was called with correct parameters
mock_jira_class.test_connection.assert_called_once_with(
user_mail="invalid@example.com",
api_token="invalid_token",
domain="invalid.atlassian.net",
raise_on_exception=False,
)
# Verify rls_transaction was called even on failure
mock_rls_transaction.assert_called_once_with("test-tenant-id")
# Verify empty projects dict was saved to integration configuration
assert integration.configuration["projects"] == {}
# Verify integration.save() was called even on connection failure
integration.save.assert_called_once()
@patch("api.utils.rls_transaction")
@patch("api.utils.Jira")
def test_jira_connection_projects_update_with_existing_configuration(
self, mock_jira_class, mock_rls_transaction
):
"""Test that projects are properly updated when integration already has configuration data"""
integration = MagicMock()
integration.integration_type = Integration.IntegrationChoices.JIRA
integration.tenant_id = "test-tenant-id"
integration.credentials = {
"user_mail": "test@example.com",
"api_token": "test_api_token",
"domain": "example.atlassian.net",
}
integration.configuration = {
"issue_types": ["Bug", "Task"], # Existing configuration
"projects": {"OLD_PROJ": "Old Project"}, # Will be overwritten
}
# Mock successful JIRA connection with new projects
mock_connection = MagicMock()
mock_connection.is_connected = True
mock_connection.error = None
mock_connection.projects = {
"NEW_PROJ1": "New Project 1",
"NEW_PROJ2": "New Project 2",
}
mock_jira_class.test_connection.return_value = mock_connection
# Mock rls_transaction context manager
mock_rls_transaction.return_value.__enter__ = MagicMock()
mock_rls_transaction.return_value.__exit__ = MagicMock()
result = prowler_integration_connection_test(integration)
assert result.is_connected is True
assert result.error is None
# Verify projects were updated (old projects replaced with new ones)
assert integration.configuration["projects"] == {
"NEW_PROJ1": "New Project 1",
"NEW_PROJ2": "New Project 2",
}
# Verify other configuration fields were preserved
assert integration.configuration["issue_types"] == ["Bug", "Task"]
# Verify integration.save() was called
integration.save.assert_called_once()
+292
View File
@@ -5708,6 +5708,47 @@ class TestIntegrationViewSet:
== data["data"]["relationships"]["providers"]["data"][0]["id"]
)
def test_integrations_create_valid_jira(
self,
authenticated_client,
):
"""Jira integrations are special"""
data = {
"data": {
"type": "integrations",
"attributes": {
"integration_type": Integration.IntegrationChoices.JIRA,
"configuration": {},
"credentials": {
"domain": "prowlerdomain",
"api_token": "this-is-an-api-token-for-jira-that-works-for-sure",
"user_mail": "testing@prowler.com",
},
"enabled": True,
},
}
}
response = authenticated_client.post(
reverse("integration-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
assert Integration.objects.count() == 1
integration = Integration.objects.first()
integration_configuration = response.json()["data"]["attributes"][
"configuration"
]
assert "projects" in integration_configuration
assert "issue_types" in integration_configuration
assert "domain" in integration_configuration
assert integration.enabled == data["data"]["attributes"]["enabled"]
assert (
integration.integration_type
== data["data"]["attributes"]["integration_type"]
)
assert "credentials" not in response.json()["data"]["attributes"]
def test_integrations_create_valid_relationships(
self,
authenticated_client,
@@ -5806,6 +5847,46 @@ class TestIntegrationViewSet:
"invalid",
None,
),
(
{
"integration_type": "jira",
"configuration": {
"projects": ["JIRA"],
},
"credentials": {"domain": "prowlerdomain"},
},
"invalid",
"configuration",
),
(
{
"integration_type": "jira",
"credentialss": {
"domain": "prowlerdomain",
"api_token": "api-token",
"user_mail": "test@prowler.com",
},
},
"required",
"configuration",
),
(
{
"integration_type": "jira",
"configuration": {},
},
"required",
"credentials",
),
(
{
"integration_type": "jira",
"configuration": {},
"credentials": {"api_token": "api-token"},
},
"invalid",
"credentials",
),
]
),
)
@@ -5995,6 +6076,217 @@ class TestIntegrationViewSet:
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_integrations_create_duplicate_amazon_s3(
self, authenticated_client, providers_fixture
):
provider = providers_fixture[0]
# Create first S3 integration
data = {
"data": {
"type": "integrations",
"attributes": {
"integration_type": Integration.IntegrationChoices.AMAZON_S3,
"configuration": {
"bucket_name": "test-bucket",
"output_directory": "test-output",
},
"credentials": {
"role_arn": "arn:aws:iam::123456789012:role/test-role",
"external_id": "test-external-id",
},
"enabled": True,
},
"relationships": {
"providers": {
"data": [{"type": "providers", "id": str(provider.id)}]
}
},
}
}
# First creation should succeed
response = authenticated_client.post(
reverse("integration-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
# Attempt to create duplicate should return 409
response = authenticated_client.post(
reverse("integration-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_409_CONFLICT
assert (
"This integration already exists" in response.json()["errors"][0]["detail"]
)
assert (
response.json()["errors"][0]["source"]["pointer"]
== "/data/attributes/configuration"
)
def test_integrations_create_duplicate_jira(self, authenticated_client):
# Create first JIRA integration
data = {
"data": {
"type": "integrations",
"attributes": {
"integration_type": Integration.IntegrationChoices.JIRA,
"configuration": {},
"credentials": {
"user_mail": "test@example.com",
"api_token": "test-api-token",
"domain": "prowlerdomain",
},
"enabled": True,
},
}
}
# First creation should succeed
response = authenticated_client.post(
reverse("integration-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
# Attempt to create duplicate should return 409
response = authenticated_client.post(
reverse("integration-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_409_CONFLICT
assert (
"This integration already exists" in response.json()["errors"][0]["detail"]
)
assert (
response.json()["errors"][0]["source"]["pointer"]
== "/data/attributes/configuration"
)
def test_integrations_update_jira_configuration_readonly(
self, authenticated_client
):
# Create JIRA integration first
create_data = {
"data": {
"type": "integrations",
"attributes": {
"integration_type": Integration.IntegrationChoices.JIRA,
"configuration": {},
"credentials": {
"user_mail": "test@example.com",
"api_token": "test-api-token",
"domain": "initial-domain",
},
"enabled": True,
},
}
}
# Create the integration
response = authenticated_client.post(
reverse("integration-list"),
data=json.dumps(create_data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
integration_id = response.json()["data"]["id"]
# Attempt to update configuration - should be ignored/not allowed
update_data = {
"data": {
"type": "integrations",
"id": integration_id,
"attributes": {
"configuration": {
"projects": {"NEW_PROJECT": "New Project"},
"issue_types": ["Epic", "Story"],
"domain": "malicious-domain",
}
},
}
}
response = authenticated_client.patch(
reverse("integration-detail", kwargs={"pk": integration_id}),
data=json.dumps(update_data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_integrations_update_jira_credentials_domain_reflects_in_configuration(
self, authenticated_client
):
# Create JIRA integration first
create_data = {
"data": {
"type": "integrations",
"attributes": {
"integration_type": Integration.IntegrationChoices.JIRA,
"configuration": {},
"credentials": {
"user_mail": "test@example.com",
"api_token": "test-api-token",
"domain": "original-domain",
},
"enabled": True,
},
}
}
# Create the integration
response = authenticated_client.post(
reverse("integration-list"),
data=json.dumps(create_data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
integration_id = response.json()["data"]["id"]
# Verify initial domain in configuration
initial_integration = response.json()["data"]
assert (
initial_integration["attributes"]["configuration"]["domain"]
== "original-domain"
)
# Update credentials with new domain
update_data = {
"data": {
"type": "integrations",
"id": integration_id,
"attributes": {
"credentials": {
"user_mail": "updated@example.com",
"api_token": "updated-api-token",
"domain": "updated-domain",
}
},
}
}
response = authenticated_client.patch(
reverse("integration-detail", kwargs={"pk": integration_id}),
data=json.dumps(update_data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
# Verify the new domain is reflected in configuration
updated_integration = response.json()["data"]
configuration = updated_integration["attributes"]["configuration"]
assert configuration["domain"] == "updated-domain"
# Verify other configuration fields are preserved
assert "projects" in configuration
assert "issue_types" in configuration
@pytest.mark.django_db
class TestSAMLTokenValidation:
+36 -2
View File
@@ -6,9 +6,11 @@ from django.db.models import Subquery
from rest_framework.exceptions import NotFound, ValidationError
from api.db_router import MainRouter
from api.db_utils import rls_transaction
from api.exceptions import InvitationTokenExpiredException
from api.models import Integration, Invitation, Processor, Provider, Resource
from api.v1.serializers import FindingMetadataSerializer
from prowler.lib.outputs.jira.jira import Jira, JiraBasicAuthError, JiraNoProjectsError
from prowler.providers.aws.aws_provider import AwsProvider
from prowler.providers.aws.lib.s3.s3 import S3
from prowler.providers.aws.lib.security_hub.security_hub import SecurityHub
@@ -199,7 +201,8 @@ def prowler_integration_connection_test(integration: Integration) -> Connection:
raise_on_exception=False,
)
# TODO: It is possible that we can unify the connection test for all integrations, but need refactoring
# to avoid code duplication. Actually the AWS integrations are similar, so SecurityHub and S3 can be unified making some changes in the SDK.
# to avoid code duplication. Actually the AWS integrations are similar, so SecurityHub and S3 can be unified
# making some changes in the SDK.
elif (
integration.integration_type == Integration.IntegrationChoices.AWS_SECURITY_HUB
):
@@ -236,7 +239,15 @@ def prowler_integration_connection_test(integration: Integration) -> Connection:
return connection
elif integration.integration_type == Integration.IntegrationChoices.JIRA:
pass
jira_connection = Jira.test_connection(
**integration.credentials,
raise_on_exception=False,
)
project_keys = jira_connection.projects if jira_connection.is_connected else {}
with rls_transaction(str(integration.tenant_id)):
integration.configuration["projects"] = project_keys
integration.save()
return jira_connection
elif integration.integration_type == Integration.IntegrationChoices.SLACK:
pass
else:
@@ -336,3 +347,26 @@ def get_findings_metadata_no_aggregations(tenant_id: str, filtered_queryset):
serializer.is_valid(raise_exception=True)
return serializer.data
def initialize_prowler_integration(integration: Integration) -> Jira:
# TODO Refactor other integrations to use this function
if integration.integration_type == Integration.IntegrationChoices.JIRA:
try:
return Jira(
**integration.credentials, domain=integration.configuration["domain"]
)
except JiraBasicAuthError as jira_auth_error:
with rls_transaction(str(integration.tenant_id)):
integration.connected = False
integration.connection_last_checked_at = datetime.now(tz=timezone.utc)
integration.save()
raise jira_auth_error
def get_jira_integration_metadata(jira_integration: Integration) -> dict:
prowler_jira = initialize_prowler_integration(jira_integration)
try:
return prowler_jira.get_jira_metadata()
except JiraNoProjectsError:
return {}
@@ -67,6 +67,17 @@ class SecurityHubConfigSerializer(BaseValidateSerializer):
resource_name = "integrations"
class JiraConfigSerializer(BaseValidateSerializer):
domain = serializers.CharField(read_only=True)
issue_types = serializers.ListField(
read_only=True, child=serializers.CharField(), default=["Task", "Bug"]
)
projects = serializers.DictField(read_only=True)
class Meta:
resource_name = "integrations"
class AWSCredentialSerializer(BaseValidateSerializer):
role_arn = serializers.CharField(required=False)
external_id = serializers.CharField(required=False)
@@ -82,6 +93,15 @@ class AWSCredentialSerializer(BaseValidateSerializer):
resource_name = "integrations"
class JiraCredentialSerializer(BaseValidateSerializer):
user_mail = serializers.EmailField(required=True)
api_token = serializers.CharField(required=True)
domain = serializers.CharField(required=True)
class Meta:
resource_name = "integrations"
@extend_schema_field(
{
"oneOf": [
@@ -133,6 +153,27 @@ class AWSCredentialSerializer(BaseValidateSerializer):
},
},
},
{
"type": "object",
"title": "JIRA Credentials",
"properties": {
"user_mail": {
"type": "string",
"format": "email",
"description": "The email address of the JIRA user account.",
},
"api_token": {
"type": "string",
"description": "The API token for authentication with JIRA. This can be generated from your "
"Atlassian account settings.",
},
"domain": {
"type": "string",
"description": "The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').",
},
},
"required": ["user_mail", "api_token", "domain"],
},
]
}
)
@@ -153,7 +194,10 @@ class IntegrationCredentialField(serializers.JSONField):
},
"output_directory": {
"type": "string",
"description": 'The directory path within the bucket where files will be saved. Optional - defaults to "output" if not provided. Path will be normalized to remove excessive slashes and invalid characters are not allowed (< > : " | ? *). Maximum length is 900 characters.',
"description": "The directory path within the bucket where files will be saved. Optional - "
'defaults to "output" if not provided. Path will be normalized to remove '
'excessive slashes and invalid characters are not allowed (< > : " | ? *). '
"Maximum length is 900 characters.",
"maxLength": 900,
"pattern": '^[^<>:"|?*]+$',
"default": "output",
@@ -177,6 +221,14 @@ class IntegrationCredentialField(serializers.JSONField):
},
},
},
{
"type": "object",
"title": "JIRA",
"description": "JIRA integration does not accept any configuration in the payload. Leave it as an "
"empty JSON object (`{}`).",
"properties": {},
"additionalProperties": False,
},
]
}
)
+68 -12
View File
@@ -15,6 +15,7 @@ from rest_framework_simplejwt.exceptions import TokenError
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework_simplejwt.tokens import RefreshToken
from api.exceptions import ConflictException
from api.models import (
Finding,
Integration,
@@ -45,6 +46,8 @@ from api.v1.serializer_utils.integrations import (
AWSCredentialSerializer,
IntegrationConfigField,
IntegrationCredentialField,
JiraConfigSerializer,
JiraCredentialSerializer,
S3ConfigSerializer,
SecurityHubConfigSerializer,
)
@@ -1952,18 +1955,33 @@ class ScheduleDailyCreateSerializer(serializers.Serializer):
class BaseWriteIntegrationSerializer(BaseWriteSerializer):
def validate(self, attrs):
integration_type = attrs.get("integration_type")
if (
attrs.get("integration_type") == Integration.IntegrationChoices.AMAZON_S3
integration_type == Integration.IntegrationChoices.AMAZON_S3
and Integration.objects.filter(
configuration=attrs.get("configuration")
).exists()
):
raise serializers.ValidationError(
{"configuration": "This integration already exists."}
raise ConflictException(
detail="This integration already exists.",
pointer="/data/attributes/configuration",
)
if (
integration_type == Integration.IntegrationChoices.JIRA
and Integration.objects.filter(
configuration__contains={
"domain": attrs.get("configuration").get("domain")
}
).exists()
):
raise ConflictException(
detail="This integration already exists.",
pointer="/data/attributes/configuration",
)
# Check if any provider already has a SecurityHub integration
integration_type = attrs.get("integration_type")
if hasattr(self, "instance") and self.instance and not integration_type:
integration_type = self.instance.integration_type
@@ -1984,10 +2002,10 @@ class BaseWriteIntegrationSerializer(BaseWriteSerializer):
query = query.exclude(integration=self.instance)
if query.exists():
raise serializers.ValidationError(
{
"providers": f"Provider {provider.id} already has a Security Hub integration. Only one Security Hub integration is allowed per provider."
}
raise ConflictException(
detail=f"Provider {provider.id} already has a Security Hub integration. Only one "
"Security Hub integration is allowed per provider.",
pointer="/data/relationships/providers",
)
return super().validate(attrs)
@@ -2018,6 +2036,30 @@ class BaseWriteIntegrationSerializer(BaseWriteSerializer):
)
config_serializer = SecurityHubConfigSerializer
credentials_serializers = [AWSCredentialSerializer]
elif integration_type == Integration.IntegrationChoices.JIRA:
if providers:
raise serializers.ValidationError(
{
"providers": "Relationship field is not accepted. This integration applies to all providers."
}
)
if configuration:
raise serializers.ValidationError(
{
"configuration": "This integration does not support custom configuration."
}
)
config_serializer = JiraConfigSerializer
# Create non-editable configuration for JIRA integration
default_jira_issue_types = ["Task", "Bug"]
configuration.update(
{
"projects": {},
"issue_types": default_jira_issue_types,
"domain": credentials.get("domain"),
}
)
credentials_serializers = [JiraCredentialSerializer]
else:
raise serializers.ValidationError(
{
@@ -2081,6 +2123,10 @@ class IntegrationSerializer(RLSSerializer):
for provider in representation["providers"]
if provider["id"] in allowed_provider_ids
]
if instance.integration_type == Integration.IntegrationChoices.JIRA:
representation["configuration"].update(
{"domain": instance.credentials.get("domain")}
)
return representation
@@ -2122,9 +2168,7 @@ class IntegrationCreateSerializer(BaseWriteIntegrationSerializer):
and integration_type == Integration.IntegrationChoices.AWS_SECURITY_HUB
):
raise serializers.ValidationError(
{
"providers": "At least one provider is required for the Security Hub integration."
}
{"providers": "At least one provider is required for this integration."}
)
self.validate_integration_data(
@@ -2183,7 +2227,10 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
def validate(self, attrs):
integration_type = self.instance.integration_type
providers = attrs.get("providers")
configuration = attrs.get("configuration") or self.instance.configuration
if integration_type != Integration.IntegrationChoices.JIRA:
configuration = attrs.get("configuration") or self.instance.configuration
else:
configuration = attrs.get("configuration", {})
credentials = attrs.get("credentials") or self.instance.credentials
self.validate_integration_data(
@@ -2213,6 +2260,15 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
return super().update(instance, validated_data)
def to_representation(self, instance):
representation = super().to_representation(instance)
# Ensure JIRA integrations show updated domain in configuration from credentials
if instance.integration_type == Integration.IntegrationChoices.JIRA:
representation["configuration"].update(
{"domain": instance.credentials.get("domain")}
)
return representation
# Processors
+32 -12
View File
@@ -293,7 +293,7 @@ class SchemaView(SpectacularAPIView):
def get(self, request, *args, **kwargs):
spectacular_settings.TITLE = "Prowler API"
spectacular_settings.VERSION = "1.12.0"
spectacular_settings.VERSION = "1.13.0"
spectacular_settings.DESCRIPTION = (
"Prowler API specification.\n\nThis file is auto-generated."
)
@@ -313,6 +313,11 @@ class SchemaView(SpectacularAPIView):
"description": "Endpoints for tenant invitations management, allowing retrieval and filtering of "
"invitations, creating new invitations, accepting and revoking them.",
},
{
"name": "Role",
"description": "Endpoints for managing RBAC roles within tenants, allowing creation, retrieval, "
"updating, and deletion of role configurations and permissions.",
},
{
"name": "Provider",
"description": "Endpoints for managing providers (AWS, GCP, Azure, etc...).",
@@ -321,10 +326,20 @@ class SchemaView(SpectacularAPIView):
"name": "Provider Group",
"description": "Endpoints for managing provider groups.",
},
{
"name": "Task",
"description": "Endpoints for task management, allowing retrieval of task status and "
"revoking tasks that have not started.",
},
{
"name": "Scan",
"description": "Endpoints for triggering manual scans and viewing scan results.",
},
{
"name": "Schedule",
"description": "Endpoints for managing scan schedules, allowing configuration of automated "
"scans with different scheduling options.",
},
{
"name": "Resource",
"description": "Endpoints for managing resources discovered by scans, allowing "
@@ -336,8 +351,9 @@ class SchemaView(SpectacularAPIView):
"findings that result from scans.",
},
{
"name": "Overview",
"description": "Endpoints for retrieving aggregated summaries of resources from the system.",
"name": "Processor",
"description": "Endpoints for managing post-processors used to process Prowler findings, including "
"registration, configuration, and deletion of post-processing actions.",
},
{
"name": "Compliance Overview",
@@ -345,9 +361,8 @@ class SchemaView(SpectacularAPIView):
" compliance framework ID.",
},
{
"name": "Task",
"description": "Endpoints for task management, allowing retrieval of task status and "
"revoking tasks that have not started.",
"name": "Overview",
"description": "Endpoints for retrieving aggregated summaries of resources from the system.",
},
{
"name": "Integration",
@@ -357,12 +372,13 @@ class SchemaView(SpectacularAPIView):
{
"name": "Lighthouse",
"description": "Endpoints for managing Lighthouse configurations, including creation, retrieval, "
"updating, and deletion of configurations such as OpenAI keys, models, and business context.",
"updating, and deletion of configurations such as OpenAI keys, models, and business "
"context.",
},
{
"name": "Processor",
"description": "Endpoints for managing post-processors used to process Prowler findings, including "
"registration, configuration, and deletion of post-processing actions.",
"name": "SAML",
"description": "Endpoints for Single Sign-On authentication management via SAML for seamless user "
"authentication.",
},
]
return super().get(request, *args, **kwargs)
@@ -3033,7 +3049,9 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet):
description="Compliance overviews metadata obtained successfully",
response=ComplianceOverviewMetadataSerializer,
),
202: OpenApiResponse(description="The task is in progress"),
202: OpenApiResponse(
description="The task is in progress", response=TaskSerializer
),
500: OpenApiResponse(
description="Compliance overviews generation task failed"
),
@@ -3065,7 +3083,9 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet):
description="Compliance requirement details obtained successfully",
response=ComplianceOverviewDetailSerializer(many=True),
),
202: OpenApiResponse(description="The task is in progress"),
202: OpenApiResponse(
description="The task is in progress", response=TaskSerializer
),
500: OpenApiResponse(
description="Compliance overviews generation task failed"
),
+3
View File
@@ -116,6 +116,9 @@ SPECTACULAR_SETTINGS = {
"PREPROCESSING_HOOKS": [
"drf_spectacular_jsonapi.hooks.fix_nested_path_parameters",
],
"POSTPROCESSING_HOOKS": [
"api.schema_hooks.attach_task_202_examples",
],
"TITLE": "API Reference - Prowler",
}
+4 -2
View File
@@ -330,7 +330,8 @@ def upload_security_hub_integration(
if not connected:
logger.error(
f"Security Hub connection failed for integration {integration.id}: {security_hub.error}"
f"Security Hub connection failed for integration {integration.id}: "
f"{security_hub.error}"
)
integration.connected = False
integration.save()
@@ -338,7 +339,8 @@ def upload_security_hub_integration(
security_hub_client = security_hub
logger.info(
f"Sending {'fail' if send_only_fails else 'all'} findings to Security Hub via integration {integration.id}"
f"Sending {'fail' if send_only_fails else 'all'} findings to Security Hub via "
f"integration {integration.id}"
)
else:
# Update findings in existing client for this batch
+1 -1
View File
@@ -50,7 +50,7 @@ Click `Go to Scans` to monitor progress.
Review findings during scan execution in the following sections:
- **Overview** Provides a high-level summary of your scans.
<img src="../../img/overview.png" alt="Overview" width="700"/>
<img src="../../products/img/overview.png" alt="Overview" width="700"/>
- **Compliance** Displays compliance insights based on security frameworks.
<img src="../../img/compliance.png" alt="Compliance" width="700"/>
+1 -1
View File
@@ -132,7 +132,7 @@ prowler --security-hub --role arn:aws:iam::123456789012:role/ProwlerExecutionRol
```
???+ note
The specified IAM role must have the necessary permissions to send findings to Security Hub. For details on the required permissions, refer to the IAM policy: [prowler-security-hub.json](https://github.com/prowler-cloud/prowler/blob/master/permissions/prowler-security-hub.json)
The specified IAM role must have the necessary permissions to send findings to Security Hub. For details on the required permissions, refer to the IAM policy: [prowler-additions-policy.json](https://github.com/prowler-cloud/prowler/blob/master/permissions/prowler-additions-policy.json)
## Sending Only Failed Findings to AWS Security Hub
+45
View File
@@ -83,6 +83,9 @@ The following list includes all the Azure checks with configurable variables tha
| `vm_sufficient_daily_backup_retention_period` | `vm_backup_min_daily_retention_days` | Integer |
| `vm_desired_sku_size` | `desired_vm_sku_sizes` | List of Strings |
| `defender_attack_path_notifications_properly_configured` | `defender_attack_path_minimal_risk_level` | String |
| `apim_threat_detection_llm_jacking` | `apim_threat_detection_llm_jacking_threshold` | Float |
| `apim_threat_detection_llm_jacking` | `apim_threat_detection_llm_jacking_minutes` | Integer |
| `apim_threat_detection_llm_jacking` | `apim_threat_detection_llm_jacking_actions` | List of Strings |
## GCP
@@ -494,6 +497,48 @@ azure:
"Standard_DS3_v2",
"Standard_D4s_v3",
]
# Azure VM Backup Configuration
# azure.vm_sufficient_daily_backup_retention_period
vm_backup_min_daily_retention_days: 7
# Azure API Management Threat Detection Configuration
# azure.apim_threat_detection_llm_jacking
apim_threat_detection_llm_jacking_threshold: 0.1
apim_threat_detection_llm_jacking_minutes: 1440
apim_threat_detection_llm_jacking_actions:
[
# OpenAI API endpoints
"ImageGenerations_Create",
"ChatCompletions_Create",
"Completions_Create",
"Embeddings_Create",
"FineTuning_Jobs_Create",
"Models_List",
# Azure OpenAI endpoints
"Deployments_List",
"Deployments_Get",
"Deployments_Create",
"Deployments_Delete",
# Anthropic endpoints
"Messages_Create",
"Claude_Create",
# Google AI endpoints
"GenerateContent",
"GenerateText",
"GenerateImage",
# Meta AI endpoints
"Llama_Create",
"CodeLlama_Create",
# Other LLM endpoints
"Gemini_Generate",
"Claude_Generate",
"Llama_Generate"
]
# GCP Configuration
gcp:
Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

@@ -8,7 +8,7 @@ Prowler for Microsoft 365 (M365) supports the following authentication methods:
- **Interactive browser authentication**
???+ warning
Prowler App supports the **Service Principal** authentication method and the **Service Principal with User Credentials** authentication method, but this last one will be deprecated in September once Microsoft will enforce MFA in all tenants not allowing User authentication without interactive method.
Prowler App supports the **Service Principal** authentication method and the **Service Principal with User Credentials** authentication method, but this last one will be deprecated in October once Microsoft will enforce MFA in all tenants not allowing User authentication without interactive method.
### Service Principal Authentication (Recommended)
@@ -109,7 +109,7 @@ When using service principal authentication, add the following **Application Per
> If you do this you will need to add also the `Organization.Read.All` permission to the service principal application in order to authenticate.
???+ note
This is the **recommended authentication method** because it allows you to run the full M365 provider including PowerShell checks, providing complete coverage of all available security checks, same as the Service Principal Authentication + User Credentials Authentication but this last one will be deprecated in September once Microsoft will enforce MFA in all tenants not allowing User authentication without interactive method.
This is the **recommended authentication method** because it allows you to run the full M365 provider including PowerShell checks, providing complete coverage of all available security checks, same as the Service Principal Authentication + User Credentials Authentication but this last one will be deprecated in October once Microsoft will enforce MFA in all tenants not allowing User authentication without interactive method.
#### Service Principal + User Credentials Authentication (`--env-auth`)
@@ -194,7 +194,7 @@ To grant the permissions for the PowerShell modules via application authenticati
#### If using user authentication
This method is not recommended because it requires a user with MFA enabled and Microsoft will not allow MFA capable users to authenticate programmatically after 1st September 2025. See [Microsoft documentation](https://learn.microsoft.com/en-us/entra/identity/authentication/concept-mandatory-multifactor-authentication?tabs=dotnet) for more information.
This method is not recommended because it requires a user with MFA enabled and Microsoft will not allow MFA capable users to authenticate programmatically after 1st October 2025. See [Microsoft documentation](https://learn.microsoft.com/en-us/entra/identity/authentication/concept-mandatory-multifactor-authentication?tabs=dotnet) for more information.
???+ warning
Remember that if the user is newly created, you need to sign in with that account first, as Microsoft will prompt you to change the password. If you dont complete this step, user authentication will fail because Microsoft marks the initial password as expired.
@@ -0,0 +1,128 @@
# AWS Security Hub Integration
Prowler App enables automatic export of security findings to AWS Security Hub, providing seamless integration with AWS's native security and compliance service. This comprehensive guide demonstrates how to configure and manage AWS Security Hub integrations to centralize security findings and enhance compliance tracking across AWS environments.
Integrating Prowler App with AWS Security Hub provides:
* **Centralized security visibility:** Consolidate findings from multiple AWS accounts and regions
* **Native AWS integration:** Leverage existing AWS security workflows and compliance frameworks
* **Automated finding management:** Archive resolved findings and filter results based on severity
* **Cost optimization:** Send only failed findings to reduce AWS Security Hub costs
* **Real-time updates:** Automatically export findings after each scan completion
## How It Works
When enabled and configured:
1. Scan results are automatically sent to AWS Security Hub after each scan completes
2. Findings are formatted in [AWS Security Finding Format](https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-findings-format.html) (ASFF)
3. The integration automatically detects new AWS regions to send findings if the Prowler partner integration is enabled
4. Previously resolved findings are archived to maintain clean Security Hub dashboards
???+ note
Refer to [AWS Security Hub pricing](https://aws.amazon.com/security-hub/pricing/) for cost information.
## Prerequisites
Before configuring AWS Security Hub Integration in Prowler App, complete these steps:
### AWS Security Hub Setup
Enable the Prowler partner integration in AWS Security Hub by following the [AWS Security Hub setup documentation](./aws/securityhub.md#enabling-aws-security-hub-for-prowler-integration).
### AWS Authentication
Configure AWS credentials by following the [AWS authentication setup guide](./aws/getting-started-aws.md#step-3-set-up-aws-authentication).
## Configuration
To configure AWS Security Hub integration in Prowler App:
1. Navigate to **Integrations** in the Prowler App interface
2. Locate the **AWS Security Hub** card and click **Manage**, then select **Add integration**
![Integrations tab](./img/security-hub/integrations-tab.png)
3. Complete the integration settings
* **AWS Provider:** Select the AWS provider whose findings should be exported to Security Hub
* **Send Only Failed Findings:** Filter out `PASS` findings to reduce AWS Security Hub costs (enabled by default)
* **Archive Previous Findings:** Automatically archive findings resolved since the last scan to maintain clean Security Hub dashboards
![Integration settings](./img/security-hub/integration-settings.png)
4. Configure authentication:
Choose the appropriate authentication method:
* **Use Provider Credentials** (recommended): Leverages the AWS provider's existing credentials
???+ tip "Simplified Credential Management"
Using provider credentials reduces administrative complexity by managing a single set of credentials instead of maintaining separate authentication mechanisms. This approach minimizes security risks and provides the most efficient integration path when the AWS account has sufficient permissions to export findings to Security Hub.
* **Custom Credentials:** Configure separate credentials specifically for Security Hub access
5. Click **Create integration** to enable the integration
![Create integration](./img/security-hub/create-integration.png)
Once configured successfully, findings from subsequent scans will automatically appear in AWS Security Hub.
### Integration Status
Once the integration is active, monitor its status and make adjustments as needed through the integrations management interface.
1. Review configured integrations in the management interface
2. Each integration displays:
- **Connection Status:** Connected or Disconnected indicator.
- **Provider Information:** Selected AWS provider name.
- **Finding Filters:** Status of failed-only and archive settings.
- **Last Checked:** Timestamp of the most recent connection test.
- **Regions:** List of regions where the integration is active.
#### Actions
Each Security Hub integration provides several management actions accessible through dedicated buttons:
| Button | Purpose | Available Actions | Notes |
|--------|---------|------------------|-------|
| **Test** | Verify integration connectivity | • Test AWS credential validity<br/>• Check Security Hub accessibility<br/>• Detect enabled regions automatically<br/>• Validate finding export capability | Results displayed in notification message |
| **Config** | Modify integration settings | • Update AWS provider selection<br/>• Change finding filter settings<br/>• Modify archive preferences | Click "Update Configuration" to save changes |
| **Credentials** | Update authentication settings | • Switch between provider/custom credentials<br/>• Update AWS access keys<br/>• Change IAM role configuration | Click "Update Credentials" to save changes |
| **Enable/Disable** | Toggle integration status | • Enable integration to start exporting findings<br/>• Disable integration to pause exports | Status change takes effect immediately |
| **Delete** | Remove integration permanently | • Permanently delete integration<br/>• Remove all configuration data | ⚠️ **Cannot be undone** - confirm before deleting |
???+ tip "Management Best Practices"
- Test the integration after any configuration changes
- Use the Enable/Disable toggle for temporary changes instead of deleting
- Monitor the Last Checked timestamp to ensure recent connectivity
## Viewing Findings in AWS Security Hub
After successful configuration and scan completion, Prowler findings automatically appear in AWS Security Hub. For detailed information about accessing and interpreting findings in the Security Hub console, refer to the [AWS Security Hub findings documentation](./aws/securityhub.md#viewing-prowler-findings-in-aws-security-hub).
## Troubleshooting
**Connection test fails:**
- Verify AWS Security Hub is enabled in target regions
- Confirm Prowler integration is accepted in Security Hub
- Check IAM permissions include required Security Hub actions
- If using IAM Role, verify trust policy and External ID
**No findings in Security Hub:**
- Ensure integration shows "Connected" status
- Verify a scan has completed after enabling integration
- Check Security Hub console in the correct region
- Confirm finding filters match expectations
**Authentication errors:**
- For provider credentials, verify provider configuration
- For custom credentials, check access key validity
- For IAM roles, confirm role ARN and External ID match
+3 -1
View File
@@ -66,7 +66,9 @@ nav:
- Social Login: tutorials/prowler-app-social-login.md
- SSO with SAML: tutorials/prowler-app-sso.md
- Mute findings: tutorials/prowler-app-mute-findings.md
- Amazon S3 Integration: tutorials/prowler-app-s3-integration.md
- Integrations:
- Amazon S3: tutorials/prowler-app-s3-integration.md
- AWS Security Hub: tutorials/prowler-app-security-hub-integration.md
- Lighthouse: tutorials/prowler-app-lighthouse.md
- Bulk Provider Provisioning: tutorials/bulk-provider-provisioning.md
- CLI:
Generated
+54 -4
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
[[package]]
name = "about-time"
@@ -394,6 +394,24 @@ cryptography = ">=2.1.4"
isodate = ">=0.6.1"
typing-extensions = ">=4.0.1"
[[package]]
name = "azure-mgmt-apimanagement"
version = "5.0.0"
description = "Microsoft Azure API Management Client Library for Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "azure_mgmt_apimanagement-5.0.0-py3-none-any.whl", hash = "sha256:b88c42a392333b60722fb86f15d092dfc19a8d67510dccd15c217381dff4e6ec"},
{file = "azure_mgmt_apimanagement-5.0.0.tar.gz", hash = "sha256:0ab7fe17e70fe3154cd840ff47d19d7a4610217003eaa7c21acf3511a6e57999"},
]
[package.dependencies]
azure-common = ">=1.1"
azure-mgmt-core = ">=1.3.2"
isodate = ">=0.6.1"
typing-extensions = ">=4.6.0"
[[package]]
name = "azure-mgmt-applicationinsights"
version = "4.1.0"
@@ -551,6 +569,23 @@ azure-mgmt-core = ">=1.3.2"
isodate = ">=0.6.1"
typing-extensions = ">=4.6.0"
[[package]]
name = "azure-mgmt-loganalytics"
version = "12.0.0"
description = "Microsoft Azure Log Analytics Management Client Library for Python"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "azure-mgmt-loganalytics-12.0.0.zip", hash = "sha256:da128a7e0291be7fa2063848df92a9180cf5c16d42adc09d2bc2efd711536bfb"},
{file = "azure_mgmt_loganalytics-12.0.0-py2.py3-none-any.whl", hash = "sha256:75ac1d47dd81179905c40765be8834643d8994acff31056ddc1863017f3faa02"},
]
[package.dependencies]
azure-common = ">=1.1,<2.0"
azure-mgmt-core = ">=1.2.0,<2.0.0"
msrest = ">=0.6.21"
[[package]]
name = "azure-mgmt-monitor"
version = "6.0.2"
@@ -761,6 +796,23 @@ azure-mgmt-core = ">=1.3.2"
isodate = ">=0.6.1"
typing-extensions = ">=4.6.0"
[[package]]
name = "azure-monitor-query"
version = "2.0.0"
description = "Microsoft Corporation Azure Monitor Query Client Library for Python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "azure_monitor_query-2.0.0-py3-none-any.whl", hash = "sha256:8f52d581271d785e12f49cd5aaa144b8910fb843db2373855a7ef94c7fc462ea"},
{file = "azure_monitor_query-2.0.0.tar.gz", hash = "sha256:7b05f2fcac4fb67fc9f77a7d4c5d98a0f3099fb73b57c69ec1b080773994671b"},
]
[package.dependencies]
azure-core = ">=1.30.0"
isodate = ">=0.6.1"
typing-extensions = ">=4.6.0"
[[package]]
name = "azure-storage-blob"
version = "12.24.1"
@@ -2352,8 +2404,6 @@ python-versions = "*"
groups = ["dev"]
files = [
{file = "jsonpath-ng-1.7.0.tar.gz", hash = "sha256:f6f5f7fd4e5ff79c785f1573b394043b39849fb2bb47bcead935d12b00beab3c"},
{file = "jsonpath_ng-1.7.0-py2-none-any.whl", hash = "sha256:898c93fc173f0c336784a3fa63d7434297544b7198124a68f9a3ef9597b0ae6e"},
{file = "jsonpath_ng-1.7.0-py3-none-any.whl", hash = "sha256:f3d7f9e848cba1b6da28c55b1c26ff915dc9e0b1ba7e752a53d6da8d5cbd00b6"},
]
[package.dependencies]
@@ -5841,4 +5891,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.1"
python-versions = ">3.9.1,<3.13"
content-hash = "fdd2cbdb6913d0dd8d05030ee41c0d36d3954e473783d43b730ec163e697ec15"
content-hash = "aea38b0311bfabac00d4bf9ee5d2fa0a7f3e32dd2ee5c5d27eb54c69a80b35e9"
+23
View File
@@ -1,6 +1,28 @@
# Prowler SDK Changelog
All notable changes to the **Prowler SDK** are documented in this file.
## [v5.12.0] (Prowler UNRELEASED)
### Added
- Get Jira Project's metadata [(#8630)](https://github.com/prowler-cloud/prowler/pull/8630)
- Add more fields for the Jira ticket and handle custom fields errors [(#8601)](https://github.com/prowler-cloud/prowler/pull/8601)
- Get Jira projects from test_connection [(#8634)](https://github.com/prowler-cloud/prowler/pull/8634)
- `AdditionalUrls` field in CheckMetadata [(#8590)](https://github.com/prowler-cloud/prowler/pull/8590)
- Support color for MANUAL finidngs in Jira tickets [(#8642)](https://github.com/prowler-cloud/prowler/pull/8642)
### Changed
### Fixed
- Renamed `AdditionalUrls` to `AdditionalURLs` field in CheckMetadata [(#8639)](https://github.com/prowler-cloud/prowler/pull/8639)
---
## [v5.11.1] (Prowler UNRELEASED)
### Fixed
- TypeError from Python 3.9 in Security Hub module by updating type annotations [(#8619)](https://github.com/prowler-cloud/prowler/pull/8619)
---
## [v5.12.0] (Prowler UNRELEASED)
@@ -33,6 +55,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `eks_cluster_deletion_protection_enabled` check for AWS provider [(#8536)](https://github.com/prowler-cloud/prowler/pull/8536)
- ECS privilege escalation patterns (StartTask and RunTask) for AWS provider [(#8541)](https://github.com/prowler-cloud/prowler/pull/8541)
- Resource Explorer enumeration v2 API actions in `cloudtrail_threat_detection_enumeration` check [(#8557)](https://github.com/prowler-cloud/prowler/pull/8557)
- `apim_threat_detection_llm_jacking` check for Azure provider [(#8571)](https://github.com/prowler-cloud/prowler/pull/8571)
- GCP `--skip-api-check` command line flag [(#8575)](https://github.com/prowler-cloud/prowler/pull/8575)
### Changed
+1 -1
View File
@@ -12,7 +12,7 @@ from prowler.lib.logger import logger
timestamp = datetime.today()
timestamp_utc = datetime.now(timezone.utc).replace(tzinfo=timezone.utc)
prowler_version = "5.11.0"
prowler_version = "5.12.0"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://prowler.com/wp-content/uploads/logo-html.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
+39
View File
@@ -463,6 +463,45 @@ azure:
# azure.vm_sufficient_daily_backup_retention_period
vm_backup_min_daily_retention_days: 7
# Azure API Management Configuration
# azure.apim_threat_detection_llm_jacking
apim_threat_detection_llm_jacking_threshold: 0.1
apim_threat_detection_llm_jacking_minutes: 1440
apim_threat_detection_llm_jacking_actions:
[
# OpenAI API endpoints
"ImageGenerations_Create",
"ChatCompletions_Create",
"Completions_Create",
"Embeddings_Create",
"FineTuning_Jobs_Create",
"Models_List",
# Azure OpenAI endpoints
"Deployments_List",
"Deployments_Get",
"Deployments_Create",
"Deployments_Delete",
# Anthropic endpoints
"Messages_Create",
"Claude_Create",
# Google AI endpoints
"GenerateContent",
"GenerateText",
"GenerateImage",
# Meta AI endpoints
"Llama_Create",
"CodeLlama_Create",
# Other LLM endpoints
"Gemini_Generate",
"Claude_Generate",
"Llama_Generate"
]
# GCP Configuration
gcp:
# GCP Compute Configuration
+19 -3
View File
@@ -7,7 +7,7 @@ from dataclasses import asdict, dataclass, is_dataclass
from enum import Enum
from typing import Any, Dict, Optional, Set
from pydantic.v1 import BaseModel, ValidationError, validator
from pydantic.v1 import BaseModel, Field, ValidationError, validator
from prowler.config.config import Provider
from prowler.lib.check.compliance_models import Compliance
@@ -85,6 +85,7 @@ class CheckMetadata(BaseModel):
Risk (str): The risk associated with the check.
RelatedUrl (str): The URL related to the check.
Remediation (Remediation): The remediation steps for the check.
AdditionalURLs (list[str]): Additional URLs related to the check. Defaults to an empty list.
Categories (list[str]): The categories of the check.
DependsOn (list[str]): The dependencies of the check.
RelatedTo (list[str]): The related checks.
@@ -97,13 +98,14 @@ class CheckMetadata(BaseModel):
valid_severity(severity): Validator function to validate the severity of the check.
valid_cli_command(remediation): Validator function to validate the CLI command is not an URL.
valid_resource_type(resource_type): Validator function to validate the resource type is not empty.
validate_additional_urls(additional_urls): Validator function to ensure AdditionalURLs contains no duplicates.
"""
Provider: str
CheckID: str
CheckTitle: str
CheckType: list[str]
CheckAliases: list[str] = []
CheckAliases: list[str] = Field(default_factory=list)
ServiceName: str
SubServiceName: str
ResourceIdTemplate: str
@@ -113,13 +115,14 @@ class CheckMetadata(BaseModel):
Risk: str
RelatedUrl: str
Remediation: Remediation
AdditionalURLs: list[str] = Field(default_factory=list)
Categories: list[str]
DependsOn: list[str]
RelatedTo: list[str]
Notes: str
# We set the compliance to None to
# store the compliance later if supplied
Compliance: Optional[list[Any]] = []
Compliance: Optional[list[Any]] = Field(default_factory=list)
@validator("Categories", each_item=True, pre=True, always=True)
def valid_category(value):
@@ -178,6 +181,19 @@ class CheckMetadata(BaseModel):
return check_id
@validator("AdditionalURLs", pre=True, always=True)
def validate_additional_urls(cls, additional_urls):
if not isinstance(additional_urls, list):
raise ValueError("AdditionalURLs must be a list")
if any(not url or not url.strip() for url in additional_urls):
raise ValueError("AdditionalURLs cannot contain empty items")
if len(additional_urls) != len(set(additional_urls)):
raise ValueError("AdditionalURLs cannot contain duplicate items")
return additional_urls
@staticmethod
def get_bulk(provider: str) -> dict[str, "CheckMetadata"]:
"""
+123 -29
View File
@@ -1,5 +1,6 @@
import base64
import os
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Dict
@@ -35,6 +36,17 @@ from prowler.lib.outputs.jira.exceptions.exceptions import (
from prowler.providers.common.models import Connection
@dataclass
class JiraConnection(Connection):
"""
Represents a Jira connection object.
Attributes:
projects (dict): Dictionary of projects in Jira.
"""
projects: dict = None
class Jira:
"""
Jira class to interact with the Jira API
@@ -464,7 +476,7 @@ class Jira:
api_token: str = None,
domain: str = None,
raise_on_exception: bool = True,
) -> Connection:
) -> JiraConnection:
"""Test the connection to Jira
Args:
@@ -477,7 +489,7 @@ class Jira:
- raise_on_exception: Whether to raise an exception or not
Returns:
- Connection: The connection object
- JiraConnection: The connection object
Raises:
- JiraGetCloudIDNoResourcesError: No resources were found in Jira when getting the cloud id
@@ -485,6 +497,8 @@ class Jira:
- JiraGetCloudIDError: Failed to get the cloud ID from Jira
- JiraAuthenticationError: Failed to authenticate
- JiraTestConnectionError: Failed to test the connection
- JiraNoProjectsError: No projects found in Jira
- JiraGetProjectsResponseError: Failed to get projects from Jira, response code did not match 200
"""
try:
jira = Jira(
@@ -495,60 +509,51 @@ class Jira:
api_token=api_token,
domain=domain,
)
access_token = jira.get_access_token()
projects = jira.get_projects()
if not access_token:
return ValueError("Failed to get access token")
if jira.using_basic_auth:
headers = {"Authorization": f"Basic {access_token}"}
else:
headers = {"Authorization": f"Bearer {access_token}"}
response = requests.get(
f"https://api.atlassian.com/ex/jira/{jira.cloud_id}/rest/api/3/myself",
headers=headers,
)
if response.status_code == 200:
return Connection(is_connected=True)
else:
return Connection(is_connected=False, error=response.json())
except JiraGetCloudIDNoResourcesError as no_resources_error:
return JiraConnection(is_connected=True, projects=projects)
except JiraNoProjectsError as no_projects_error:
logger.error(
f"{no_resources_error.__class__.__name__}[{no_resources_error.__traceback__.tb_lineno}]: {no_resources_error}"
f"{no_projects_error.__class__.__name__}[{no_projects_error.__traceback__.tb_lineno}]: {no_projects_error}"
)
if raise_on_exception:
raise no_resources_error
return Connection(error=no_resources_error)
raise no_projects_error
return JiraConnection(error=no_projects_error)
except JiraGetCloudIDResponseError as response_error:
logger.error(
f"{response_error.__class__.__name__}[{response_error.__traceback__.tb_lineno}]: {response_error}"
)
if raise_on_exception:
raise response_error
return Connection(error=response_error)
return JiraConnection(error=response_error)
except JiraGetCloudIDError as cloud_id_error:
logger.error(
f"{cloud_id_error.__class__.__name__}[{cloud_id_error.__traceback__.tb_lineno}]: {cloud_id_error}"
)
if raise_on_exception:
raise cloud_id_error
return Connection(error=cloud_id_error)
return JiraConnection(error=cloud_id_error)
except JiraAuthenticationError as auth_error:
logger.error(
f"{auth_error.__class__.__name__}[{auth_error.__traceback__.tb_lineno}]: {auth_error}"
)
if raise_on_exception:
raise auth_error
return Connection(error=auth_error)
return JiraConnection(error=auth_error)
except JiraBasicAuthError as basic_auth_error:
logger.error(
f"{basic_auth_error.__class__.__name__}[{basic_auth_error.__traceback__.tb_lineno}]: {basic_auth_error}"
)
if raise_on_exception:
raise basic_auth_error
return Connection(error=basic_auth_error)
return JiraConnection(error=basic_auth_error)
except JiraGetProjectsResponseError as projects_response_error:
logger.error(
f"{projects_response_error.__class__.__name__}[{projects_response_error.__traceback__.tb_lineno}]: {projects_response_error}"
)
if raise_on_exception:
raise projects_response_error
return JiraConnection(error=projects_response_error)
except Exception as error:
logger.error(f"Failed to test connection: {error}")
if raise_on_exception:
@@ -556,7 +561,7 @@ class Jira:
message="Failed to test connection on the Jira integration",
file=os.path.basename(__file__),
)
return Connection(is_connected=False, error=error)
return JiraConnection(is_connected=False, error=error)
def get_projects(self) -> Dict[str, str]:
"""Get the projects from Jira
@@ -683,6 +688,92 @@ class Jira:
file=os.path.basename(__file__),
)
def get_metadata(self) -> dict:
"""Get the metadata from Jira
Returns:
- dict: The projects and issue types from Jira as a dictionary, the projects format is {"KEY": {"name": "NAME", "issue_types": ["ISSUE_TYPE_1", "ISSUE_TYPE_2"]}}
"""
try:
access_token = self.get_access_token()
if not access_token:
return ValueError("Failed to get access token")
if self._using_basic_auth:
headers = {"Authorization": f"Basic {access_token}"}
else:
headers = {"Authorization": f"Bearer {access_token}"}
response = requests.get(
f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/project",
headers=headers,
)
if response.status_code == 200:
projects_data = {}
projects_list = response.json()
if not projects_list:
logger.error("No projects found")
raise JiraNoProjectsError(
message="No projects found in Jira",
file=os.path.basename(__file__),
)
else:
for project in projects_list:
project_response = requests.get(
f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue/createmeta?projectKeys={project['key']}&expand=projects.issuetypes.fields",
headers=headers,
)
if project_response.status_code == 200:
project_metadata = project_response.json()
if len(project_metadata["projects"]) == 0:
logger.warning(
f"No project metadata found for project {project['key']}, setting empty issue types"
)
issue_types = []
else:
issue_types = [
issue_type["name"]
for issue_type in project_metadata["projects"][0][
"issuetypes"
]
]
else:
raise JiraGetAvailableIssueTypesResponseError(
message="Failed to get available issue types from Jira",
file=os.path.basename(__file__),
)
projects_data[project["key"]] = {
"name": project["name"],
"issue_types": issue_types,
}
return projects_data
else:
logger.error(
f"Failed to get projects: {response.status_code} - {response.text}"
)
raise JiraGetProjectsResponseError(
message="Failed to get projects from Jira",
file=os.path.basename(__file__),
)
except JiraNoProjectsError as no_projects_error:
raise no_projects_error
except JiraGetAvailableIssueTypesResponseError as issue_types_error:
raise JiraGetProjectsError(
message=f"Failed to get projects and issue types from Jira: {issue_types_error}",
file=os.path.basename(__file__),
)
except JiraRefreshTokenError as refresh_error:
raise refresh_error
except JiraRefreshTokenResponseError as response_error:
raise response_error
except Exception as e:
logger.error(f"Failed to get projects: {e}")
raise JiraGetProjectsError(
message="Failed to get projects from Jira",
file=os.path.basename(__file__),
)
@staticmethod
def get_color_from_status(status: str) -> str:
"""Get the color from the status
@@ -699,6 +790,9 @@ class Jira:
return "#FF0000"
if status == "MUTED":
return "#FFA500"
if status == "MANUAL":
return "#FFFF00"
return "#000000"
@staticmethod
def get_severity_color(severity: str) -> str:
@@ -1,7 +1,7 @@
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from typing import Optional
from typing import Optional, Union
from boto3 import Session
from botocore.client import ClientError
@@ -219,7 +219,7 @@ class SecurityHub:
session: Session,
aws_account_id: str,
aws_partition: str,
) -> tuple[str, Session | None]:
) -> tuple[str, Union[Session, None]]:
"""
Check if Security Hub is enabled in a specific region and if Prowler integration is active.
@@ -25,6 +25,9 @@ class AzureService:
try:
if "GraphServiceClient" in str(service):
clients.update({identity.tenant_domain: service(credentials=session)})
elif "LogsQueryClient" in str(service):
for display_name, id in identity.subscriptions.items():
clients.update({display_name: service(credential=session)})
else:
for display_name, id in identity.subscriptions.items():
clients.update(
@@ -0,0 +1,4 @@
from prowler.providers.azure.services.apim.apim_service import APIM
from prowler.providers.common.provider import Provider
apim_client = APIM(Provider.get_global_provider())
@@ -0,0 +1,252 @@
from datetime import datetime, timedelta
from typing import List, Optional
from azure.mgmt.apimanagement import ApiManagementClient
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
from prowler.providers.azure.azure_provider import AzureProvider
from prowler.providers.azure.lib.service.service import AzureService
from prowler.providers.azure.services.logs.loganalytics_client import (
loganalytics_client,
)
from prowler.providers.azure.services.logs.logsquery_client import logsquery_client
from prowler.providers.azure.services.monitor.monitor_client import monitor_client
class APIMInstance(BaseModel):
"""APIM Instance model"""
id: str
name: str
location: str
log_analytics_workspace_id: Optional[str] = None
class LogsQueryLogEntry(BaseModel):
"""Represents a log entry from Azure Log Analytics query results."""
time_generated: datetime
operation_id: str
caller_ip_address: str
correlation_id: str
class APIM(AzureService):
def __init__(self, provider: AzureProvider):
"""Initialize the APIM service client.
Args:
provider: The Azure provider instance containing authentication and client configuration
"""
super().__init__(ApiManagementClient, provider)
self.instances = self._get_instances()
def _get_workspace_customer_id(
self, subscription: str, workspace_arm_id: str
) -> Optional[str]:
"""Get the Customer ID (GUID) for a workspace from its full ARM ID.
This method extracts the resource group and workspace name from the ARM ID
and queries the Log Analytics client to retrieve the customer ID (GUID)
needed for workspace-specific queries.
Args:
subscription: The Azure subscription ID
workspace_arm_id: The full ARM ID of the Log Analytics workspace
Returns:
The customer ID (GUID) of the workspace if successful, None otherwise
"""
try:
resource_group = workspace_arm_id.split("/")[4]
workspace_name = workspace_arm_id.split("/")[-1]
workspace = loganalytics_client.clients[subscription].workspaces.get(
resource_group_name=resource_group, workspace_name=workspace_name
)
return workspace.customer_id
except Exception as error:
logger.error(
f"Failed to get customer ID for workspace {workspace_arm_id}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return None
def _get_log_analytics_workspace_id(
self, instance_id: str, subscription: str
) -> Optional[str]:
"""Retrieve the Log Analytics workspace ARM ID from an APIM instance's diagnostic settings.
This method queries the Azure Monitor diagnostic settings for a specific APIM
instance to find the configured Log Analytics workspace. It specifically looks
for diagnostic settings that have GatewayLogs enabled, which are essential for
monitoring APIM API calls and operations.
Args:
instance_id: The ARM ID of the APIM instance
subscription: The Azure subscription ID
Returns:
The ARM ID of the Log Analytics workspace if diagnostic settings are found
and GatewayLogs are enabled, None otherwise
"""
try:
diagnostic_settings = monitor_client.diagnostic_settings_with_uri(
subscription, instance_id, monitor_client.clients[subscription]
)
for setting in diagnostic_settings:
if setting.workspace_id and setting.logs:
for log_setting in setting.logs:
if (
log_setting.enabled
and log_setting.category == "GatewayLogs"
):
logger.info(
f"Found enabled Log Analytics workspace for APIM instance {instance_id} with category {log_setting.category}"
)
return setting.workspace_id
except Exception as error:
logger.error(
f"Failed to get diagnostic settings for {instance_id}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return None
def _get_instances(self):
"""Get all APIM instances and their configured Log Analytics workspace.
This method iterates through all accessible Azure subscriptions and retrieves
all APIM instances within each subscription. For each instance, it also
determines the associated Log Analytics workspace by checking diagnostic
settings. The method populates the instances dictionary with APIMInstance
objects containing all relevant metadata and configuration.
Returns:
A dictionary mapping subscription IDs to lists of APIMInstance objects.
Each APIMInstance contains the instance details and its associated
Log Analytics workspace ID if configured.
"""
logger.info("APIM - Getting instances...")
instances = {}
for subscription, client in self.clients.items():
try:
instances.update({subscription: []})
apim_instances = client.api_management_service.list()
for instance in apim_instances:
workspace_id = self._get_log_analytics_workspace_id(
instance.id, subscription
)
instances[subscription].append(
APIMInstance(
id=instance.id,
name=instance.name,
location=instance.location,
log_analytics_workspace_id=workspace_id,
)
)
except Exception as error:
logger.error(
f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return instances
def query_logs(
self,
subscription: str,
query: str,
timespan: timedelta,
workspace_customer_id: str,
) -> List[LogsQueryLogEntry]:
"""Query a specific Log Analytics workspace using its Customer ID (GUID).
This method executes Kusto Query Language (KQL) queries against a specific
Log Analytics workspace. It's used to retrieve log data for analysis and
monitoring purposes. The method handles the response parsing and converts
the tabular results into a list of dictionaries for easy consumption.
Args:
subscription: The Azure subscription ID
query: The KQL query string to execute
timespan: The time range for the query as a timedelta
workspace_customer_id: The customer ID (GUID) of the Log Analytics workspace
Returns:
A list of dictionaries where each dictionary represents a row from the
query results. The keys are the column names from the query response.
Returns an empty list if the query fails or returns no results.
"""
try:
response = logsquery_client.clients[subscription].query_workspace(
workspace_id=workspace_customer_id,
query=query,
timespan=timespan,
)
if response.tables:
columns = response.tables[0].columns
rows = response.tables[0].rows
result = []
for row in rows:
# Create a mapping from Azure column names to our snake_case field names
row_dict = dict(zip(columns, row))
mapped_dict = {
"time_generated": row_dict.get("TimeGenerated"),
"operation_id": row_dict.get("OperationId"),
"caller_ip_address": row_dict.get("CallerIpAddress"),
"correlation_id": row_dict.get("CorrelationId"),
}
result.append(LogsQueryLogEntry(**mapped_dict))
return result
except Exception as error:
logger.error(
f"Failed to query Log Analytics workspace with customer ID {workspace_customer_id}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return []
def get_llm_operations_logs(
self, subscription: str, instance: APIMInstance, minutes: int = 1440
) -> List[LogsQueryLogEntry]:
"""Get LLM-related operations from the APIM instance's specific Log Analytics workspace.
This method retrieves logs related to Large Language Model (LLM) operations
from a specific APIM instance. It queries the GatewayLogs table in the
associated Log Analytics workspace to find API calls and operations that
may be related to LLM services. The method automatically handles the
translation from workspace ARM ID to customer ID for querying.
Args:
subscription: The Azure subscription ID
instance: The APIMInstance object containing the instance details
minutes: The time range in minutes to look back (default: 1440 = 24 hours)
Returns:
A list of dictionaries containing log entries with fields like
time_generated, operation_id, caller_ip_address, and correlation_id.
Returns an empty list if no workspace is configured or if the query fails.
"""
if not instance.log_analytics_workspace_id:
logger.warning(
f"APIM instance {instance.name} has no configured Log Analytics workspace."
)
return []
# Translate the workspace ARM ID to the Customer ID (GUID) before querying
workspace_customer_id = self._get_workspace_customer_id(
subscription, instance.log_analytics_workspace_id
)
if not workspace_customer_id:
return []
query = f"""
ApiManagementGatewayLogs
| where _ResourceId has '{instance.id}'
| where isnotempty(OperationId)
| project TimeGenerated, OperationId, CallerIpAddress, CorrelationId
"""
timespan = timedelta(minutes=minutes)
return self.query_logs(subscription, query, timespan, workspace_customer_id)
@@ -0,0 +1,34 @@
{
"Provider": "azure",
"CheckID": "apim_threat_detection_llm_jacking",
"CheckTitle": "Ensure Azure API Management is protected against LLM Jacking attacks",
"CheckType": [],
"ServiceName": "apim",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "Azure API Management Instance",
"Description": "This check analyzes Azure API Management diagnostic logs in Log Analytics to detect potential LLM Jacking attacks by monitoring the frequency of LLM-related operations (ImageGenerations_Create, ChatCompletions_Create, Completions_Create) from individual IP addresses within a configurable time window.",
"Risk": "LLM Jacking attacks can lead to unauthorized access to AI models, potential data exfiltration, increased costs, and abuse of AI services. Attackers may use these endpoints to generate content, bypass rate limits, or access premium AI capabilities without proper authorization.",
"RelatedUrl": "https://learn.microsoft.com/en-us/azure/api-management/monitor-api-management",
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": ""
},
"Recommendation": {
"Text": "To protect against LLM Jacking attacks: 1. Enable diagnostic logging for APIM instances and send logs to Log Analytics workspace 2. Configure appropriate thresholds for LLM operation frequency monitoring 3. Set up alerts for suspicious activity patterns 4. Implement rate limiting and IP allowlisting for sensitive AI endpoints 5. Regularly review and analyze APIM access logs for anomalies",
"Url": "https://learn.microsoft.com/en-us/azure/api-management/monitor-api-management"
}
},
"Categories": [
"threat-detection",
"monitoring",
"logging"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "This check requires: 1. APIM diagnostic logging to be enabled and configured to send logs to Log Analytics workspace 2. Log Analytics workspace ID and key to be configured in the audit configuration 3. Appropriate permissions to query Log Analytics workspace"
}
@@ -0,0 +1,107 @@
from typing import List
from prowler.lib.check.models import Check, Check_Report_Azure
from prowler.providers.azure.services.apim.apim_client import apim_client
from prowler.providers.azure.services.apim.apim_service import LogsQueryLogEntry
class apim_threat_detection_llm_jacking(Check):
def execute(self):
findings = []
# Get configuration from audit config with defaults
threshold = float(
getattr(apim_client, "audit_config", {}).get(
"apim_threat_detection_llm_jacking_threshold", 0.1
)
)
threat_detection_minutes = getattr(apim_client, "audit_config", {}).get(
"apim_threat_detection_llm_jacking_minutes", 1440
)
monitored_actions = getattr(apim_client, "audit_config", {}).get(
"apim_threat_detection_llm_jacking_actions",
[
# OpenAI API endpoints
"ImageGenerations_Create",
"ChatCompletions_Create",
"Completions_Create",
"Embeddings_Create",
"FineTuning_Jobs_Create",
"Models_List",
# Azure OpenAI endpoints
"Deployments_List",
"Deployments_Get",
"Deployments_Create",
"Deployments_Delete",
# Anthropic endpoints
"Messages_Create",
"Claude_Create",
# Google AI endpoints
"GenerateContent",
"GenerateText",
"GenerateImage",
# Meta AI endpoints
"Llama_Create",
"CodeLlama_Create",
# Other LLM endpoints
"Gemini_Generate",
"Claude_Generate",
"Llama_Generate",
],
)
# 1. Aggregate logs from all APIM instances first
all_llm_logs: List[LogsQueryLogEntry] = []
for subscription, instances in apim_client.instances.items():
for instance in instances:
if instance.log_analytics_workspace_id:
logs = apim_client.get_llm_operations_logs(
subscription, instance, threat_detection_minutes
)
all_llm_logs.extend(logs)
# 2. Perform a single, global analysis on all collected logs
potential_llm_jacking_attackers = {}
for log in all_llm_logs:
operation_name = log.operation_id
caller_ip = log.caller_ip_address
if operation_name in monitored_actions and caller_ip:
# Use IP address as the principal identifier
if caller_ip not in potential_llm_jacking_attackers:
potential_llm_jacking_attackers[caller_ip] = set()
potential_llm_jacking_attackers[caller_ip].add(operation_name)
# 3. Check each principal against the threshold and report failures
found_potential_llm_jacking_attackers = False
for (
principal_ip,
distinct_actions,
) in potential_llm_jacking_attackers.items():
action_ratio = round(len(distinct_actions) / len(monitored_actions), 2)
if action_ratio > threshold:
found_potential_llm_jacking_attackers = True
# Build Identity resource for the report
resource = {
"name": principal_ip,
"id": principal_ip,
}
# Report against the subscription, identifying the offending principal (IP)
report = Check_Report_Azure(self.metadata(), resource=resource)
report.subscription = subscription
report.status = "FAIL"
report.status_extended = f"Potential LLM Jacking attack detected from IP address {principal_ip} with a threshold of {action_ratio}."
findings.append(report)
# 4. If no threats were found after checking all principals, create a single PASS report
if not found_potential_llm_jacking_attackers:
report = Check_Report_Azure(self.metadata(), resource={})
report.resource_name = "Azure API Management"
report.resource_id = "Azure API Management"
report.subscription = subscription
report.status = "PASS"
report.status_extended = f"No potential LLM Jacking attacks detected across all monitored APIM instances in the last {threat_detection_minutes} minutes."
findings.append(report)
return findings
@@ -0,0 +1,4 @@
from prowler.providers.azure.services.logs.logs_service import LogAnalytics
from prowler.providers.common.provider import Provider
loganalytics_client = LogAnalytics(Provider.get_global_provider())
@@ -0,0 +1,15 @@
from azure.mgmt.loganalytics import LogAnalyticsManagementClient
from azure.monitor.query import LogsQueryClient
from prowler.providers.azure.azure_provider import AzureProvider
from prowler.providers.azure.lib.service.service import AzureService
class LogsQuery(AzureService):
def __init__(self, provider: AzureProvider):
super().__init__(LogsQueryClient, provider)
class LogAnalytics(AzureService):
def __init__(self, provider: AzureProvider):
super().__init__(LogAnalyticsManagementClient, provider)
@@ -0,0 +1,4 @@
from prowler.providers.azure.services.logs.logs_service import LogsQuery
from prowler.providers.common.provider import Provider
logsquery_client = LogsQuery(Provider.get_global_provider())
@@ -56,6 +56,7 @@ class Monitor(AzureService):
for log_settings in (getattr(setting, "logs", []) or [])
],
storage_account_id=setting.storage_account_id,
workspace_id=getattr(setting, "workspace_id", None),
)
)
except Exception as error:
@@ -112,6 +113,7 @@ class DiagnosticSetting:
storage_account_name: str
logs: List[LogSettings]
name: str
workspace_id: Optional[str] = None
@dataclass
@@ -77,7 +77,7 @@ class M365PowerShell(PowerShellSession):
Initialize PowerShell credential object for Microsoft 365 authentication.
Supports three authentication methods:
1. User authentication (username/password) - Will be deprecated in September 2025
1. User authentication (username/password) - Will be deprecated in October 2025
2. Application authentication (client_id/client_secret)
3. Certificate authentication (certificate_content in base64/application_id)
@@ -115,7 +115,7 @@ class M365PowerShell(PowerShellSession):
self.execute(f'$tenantID = "{sanitized_tenant_id}"')
self.execute(f'$tenantDomain = "{credentials.tenant_domains[0]}"')
# User Auth (Will be deprecated in September 2025)
# User Auth (Will be deprecated in October 2025)
elif credentials.user and credentials.passwd:
credentials.encrypted_passwd = self.encrypt_password(credentials.passwd)
+4 -1
View File
@@ -35,6 +35,9 @@ dependencies = [
"azure-mgmt-storage==22.1.1",
"azure-mgmt-subscription==3.1.1",
"azure-mgmt-web==8.0.0",
"azure-mgmt-apimanagement==5.0.0",
"azure-mgmt-loganalytics==12.0.0",
"azure-monitor-query==2.0.0",
"azure-storage-blob==12.24.1",
"boto3==1.39.15",
"botocore==1.39.15",
@@ -71,7 +74,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">3.9.1,<3.13"
version = "5.11.0"
version = "5.12.0"
[project.scripts]
prowler = "prowler.__main__:prowler"
+391
View File
@@ -1,5 +1,8 @@
from unittest import mock
import pytest
from pydantic.v1 import ValidationError
from prowler.lib.check.models import CheckMetadata
from tests.lib.check.compliance_check_test import custom_compliance_metadata
@@ -325,3 +328,391 @@ class TestCheckMetada:
result = CheckMetadata.list(bulk_checks_metadata=bulk_metadata)
assert result == set()
def test_additional_urls_valid_empty_list(self):
"""Test AdditionalURLs with valid empty list (default)"""
metadata = CheckMetadata(
Provider="aws",
CheckID="test_check",
CheckTitle="Test Check",
CheckType=["type1"],
ServiceName="test",
SubServiceName="subservice1",
ResourceIdTemplate="template1",
Severity="high",
ResourceType="resource1",
Description="Description 1",
Risk="risk1",
RelatedUrl="url1",
Remediation={
"Code": {
"CLI": "cli1",
"NativeIaC": "native1",
"Other": "other1",
"Terraform": "terraform1",
},
"Recommendation": {"Text": "text1", "Url": "url1"},
},
Categories=["categoryone"],
DependsOn=["dependency1"],
RelatedTo=["related1"],
Notes="notes1",
AdditionalURLs=[],
Compliance=[],
)
assert metadata.AdditionalURLs == []
def test_additional_urls_valid_with_urls(self):
"""Test AdditionalURLs with valid URLs"""
valid_urls = [
"https://example.com/doc1",
"https://example.com/doc2",
"https://aws.amazon.com/docs",
]
metadata = CheckMetadata(
Provider="aws",
CheckID="test_check",
CheckTitle="Test Check",
CheckType=["type1"],
ServiceName="test",
SubServiceName="subservice1",
ResourceIdTemplate="template1",
Severity="high",
ResourceType="resource1",
Description="Description 1",
Risk="risk1",
RelatedUrl="url1",
Remediation={
"Code": {
"CLI": "cli1",
"NativeIaC": "native1",
"Other": "other1",
"Terraform": "terraform1",
},
"Recommendation": {"Text": "text1", "Url": "url1"},
},
Categories=["categoryone"],
DependsOn=["dependency1"],
RelatedTo=["related1"],
Notes="notes1",
AdditionalURLs=valid_urls,
Compliance=[],
)
assert metadata.AdditionalURLs == valid_urls
def test_additional_urls_invalid_not_list(self):
"""Test AdditionalURLs with non-list value"""
with pytest.raises(ValidationError) as exc_info:
CheckMetadata(
Provider="aws",
CheckID="test_check",
CheckTitle="Test Check",
CheckType=["type1"],
ServiceName="test",
SubServiceName="subservice1",
ResourceIdTemplate="template1",
Severity="high",
ResourceType="resource1",
Description="Description 1",
Risk="risk1",
RelatedUrl="url1",
Remediation={
"Code": {
"CLI": "cli1",
"NativeIaC": "native1",
"Other": "other1",
"Terraform": "terraform1",
},
"Recommendation": {"Text": "text1", "Url": "url1"},
},
Categories=["categoryone"],
DependsOn=["dependency1"],
RelatedTo=["related1"],
Notes="notes1",
AdditionalURLs="not_a_list",
Compliance=[],
)
assert "AdditionalURLs must be a list" in str(exc_info.value)
def test_additional_urls_invalid_empty_items(self):
"""Test AdditionalURLs with empty string items"""
with pytest.raises(ValidationError) as exc_info:
CheckMetadata(
Provider="aws",
CheckID="test_check",
CheckTitle="Test Check",
CheckType=["type1"],
ServiceName="test",
SubServiceName="subservice1",
ResourceIdTemplate="template1",
Severity="high",
ResourceType="resource1",
Description="Description 1",
Risk="risk1",
RelatedUrl="url1",
Remediation={
"Code": {
"CLI": "cli1",
"NativeIaC": "native1",
"Other": "other1",
"Terraform": "terraform1",
},
"Recommendation": {"Text": "text1", "Url": "url1"},
},
Categories=["categoryone"],
DependsOn=["dependency1"],
RelatedTo=["related1"],
Notes="notes1",
AdditionalURLs=["https://example.com", "", "https://example2.com"],
Compliance=[],
)
assert "AdditionalURLs cannot contain empty items" in str(exc_info.value)
def test_additional_urls_invalid_whitespace_items(self):
"""Test AdditionalURLs with whitespace-only items"""
with pytest.raises(ValidationError) as exc_info:
CheckMetadata(
Provider="aws",
CheckID="test_check",
CheckTitle="Test Check",
CheckType=["type1"],
ServiceName="test",
SubServiceName="subservice1",
ResourceIdTemplate="template1",
Severity="high",
ResourceType="resource1",
Description="Description 1",
Risk="risk1",
RelatedUrl="url1",
Remediation={
"Code": {
"CLI": "cli1",
"NativeIaC": "native1",
"Other": "other1",
"Terraform": "terraform1",
},
"Recommendation": {"Text": "text1", "Url": "url1"},
},
Categories=["categoryone"],
DependsOn=["dependency1"],
RelatedTo=["related1"],
Notes="notes1",
AdditionalURLs=["https://example.com", " ", "https://example2.com"],
Compliance=[],
)
assert "AdditionalURLs cannot contain empty items" in str(exc_info.value)
def test_additional_urls_invalid_duplicates(self):
"""Test AdditionalURLs with duplicate items"""
with pytest.raises(ValidationError) as exc_info:
CheckMetadata(
Provider="aws",
CheckID="test_check",
CheckTitle="Test Check",
CheckType=["type1"],
ServiceName="test",
SubServiceName="subservice1",
ResourceIdTemplate="template1",
Severity="high",
ResourceType="resource1",
Description="Description 1",
Risk="risk1",
RelatedUrl="url1",
Remediation={
"Code": {
"CLI": "cli1",
"NativeIaC": "native1",
"Other": "other1",
"Terraform": "terraform1",
},
"Recommendation": {"Text": "text1", "Url": "url1"},
},
Categories=["categoryone"],
DependsOn=["dependency1"],
RelatedTo=["related1"],
Notes="notes1",
AdditionalURLs=[
"https://example.com",
"https://example2.com",
"https://example.com",
],
Compliance=[],
)
assert "AdditionalURLs cannot contain duplicate items" in str(exc_info.value)
def test_fields_with_explicit_empty_values(self):
"""Test that RelatedUrl and AdditionalURLs can be set to explicit empty values"""
metadata = CheckMetadata(
Provider="aws",
CheckID="test_check_empty_fields",
CheckTitle="Test Check with Empty Fields",
CheckType=["type1"],
ServiceName="test",
SubServiceName="subservice1",
ResourceIdTemplate="template1",
Severity="high",
ResourceType="resource1",
Description="Description 1",
Risk="risk1",
RelatedUrl="", # Explicit empty string
Remediation={
"Code": {
"CLI": "cli1",
"NativeIaC": "native1",
"Other": "other1",
"Terraform": "terraform1",
},
"Recommendation": {"Text": "text1", "Url": "url1"},
},
Categories=["categoryone"],
DependsOn=["dependency1"],
RelatedTo=["related1"],
Notes="notes1",
AdditionalURLs=[], # Explicit empty list
Compliance=[],
)
# Assert that the fields are set to empty values
assert metadata.RelatedUrl == ""
assert metadata.AdditionalURLs == []
def test_fields_default_values(self):
"""Test that RelatedUrl and AdditionalURLs use proper defaults when not provided"""
metadata = CheckMetadata(
Provider="aws",
CheckID="test_check_defaults",
CheckTitle="Test Check with Default Fields",
CheckType=["type1"],
ServiceName="test",
SubServiceName="subservice1",
ResourceIdTemplate="template1",
Severity="high",
ResourceType="resource1",
Description="Description 1",
Risk="risk1",
RelatedUrl="",
Remediation={
"Code": {
"CLI": "cli1",
"NativeIaC": "native1",
"Other": "other1",
"Terraform": "terraform1",
},
"Recommendation": {"Text": "text1", "Url": "url1"},
},
Categories=["categoryone"],
DependsOn=["dependency1"],
RelatedTo=["related1"],
Notes="notes1",
# AdditionalURLs not provided - should default to empty list via default_factory
Compliance=[],
)
# Assert that the fields use their default values
assert metadata.RelatedUrl == "" # Should default to empty string
assert metadata.AdditionalURLs == [] # Should default to empty list
def test_related_url_none_fails(self):
"""Test that setting RelatedUrl to None raises a ValidationError"""
with pytest.raises(ValidationError) as exc_info:
CheckMetadata(
Provider="aws",
CheckID="test_check_none_related_url",
CheckTitle="Test Check with None RelatedUrl",
CheckType=["type1"],
ServiceName="test",
SubServiceName="subservice1",
ResourceIdTemplate="template1",
Severity="high",
ResourceType="resource1",
Description="Description 1",
Risk="risk1",
RelatedUrl=None, # This should fail
Remediation={
"Code": {
"CLI": "cli1",
"NativeIaC": "native1",
"Other": "other1",
"Terraform": "terraform1",
},
"Recommendation": {"Text": "text1", "Url": "url1"},
},
Categories=["categoryone"],
DependsOn=["dependency1"],
RelatedTo=["related1"],
Notes="notes1",
AdditionalURLs=[],
Compliance=[],
)
# Should contain a validation error for RelatedUrl
assert "RelatedUrl" in str(exc_info.value)
def test_additional_urls_none_fails(self):
"""Test that setting AdditionalURLs to None raises a ValidationError"""
with pytest.raises(ValidationError) as exc_info:
CheckMetadata(
Provider="aws",
CheckID="test_check_none_additional_urls",
CheckTitle="Test Check with None AdditionalURLs",
CheckType=["type1"],
ServiceName="test",
SubServiceName="subservice1",
ResourceIdTemplate="template1",
Severity="high",
ResourceType="resource1",
Description="Description 1",
Risk="risk1",
RelatedUrl="https://example.com",
Remediation={
"Code": {
"CLI": "cli1",
"NativeIaC": "native1",
"Other": "other1",
"Terraform": "terraform1",
},
"Recommendation": {"Text": "text1", "Url": "url1"},
},
Categories=["categoryone"],
DependsOn=["dependency1"],
RelatedTo=["related1"],
Notes="notes1",
AdditionalURLs=None, # This should fail
Compliance=[],
)
# Should contain the validation error we set in the validator
assert "AdditionalURLs must be a list" in str(exc_info.value)
def test_additional_urls_invalid_type_fails(self):
"""Test that setting AdditionalURLs to non-list value raises a ValidationError"""
with pytest.raises(ValidationError) as exc_info:
CheckMetadata(
Provider="aws",
CheckID="test_check_invalid_additional_urls",
CheckTitle="Test Check with Invalid AdditionalURLs",
CheckType=["type1"],
ServiceName="test",
SubServiceName="subservice1",
ResourceIdTemplate="template1",
Severity="high",
ResourceType="resource1",
Description="Description 1",
Risk="risk1",
RelatedUrl="https://example.com",
Remediation={
"Code": {
"CLI": "cli1",
"NativeIaC": "native1",
"Other": "other1",
"Terraform": "terraform1",
},
"Recommendation": {"Text": "text1", "Url": "url1"},
},
Categories=["categoryone"],
DependsOn=["dependency1"],
RelatedTo=["related1"],
Notes="notes1",
AdditionalURLs="not_a_list", # This should fail
Compliance=[],
)
# Should contain the validation error we set in the validator
assert "AdditionalURLs must be a list" in str(exc_info.value)
+409 -39
View File
@@ -13,9 +13,11 @@ from prowler.lib.outputs.jira.exceptions.exceptions import (
JiraGetAvailableIssueTypesError,
JiraGetCloudIDError,
JiraGetProjectsError,
JiraGetProjectsResponseError,
JiraNoProjectsError,
JiraRefreshTokenError,
JiraRequiredCustomFieldsError,
JiraTestConnectionError,
)
from prowler.lib.outputs.jira.jira import Jira
@@ -293,25 +295,19 @@ class TestJiraIntegration:
with pytest.raises(JiraRefreshTokenError):
self.jira_integration.refresh_access_token()
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(Jira, "get_cloud_id", return_value="test_cloud_id")
@patch.object(Jira, "get_auth", return_value=None)
@patch("prowler.lib.outputs.jira.jira.requests.get")
def test_test_connection_successful(
self, mock_get, mock_get_cloud_id, mock_get_auth, mock_get_access_token
):
"""Test that a successful connection returns an active Connection object."""
@patch.object(
Jira,
"get_projects",
return_value={"PROJ1": "Project One", "PROJ2": "Project Two"},
)
def test_test_connection_successful(self, mock_get_projects, mock_get_auth):
"""Test that a successful connection returns an active Connection object with projects."""
# To disable vulture
mock_get_cloud_id = mock_get_cloud_id
mock_get_projects = mock_get_projects
mock_get_auth = mock_get_auth
mock_get_access_token = mock_get_access_token
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"id": "test_user_id"}
mock_get.return_value = mock_response
connection = self.jira_integration.test_connection(
connection = Jira.test_connection(
redirect_uri=self.redirect_uri,
client_id=self.client_id,
client_secret=self.client_secret,
@@ -319,26 +315,23 @@ class TestJiraIntegration:
assert connection.is_connected
assert connection.error is None
assert connection.projects == {"PROJ1": "Project One", "PROJ2": "Project Two"}
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(Jira, "get_cloud_id", return_value="test_cloud_id")
@patch.object(Jira, "get_basic_auth", return_value=None)
@patch("prowler.lib.outputs.jira.jira.requests.get")
@patch.object(
Jira,
"get_projects",
return_value={"PROJ1": "Project One", "PROJ2": "Project Two"},
)
def test_test_connection_successful_basic_auth(
self, mock_get, mock_get_cloud_id, mock_get_auth, mock_get_access_token
self, mock_get_projects, mock_get_basic_auth
):
"""Test that a successful connection returns an active Connection object."""
"""Test that a successful connection returns an active Connection object with projects."""
# To disable vulture
mock_get_cloud_id = mock_get_cloud_id
mock_get_auth = mock_get_auth
mock_get_access_token = mock_get_access_token
mock_get_projects = mock_get_projects
mock_get_basic_auth = mock_get_basic_auth
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"id": "test_user_id"}
mock_get.return_value = mock_response
connection = self.jira_integration_basic_auth.test_connection(
connection = Jira.test_connection(
user_mail=self.user_mail,
api_token=self.api_token,
domain=self.domain,
@@ -346,19 +339,20 @@ class TestJiraIntegration:
assert connection.is_connected
assert connection.error is None
assert connection.projects == {"PROJ1": "Project One", "PROJ2": "Project Two"}
@patch.object(
Jira,
"get_access_token",
"get_auth",
side_effect=JiraAuthenticationError("Failed to authenticate with Jira"),
)
def test_test_connection_failed(self, mock_get_access_token):
def test_test_connection_failed(self, mock_get_auth):
"""Test that a failed connection raises JiraAuthenticationError."""
# To disable vulture
mock_get_access_token = mock_get_access_token
mock_get_auth = mock_get_auth
with pytest.raises(JiraAuthenticationError):
self.jira_integration.test_connection(
Jira.test_connection(
redirect_uri=self.redirect_uri,
client_id=self.client_id,
client_secret=self.client_secret,
@@ -366,21 +360,120 @@ class TestJiraIntegration:
@patch.object(
Jira,
"get_cloud_id",
"get_basic_auth",
side_effect=JiraBasicAuthError("Failed to authenticate with Jira"),
)
def test_test_connection_failed_basic_auth(self, mock_get_access_token):
"""Test that a failed connection raises JiraAuthenticationError."""
def test_test_connection_failed_basic_auth(self, mock_get_basic_auth):
"""Test that a failed connection raises JiraBasicAuthError."""
# To disable vulture
mock_get_access_token = mock_get_access_token
mock_get_basic_auth = mock_get_basic_auth
with pytest.raises(JiraBasicAuthError):
self.jira_integration_basic_auth.test_connection(
Jira.test_connection(
user_mail=self.user_mail,
api_token=self.api_token,
domain=self.domain,
)
@patch.object(Jira, "get_auth", return_value=None)
@patch.object(
Jira, "get_projects", side_effect=JiraNoProjectsError("No projects found")
)
def test_test_connection_no_projects_found(self, mock_get_projects, mock_get_auth):
"""Test that test_connection raises JiraNoProjectsError when no projects are found."""
# To disable vulture
mock_get_projects = mock_get_projects
mock_get_auth = mock_get_auth
with pytest.raises(JiraNoProjectsError):
Jira.test_connection(
redirect_uri=self.redirect_uri,
client_id=self.client_id,
client_secret=self.client_secret,
)
@patch.object(Jira, "get_auth", return_value=None)
@patch.object(
Jira,
"get_projects",
side_effect=JiraGetProjectsResponseError("Projects request failed"),
)
def test_test_connection_projects_request_error(
self, mock_get_projects, mock_get_auth
):
"""Test that test_connection raises JiraGetProjectsResponseError when projects request fails."""
# To disable vulture
mock_get_projects = mock_get_projects
mock_get_auth = mock_get_auth
with pytest.raises(JiraGetProjectsResponseError):
Jira.test_connection(
redirect_uri=self.redirect_uri,
client_id=self.client_id,
client_secret=self.client_secret,
)
@patch.object(Jira, "get_auth", return_value=None)
@patch.object(
Jira, "get_projects", side_effect=JiraNoProjectsError("No projects found")
)
def test_test_connection_no_projects_found_no_exception(
self, mock_get_projects, mock_get_auth
):
"""Test that test_connection returns error connection object when no projects found and raise_on_exception=False."""
# To disable vulture
mock_get_projects = mock_get_projects
mock_get_auth = mock_get_auth
connection = Jira.test_connection(
redirect_uri=self.redirect_uri,
client_id=self.client_id,
client_secret=self.client_secret,
raise_on_exception=False,
)
assert not connection.is_connected
assert isinstance(connection.error, JiraNoProjectsError)
@patch.object(Jira, "get_auth", return_value=None)
@patch.object(
Jira,
"get_projects",
side_effect=JiraGetProjectsResponseError("Projects request failed"),
)
def test_test_connection_projects_request_error_no_exception(
self, mock_get_projects, mock_get_auth
):
"""Test that test_connection returns error connection object when projects request fails and raise_on_exception=False."""
# To disable vulture
mock_get_projects = mock_get_projects
mock_get_auth = mock_get_auth
connection = Jira.test_connection(
redirect_uri=self.redirect_uri,
client_id=self.client_id,
client_secret=self.client_secret,
raise_on_exception=False,
)
assert not connection.is_connected
assert isinstance(connection.error, JiraGetProjectsResponseError)
@patch.object(Jira, "get_auth", return_value=None)
@patch.object(Jira, "get_projects", side_effect=Exception("Unexpected error"))
def test_test_connection_unexpected_error(self, mock_get_projects, mock_get_auth):
"""Test that test_connection raises JiraTestConnectionError on unexpected exceptions."""
# To disable vulture
mock_get_projects = mock_get_projects
mock_get_auth = mock_get_auth
with pytest.raises(JiraTestConnectionError):
Jira.test_connection(
redirect_uri=self.redirect_uri,
client_id=self.client_id,
client_secret=self.client_secret,
)
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(
Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id"
@@ -616,6 +709,10 @@ class TestJiraIntegration:
call_args = mock_post.call_args
mock_post.assert_called_once()
call_args = mock_post.call_args
expected_url = (
"https://api.atlassian.com/ex/jira/valid_cloud_id/rest/api/3/issue"
)
@@ -736,6 +833,7 @@ class TestJiraIntegration:
mock_get_available_issue_types = mock_get_available_issue_types
mock_get_access_token = mock_get_access_token
mock_post = mock_post
mock_post = mock_post
with pytest.raises(JiraCreateIssueError):
self.jira_integration.send_findings(
@@ -887,7 +985,12 @@ class TestJiraIntegration:
@pytest.mark.parametrize(
"status, expected_color",
[("FAIL", "#FF0000"), ("PASS", "#008000"), ("MUTED", "#FFA500")],
[
("FAIL", "#FF0000"),
("PASS", "#008000"),
("MUTED", "#FFA500"),
("MANUAL", "#FFFF00"),
],
)
def test_get_color_from_status(self, status, expected_color):
"""Test that get_color_from_status returns the correct color for a status."""
@@ -907,3 +1010,270 @@ class TestJiraIntegration:
def test_get_severity_color(self, severity, expected_color):
"""Test that get_severity_color returns the correct color for a severity."""
assert self.jira_integration.get_severity_color(severity) == expected_color
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(
Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id"
)
@patch("prowler.lib.outputs.jira.jira.requests.get")
def test_get_projects_and_issue_types_successful(
self, mock_get, mock_cloud_id, mock_get_access_token
):
"""Test successful retrieval of metadata associated to projects from Jira."""
# To disable vulture
mock_cloud_id = mock_cloud_id
mock_get_access_token = mock_get_access_token
# Mock the projects response
mock_projects_response = MagicMock()
mock_projects_response.status_code = 200
mock_projects_response.json.return_value = [
{"key": "PROJ1", "name": "Project One"},
{"key": "PROJ2", "name": "Project Two"},
]
# Mock the issue types responses
mock_issue_types_response_1 = MagicMock()
mock_issue_types_response_1.status_code = 200
mock_issue_types_response_1.json.return_value = {
"projects": [
{
"issuetypes": [
{"name": "Bug", "id": "1"},
{"name": "Task", "id": "2"},
]
}
]
}
mock_issue_types_response_2 = MagicMock()
mock_issue_types_response_2.status_code = 200
mock_issue_types_response_2.json.return_value = {
"projects": [
{
"issuetypes": [
{"name": "Story", "id": "3"},
{"name": "Epic", "id": "4"},
]
}
]
}
# Configure side_effect to return different responses for different calls
mock_get.side_effect = [
mock_projects_response,
mock_issue_types_response_1,
mock_issue_types_response_2,
]
jira_metadata = self.jira_integration.get_metadata()
expected_result = {
"PROJ1": {
"name": "Project One",
"issue_types": ["Bug", "Task"],
},
"PROJ2": {
"name": "Project Two",
"issue_types": ["Story", "Epic"],
},
}
assert jira_metadata == expected_result
# Verify the correct number of calls were made
assert mock_get.call_count == 3
# Verify the URLs called
calls = mock_get.call_args_list
assert (
calls[0][0][0]
== "https://api.atlassian.com/ex/jira/test_cloud_id/rest/api/3/project"
)
assert "projectKeys=PROJ1" in calls[1][0][0]
assert "projectKeys=PROJ2" in calls[2][0][0]
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(
Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id"
)
@patch("prowler.lib.outputs.jira.jira.requests.get")
def test_get_projects_and_issue_types_no_projects_found(
self, mock_get, mock_cloud_id, mock_get_access_token
):
"""Test that get_metadata raises JiraNoProjectsError when no projects are found."""
# To disable vulture
mock_cloud_id = mock_cloud_id
mock_get_access_token = mock_get_access_token
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
mock_get.return_value = mock_response
with pytest.raises(JiraNoProjectsError):
self.jira_integration.get_metadata()
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(
Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id"
)
@patch("prowler.lib.outputs.jira.jira.requests.get")
def test_get_projects_and_issue_types_projects_response_error(
self, mock_get, mock_cloud_id, mock_get_access_token
):
"""Test that get_metadata raises JiraGetProjectsError when projects request fails."""
# To disable vulture
mock_cloud_id = mock_cloud_id
mock_get_access_token = mock_get_access_token
mock_response = MagicMock()
mock_response.status_code = 404
mock_response.text = "Not Found"
mock_get.return_value = mock_response
with pytest.raises(JiraGetProjectsError):
self.jira_integration.get_metadata()
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(
Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id"
)
@patch("prowler.lib.outputs.jira.jira.requests.get")
def test_get_projects_and_issue_types_issue_types_response_error(
self, mock_get, mock_cloud_id, mock_get_access_token
):
"""Test that get_metadata raises JiraGetProjectsError when issue types request fails."""
# To disable vulture
mock_cloud_id = mock_cloud_id
mock_get_access_token = mock_get_access_token
# Mock successful projects response
mock_projects_response = MagicMock()
mock_projects_response.status_code = 200
mock_projects_response.json.return_value = [
{"key": "PROJ1", "name": "Project One"}
]
# Mock failed issue types response
mock_issue_types_response = MagicMock()
mock_issue_types_response.status_code = 404
mock_get.side_effect = [mock_projects_response, mock_issue_types_response]
with pytest.raises(JiraGetProjectsError):
self.jira_integration.get_metadata()
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(
Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id"
)
@patch("prowler.lib.outputs.jira.jira.requests.get")
def test_get_projects_and_issue_types_no_project_metadata(
self, mock_get, mock_cloud_id, mock_get_access_token
):
"""Test that get_metadata returns empty issue_types when project metadata is empty."""
# To disable vulture
mock_cloud_id = mock_cloud_id
mock_get_access_token = mock_get_access_token
# Mock successful projects response
mock_projects_response = MagicMock()
mock_projects_response.status_code = 200
mock_projects_response.json.return_value = [
{"key": "PROJ1", "name": "Project One"}
]
# Mock issue types response with empty projects list
mock_issue_types_response = MagicMock()
mock_issue_types_response.status_code = 200
mock_issue_types_response.json.return_value = {"projects": []}
mock_get.side_effect = [mock_projects_response, mock_issue_types_response]
projects_and_issue_types = self.jira_integration.get_metadata()
expected_result = {
"PROJ1": {
"name": "Project One",
"issue_types": [],
}
}
assert projects_and_issue_types == expected_result
@patch.object(
Jira,
"get_access_token",
side_effect=JiraRefreshTokenError("Failed to refresh the access token"),
)
def test_get_projects_and_issue_types_refresh_token_error(
self, mock_get_access_token
):
"""Test that get_metadata raises JiraRefreshTokenError when refreshing the token fails."""
# To disable vulture
mock_get_access_token = mock_get_access_token
with pytest.raises(JiraRefreshTokenError):
self.jira_integration.get_metadata()
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(
Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id"
)
@patch("prowler.lib.outputs.jira.jira.requests.get")
def test_get_projects_and_issue_types_mixed_scenarios(
self, mock_get, mock_cloud_id, mock_get_access_token
):
"""Test get_metadata with mixed success and empty metadata scenarios."""
# To disable vulture
mock_cloud_id = mock_cloud_id
mock_get_access_token = mock_get_access_token
# Mock projects response with two projects
mock_projects_response = MagicMock()
mock_projects_response.status_code = 200
mock_projects_response.json.return_value = [
{"key": "PROJ1", "name": "Project One"},
{"key": "PROJ2", "name": "Project Two"},
]
# Mock successful issue types response for first project
mock_issue_types_response_1 = MagicMock()
mock_issue_types_response_1.status_code = 200
mock_issue_types_response_1.json.return_value = {
"projects": [
{
"issuetypes": [
{"name": "Bug", "id": "1"},
{"name": "Task", "id": "2"},
]
}
]
}
# Mock empty issue types response for second project
mock_issue_types_response_2 = MagicMock()
mock_issue_types_response_2.status_code = 200
mock_issue_types_response_2.json.return_value = {"projects": []}
mock_get.side_effect = [
mock_projects_response,
mock_issue_types_response_1,
mock_issue_types_response_2,
]
projects_and_issue_types = self.jira_integration.get_metadata()
expected_result = {
"PROJ1": {
"name": "Project One",
"issue_types": ["Bug", "Task"],
},
"PROJ2": {
"name": "Project Two",
"issue_types": [],
},
}
assert projects_and_issue_types == expected_result
@@ -1283,3 +1283,167 @@ class TestSecurityHub:
assert connection.error is None
assert len(connection.enabled_regions) == 1
assert len(connection.disabled_regions) == 1
# Tests for _check_region_security_hub static method
@patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call)
def test_check_region_security_hub_success(self):
# Test successful security hub check
mock_session = session.Session(region_name=AWS_REGION_EU_WEST_1)
region, client = SecurityHub._check_region_security_hub(
region=AWS_REGION_EU_WEST_1,
session=mock_session,
aws_account_id=AWS_ACCOUNT_NUMBER,
aws_partition=AWS_COMMERCIAL_PARTITION,
)
assert region == AWS_REGION_EU_WEST_1
assert client is not None
assert hasattr(client, "_make_api_call")
def test_check_region_security_hub_invalid_access_exception(self, caplog):
caplog.set_level(WARNING)
with patch("boto3.Session.client") as mock_client:
error_message = (
f"Account {AWS_ACCOUNT_NUMBER} is not subscribed to AWS Security Hub"
)
error_code = "InvalidAccessException"
error_response = {
"Error": {
"Code": error_code,
"Message": error_message,
}
}
operation_name = "DescribeHub"
mock_client.side_effect = ClientError(error_response, operation_name)
mock_session = session.Session(region_name=AWS_REGION_EU_WEST_1)
region, client = SecurityHub._check_region_security_hub(
region=AWS_REGION_EU_WEST_1,
session=mock_session,
aws_account_id=AWS_ACCOUNT_NUMBER,
aws_partition=AWS_COMMERCIAL_PARTITION,
)
assert region == AWS_REGION_EU_WEST_1
assert client is None
# Check that warning was logged for InvalidAccessException
log_pattern = re.compile(
r"ClientError -- \[\d+\]: An error occurred \({error_code}\) when calling the {operation_name} operation: {error_message}".format(
error_code=re.escape(error_code),
operation_name=re.escape(operation_name),
error_message=re.escape(error_message),
)
)
assert any(
log_pattern.match(record.message) for record in caplog.records
), "Expected log message not found"
def test_check_region_security_hub_prowler_integration_not_enabled(self, caplog):
from logging import INFO
caplog.set_level(INFO)
with patch("boto3.Session.client") as mock_client:
mock_security_hub_client = mock_client.return_value
mock_security_hub_client.describe_hub.return_value = {}
mock_security_hub_client.list_enabled_products_for_import.return_value = {
"ProductSubscriptions": []
}
mock_session = session.Session(region_name=AWS_REGION_EU_WEST_1)
region, client = SecurityHub._check_region_security_hub(
region=AWS_REGION_EU_WEST_1,
session=mock_session,
aws_account_id=AWS_ACCOUNT_NUMBER,
aws_partition=AWS_COMMERCIAL_PARTITION,
)
assert region == AWS_REGION_EU_WEST_1
assert client is None
# Check that warning was logged for missing Prowler integration
assert caplog.record_tuples == [
(
"root",
INFO,
f"Checking if the prowler/prowler is enabled in the {AWS_REGION_EU_WEST_1} region.",
),
(
"root",
WARNING,
f"Security Hub is enabled in {AWS_REGION_EU_WEST_1} but Prowler integration does not accept findings. More info: https://docs.prowler.cloud/en/latest/tutorials/aws/securityhub/",
),
]
def test_check_region_security_hub_other_client_error(self, caplog):
caplog.set_level(WARNING)
with patch("boto3.Session.client") as mock_client:
error_message = "Some other error"
error_code = "SomeOtherException"
error_response = {
"Error": {
"Code": error_code,
"Message": error_message,
}
}
operation_name = "DescribeHub"
mock_client.side_effect = ClientError(error_response, operation_name)
mock_session = session.Session(region_name=AWS_REGION_EU_WEST_1)
region, client = SecurityHub._check_region_security_hub(
region=AWS_REGION_EU_WEST_1,
session=mock_session,
aws_account_id=AWS_ACCOUNT_NUMBER,
aws_partition=AWS_COMMERCIAL_PARTITION,
)
assert region == AWS_REGION_EU_WEST_1
assert client is None
# Check that error was logged for other ClientError
log_pattern = re.compile(
r"ClientError -- \[\d+\]: An error occurred \({error_code}\) when calling the {operation_name} operation: {error_message}".format(
error_code=re.escape(error_code),
operation_name=re.escape(operation_name),
error_message=re.escape(error_message),
)
)
assert any(
log_pattern.match(record.message) for record in caplog.records
), "Expected log message not found"
def test_check_region_security_hub_generic_exception(self, caplog):
caplog.set_level(WARNING)
with patch("boto3.Session.client") as mock_client:
error_message = "Generic exception occurred"
mock_client.side_effect = Exception(error_message)
mock_session = session.Session(region_name=AWS_REGION_EU_WEST_1)
region, client = SecurityHub._check_region_security_hub(
region=AWS_REGION_EU_WEST_1,
session=mock_session,
aws_account_id=AWS_ACCOUNT_NUMBER,
aws_partition=AWS_COMMERCIAL_PARTITION,
)
assert region == AWS_REGION_EU_WEST_1
assert client is None
# Check that error was logged for generic exception
log_pattern = re.compile(
r"Exception -- \[\d+\]: {error_message}".format(
error_message=re.escape(error_message),
)
)
assert any(
log_pattern.match(record.message) for record in caplog.records
), "Expected log message not found"
@@ -92,6 +92,30 @@ class TestAzureProvider:
"Standard_D4s_v3",
],
"defender_attack_path_minimal_risk_level": "High",
"apim_threat_detection_llm_jacking_threshold": 0.1,
"apim_threat_detection_llm_jacking_minutes": 1440,
"apim_threat_detection_llm_jacking_actions": [
"ImageGenerations_Create",
"ChatCompletions_Create",
"Completions_Create",
"Embeddings_Create",
"FineTuning_Jobs_Create",
"Models_List",
"Deployments_List",
"Deployments_Get",
"Deployments_Create",
"Deployments_Delete",
"Messages_Create",
"Claude_Create",
"GenerateContent",
"GenerateText",
"GenerateImage",
"Llama_Create",
"CodeLlama_Create",
"Gemini_Generate",
"Claude_Generate",
"Llama_Generate",
],
}
def test_azure_provider_not_auth_methods(self):
@@ -0,0 +1,318 @@
from datetime import timedelta
from unittest import TestCase, mock
from unittest.mock import patch
from azure.mgmt.loganalytics.models import Workspace
from azure.mgmt.monitor.models import DiagnosticSettingsResource
from azure.monitor.query import LogsQueryResult
from tests.providers.azure.azure_fixtures import (
AZURE_SUBSCRIPTION_ID,
set_mocked_azure_provider,
)
# Define constants for reusable mock data
APIM_INSTANCE_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg/providers/Microsoft.ApiManagement/service/apim1"
APIM_INSTANCE_NAME = "apim1"
LOCATION = "West US"
RESOURCE_GROUP = "rg"
WORKSPACE_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourcegroups/rg/providers/microsoft.operationalinsights/workspaces/loganalytics"
WORKSPACE_CUSTOMER_ID = "12345678-1234-1234-1234-1234567890ab"
def mock_apim_get_instances(_):
"""Mock function to replace APIM._get_instances."""
from prowler.providers.azure.services.apim.apim_service import APIMInstance
return {
AZURE_SUBSCRIPTION_ID: [
APIMInstance(
id=APIM_INSTANCE_ID,
name=APIM_INSTANCE_NAME,
location=LOCATION,
log_analytics_workspace_id=WORKSPACE_ID,
)
]
}
class Test_APIM_Service(TestCase):
def test_get_client(self):
"""Test that the APIM service client is created correctly."""
mock_provider = mock.MagicMock()
mock_provider.identity = mock.MagicMock()
with (
patch(
"prowler.providers.azure.azure_provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
return_value={},
),
):
from prowler.providers.azure.services.apim.apim_service import APIM
apim = APIM(set_mocked_azure_provider())
self.assertEqual(
apim.clients[AZURE_SUBSCRIPTION_ID].__class__.__name__,
"ApiManagementClient",
)
def test_get_subscriptions(self):
"""Test that subscriptions are retrieved correctly."""
mock_provider = mock.MagicMock()
mock_provider.identity = mock.MagicMock()
with (
patch(
"prowler.providers.azure.azure_provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
return_value={},
),
):
from prowler.providers.azure.services.apim.apim_service import APIM
apim = APIM(set_mocked_azure_provider())
self.assertEqual(apim.subscriptions.__class__.__name__, "dict")
def test_get_instances(self):
"""Test that APIM instances are retrieved and parsed correctly."""
mock_provider = mock.MagicMock()
mock_provider.identity = mock.MagicMock()
with (
patch(
"prowler.providers.azure.azure_provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
new=mock_apim_get_instances,
),
):
from prowler.providers.azure.services.apim.apim_service import APIM
apim = APIM(set_mocked_azure_provider())
self.assertEqual(len(apim.instances), 1)
self.assertEqual(len(apim.instances[AZURE_SUBSCRIPTION_ID]), 1)
instance = apim.instances[AZURE_SUBSCRIPTION_ID][0]
self.assertEqual(instance.id, APIM_INSTANCE_ID)
self.assertEqual(instance.name, APIM_INSTANCE_NAME)
self.assertEqual(instance.location, LOCATION)
self.assertEqual(instance.log_analytics_workspace_id, WORKSPACE_ID)
def test_get_log_analytics_workspace_id_success(self):
"""Test retrieving a Log Analytics workspace ID successfully."""
mock_provider = mock.MagicMock()
mock_provider.identity = mock.MagicMock()
with patch(
"prowler.providers.azure.azure_provider.Provider.get_global_provider",
return_value=mock_provider,
):
from prowler.providers.azure.services.apim.apim_service import APIM
with (
patch(
"prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
return_value={},
),
patch(
"prowler.providers.azure.services.apim.apim_service.monitor_client"
) as mock_monitor_client,
):
apim = APIM(set_mocked_azure_provider())
mock_log_setting = mock.MagicMock(enabled=True, category="GatewayLogs")
mock_setting = DiagnosticSettingsResource(
workspace_id=WORKSPACE_ID, logs=[mock_log_setting]
)
mock_monitor_client.diagnostic_settings_with_uri.return_value = [
mock_setting
]
workspace_id = apim._get_log_analytics_workspace_id(
APIM_INSTANCE_ID, AZURE_SUBSCRIPTION_ID
)
self.assertEqual(workspace_id, WORKSPACE_ID)
def test_get_log_analytics_workspace_id_not_enabled(self):
"""Test that no workspace ID is returned if GatewayLogs are not enabled."""
mock_provider = mock.MagicMock()
mock_provider.identity = mock.MagicMock()
with patch(
"prowler.providers.azure.azure_provider.Provider.get_global_provider",
return_value=mock_provider,
):
from prowler.providers.azure.services.apim.apim_service import APIM
with (
patch(
"prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
return_value={},
),
patch(
"prowler.providers.azure.services.apim.apim_service.monitor_client"
) as mock_monitor_client,
):
apim = APIM(set_mocked_azure_provider())
mock_log_setting = mock.MagicMock(enabled=False, category="GatewayLogs")
mock_setting = DiagnosticSettingsResource(
workspace_id=WORKSPACE_ID, logs=[mock_log_setting]
)
mock_monitor_client.diagnostic_settings_with_uri.return_value = [
mock_setting
]
workspace_id = apim._get_log_analytics_workspace_id(
APIM_INSTANCE_ID, AZURE_SUBSCRIPTION_ID
)
self.assertIsNone(workspace_id)
def test_get_workspace_customer_id_success(self):
"""Test retrieving a workspace customer ID successfully."""
mock_provider = mock.MagicMock()
mock_provider.identity = mock.MagicMock()
with patch(
"prowler.providers.azure.azure_provider.Provider.get_global_provider",
return_value=mock_provider,
):
from prowler.providers.azure.services.apim.apim_service import APIM
with (
patch(
"prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
return_value={},
),
patch(
"prowler.providers.azure.services.apim.apim_service.loganalytics_client"
) as mock_loganalytics_client,
):
apim = APIM(set_mocked_azure_provider())
mock_workspace = Workspace(location=LOCATION)
# Set customer_id after creation since it's readonly
mock_workspace.customer_id = WORKSPACE_CUSTOMER_ID
# Properly mock the nested client structure
mock_client = mock.MagicMock()
mock_workspaces = mock.MagicMock()
mock_workspaces.get.return_value = mock_workspace
mock_client.workspaces = mock_workspaces
mock_loganalytics_client.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
customer_id = apim._get_workspace_customer_id(
AZURE_SUBSCRIPTION_ID, WORKSPACE_ID
)
self.assertEqual(customer_id, WORKSPACE_CUSTOMER_ID)
def test_query_logs_success(self):
"""Test querying logs successfully."""
mock_provider = mock.MagicMock()
mock_provider.identity = mock.MagicMock()
with patch(
"prowler.providers.azure.azure_provider.Provider.get_global_provider",
return_value=mock_provider,
):
from prowler.providers.azure.services.apim.apim_service import APIM
with (
patch(
"prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
return_value={},
),
patch(
"prowler.providers.azure.services.apim.apim_service.logsquery_client"
) as mock_logsquery_client,
):
apim = APIM(set_mocked_azure_provider())
# Create a mock table with the expected structure for LogsQueryLogEntry
mock_table = mock.MagicMock()
mock_table.columns = [
"TimeGenerated",
"OperationId",
"CallerIpAddress",
"CorrelationId",
]
from datetime import datetime
mock_table.rows = [
[
datetime.fromisoformat("2024-01-01T10:00:00+00:00"),
"test-operation",
"192.168.1.100",
"test-correlation",
]
]
mock_response = LogsQueryResult(tables=[mock_table], status="Success")
# Properly mock the nested client structure
mock_client = mock.MagicMock()
mock_client.query_workspace.return_value = mock_response
mock_logsquery_client.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
result = apim.query_logs(
AZURE_SUBSCRIPTION_ID,
"query",
timedelta(minutes=60),
WORKSPACE_CUSTOMER_ID,
)
self.assertEqual(len(result), 1)
# The result should be LogsQueryLogEntry objects
from datetime import datetime
self.assertEqual(
result[0].time_generated,
datetime.fromisoformat("2024-01-01T10:00:00+00:00"),
)
self.assertEqual(result[0].operation_id, "test-operation")
self.assertEqual(result[0].caller_ip_address, "192.168.1.100")
self.assertEqual(result[0].correlation_id, "test-correlation")
def test_get_llm_operations_logs_no_workspace_id(self):
"""Test getting logs when the APIM instance has no workspace configured."""
mock_provider = mock.MagicMock()
mock_provider.identity = mock.MagicMock()
with patch(
"prowler.providers.azure.azure_provider.Provider.get_global_provider",
return_value=mock_provider,
):
from prowler.providers.azure.services.apim.apim_service import APIM
with patch(
"prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
return_value={},
):
apim = APIM(set_mocked_azure_provider())
instance = mock.MagicMock(
log_analytics_workspace_id=None, name="test-apim"
)
result = apim.get_llm_operations_logs(AZURE_SUBSCRIPTION_ID, instance)
self.assertEqual(result, [])
def test_get_llm_operations_logs_success(self):
"""Test the successful retrieval of LLM operation logs."""
mock_provider = mock.MagicMock()
mock_provider.identity = mock.MagicMock()
with patch(
"prowler.providers.azure.azure_provider.Provider.get_global_provider",
return_value=mock_provider,
):
from prowler.providers.azure.services.apim.apim_service import APIM
with (
patch(
"prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
new=mock_apim_get_instances,
),
patch(
"prowler.providers.azure.services.apim.apim_service.APIM.query_logs",
return_value=[{"log": "data"}],
),
patch(
"prowler.providers.azure.services.apim.apim_service.APIM._get_workspace_customer_id",
return_value=WORKSPACE_CUSTOMER_ID,
),
):
apim = APIM(set_mocked_azure_provider())
instance = apim.instances[AZURE_SUBSCRIPTION_ID][0]
result = apim.get_llm_operations_logs(AZURE_SUBSCRIPTION_ID, instance)
self.assertEqual(result, [{"log": "data"}])
@@ -0,0 +1,491 @@
from datetime import datetime
from unittest import mock
from tests.providers.azure.azure_fixtures import (
AZURE_SUBSCRIPTION_ID,
set_mocked_azure_provider,
)
# Create a mock LogsQueryLogEntry class for testing
class MockLogsQueryLogEntry:
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
def mock_get_llm_operations_logs(subscription, instance, minutes):
"""Mock LLM operations logs for testing - returns 2 operations"""
return [
MockLogsQueryLogEntry(
time_generated=datetime.fromisoformat("2024-01-01T10:00:00+00:00"),
operation_id="ChatCompletions_Create",
caller_ip_address="192.168.1.100",
correlation_id="test-correlation-id-1",
),
MockLogsQueryLogEntry(
time_generated=datetime.fromisoformat("2024-01-01T10:01:00+00:00"),
operation_id="ImageGenerations_Create",
caller_ip_address="192.168.1.100",
correlation_id="test-correlation-id-2",
),
]
def mock_get_llm_operations_logs_6_operations(subscription, instance, minutes):
"""Mock LLM operations logs for testing - returns 6 operations"""
return [
MockLogsQueryLogEntry(
time_generated=datetime.fromisoformat("2024-01-01T10:00:00+00:00"),
operation_id="ChatCompletions_Create",
caller_ip_address="192.168.1.100",
correlation_id="test-correlation-id-1",
),
MockLogsQueryLogEntry(
time_generated=datetime.fromisoformat("2024-01-01T10:01:00+00:00"),
operation_id="ImageGenerations_Create",
caller_ip_address="192.168.1.100",
correlation_id="test-correlation-id-2",
),
MockLogsQueryLogEntry(
time_generated=datetime.fromisoformat("2024-01-01T10:02:00+00:00"),
operation_id="Completions_Create",
caller_ip_address="192.168.1.100",
correlation_id="test-correlation-id-3",
),
MockLogsQueryLogEntry(
time_generated=datetime.fromisoformat("2024-01-01T10:03:00+00:00"),
operation_id="Embeddings_Create",
caller_ip_address="192.168.1.100",
correlation_id="test-correlation-id-4",
),
MockLogsQueryLogEntry(
time_generated=datetime.fromisoformat("2024-01-01T10:04:00+00:00"),
operation_id="FineTuning_Jobs_Create",
caller_ip_address="192.168.1.100",
correlation_id="test-correlation-id-5",
),
MockLogsQueryLogEntry(
time_generated=datetime.fromisoformat("2024-01-01T10:05:00+00:00"),
operation_id="Models_List",
caller_ip_address="192.168.1.100",
correlation_id="test-correlation-id-6",
),
]
def mock_get_llm_operations_logs_2_operations(subscription, instance, minutes):
"""Mock LLM operations logs for testing - returns 2 operations"""
return [
MockLogsQueryLogEntry(
time_generated=datetime.fromisoformat("2024-01-01T10:00:00+00:00"),
operation_id="ChatCompletions_Create",
caller_ip_address="192.168.1.100",
correlation_id="test-correlation-id-1",
),
MockLogsQueryLogEntry(
time_generated=datetime.fromisoformat("2024-01-01T10:01:00+00:00"),
operation_id="ImageGenerations_Create",
caller_ip_address="192.168.1.100",
correlation_id="test-correlation-id-2",
),
]
def mock_get_llm_operations_logs_attacker(subscription, instance, minutes):
"""Mock LLM operations logs showing potential attack"""
return [
MockLogsQueryLogEntry(
time_generated=datetime.fromisoformat("2024-01-01T10:00:00+00:00"),
operation_id="ChatCompletions_Create",
caller_ip_address="10.0.0.50",
correlation_id="test-correlation-id-1",
),
MockLogsQueryLogEntry(
time_generated=datetime.fromisoformat("2024-01-01T10:01:00+00:00"),
operation_id="ImageGenerations_Create",
caller_ip_address="10.0.0.50",
correlation_id="test-correlation-id-2",
),
MockLogsQueryLogEntry(
time_generated=datetime.fromisoformat("2024-01-01T10:02:00+00:00"),
operation_id="Completions_Create",
caller_ip_address="10.0.0.50",
correlation_id="test-correlation-id-3",
),
MockLogsQueryLogEntry(
time_generated=datetime.fromisoformat("2024-01-01T10:03:00+00:00"),
operation_id="Embeddings_Create",
caller_ip_address="10.0.0.50",
correlation_id="test-correlation-id-4",
),
MockLogsQueryLogEntry(
time_generated=datetime.fromisoformat("2024-01-01T10:04:00+00:00"),
operation_id="FineTuning_Jobs_Create",
caller_ip_address="10.0.0.50",
correlation_id="test-correlation-id-5",
),
MockLogsQueryLogEntry(
time_generated=datetime.fromisoformat("2024-01-01T10:05:00+00:00"),
operation_id="Models_List",
caller_ip_address="10.0.0.50",
correlation_id="test-correlation-id-6",
),
]
def mock_get_llm_operations_logs_no_workspace(subscription, instance, minutes):
"""Mock LLM operations logs for instance without workspace"""
return []
class Test_apim_threat_detection_llm_jacking:
def test_no_apim_instances(self):
"""Test when there are no APIM instances"""
apim_client = mock.MagicMock()
apim_client.instances = {}
apim_client.audit_config = {
"apim_threat_detection_llm_jacking_threshold": 0.1,
"apim_threat_detection_llm_jacking_minutes": 1440,
"apim_threat_detection_llm_jacking_actions": [
"ChatCompletions_Create",
"ImageGenerations_Create",
],
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking.apim_client",
new=apim_client,
),
):
from prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking import (
apim_threat_detection_llm_jacking,
)
check = apim_threat_detection_llm_jacking()
result = check.execute()
assert len(result) == 0
def test_no_potential_llm_jacking(self):
"""Test when no potential LLM jacking is detected"""
apim_client = mock.MagicMock()
apim_client.instances = {
AZURE_SUBSCRIPTION_ID: [
mock.MagicMock(
id="/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.ApiManagement/service/test-apim",
name="test-apim",
log_analytics_workspace_id="/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.OperationalInsights/workspaces/test-workspace",
)
]
}
apim_client.audit_config = {
"apim_threat_detection_llm_jacking_threshold": 0.9,
"apim_threat_detection_llm_jacking_minutes": 1440,
"apim_threat_detection_llm_jacking_actions": [
"ChatCompletions_Create",
"ImageGenerations_Create",
"Completions_Create",
"Embeddings_Create",
"FineTuning_Jobs_Create",
"Models_List",
"Deployments_List",
"Deployments_Get",
"Deployments_Create",
"Deployments_Delete",
"Messages_Create",
"Claude_Create",
"GenerateContent",
"GenerateText",
"GenerateImage",
"Llama_Create",
"CodeLlama_Create",
"Gemini_Generate",
"Claude_Generate",
"Llama_Generate",
],
}
apim_client.get_llm_operations_logs = mock_get_llm_operations_logs_6_operations
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking.apim_client",
new=apim_client,
),
):
from prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking import (
apim_threat_detection_llm_jacking,
)
check = apim_threat_detection_llm_jacking()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
"No potential LLM Jacking attacks detected" in result[0].status_extended
)
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
def test_potential_llm_jacking_detected(self):
"""Test when potential LLM jacking is detected"""
apim_client = mock.MagicMock()
apim_client.instances = {
AZURE_SUBSCRIPTION_ID: [
mock.MagicMock(
id="/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.ApiManagement/service/test-apim",
name="test-apim",
log_analytics_workspace_id="/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.OperationalInsights/workspaces/test-workspace",
)
]
}
apim_client.audit_config = {
"apim_threat_detection_llm_jacking_threshold": 0.1,
"apim_threat_detection_llm_jacking_minutes": 1440,
"apim_threat_detection_llm_jacking_actions": [
"ChatCompletions_Create",
"ImageGenerations_Create",
"Completions_Create",
"Embeddings_Create",
"FineTuning_Jobs_Create",
"Models_List",
],
}
apim_client.get_llm_operations_logs = mock_get_llm_operations_logs_attacker
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking.apim_client",
new=apim_client,
),
):
from prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking import (
apim_threat_detection_llm_jacking,
)
check = apim_threat_detection_llm_jacking()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
"Potential LLM Jacking attack detected from IP address 10.0.0.50"
in result[0].status_extended
)
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource["name"] == "10.0.0.50"
assert result[0].resource["id"] == "10.0.0.50"
def test_higher_threshold_no_detection(self):
"""Test when threshold is higher and no attack is detected"""
apim_client = mock.MagicMock()
apim_client.instances = {
AZURE_SUBSCRIPTION_ID: [
mock.MagicMock(
id="/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.ApiManagement/service/test-apim",
name="test-apim",
log_analytics_workspace_id="/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.OperationalInsights/workspaces/test-workspace",
)
]
}
apim_client.audit_config = {
"apim_threat_detection_llm_jacking_threshold": 0.9,
"apim_threat_detection_llm_jacking_minutes": 1440,
"apim_threat_detection_llm_jacking_actions": [
"ChatCompletions_Create",
"ImageGenerations_Create",
"Completions_Create",
"Embeddings_Create",
"FineTuning_Jobs_Create",
"Models_List",
"Deployments_List",
"Deployments_Get",
"Deployments_Create",
"Deployments_Delete",
"Messages_Create",
"Claude_Create",
"GenerateContent",
"GenerateText",
"GenerateImage",
"Llama_Create",
"CodeLlama_Create",
"Gemini_Generate",
"Claude_Generate",
"Llama_Generate",
],
}
apim_client.get_llm_operations_logs = mock_get_llm_operations_logs_6_operations
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking.apim_client",
new=apim_client,
),
):
from prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking import (
apim_threat_detection_llm_jacking,
)
check = apim_threat_detection_llm_jacking()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
"No potential LLM Jacking attacks detected" in result[0].status_extended
)
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
def test_instance_without_workspace(self):
"""Test when APIM instance has no Log Analytics workspace configured"""
apim_client = mock.MagicMock()
apim_client.instances = {
AZURE_SUBSCRIPTION_ID: [
mock.MagicMock(
id="/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.ApiManagement/service/test-apim",
name="test-apim",
log_analytics_workspace_id=None,
)
]
}
apim_client.audit_config = {
"apim_threat_detection_llm_jacking_threshold": 0.9,
"apim_threat_detection_llm_jacking_minutes": 1440,
"apim_threat_detection_llm_jacking_actions": [
"ChatCompletions_Create",
"ImageGenerations_Create",
"Completions_Create",
"Embeddings_Create",
"FineTuning_Jobs_Create",
"Models_List",
"Deployments_List",
"Deployments_Get",
"Deployments_Create",
"Deployments_Delete",
"Messages_Create",
"Claude_Create",
"GenerateContent",
"GenerateText",
"GenerateImage",
"Llama_Create",
"CodeLlama_Create",
"Gemini_Generate",
"Claude_Generate",
"Llama_Generate",
],
}
apim_client.get_llm_operations_logs = mock_get_llm_operations_logs_2_operations
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking.apim_client",
new=apim_client,
),
):
from prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking import (
apim_threat_detection_llm_jacking,
)
check = apim_threat_detection_llm_jacking()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
"No potential LLM Jacking attacks detected" in result[0].status_extended
)
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
def test_multiple_subscriptions(self):
"""Test with multiple subscriptions"""
apim_client = mock.MagicMock()
apim_client.instances = {
AZURE_SUBSCRIPTION_ID: [
mock.MagicMock(
id="/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.ApiManagement/service/test-apim",
name="test-apim",
log_analytics_workspace_id="/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.OperationalInsights/workspaces/test-workspace",
)
],
"another-subscription": [
mock.MagicMock(
id="/subscriptions/another-sub/resourceGroups/test-rg/providers/Microsoft.ApiManagement/service/another-apim",
name="another-apim",
log_analytics_workspace_id="/subscriptions/another-sub/resourceGroups/test-rg/providers/Microsoft.OperationalInsights/workspaces/another-workspace",
)
],
}
apim_client.audit_config = {
"apim_threat_detection_llm_jacking_threshold": 0.9,
"apim_threat_detection_llm_jacking_minutes": 1440,
"apim_threat_detection_llm_jacking_actions": [
"ChatCompletions_Create",
"ImageGenerations_Create",
"Completions_Create",
"Embeddings_Create",
"FineTuning_Jobs_Create",
"Models_List",
"Deployments_List",
"Deployments_Get",
"Deployments_Create",
"Deployments_Delete",
"Messages_Create",
"Claude_Create",
"GenerateContent",
"GenerateText",
"GenerateImage",
"Llama_Create",
"CodeLlama_Create",
"Gemini_Generate",
"Claude_Generate",
"Llama_Generate",
],
}
apim_client.get_llm_operations_logs = mock_get_llm_operations_logs_2_operations
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking.apim_client",
new=apim_client,
),
):
from prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking import (
apim_threat_detection_llm_jacking,
)
check = apim_threat_detection_llm_jacking()
result = check.execute()
assert len(result) == 2
# Both subscriptions should have PASS results
for report in result:
assert report.status == "PASS"
assert (
"No potential LLM Jacking attacks detected"
in report.status_extended
)
+16 -3
View File
@@ -2,7 +2,22 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.11.0] (Prowler v5.11.0 - UNRELEASED)
## [1.11.1] (Prowler v5.11.1)
### 🐞 Added
- Handle API responses and errors consistently across the app [(#8621)](https://github.com/prowler-cloud/prowler/pull/8621)
- No-permission message on the scan page [(#8624)](https://github.com/prowler-cloud/prowler/pull/8624)
### 🔄 Changed
- Markdown rendering in finding details page [(#8604)](https://github.com/prowler-cloud/prowler/pull/8604)
### 🐞 Fixed
- Scan page shows NoProvidersAdded when no providers [(#8626)](https://github.com/prowler-cloud/prowler/pull/8626)
- XML field in SAML configuration form validation [(#8638)](https://github.com/prowler-cloud/prowler/pull/8638)
## [1.11.0] (Prowler v5.11.0)
### 🚀 Added
@@ -25,8 +40,6 @@ All notable changes to the **Prowler UI** are documented in this file.
- Auth callback route checking working as expected [(#8556)](https://github.com/prowler-cloud/prowler/pull/8556)
- DataTable column headers set to single-line [(#8480)](https://github.com/prowler-cloud/prowler/pull/8480)
### ❌ Removed
---
## [1.10.2] (Prowler v5.10.3)
+9 -44
View File
@@ -1,7 +1,6 @@
"use server";
import { revalidatePath } from "next/cache";
import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib";
import { apiBaseUrl, getAuthHeaders, handleApiResponse } from "@/lib";
export const getCompliancesOverview = async ({
scanId,
@@ -25,15 +24,12 @@ export const getCompliancesOverview = async ({
}
try {
const compliances = await fetch(url.toString(), {
const response = await fetch(url.toString(), {
headers,
});
const data = await compliances.json();
const parsedData = parseStringify(data);
revalidatePath("/compliance");
return parsedData;
return handleApiResponse(response, "/compliance");
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching providers:", error);
return undefined;
}
@@ -52,8 +48,8 @@ export const getComplianceOverviewMetadataInfo = async ({
if (sort) url.searchParams.append("sort", sort);
Object.entries(filters).forEach(([key, value]) => {
// Define filters to exclude
if (key !== "filter[search]") {
// Define filters to exclude and check for valid values
if (key !== "filter[search]" && value && String(value).trim() !== "") {
url.searchParams.append(key, String(value));
}
});
@@ -63,17 +59,8 @@ export const getComplianceOverviewMetadataInfo = async ({
headers,
});
if (!response.ok) {
throw new Error(
`Failed to fetch compliance overview metadata info: ${response.statusText}`,
);
}
const parsedData = parseStringify(await response.json());
return parsedData;
return handleApiResponse(response);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching compliance overview metadata info:", error);
return undefined;
}
@@ -90,22 +77,11 @@ export const getComplianceAttributes = async (complianceId: string) => {
headers,
});
if (!response.ok) {
throw new Error(
`Failed to fetch compliance attributes: ${response.statusText}`,
);
}
const data = await response.json();
const parsedData = parseStringify(data);
return parsedData;
return handleApiResponse(response);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching compliance attributes:", error);
return undefined;
}
// */
};
export const getComplianceRequirements = async ({
@@ -135,20 +111,9 @@ export const getComplianceRequirements = async ({
headers,
});
if (!response.ok) {
throw new Error(
`Failed to fetch compliance requirements: ${response.statusText}`,
);
}
const data = await response.json();
const parsedData = parseStringify(data);
return parsedData;
return handleApiResponse(response);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching compliance requirements:", error);
return undefined;
}
// */
};
+7 -33
View File
@@ -1,9 +1,8 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib";
import { apiBaseUrl, getAuthHeaders, handleApiResponse } from "@/lib";
export const getFindings = async ({
page = 1,
@@ -33,12 +32,8 @@ export const getFindings = async ({
const findings = await fetch(url.toString(), {
headers,
});
const data = await findings.json();
const parsedData = parseStringify(data);
revalidatePath("/findings");
return parsedData;
return handleApiResponse(findings, "/findings");
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching findings:", error);
return undefined;
}
@@ -74,12 +69,8 @@ export const getLatestFindings = async ({
const findings = await fetch(url.toString(), {
headers,
});
const data = await findings.json();
const parsedData = parseStringify(data);
revalidatePath("/findings");
return parsedData;
return handleApiResponse(findings, "/findings");
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching findings:", error);
return undefined;
}
@@ -113,15 +104,8 @@ export const getMetadataInfo = async ({
headers,
});
if (!response.ok) {
throw new Error(`Failed to fetch metadata info: ${response.statusText}`);
}
const parsedData = parseStringify(await response.json());
return parsedData;
return handleApiResponse(response);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching metadata info:", error);
return undefined;
}
@@ -155,15 +139,8 @@ export const getLatestMetadataInfo = async ({
headers,
});
if (!response.ok) {
throw new Error(`Failed to fetch metadata info: ${response.statusText}`);
}
const parsedData = parseStringify(await response.json());
return parsedData;
return handleApiResponse(response);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching metadata info:", error);
return undefined;
}
@@ -176,14 +153,11 @@ export const getFindingById = async (findingId: string, include = "") => {
if (include) url.searchParams.append("include", include);
try {
const finding = await fetch(url.toString(), {
const response = await fetch(url.toString(), {
headers,
});
const data = await finding.json();
const parsedData = parseStringify(data);
return parsedData;
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching finding by ID:", error);
return undefined;
-1
View File
@@ -1,7 +1,6 @@
export {
createIntegration,
deleteIntegration,
getIntegration,
getIntegrations,
pollConnectionTestStatus,
testIntegrationConnection,
+3 -29
View File
@@ -2,14 +2,14 @@
import { revalidatePath } from "next/cache";
import { getTask } from "@/actions/task";
import {
apiBaseUrl,
getAuthHeaders,
handleApiError,
handleApiResponse,
parseStringify,
} from "@/lib";
import { getTask } from "@/actions/task";
import { IntegrationType } from "@/types/integrations";
export const getIntegrations = async (searchParams?: URLSearchParams) => {
@@ -25,39 +25,13 @@ export const getIntegrations = async (searchParams?: URLSearchParams) => {
try {
const response = await fetch(url.toString(), { method: "GET", headers });
if (response.ok) {
const data = await response.json();
return parseStringify(data);
}
console.error(`Failed to fetch integrations: ${response.statusText}`);
return { data: [], meta: { pagination: { count: 0 } } };
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching integrations:", error);
return { data: [], meta: { pagination: { count: 0 } } };
}
};
export const getIntegration = async (id: string) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/integrations/${id}`);
try {
const response = await fetch(url.toString(), { method: "GET", headers });
if (response.ok) {
const data = await response.json();
return parseStringify(data);
}
console.error(`Failed to fetch integration: ${response.statusText}`);
return null;
} catch (error) {
console.error("Error fetching integration:", error);
return null;
}
};
export const createIntegration = async (
formData: FormData,
): Promise<{ success: string; integrationId?: string } | { error: string }> => {
+4 -28
View File
@@ -2,7 +2,7 @@
import { revalidatePath } from "next/cache";
import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib/helper";
import { apiBaseUrl, getAuthHeaders, handleApiResponse } from "@/lib/helper";
import { samlConfigFormSchema } from "@/types/formSchemas";
export const createSamlConfig = async (_prevState: any, formData: FormData) => {
@@ -39,16 +39,7 @@ export const createSamlConfig = async (_prevState: any, formData: FormData) => {
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.errors?.[0]?.detail ||
`Failed to create SAML config: ${response.statusText}`,
);
}
await response.json();
revalidatePath("/integrations");
handleApiResponse(response, "/integrations", false);
return { success: "SAML configuration created successfully!" };
} catch (error) {
console.error("Error creating SAML config:", error);
@@ -98,16 +89,7 @@ export const updateSamlConfig = async (_prevState: any, formData: FormData) => {
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.errors?.[0]?.detail ||
`Failed to update SAML config: ${response.statusText}`,
);
}
await response.json();
revalidatePath("/integrations");
handleApiResponse(response, "/integrations", false);
return { success: "SAML configuration updated successfully!" };
} catch (error) {
console.error("Error updating SAML config:", error);
@@ -132,13 +114,7 @@ export const getSamlConfig = async () => {
headers,
});
if (!response.ok) {
throw new Error(`Failed to fetch SAML config: ${response.statusText}`);
}
const data = await response.json();
const parsedData = parseStringify(data);
return parsedData;
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching SAML config:", error);
return undefined;
+12 -27
View File
@@ -6,8 +6,8 @@ import { redirect } from "next/navigation";
import {
apiBaseUrl,
getAuthHeaders,
getErrorMessage,
parseStringify,
handleApiError,
handleApiResponse,
} from "@/lib";
export const getInvitations = async ({
@@ -36,15 +36,12 @@ export const getInvitations = async ({
});
try {
const invitations = await fetch(url.toString(), {
const response = await fetch(url.toString(), {
headers,
});
const data = await invitations.json();
const parsedData = parseStringify(data);
revalidatePath("/invitations");
return parsedData;
return handleApiResponse(response, "/invitations");
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching invitations:", error);
return undefined;
}
@@ -84,13 +81,10 @@ export const sendInvite = async (formData: FormData) => {
headers,
body,
});
const data = await response.json();
return parseStringify(data);
return handleApiResponse(response);
} catch (error) {
return {
error: getErrorMessage(error),
};
handleApiError(error);
}
};
@@ -145,13 +139,9 @@ export const updateInvite = async (formData: FormData) => {
return { error };
}
const data = await response.json();
revalidatePath("/invitations");
return parseStringify(data);
return handleApiResponse(response, "/invitations");
} catch (error) {
return {
error: getErrorMessage(error),
};
handleApiError(error);
}
};
@@ -165,12 +155,9 @@ export const getInvitationInfoById = async (invitationId: string) => {
headers,
});
const data = await response.json();
return parseStringify(data);
return handleApiResponse(response);
} catch (error) {
return {
error: getErrorMessage(error),
};
handleApiError(error);
}
};
@@ -209,8 +196,6 @@ export const revokeInvite = async (formData: FormData) => {
revalidatePath("/invitations");
return data || { success: true };
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error revoking invitation:", error);
return { error: getErrorMessage(error) };
handleApiError(error);
}
};
+11 -41
View File
@@ -7,7 +7,8 @@ import {
apiBaseUrl,
getAuthHeaders,
getErrorMessage,
parseStringify,
handleApiError,
handleApiResponse,
} from "@/lib";
import { ManageGroupPayload, ProviderGroupsResponse } from "@/types/components";
@@ -47,16 +48,8 @@ export const getProviderGroups = async ({
headers,
});
if (!response.ok) {
throw new Error(`Error fetching provider groups: ${response.statusText}`);
}
const data: ProviderGroupsResponse = await response.json();
const parsedData = parseStringify(data);
revalidatePath("/manage-groups");
return parsedData;
return handleApiResponse(response, "/manage-groups");
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching provider groups:", error);
return undefined;
}
@@ -72,18 +65,9 @@ export const getProviderGroupInfoById = async (providerGroupId: string) => {
headers,
});
if (!response.ok) {
throw new Error(
`Failed to fetch provider group info: ${response.statusText}`,
);
}
const data = await response.json();
return parseStringify(data);
return handleApiResponse(response);
} catch (error) {
return {
error: getErrorMessage(error),
};
handleApiError(error);
}
};
@@ -131,13 +115,10 @@ export const createProviderGroup = async (formData: FormData) => {
headers,
body,
});
const data = await response.json();
revalidatePath("/manage-groups");
return parseStringify(data);
return handleApiResponse(response, "/manage-groups");
} catch (error) {
return {
error: getErrorMessage(error),
};
handleApiError(error);
}
};
@@ -180,19 +161,9 @@ export const updateProviderGroup = async (
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(
`Failed to update provider group: ${response.status} ${response.statusText}`,
);
}
const data = await response.json();
revalidatePath("/manage-groups");
return parseStringify(data);
return handleApiResponse(response, "/manage-groups");
} catch (error) {
return {
error: getErrorMessage(error),
};
handleApiError(error);
}
};
@@ -233,9 +204,8 @@ export const deleteProviderGroup = async (formData: FormData) => {
revalidatePath("/manage-groups");
return data || { success: true };
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error deleting provider group:", error);
const message = await getErrorMessage(error);
const message = getErrorMessage(error);
return { errors: [{ detail: message }] };
}
};
+4 -25
View File
@@ -1,8 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib";
import { apiBaseUrl, getAuthHeaders, handleApiResponse } from "@/lib";
export const getProvidersOverview = async ({
page = 1,
@@ -32,12 +31,8 @@ export const getProvidersOverview = async ({
headers,
});
const data = await response.json();
const parsedData = parseStringify(data);
revalidatePath("/");
return parsedData;
return handleApiResponse(response, "/");
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching providers overview:", error);
return undefined;
}
@@ -71,16 +66,8 @@ export const getFindingsByStatus = async ({
headers,
});
if (!response.ok) {
throw new Error(`Failed to fetch findings severity: ${response.status}`);
}
const data = await response.json();
const parsedData = parseStringify(data);
revalidatePath("/");
return parsedData;
return handleApiResponse(response, "/");
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching findings severity overview:", error);
return undefined;
}
@@ -114,16 +101,8 @@ export const getFindingsBySeverity = async ({
headers,
});
if (!response.ok) {
throw new Error(`Failed to fetch findings severity: ${response.status}`);
}
const data = await response.json();
const parsedData = parseStringify(data);
revalidatePath("/");
return parsedData;
return handleApiResponse(response, "/");
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching findings severity overview:", error);
return undefined;
}
+12 -34
View File
@@ -6,11 +6,9 @@ import { redirect } from "next/navigation";
import {
apiBaseUrl,
getAuthHeaders,
getErrorMessage,
getFormValue,
handleApiError,
handleApiResponse,
parseStringify,
wait,
} from "@/lib";
import { buildSecretConfig } from "@/lib/provider-credentials/build-crendentials";
@@ -43,15 +41,12 @@ export const getProviders = async ({
});
try {
const providers = await fetch(url.toString(), {
const response = await fetch(url.toString(), {
headers,
});
const data = await providers.json();
const parsedData = parseStringify(data);
revalidatePath("/providers");
return parsedData;
return handleApiResponse(response, "/providers");
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching providers:", error);
return undefined;
}
@@ -64,16 +59,13 @@ export const getProvider = async (formData: FormData) => {
const url = new URL(`${apiBaseUrl}/providers/${providerId}`);
try {
const providers = await fetch(url.toString(), {
const response = await fetch(url.toString(), {
headers,
});
const data = await providers.json();
const parsedData = parseStringify(data);
return parsedData;
return handleApiResponse(response, "/providers");
} catch (error) {
return {
error: getErrorMessage(error),
};
return handleApiError(error);
}
};
@@ -129,15 +121,9 @@ export const addProvider = async (formData: FormData) => {
body: JSON.stringify(bodyData),
});
const data = await response.json();
revalidatePath("/providers");
return parseStringify(data);
return handleApiResponse(response, "/providers");
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
return {
error: getErrorMessage(error),
};
return handleApiError(error);
}
};
@@ -204,11 +190,6 @@ export const updateCredentialsProvider = async (
}),
});
if (!response.ok) {
const data = await response.json();
return parseStringify(data); // Return API errors for UI handling
}
return handleApiResponse(response, "/providers");
} catch (error) {
return handleApiError(error);
@@ -223,6 +204,7 @@ export const checkConnectionProvider = async (formData: FormData) => {
try {
const response = await fetch(url.toString(), { method: "POST", headers });
await wait(2000);
return handleApiResponse(response, "/providers");
} catch (error) {
return handleApiError(error);
@@ -263,9 +245,7 @@ export const deleteCredentials = async (secretId: string) => {
revalidatePath("/providers");
return data || { success: true };
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error deleting credentials:", error);
return { error: getErrorMessage(error) };
handleApiError(error);
}
};
@@ -302,8 +282,6 @@ export const deleteProvider = async (formData: FormData) => {
revalidatePath("/providers");
return data || { success: true };
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error deleting provider:", error);
return { error: getErrorMessage(error) };
handleApiError(error);
}
};
+10 -29
View File
@@ -1,9 +1,8 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib";
import { apiBaseUrl, getAuthHeaders, handleApiResponse } from "@/lib";
export const getResources = async ({
page = 1,
@@ -43,15 +42,11 @@ export const getResources = async ({
});
try {
const resources = await fetch(url.toString(), {
const response = await fetch(url.toString(), {
headers,
});
const data = await resources.json();
const parsedData = parseStringify(data);
revalidatePath("/resources");
return parsedData;
return handleApiResponse(response, "/resources");
} catch (error) {
console.error("Error fetching resources:", error);
return undefined;
@@ -96,15 +91,11 @@ export const getLatestResources = async ({
});
try {
const resources = await fetch(url.toString(), {
const response = await fetch(url.toString(), {
headers,
});
const data = await resources.json();
const parsedData = parseStringify(data);
revalidatePath("/resources");
return parsedData;
return handleApiResponse(response, "/resources");
} catch (error) {
console.error("Error fetching latest resources:", error);
return undefined;
@@ -128,16 +119,12 @@ export const getMetadataInfo = async ({
});
try {
const metadata = await fetch(url.toString(), {
const response = await fetch(url.toString(), {
headers,
});
const data = await metadata.json();
const parsedData = parseStringify(data);
return parsedData;
return handleApiResponse(response);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching metadata info:", error);
return undefined;
}
@@ -160,14 +147,11 @@ export const getLatestMetadataInfo = async ({
});
try {
const metadata = await fetch(url.toString(), {
const response = await fetch(url.toString(), {
headers,
});
const data = await metadata.json();
const parsedData = parseStringify(data);
return parsedData;
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching latest metadata info:", error);
return undefined;
@@ -205,10 +189,7 @@ export const getResourceById = async (
throw new Error(`Error fetching resource: ${resource.status}`);
}
const data = await resource.json();
const parsedData = parseStringify(data);
return parsedData;
return handleApiResponse(resource);
} catch (error) {
console.error("Error fetching resource by ID:", error);
return undefined;
+13 -43
View File
@@ -6,8 +6,8 @@ import { redirect } from "next/navigation";
import {
apiBaseUrl,
getAuthHeaders,
getErrorMessage,
parseStringify,
handleApiError,
handleApiResponse,
} from "@/lib";
export const getRoles = async ({
@@ -36,15 +36,12 @@ export const getRoles = async ({
});
try {
const roles = await fetch(url.toString(), {
const response = await fetch(url.toString(), {
headers,
});
const data = await roles.json();
const parsedData = parseStringify(data);
revalidatePath("/roles");
return parsedData;
return handleApiResponse(response, "/roles");
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching roles:", error);
return undefined;
}
@@ -60,16 +57,9 @@ export const getRoleInfoById = async (roleId: string) => {
headers,
});
if (!response.ok) {
throw new Error(`Failed to fetch role info: ${response.statusText}`);
}
const data = await response.json();
return parseStringify(data);
return handleApiResponse(response);
} catch (error) {
return {
error: getErrorMessage(error),
};
handleApiError(error);
}
};
@@ -92,14 +82,8 @@ export const getRolesByIds = async (roleIds: string[]) => {
headers,
});
if (!response.ok) {
throw new Error(`Failed to fetch roles: ${response.statusText}`);
}
const data = await response.json();
return parseStringify(data);
return handleApiResponse(response);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching roles by IDs:", error);
return { data: [] };
}
@@ -153,15 +137,9 @@ export const addRole = async (formData: FormData) => {
body,
});
const data = await response.json();
revalidatePath("/roles");
return data;
return handleApiResponse(response, "/roles", false);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error during API call:", error);
return {
error: getErrorMessage(error),
};
handleApiError(error);
}
};
@@ -215,15 +193,9 @@ export const updateRole = async (formData: FormData, roleId: string) => {
body,
});
const data = await response.json();
revalidatePath("/roles");
return data;
return handleApiResponse(response, "/roles", false);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error during API call:", error);
return {
error: getErrorMessage(error),
};
handleApiError(error);
}
};
@@ -254,8 +226,6 @@ export const deleteRole = async (roleId: string) => {
revalidatePath("/roles");
return data || { success: true };
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error deleting role:", error);
return { error: getErrorMessage(error) };
handleApiError(error);
}
};
+15 -62
View File
@@ -1,13 +1,13 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import {
apiBaseUrl,
getAuthHeaders,
getErrorMessage,
parseStringify,
handleApiError,
handleApiResponse,
} from "@/lib";
export const getScans = async ({
@@ -43,12 +43,9 @@ export const getScans = async ({
try {
const response = await fetch(url.toString(), { headers });
const data = await response.json();
const parsedData = parseStringify(data);
revalidatePath("/scans");
return parsedData;
return handleApiResponse(response, "/scans");
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching scans:", error);
return undefined;
}
@@ -56,9 +53,7 @@ export const getScans = async ({
export const getScansByState = async () => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/scans`);
// Request only the necessary fields to optimize the response
url.searchParams.append("fields[scans]", "state");
@@ -67,20 +62,8 @@ export const getScansByState = async () => {
headers,
});
if (!response.ok) {
try {
const errorData = await response.json();
throw new Error(errorData?.message || "Failed to fetch scans by state");
} catch {
throw new Error("Failed to fetch scans by state");
}
}
const data = await response.json();
return parseStringify(data);
return handleApiResponse(response);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching scans by state:", error);
return undefined;
}
@@ -92,17 +75,13 @@ export const getScan = async (scanId: string) => {
const url = new URL(`${apiBaseUrl}/scans/${scanId}`);
try {
const scan = await fetch(url.toString(), {
const response = await fetch(url.toString(), {
headers,
});
const data = await scan.json();
const parsedData = parseStringify(data);
return parsedData;
return handleApiResponse(response);
} catch (error) {
return {
error: getErrorMessage(error),
};
return handleApiError(error);
}
};
@@ -139,20 +118,9 @@ export const scanOnDemand = async (formData: FormData) => {
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorData = await response.json();
return { success: false, error: errorData.errors[0].detail };
}
const data = await response.json();
revalidatePath("/scans");
return parseStringify(data);
return handleApiResponse(response, "/scans");
} catch (error) {
console.error("Error starting scan:", error);
return { error: getErrorMessage(error) };
return handleApiError(error);
}
};
@@ -177,19 +145,9 @@ export const scheduleDaily = async (formData: FormData) => {
}),
});
if (!response.ok) {
throw new Error(`Failed to schedule daily: ${response.statusText}`);
}
const data = await response.json();
revalidatePath("/scans");
return parseStringify(data);
return handleApiResponse(response, "/scans");
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
return {
error: getErrorMessage(error),
};
return handleApiError(error);
}
};
@@ -215,15 +173,10 @@ export const updateScan = async (formData: FormData) => {
},
}),
});
const data = await response.json();
revalidatePath("/scans");
return parseStringify(data);
return handleApiResponse(response, "/scans");
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
return {
error: getErrorMessage(error),
};
return handleApiError(error);
}
};
+5 -5
View File
@@ -3,8 +3,8 @@
import {
apiBaseUrl,
getAuthHeaders,
getErrorMessage,
parseStringify,
handleApiError,
handleApiResponse,
} from "@/lib";
export const getTask = async (taskId: string) => {
@@ -16,9 +16,9 @@ export const getTask = async (taskId: string) => {
const response = await fetch(url.toString(), {
headers,
});
const data = await response.json();
return parseStringify(data);
return handleApiResponse(response);
} catch (error) {
return { error: getErrorMessage(error) };
return handleApiError(error);
}
};
+9 -16
View File
@@ -1,9 +1,13 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib/helper";
import {
apiBaseUrl,
getAuthHeaders,
handleApiError,
handleApiResponse,
} from "@/lib/helper";
export const getAllTenants = async () => {
const headers = await getAuthHeaders({ contentType: false });
@@ -19,12 +23,8 @@ export const getAllTenants = async () => {
throw new Error(`Failed to fetch tenants data: ${response.statusText}`);
}
const data = await response.json();
const parsedData = parseStringify(data);
revalidatePath("/profile");
return parsedData;
return handleApiResponse(response, "/profile");
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching tenants:", error);
return undefined;
}
@@ -80,16 +80,9 @@ export async function updateTenantName(prevState: any, formData: FormData) {
throw new Error(`Failed to update tenant name: ${response.statusText}`);
}
await response.json();
revalidatePath("/profile");
handleApiResponse(response, "/profile", false);
return { success: "Tenant name updated successfully!" };
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error updating tenant name:", error);
return {
errors: {
general: "Error updating tenant name. Please try again.",
},
};
return handleApiError(error);
}
}
+11 -45
View File
@@ -6,8 +6,8 @@ import { redirect } from "next/navigation";
import {
apiBaseUrl,
getAuthHeaders,
getErrorMessage,
parseStringify,
handleApiError,
handleApiResponse,
} from "@/lib";
export const getUsers = async ({
@@ -39,12 +39,9 @@ export const getUsers = async ({
const users = await fetch(url.toString(), {
headers,
});
const data = await users.json();
const parsedData = parseStringify(data);
revalidatePath("/users");
return parsedData;
return handleApiResponse(users, "/users");
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching users:", error);
return undefined;
}
@@ -88,15 +85,9 @@ export const updateUser = async (formData: FormData) => {
}),
});
const data = await response.json();
revalidatePath("/users");
return parseStringify(data);
return handleApiResponse(response, "/users");
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
return {
error: getErrorMessage(error),
};
handleApiError(error);
}
};
@@ -129,20 +120,9 @@ export const updateUserRole = async (formData: FormData) => {
body: JSON.stringify(requestBody),
});
const data = await response.json();
if (!response.ok) {
return { error: data.errors || "An error occurred" };
}
revalidatePath("/users"); // Update the path as needed
return parseStringify(data);
return handleApiResponse(response, "/users");
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
return {
error: getErrorMessage(error),
};
handleApiError(error);
}
};
@@ -178,9 +158,7 @@ export const deleteUser = async (formData: FormData) => {
revalidatePath("/users");
return data || { success: true };
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error deleting user:", error);
return { error: getErrorMessage(error) };
handleApiError(error);
}
};
@@ -198,12 +176,8 @@ export const getUserInfo = async () => {
throw new Error(`Failed to fetch user data: ${response.statusText}`);
}
const data = await response.json();
const parsedData = parseStringify(data);
revalidatePath("/profile");
return parsedData;
return handleApiResponse(response, "/profile");
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching profile:", error);
return undefined;
}
@@ -224,16 +198,8 @@ export const getUserMemberships = async (userId: string) => {
headers,
});
if (!response.ok) {
throw new Error(
`Failed to fetch user memberships: ${response.statusText}`,
);
}
const data = await response.json();
return parseStringify(data);
return handleApiResponse(response);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching user memberships:", error);
return { data: [] };
}
+17 -11
View File
@@ -91,12 +91,14 @@ export default async function Compliance({
}
: undefined;
const metadataInfoData = await getComplianceOverviewMetadataInfo({
query,
filters: {
"filter[scan_id]": selectedScanId,
},
});
const metadataInfoData = selectedScanId
? await getComplianceOverviewMetadataInfo({
query,
filters: {
"filter[scan_id]": selectedScanId,
},
})
: { data: { attributes: { regions: [] } } };
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
@@ -140,11 +142,15 @@ const SSRComplianceGrid = async ({
// Extract query from filters
const query = (filters["filter[search]"] as string) || "";
const compliancesData = await getCompliancesOverview({
scanId,
region: regionFilter,
query,
});
// Only fetch compliance data if we have a valid scanId
const compliancesData =
scanId && scanId.trim() !== ""
? await getCompliancesOverview({
scanId,
region: regionFilter,
query,
})
: { data: [], errors: [] };
const type = compliancesData?.data?.type;
+61 -17
View File
@@ -1,35 +1,79 @@
"use client";
import { Icon } from "@iconify/react";
import { useEffect } from "react";
import { RocketIcon } from "@/components/icons";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui";
import { CustomButton } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
export default function Error({
error,
// reset,
reset,
}: {
error: Error;
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log the error to an error reporting service
/* eslint-disable no-console */
console.error(error);
// Check if it's a 500 error
const is500Error =
error.message?.includes("500") ||
error.message?.includes("502") ||
error.message?.includes("503") ||
error.message?.toLowerCase().includes("server error");
if (is500Error) {
// Log 500 errors specifically for monitoring
console.error("Server error detected:", {
message: error.message,
digest: error.digest,
timestamp: new Date().toISOString(),
});
// TODO: sent to sentry
} else {
console.error("Application error:", error);
}
}, [error]);
const is500Error =
error.message?.includes("500") ||
error.message?.includes("502") ||
error.message?.includes("503") ||
error.message?.toLowerCase().includes("server error");
return (
<Alert className="mx-auto mt-[35%] w-fit">
<RocketIcon className="h-5 w-5" />
<AlertTitle className="text-lg">An unexpected error occurred</AlertTitle>
<AlertDescription className="mb-5">
We&apos;re sorry for the inconvenience. Please try again or contact
support if the problem persists.
</AlertDescription>
<CustomLink href="/" target="_self" className="font-bold">
Go to the homepage
</CustomLink>
</Alert>
<div className="flex min-h-screen items-center justify-center p-4">
<Alert className="w-full max-w-lg">
<Icon
icon={is500Error ? "tabler:server-off" : "tabler:rocket-off"}
className="h-5 w-5"
/>
<AlertTitle className="text-lg">
{is500Error
? "Server temporarily unavailable"
: "An unexpected error occurred"}
</AlertTitle>
<AlertDescription className="mb-5">
{is500Error
? "The server is experiencing issues. Our team has been notified and is working on it. Please try again in a few moments."
: "We're sorry for the inconvenience. Please try again or contact support if the problem persists."}
</AlertDescription>
<div className="flex items-center justify-start gap-3">
<CustomButton
onPress={reset}
variant="solid"
color="primary"
size="sm"
startContent={<Icon icon="tabler:refresh" className="h-4 w-4" />}
ariaLabel="Try Again"
>
Try Again
</CustomButton>
<CustomLink href="/" target="_self" className="font-bold">
Go to Overview
</CustomLink>
</div>
</Alert>
</div>
);
}
+7
View File
@@ -1,3 +1,5 @@
import { redirect } from "next/navigation";
import { getLighthouseConfig } from "@/actions/lighthouse/lighthouse";
import { LighthouseIcon } from "@/components/icons/Icons";
import { Chat } from "@/components/lighthouse";
@@ -7,6 +9,11 @@ export default async function AIChatbot() {
const lighthouseConfig = await getLighthouseConfig();
const hasConfig = !!lighthouseConfig;
if (!hasConfig) {
return redirect("/lighthouse/config");
}
const isActive = lighthouseConfig.is_active ?? false;
return (
+14 -2
View File
@@ -3,6 +3,7 @@ import { Suspense } from "react";
import { getProviders } from "@/actions/providers";
import { getScans, getScansByState } from "@/actions/scans";
import { auth } from "@/auth.config";
import { MutedFindingsConfigButton } from "@/components/providers";
import {
AutoRefresh,
@@ -14,6 +15,7 @@ import { LaunchScanWorkflow } from "@/components/scans/launch-workflow";
import { SkeletonTableScans } from "@/components/scans/table";
import { ColumnGetScans } from "@/components/scans/table/scans";
import { ContentLayout } from "@/components/ui";
import { CustomBanner } from "@/components/ui/custom/custom-banner";
import { DataTable } from "@/components/ui/table";
import {
createProviderDetailsMapping,
@@ -26,6 +28,7 @@ export default async function Scans({
}: {
searchParams: SearchParamsProps;
}) {
const session = await auth();
const filteredParams = { ...searchParams };
delete filteredParams.scanId;
const searchParamsKey = JSON.stringify(filteredParams);
@@ -48,12 +51,15 @@ export default async function Scans({
connected: provider.attributes.connection.connected,
})) || [];
const thereIsNoProviders = !providersData?.data;
const thereIsNoProviders =
!providersData?.data || providersData.data.length === 0;
const thereIsNoProvidersConnected = providersData?.data?.every(
(provider: ProviderProps) => !provider.attributes.connection.connected,
);
const hasManageScansPermission = session?.user?.permissions?.manage_scans;
// Get scans data to check for executing scans
const scansData = await getScansByState();
@@ -81,7 +87,12 @@ export default async function Scans({
<ContentLayout title="Scans" icon="lucide:scan-search">
<AutoRefresh hasExecutingScan={hasExecutingScan} />
<>
{thereIsNoProvidersConnected ? (
{!hasManageScansPermission ? (
<CustomBanner
title={"Access Denied"}
message={"You don't have permission to launch the scan."}
/>
) : thereIsNoProvidersConnected ? (
<>
<Spacer y={8} />
<NoProvidersConnected />
@@ -90,6 +101,7 @@ export default async function Scans({
) : (
<LaunchScanWorkflow providers={providerInfo} />
)}
<ScansFilters
providerUIDs={providerUIDs}
providerDetails={providerDetails}
@@ -284,7 +284,6 @@ export const SamlConfigForm = ({
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setEmailDomain(newValue);
validateFields(newValue, !!uploadedFile);
}}
/>
@@ -69,7 +69,7 @@ export const M365CredentialsForm = ({
<div className="flex items-center rounded-lg border border-system-warning bg-system-warning-medium p-2 text-sm dark:text-default-300">
<InfoIcon className="mr-2 inline h-4 w-4 flex-shrink-0" />
<p className="text-xs font-extrabold">
By September 2025, MFA will be mandatory.
By October 2025, MFA will be mandatory.
</p>
</div>
<p className="text-sm text-default-500">
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { InfoIcon } from "lucide-react";
import { CustomButton } from ".";
interface CustomBannerProps {
title: string;
message: string;
buttonLabel?: string;
buttonLink?: string;
}
export const CustomBanner = ({
title,
message,
buttonLabel = "Go Home",
buttonLink = "/",
}: CustomBannerProps) => {
return (
<div className="flex items-center justify-start rounded-lg border-1 border-system-warning-light px-4 py-6 shadow-box dark:bg-prowler-blue-400">
<div className="flex w-full flex-col items-start gap-6 md:flex-row md:items-center md:justify-between md:gap-8">
<div className="flex flex-col space-y-3">
<div className="flex items-center justify-start gap-3">
<InfoIcon className="h-6 w-6 text-gray-800 dark:text-white" />
<h2 className="text-lg font-bold text-gray-800 dark:text-white">
{title}
</h2>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300">{message}</p>
</div>
<div className="w-full md:w-auto md:flex-shrink-0">
<CustomButton
asLink={buttonLink}
className="w-full justify-center md:w-fit"
ariaLabel={buttonLabel}
variant="solid"
color="action"
size="md"
>
{buttonLabel}
</CustomButton>
</div>
</div>
</div>
);
};
@@ -21,17 +21,17 @@ export const EditTenantForm = ({
const { toast } = useToast();
useEffect(() => {
if (state?.success) {
if (state && "success" in state) {
toast({
title: "Changed successfully",
description: state.success,
});
setIsOpen(false);
} else if (state?.errors?.general) {
} else if (state && "error" in state) {
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: state.errors.general,
description: state.error,
});
}
}, [state, toast, setIsOpen]);
@@ -49,8 +49,8 @@ export const EditTenantForm = ({
labelPlacement="outside"
variant="bordered"
isRequired={true}
isInvalid={!!state?.errors?.name}
errorMessage={state?.errors?.name}
isInvalid={!!(state && "error" in state)}
errorMessage={state && "error" in state ? state.error : undefined}
/>
{/* Hidden inputs for Server Action */}
+23 -3
View File
@@ -345,18 +345,38 @@ export const permissionFormFields: PermissionInfo[] = [
export const handleApiResponse = async (
response: Response,
pathToRevalidate?: string,
parse = true,
) => {
if (!response.ok) {
const errorData = await response.json().catch(() => null);
const errorDetail = errorData?.errors?.[0]?.detail;
// Special handling for server errors (500+)
if (response.status >= 500) {
throw new Error(
errorDetail ||
`Server error (${response.status}): The server encountered an error. Please try again later.`,
);
}
// Client errors (4xx)
throw new Error(
errorDetail ||
`Request failed (${response.status}): ${response.statusText}`,
);
}
const data = await response.json();
if (pathToRevalidate) {
if (pathToRevalidate && pathToRevalidate !== "") {
revalidatePath(pathToRevalidate);
}
return parseStringify(data);
return parse ? parseStringify(data) : data;
};
// Helper function to handle API errors consistently
export const handleApiError = (error: unknown) => {
export const handleApiError = (error: unknown): { error: string } => {
console.error(error);
return {
error: getErrorMessage(error),
+52 -52
View File
@@ -44,7 +44,7 @@
"jwt-decode": "^4.0.0",
"lucide-react": "^0.471.0",
"marked": "^15.0.12",
"next": "^14.2.30",
"next": "^14.2.32",
"next-auth": "^5.0.0-beta.25",
"next-themes": "^0.2.1",
"radix-ui": "^1.1.3",
@@ -75,7 +75,7 @@
"@typescript-eslint/parser": "^7.10.0",
"autoprefixer": "10.4.19",
"eslint": "^8.56.0",
"eslint-config-next": "^14.2.30",
"eslint-config-next": "^14.2.32",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.9.0",
@@ -1258,15 +1258,15 @@
}
},
"node_modules/@next/env": {
"version": "14.2.30",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.30.tgz",
"integrity": "sha512-KBiBKrDY6kxTQWGzKjQB7QirL3PiiOkV7KW98leHFjtVRKtft76Ra5qSA/SL75xT44dp6hOcqiiJ6iievLOYug==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.32.tgz",
"integrity": "sha512-n9mQdigI6iZ/DF6pCTwMKeWgF2e8lg7qgt5M7HXMLtyhZYMnf/u905M18sSpPmHL9MKp9JHo56C6jrD2EvWxng==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
"version": "14.2.30",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.30.tgz",
"integrity": "sha512-mvVsMIutMxQ4NGZEMZ1kiBNc+la8Xmlk30bKUmCPQz2eFkmsLv54Mha8QZarMaCtSPkkFA1TMD+FIZk0l/PpzA==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.32.tgz",
"integrity": "sha512-tyZMX8g4cWg/uPW4NxiJK13t62Pab47SKGJGVZJa6YtFwtfrXovH4j1n9tdpRdXW03PGQBugYEVGM7OhWfytdA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1274,9 +1274,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "14.2.30",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.30.tgz",
"integrity": "sha512-EAqfOTb3bTGh9+ewpO/jC59uACadRHM6TSA9DdxJB/6gxOpyV+zrbqeXiFTDy9uV6bmipFDkfpAskeaDcO+7/g==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.32.tgz",
"integrity": "sha512-osHXveM70zC+ilfuFa/2W6a1XQxJTvEhzEycnjUaVE8kpUS09lDpiDDX2YLdyFCzoUbvbo5r0X1Kp4MllIOShw==",
"cpu": [
"arm64"
],
@@ -1290,9 +1290,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "14.2.30",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.30.tgz",
"integrity": "sha512-TyO7Wz1IKE2kGv8dwQ0bmPL3s44EKVencOqwIY69myoS3rdpO1NPg5xPM5ymKu7nfX4oYJrpMxv8G9iqLsnL4A==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.32.tgz",
"integrity": "sha512-P9NpCAJuOiaHHpqtrCNncjqtSBi1f6QUdHK/+dNabBIXB2RUFWL19TY1Hkhu74OvyNQEYEzzMJCMQk5agjw1Qg==",
"cpu": [
"x64"
],
@@ -1306,9 +1306,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.2.30",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.30.tgz",
"integrity": "sha512-I5lg1fgPJ7I5dk6mr3qCH1hJYKJu1FsfKSiTKoYwcuUf53HWTrEkwmMI0t5ojFKeA6Vu+SfT2zVy5NS0QLXV4Q==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.32.tgz",
"integrity": "sha512-v7JaO0oXXt6d+cFjrrKqYnR2ubrD+JYP7nQVRZgeo5uNE5hkCpWnHmXm9vy3g6foMO8SPwL0P3MPw1c+BjbAzA==",
"cpu": [
"arm64"
],
@@ -1322,9 +1322,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.30",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.30.tgz",
"integrity": "sha512-8GkNA+sLclQyxgzCDs2/2GSwBc92QLMrmYAmoP2xehe5MUKBLB2cgo34Yu242L1siSkwQkiV4YLdCnjwc/Micw==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.32.tgz",
"integrity": "sha512-tA6sIKShXtSJBTH88i0DRd6I9n3ZTirmwpwAqH5zdJoQF7/wlJXR8DkPmKwYl5mFWhEKr5IIa3LfpMW9RRwKmQ==",
"cpu": [
"arm64"
],
@@ -1338,9 +1338,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "14.2.30",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.30.tgz",
"integrity": "sha512-8Ly7okjssLuBoe8qaRCcjGtcMsv79hwzn/63wNeIkzJVFVX06h5S737XNr7DZwlsbTBDOyI6qbL2BJB5n6TV/w==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.32.tgz",
"integrity": "sha512-7S1GY4TdnlGVIdeXXKQdDkfDysoIVFMD0lJuVVMeb3eoVjrknQ0JNN7wFlhCvea0hEk0Sd4D1hedVChDKfV2jw==",
"cpu": [
"x64"
],
@@ -1354,9 +1354,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "14.2.30",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.30.tgz",
"integrity": "sha512-dBmV1lLNeX4mR7uI7KNVHsGQU+OgTG5RGFPi3tBJpsKPvOPtg9poyav/BYWrB3GPQL4dW5YGGgalwZ79WukbKQ==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.32.tgz",
"integrity": "sha512-OHHC81P4tirVa6Awk6eCQ6RBfWl8HpFsZtfEkMpJ5GjPsJ3nhPe6wKAJUZ/piC8sszUkAgv3fLflgzPStIwfWg==",
"cpu": [
"x64"
],
@@ -1370,9 +1370,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.2.30",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.30.tgz",
"integrity": "sha512-6MMHi2Qc1Gkq+4YLXAgbYslE1f9zMGBikKMdmQRHXjkGPot1JY3n5/Qrbg40Uvbi8//wYnydPnyvNhI1DMUW1g==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.32.tgz",
"integrity": "sha512-rORQjXsAFeX6TLYJrCG5yoIDj+NKq31Rqwn8Wpn/bkPNy5rTHvOXkW8mLFonItS7QC6M+1JIIcLe+vOCTOYpvg==",
"cpu": [
"arm64"
],
@@ -1386,9 +1386,9 @@
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.30",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.30.tgz",
"integrity": "sha512-pVZMnFok5qEX4RT59mK2hEVtJX+XFfak+/rjHpyFh7juiT52r177bfFKhnlafm0UOSldhXjj32b+LZIOdswGTg==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.32.tgz",
"integrity": "sha512-jHUeDPVHrgFltqoAqDB6g6OStNnFxnc7Aks3p0KE0FbwAvRg6qWKYF5mSTdCTxA3axoSAUwxYdILzXJfUwlHhA==",
"cpu": [
"ia32"
],
@@ -1402,9 +1402,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "14.2.30",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.30.tgz",
"integrity": "sha512-4KCo8hMZXMjpTzs3HOqOGYYwAXymXIy7PEPAXNEcEOyKqkjiDlECumrWziy+JEF0Oi4ILHGxzgQ3YiMGG2t/Lg==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.32.tgz",
"integrity": "sha512-2N0lSoU4GjfLSO50wvKpMQgKd4HdI2UHEhQPPPnlgfBJlOgJxkjpkYBqzk08f1gItBB6xF/n+ykso2hgxuydsA==",
"cpu": [
"x64"
],
@@ -9070,13 +9070,13 @@
}
},
"node_modules/eslint-config-next": {
"version": "14.2.30",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.30.tgz",
"integrity": "sha512-4pTMb3wfpI+piVeEz3TWG1spjuXJJBZaYabi2H08z2ZTk6/N304POEovHdFmK6EZb4QlKpETulBNaRIITA0+xg==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.32.tgz",
"integrity": "sha512-mP/NmYtDBsKlKIOBnH+CW+pYeyR3wBhE+26DAqQ0/aRtEBeTEjgY2wAFUugUELkTLmrX6PpuMSSTpOhz7j9kdQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@next/eslint-plugin-next": "14.2.30",
"@next/eslint-plugin-next": "14.2.32",
"@rushstack/eslint-patch": "^1.3.3",
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
@@ -12758,12 +12758,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "14.2.30",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.30.tgz",
"integrity": "sha512-+COdu6HQrHHFQ1S/8BBsCag61jZacmvbuL2avHvQFbWa2Ox7bE+d8FyNgxRLjXQ5wtPyQwEmk85js/AuaG2Sbg==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.32.tgz",
"integrity": "sha512-fg5g0GZ7/nFc09X8wLe6pNSU8cLWbLRG3TZzPJ1BJvi2s9m7eF991se67wliM9kR5yLHRkyGKU49MMx58s3LJg==",
"license": "MIT",
"dependencies": {
"@next/env": "14.2.30",
"@next/env": "14.2.32",
"@swc/helpers": "0.5.5",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
@@ -12778,15 +12778,15 @@
"node": ">=18.17.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.30",
"@next/swc-darwin-x64": "14.2.30",
"@next/swc-linux-arm64-gnu": "14.2.30",
"@next/swc-linux-arm64-musl": "14.2.30",
"@next/swc-linux-x64-gnu": "14.2.30",
"@next/swc-linux-x64-musl": "14.2.30",
"@next/swc-win32-arm64-msvc": "14.2.30",
"@next/swc-win32-ia32-msvc": "14.2.30",
"@next/swc-win32-x64-msvc": "14.2.30"
"@next/swc-darwin-arm64": "14.2.32",
"@next/swc-darwin-x64": "14.2.32",
"@next/swc-linux-arm64-gnu": "14.2.32",
"@next/swc-linux-arm64-musl": "14.2.32",
"@next/swc-linux-x64-gnu": "14.2.32",
"@next/swc-linux-x64-musl": "14.2.32",
"@next/swc-win32-arm64-msvc": "14.2.32",
"@next/swc-win32-ia32-msvc": "14.2.32",
"@next/swc-win32-x64-msvc": "14.2.32"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
+2 -2
View File
@@ -36,7 +36,7 @@
"jwt-decode": "^4.0.0",
"lucide-react": "^0.471.0",
"marked": "^15.0.12",
"next": "^14.2.30",
"next": "^14.2.32",
"next-auth": "^5.0.0-beta.25",
"next-themes": "^0.2.1",
"radix-ui": "^1.1.3",
@@ -67,7 +67,7 @@
"@typescript-eslint/parser": "^7.10.0",
"autoprefixer": "10.4.19",
"eslint": "^8.56.0",
"eslint-config-next": "^14.2.30",
"eslint-config-next": "^14.2.32",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.9.0",
+37 -23
View File
@@ -55,14 +55,16 @@ const validateAwsCredentialsCreate = (
if (!data.aws_access_key_id) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "AWS Access Key ID is required when using access and secret key",
message:
"AWS Access Key ID is required when using access and secret key",
path: ["aws_access_key_id"],
});
}
if (!data.aws_secret_access_key) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "AWS Secret Access Key is required when using access and secret key",
message:
"AWS Secret Access Key is required when using access and secret key",
path: ["aws_secret_access_key"],
});
}
@@ -78,7 +80,8 @@ const validateAwsCredentialsEdit = (data: any, ctx: z.RefinementCtx) => {
if (hasAccessKey && !hasSecretKey) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "AWS Secret Access Key is required when providing Access Key ID",
message:
"AWS Secret Access Key is required when providing Access Key ID",
path: ["aws_secret_access_key"],
});
}
@@ -86,7 +89,8 @@ const validateAwsCredentialsEdit = (data: any, ctx: z.RefinementCtx) => {
if (hasSecretKey && !hasAccessKey) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "AWS Access Key ID is required when providing Secret Access Key",
message:
"AWS Access Key ID is required when providing Secret Access Key",
path: ["aws_access_key_id"],
});
}
@@ -99,8 +103,10 @@ const validateIamRole = (
ctx: z.RefinementCtx,
checkShowSection: boolean = true,
) => {
const shouldValidate = checkShowSection ? data.show_role_section === true : true;
const shouldValidate = checkShowSection
? data.show_role_section === true
: true;
if (shouldValidate && data.role_arn) {
if (data.role_arn.trim() === "") {
ctx.addIssue({
@@ -160,9 +166,14 @@ export const s3IntegrationFormSchema = baseS3IntegrationSchema
export const editS3IntegrationFormSchema = baseS3IntegrationSchema
.extend({
bucket_name: z.string().min(1, "Bucket name is required").optional(),
output_directory: z.string().min(1, "Output directory is required").optional(),
output_directory: z
.string()
.min(1, "Output directory is required")
.optional(),
providers: z.array(z.string()).optional(),
credentials_type: z.enum(["aws-sdk-default", "access-secret-key"]).optional(),
credentials_type: z
.enum(["aws-sdk-default", "access-secret-key"])
.optional(),
})
.superRefine((data, ctx) => {
validateAwsCredentialsEdit(data, ctx);
@@ -201,18 +212,21 @@ export const securityHubIntegrationFormSchema = baseSecurityHubIntegrationSchema
}
});
export const editSecurityHubIntegrationFormSchema = baseSecurityHubIntegrationSchema
.extend({
provider_id: z.string().optional(),
send_only_fails: z.boolean().optional(),
archive_previous_findings: z.boolean().optional(),
use_custom_credentials: z.boolean().optional(),
credentials_type: z.enum(["aws-sdk-default", "access-secret-key"]).optional(),
})
.superRefine((data, ctx) => {
if (data.use_custom_credentials !== false) {
validateAwsCredentialsEdit(data, ctx);
}
// Always validate role if role_arn is provided
validateIamRole(data, ctx, false);
});
export const editSecurityHubIntegrationFormSchema =
baseSecurityHubIntegrationSchema
.extend({
provider_id: z.string().optional(),
send_only_fails: z.boolean().optional(),
archive_previous_findings: z.boolean().optional(),
use_custom_credentials: z.boolean().optional(),
credentials_type: z
.enum(["aws-sdk-default", "access-secret-key"])
.optional(),
})
.superRefine((data, ctx) => {
if (data.use_custom_credentials !== false) {
validateAwsCredentialsEdit(data, ctx);
}
// Always validate role if role_arn is provided
validateIamRole(data, ctx, false);
});
View File