diff --git a/.github/workflows/pr-conflict-checker.yml b/.github/workflows/pr-conflict-checker.yml index 2071dbaa78..ee820e87f0 100644 --- a/.github/workflows/pr-conflict-checker.yml +++ b/.github/workflows/pr-conflict-checker.yml @@ -97,6 +97,7 @@ jobs: if: steps.conflict-check.outputs.has_conflicts == 'false' uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: + github-token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} script: | try { await github.rest.issues.removeLabel({ diff --git a/.github/workflows/sdk-build-lint-push-containers.yml b/.github/workflows/sdk-build-lint-push-containers.yml index ec1d71a0cb..591b07dc50 100644 --- a/.github/workflows/sdk-build-lint-push-containers.yml +++ b/.github/workflows/sdk-build-lint-push-containers.yml @@ -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 diff --git a/README.md b/README.md index fce0fbdc48..2fb1b94cf4 100644 --- a/README.md +++ b/README.md @@ -19,19 +19,16 @@ Slack Shield Python Version Python Version - PyPI Prowler Downloads + PyPI Downloads Docker Pulls - Docker - Docker AWS ECR Gallery

- Repo size - Issues - Version + Version Version Contributors + Issues License Twitter Twitter @@ -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 Prowler Cloud. - ## 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 ```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 --list-checks`, `prowler --list-services`, `prowler --list-compliance` and `prowler --list-categories`. +> Use the following commands to list Prowler's available checks, services, compliance frameworks, and categories: +> - `prowler --list-checks` +> - `prowler --list-services` +> - `prowler --list-compliance` +> - `prowler --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 diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index adc80ed8cf..cb8a4e3b17 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,13 +2,23 @@ All notable changes to the **Prowler API** are documented in this file. -## [1.12.0] (Prowler 5.11.0 - UNRELEASED) +## [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) + +--- + +## [1.12.0] (Prowler 5.11.0) ### Added - Lighthouse support for OpenAI GPT-5 [(#8527)](https://github.com/prowler-cloud/prowler/pull/8527) - Integration with Amazon Security Hub, enabling sending findings to Security Hub [(#8365)](https://github.com/prowler-cloud/prowler/pull/8365) - Generate ASFF output for AWS providers with SecurityHub integration enabled [(#8569)](https://github.com/prowler-cloud/prowler/pull/8569) +### Fixed +- GitHub provider always scans user instead of organization when using provider UID [(#8587)](https://github.com/prowler-cloud/prowler/pull/8587) + ## [1.11.0] (Prowler 5.10.0) ### Added diff --git a/api/pyproject.toml b/api/pyproject.toml index 3a597281cc..6673154e03 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -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" diff --git a/api/src/backend/api/schema_hooks.py b/api/src/backend/api/schema_hooks.py new file mode 100644 index 0000000000..f94db59562 --- /dev/null +++ b/api/src/backend/api/schema_hooks.py @@ -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 diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index cf64a00ffd..5ff07712a3 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Prowler API - version: 1.12.0 + version: 1.13.0 description: |- Prowler API specification. @@ -147,7 +147,22 @@ paths: content: application/vnd.api+json: schema: - $ref: '#/components/schemas/PaginatedTaskList' + $ref: '#/components/schemas/TaskResponse' + examples: + Task queued: + summary: Task queued + value: + 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: null + task_args: null + metadata: null description: The task is in progress '500': description: Compliance overviews generation task failed @@ -230,6 +245,25 @@ paths: $ref: '#/components/schemas/OpenApiResponseResponse' description: Compliance overviews metadata obtained successfully '202': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/TaskResponse' + examples: + Task queued: + summary: Task queued + value: + 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: null + task_args: null + metadata: null description: The task is in progress '500': description: Compliance overviews generation task failed @@ -360,6 +394,25 @@ paths: $ref: '#/components/schemas/PaginatedComplianceOverviewDetailList' description: Compliance requirement details obtained successfully '202': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/TaskResponse' + examples: + Task queued: + summary: Task queued + value: + 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: null + task_args: null + metadata: null description: The task is in progress '500': description: Compliance overviews generation task failed @@ -417,6 +470,7 @@ paths: name: filter[delta] schema: type: string + x-spec-enum-id: fef6b0e2506af8bc nullable: true enum: - changed @@ -452,6 +506,7 @@ paths: name: filter[impact] schema: type: string + x-spec-enum-id: c93e070ed135d9bf enum: - critical - high @@ -540,6 +595,7 @@ paths: name: filter[provider_type] schema: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -560,6 +616,7 @@ paths: type: array items: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -715,6 +772,7 @@ paths: name: filter[severity] schema: type: string + x-spec-enum-id: c93e070ed135d9bf enum: - critical - high @@ -740,6 +798,7 @@ paths: name: filter[status] schema: type: string + x-spec-enum-id: ce612ddbfa464789 enum: - FAIL - MANUAL @@ -941,6 +1000,7 @@ paths: name: filter[delta] schema: type: string + x-spec-enum-id: fef6b0e2506af8bc nullable: true enum: - changed @@ -976,6 +1036,7 @@ paths: name: filter[impact] schema: type: string + x-spec-enum-id: c93e070ed135d9bf enum: - critical - high @@ -1061,6 +1122,7 @@ paths: name: filter[provider_type] schema: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -1081,6 +1143,7 @@ paths: type: array items: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -1236,6 +1299,7 @@ paths: name: filter[severity] schema: type: string + x-spec-enum-id: c93e070ed135d9bf enum: - critical - high @@ -1261,6 +1325,7 @@ paths: name: filter[status] schema: type: string + x-spec-enum-id: ce612ddbfa464789 enum: - FAIL - MANUAL @@ -1392,6 +1457,7 @@ paths: name: filter[delta] schema: type: string + x-spec-enum-id: fef6b0e2506af8bc nullable: true enum: - changed @@ -1427,6 +1493,7 @@ paths: name: filter[impact] schema: type: string + x-spec-enum-id: c93e070ed135d9bf enum: - critical - high @@ -1490,6 +1557,7 @@ paths: name: filter[provider_type] schema: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -1510,6 +1578,7 @@ paths: type: array items: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -1650,6 +1719,7 @@ paths: name: filter[severity] schema: type: string + x-spec-enum-id: c93e070ed135d9bf enum: - critical - high @@ -1675,6 +1745,7 @@ paths: name: filter[status] schema: type: string + x-spec-enum-id: ce612ddbfa464789 enum: - FAIL - MANUAL @@ -1794,6 +1865,7 @@ paths: name: filter[delta] schema: type: string + x-spec-enum-id: fef6b0e2506af8bc nullable: true enum: - changed @@ -1829,6 +1901,7 @@ paths: name: filter[impact] schema: type: string + x-spec-enum-id: c93e070ed135d9bf enum: - critical - high @@ -1917,6 +1990,7 @@ paths: name: filter[provider_type] schema: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -1937,6 +2011,7 @@ paths: type: array items: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -2092,6 +2167,7 @@ paths: name: filter[severity] schema: type: string + x-spec-enum-id: c93e070ed135d9bf enum: - critical - high @@ -2117,6 +2193,7 @@ paths: name: filter[status] schema: type: string + x-spec-enum-id: ce612ddbfa464789 enum: - FAIL - MANUAL @@ -2234,6 +2311,7 @@ paths: name: filter[delta] schema: type: string + x-spec-enum-id: fef6b0e2506af8bc nullable: true enum: - changed @@ -2269,6 +2347,7 @@ paths: name: filter[impact] schema: type: string + x-spec-enum-id: c93e070ed135d9bf enum: - critical - high @@ -2332,6 +2411,7 @@ paths: name: filter[provider_type] schema: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -2352,6 +2432,7 @@ paths: type: array items: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -2492,6 +2573,7 @@ paths: name: filter[severity] schema: type: string + x-spec-enum-id: c93e070ed135d9bf enum: - critical - high @@ -2517,6 +2599,7 @@ paths: name: filter[status] schema: type: string + x-spec-enum-id: ce612ddbfa464789 enum: - FAIL - MANUAL @@ -2633,6 +2716,7 @@ paths: name: filter[integration_type] schema: type: string + x-spec-enum-id: 6cfd0ff9cf4d6dcc enum: - amazon_s3 - aws_security_hub @@ -2649,6 +2733,7 @@ paths: type: array items: type: string + x-spec-enum-id: 6cfd0ff9cf4d6dcc enum: - amazon_s3 - aws_security_hub @@ -2893,7 +2978,22 @@ paths: content: application/vnd.api+json: schema: - $ref: '#/components/schemas/OpenApiResponseResponse' + $ref: '#/components/schemas/TaskResponse' + examples: + Task queued: + summary: Task queued + value: + 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: null + task_args: null + metadata: null description: '' /api/v1/invitations/accept: post: @@ -3096,7 +3196,22 @@ paths: content: application/vnd.api+json: schema: - $ref: '#/components/schemas/OpenApiResponseResponse' + $ref: '#/components/schemas/TaskResponse' + examples: + Task queued: + summary: Task queued + value: + 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: null + task_args: null + metadata: null description: '' /api/v1/overviews/findings: get: @@ -3161,6 +3276,7 @@ paths: name: filter[provider_type] schema: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -3181,6 +3297,7 @@ paths: type: array items: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -3326,6 +3443,7 @@ paths: name: filter[provider_type] schema: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -3346,6 +3464,7 @@ paths: type: array items: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -3507,6 +3626,7 @@ paths: name: filter[provider_type] schema: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -3527,6 +3647,7 @@ paths: type: array items: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -3625,6 +3746,7 @@ paths: name: filter[processor_type] schema: type: string + x-spec-enum-id: 53cda4a99fe3d655 enum: - mutelist description: '* `mutelist` - Mutelist' @@ -3634,6 +3756,7 @@ paths: type: array items: type: string + x-spec-enum-id: 53cda4a99fe3d655 enum: - mutelist description: |- @@ -4217,6 +4340,7 @@ paths: name: filter[provider] schema: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -4473,7 +4597,22 @@ paths: content: application/vnd.api+json: schema: - $ref: '#/components/schemas/OpenApiResponseResponse' + $ref: '#/components/schemas/TaskResponse' + examples: + Task queued: + summary: Task queued + value: + 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: null + task_args: null + metadata: null description: '' /api/v1/providers/{id}/connection: post: @@ -4498,7 +4637,22 @@ paths: content: application/vnd.api+json: schema: - $ref: '#/components/schemas/OpenApiResponseResponse' + $ref: '#/components/schemas/TaskResponse' + examples: + Task queued: + summary: Task queued + value: + 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: null + task_args: null + metadata: null description: '' /api/v1/providers/secrets: get: @@ -4800,6 +4954,7 @@ paths: name: filter[provider_type] schema: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -4820,6 +4975,7 @@ paths: type: array items: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -5163,6 +5319,7 @@ paths: name: filter[provider_type] schema: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -5183,6 +5340,7 @@ paths: type: array items: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -5427,6 +5585,7 @@ paths: name: filter[provider_type] schema: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -5447,6 +5606,7 @@ paths: type: array items: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -5697,6 +5857,7 @@ paths: name: filter[provider_type] schema: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -5717,6 +5878,7 @@ paths: type: array items: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -6527,6 +6689,7 @@ paths: name: filter[provider_type] schema: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -6547,6 +6710,7 @@ paths: type: array items: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure @@ -6607,6 +6771,7 @@ paths: name: filter[state] schema: type: string + x-spec-enum-id: d38ba07264e1ed34 enum: - available - cancelled @@ -6627,6 +6792,7 @@ paths: type: array items: type: string + x-spec-enum-id: d38ba07264e1ed34 enum: - available - cancelled @@ -6649,6 +6815,7 @@ paths: name: filter[trigger] schema: type: string + x-spec-enum-id: 2e52c981d1c89bdf enum: - manual - scheduled @@ -6741,7 +6908,22 @@ paths: content: application/vnd.api+json: schema: - $ref: '#/components/schemas/OpenApiResponseResponse' + $ref: '#/components/schemas/TaskResponse' + examples: + Task queued: + summary: Task queued + value: + 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: null + task_args: null + metadata: null description: '' /api/v1/scans/{id}: get: @@ -6945,7 +7127,22 @@ paths: content: application/vnd.api+json: schema: - $ref: '#/components/schemas/OpenApiResponseResponse' + $ref: '#/components/schemas/TaskResponse' + examples: + Task queued: + summary: Task queued + value: + 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: null + task_args: null + metadata: null description: '' /api/v1/tasks: get: @@ -7110,7 +7307,22 @@ paths: content: application/vnd.api+json: schema: - $ref: '#/components/schemas/OpenApiResponseResponse' + $ref: '#/components/schemas/TaskResponse' + examples: + Task queued: + summary: Task queued + value: + 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: null + task_args: null + metadata: null description: '' /api/v1/tenants: get: @@ -7532,6 +7744,7 @@ paths: name: filter[state] schema: type: string + x-spec-enum-id: 92a1c1fd12e13e25 enum: - accepted - expired @@ -8217,6 +8430,7 @@ paths: name: filter[role] schema: type: string + x-spec-enum-id: 12c359ee5dc51001 enum: - member - owner @@ -8378,11 +8592,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/ComplianceOverviewTypeEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - compliance-overviews id: {} attributes: type: object @@ -8417,11 +8632,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/ComplianceOverviewAttributesTypeEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - compliance-requirements-attributes id: {} attributes: type: object @@ -8447,10 +8663,6 @@ components: - version - description - attributes - ComplianceOverviewAttributesTypeEnum: - type: string - enum: - - compliance-requirements-attributes ComplianceOverviewDetail: type: object required: @@ -8459,11 +8671,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/ComplianceOverviewDetailTypeEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - compliance-requirements-details id: {} attributes: type: object @@ -8486,16 +8699,13 @@ components: * `FAIL` - Fail * `PASS` - Pass * `MANUAL` - Manual + x-spec-enum-id: ce612ddbfa464789 required: - id - framework - version - description - status - ComplianceOverviewDetailTypeEnum: - type: string - enum: - - compliance-requirements-details ComplianceOverviewMetadata: type: object required: @@ -8504,11 +8714,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/ComplianceOverviewMetadataTypeEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - compliance-overviews-metadata id: {} attributes: type: object @@ -8519,14 +8730,6 @@ components: type: string required: - regions - ComplianceOverviewMetadataTypeEnum: - type: string - enum: - - compliance-overviews-metadata - ComplianceOverviewTypeEnum: - type: string - enum: - - compliance-overviews Finding: type: object required: @@ -8535,11 +8738,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/FindingTypeEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - findings id: type: string format: uuid @@ -8558,6 +8762,7 @@ components: description: |- * `new` - New * `changed` - Changed + x-spec-enum-id: fef6b0e2506af8bc nullable: true status: enum: @@ -8569,6 +8774,7 @@ components: * `FAIL` - Fail * `PASS` - Pass * `MANUAL` - Manual + x-spec-enum-id: ce612ddbfa464789 status_extended: type: string nullable: true @@ -8586,6 +8792,7 @@ components: * `medium` - Medium * `low` - Low * `informational` - Informational + x-spec-enum-id: c93e070ed135d9bf check_id: type: string maxLength: 100 @@ -8682,11 +8889,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/FindingDynamicFilterTypeEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - finding-dynamic-filters id: {} attributes: type: object @@ -8709,10 +8917,6 @@ components: $ref: '#/components/schemas/FindingDynamicFilter' required: - data - FindingDynamicFilterTypeEnum: - type: string - enum: - - finding-dynamic-filters FindingMetadata: type: object required: @@ -8721,11 +8925,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/FindingMetadataTypeEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - findings-metadata id: {} attributes: type: object @@ -8753,10 +8958,6 @@ components: $ref: '#/components/schemas/FindingMetadata' required: - data - FindingMetadataTypeEnum: - type: string - enum: - - findings-metadata FindingResponse: type: object properties: @@ -8764,10 +8965,6 @@ components: $ref: '#/components/schemas/Finding' required: - data - FindingTypeEnum: - type: string - enum: - - findings Integration: type: object required: @@ -8776,11 +8973,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/TypeC82Enum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - integrations id: type: string format: uuid @@ -8816,6 +9014,7 @@ components: * `aws_security_hub` - AWS Security Hub * `jira` - JIRA * `slack` - Slack + x-spec-enum-id: 6cfd0ff9cf4d6dcc configuration: {} required: - integration_type @@ -8859,11 +9058,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/TypeC82Enum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - integrations attributes: type: object properties: @@ -8898,6 +9098,7 @@ components: * `aws_security_hub` - AWS Security Hub * `jira` - JIRA * `slack` - Slack + x-spec-enum-id: 6cfd0ff9cf4d6dcc configuration: oneOf: - type: object @@ -8924,13 +9125,32 @@ components: send_only_fails: type: boolean default: false - description: If true, only findings with status 'FAIL' will - be sent to Security Hub. + description: If true, only findings with status 'FAIL' will be + sent to Security Hub. archive_previous_findings: type: boolean default: false description: If true, archives findings that are not present in the current execution. + - type: object + title: JIRA + properties: + project_key: + type: string + description: The JIRA project key where issues will be created + (e.g., 'PROJ', 'SEC'). + issue_types: + type: array + items: + type: string + description: List of JIRA issue types to create for findings. + issue_labels: + type: array + items: + type: string + description: List of labels to apply to created JIRA issues.. + required: + - project_key credentials: oneOf: - type: object @@ -8970,6 +9190,24 @@ components: - User_Session-1 - Test.Session@2 pattern: ^[a-zA-Z0-9=,.@_-]+$ + - type: object + title: JIRA Credentials + properties: + user_mail: + type: string + format: email + description: The email address of the JIRA user account. + domain: + type: string + description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net'). + api_token: + type: string + description: The API token for authentication with JIRA. This + can be generated from your Atlassian account settings. + required: + - user_mail + - domain + - api_token writeOnly: true required: - integration_type @@ -9056,6 +9294,7 @@ components: * `aws_security_hub` - AWS Security Hub * `jira` - JIRA * `slack` - Slack + x-spec-enum-id: 6cfd0ff9cf4d6dcc configuration: oneOf: - type: object @@ -9090,6 +9329,25 @@ components: default: false description: If true, archives findings that are not present in the current execution. + - type: object + title: JIRA + properties: + project_key: + type: string + description: The JIRA project key where issues will be created + (e.g., 'PROJ', 'SEC'). + issue_types: + type: array + items: + type: string + description: List of JIRA issue types to create for findings. + issue_labels: + type: array + items: + type: string + description: List of labels to apply to created JIRA issues.. + required: + - project_key credentials: oneOf: - type: object @@ -9130,6 +9388,24 @@ components: - User_Session-1 - Test.Session@2 pattern: ^[a-zA-Z0-9=,.@_-]+$ + - type: object + title: JIRA Credentials + properties: + user_mail: + type: string + format: email + description: The email address of the JIRA user account. + domain: + type: string + description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net'). + api_token: + type: string + description: The API token for authentication with JIRA. This + can be generated from your Atlassian account settings. + required: + - user_mail + - domain + - api_token writeOnly: true required: - integration_type @@ -9190,11 +9466,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/TypeC82Enum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - integrations id: {} attributes: type: object @@ -9230,6 +9507,7 @@ components: * `aws_security_hub` - AWS Security Hub * `jira` - JIRA * `slack` - Slack + x-spec-enum-id: 6cfd0ff9cf4d6dcc readOnly: true configuration: oneOf: @@ -9257,13 +9535,32 @@ components: send_only_fails: type: boolean default: false - description: If true, only findings with status 'FAIL' will - be sent to Security Hub. + description: If true, only findings with status 'FAIL' will be + sent to Security Hub. archive_previous_findings: type: boolean default: false description: If true, archives findings that are not present in the current execution. + - type: object + title: JIRA + properties: + project_key: + type: string + description: The JIRA project key where issues will be created + (e.g., 'PROJ', 'SEC'). + issue_types: + type: array + items: + type: string + description: List of JIRA issue types to create for findings. + issue_labels: + type: array + items: + type: string + description: List of labels to apply to created JIRA issues.. + required: + - project_key credentials: oneOf: - type: object @@ -9303,6 +9600,24 @@ components: - User_Session-1 - Test.Session@2 pattern: ^[a-zA-Z0-9=,.@_-]+$ + - type: object + title: JIRA Credentials + properties: + user_mail: + type: string + format: email + description: The email address of the JIRA user account. + domain: + type: string + description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net'). + api_token: + type: string + description: The API token for authentication with JIRA. This + can be generated from your Atlassian account settings. + required: + - user_mail + - domain + - api_token writeOnly: true relationships: type: object @@ -9350,11 +9665,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/TypeD4dEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - invitations id: type: string format: uuid @@ -9385,6 +9701,7 @@ components: * `accepted` - Invitation was accepted by a user * `expired` - Invitation expired after the configured time * `revoked` - Invitation was revoked by a user + x-spec-enum-id: 92a1c1fd12e13e25 token: type: string readOnly: true @@ -9485,11 +9802,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/TypeD4dEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - invitations attributes: type: object properties: @@ -9514,6 +9832,7 @@ components: * `accepted` - Invitation was accepted by a user * `expired` - Invitation expired after the configured time * `revoked` - Invitation was revoked by a user + x-spec-enum-id: 92a1c1fd12e13e25 readOnly: true token: type: string @@ -9620,6 +9939,7 @@ components: * `accepted` - Invitation was accepted by a user * `expired` - Invitation expired after the configured time * `revoked` - Invitation was revoked by a user + x-spec-enum-id: 92a1c1fd12e13e25 readOnly: true token: type: string @@ -9710,11 +10030,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/TypeD4dEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - invitations id: type: string format: uuid @@ -9740,6 +10061,7 @@ components: * `accepted` - Invitation was accepted by a user * `expired` - Invitation expired after the configured time * `revoked` - Invitation was revoked by a user + x-spec-enum-id: 92a1c1fd12e13e25 readOnly: true token: type: string @@ -9790,11 +10112,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/Type4bfEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - lighthouse-configurations id: type: string format: uuid @@ -9821,6 +10144,7 @@ components: - gpt-5-mini-2025-08-07 - gpt-5-mini type: string + x-spec-enum-id: bd2108eb25b2d472 description: |- Must be one of the supported model names @@ -9866,11 +10190,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/Type4bfEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - lighthouse-configurations attributes: type: object properties: @@ -9895,6 +10220,7 @@ components: - gpt-5-mini-2025-08-07 - gpt-5-mini type: string + x-spec-enum-id: bd2108eb25b2d472 description: |- Must be one of the supported model names @@ -9975,6 +10301,7 @@ components: - gpt-5-mini-2025-08-07 - gpt-5-mini type: string + x-spec-enum-id: bd2108eb25b2d472 description: |- Must be one of the supported model names @@ -10031,11 +10358,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/Type4bfEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - lighthouse-configurations id: type: string format: uuid @@ -10063,6 +10391,7 @@ components: - gpt-5-mini-2025-08-07 - gpt-5-mini type: string + x-spec-enum-id: bd2108eb25b2d472 description: |- Must be one of the supported model names @@ -10105,11 +10434,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/MembershipTypeEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - memberships attributes: type: object properties: @@ -10121,6 +10451,7 @@ components: description: |- * `owner` - Owner * `member` - Member + x-spec-enum-id: 12c359ee5dc51001 date_joined: type: string format: date-time @@ -10187,10 +10518,6 @@ components: $ref: '#/components/schemas/Membership' required: - data - MembershipTypeEnum: - type: string - enum: - - memberships OpenApiResponseResponse: type: object properties: @@ -10206,11 +10533,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/OverviewFindingTypeEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - findings-overview id: {} attributes: type: object @@ -10265,10 +10593,6 @@ components: $ref: '#/components/schemas/OverviewFinding' required: - data - OverviewFindingTypeEnum: - type: string - enum: - - findings-overview OverviewProvider: type: object required: @@ -10277,11 +10601,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/OverviewProviderTypeEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - providers-overview id: {} attributes: type: object @@ -10315,10 +10640,6 @@ components: $ref: '#/components/schemas/OverviewProvider' required: - data - OverviewProviderTypeEnum: - type: string - enum: - - providers-overview OverviewService: type: object required: @@ -10327,11 +10648,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/OverviewServiceTypeEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - services-overview id: {} attributes: type: object @@ -10359,10 +10681,6 @@ components: $ref: '#/components/schemas/OverviewService' required: - data - OverviewServiceTypeEnum: - type: string - enum: - - services-overview OverviewSeverity: type: object required: @@ -10371,11 +10689,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/OverviewSeverityTypeEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - findings-severity-overview id: {} attributes: type: object @@ -10406,10 +10725,6 @@ components: $ref: '#/components/schemas/OverviewSeverity' required: - data - OverviewSeverityTypeEnum: - type: string - enum: - - findings-severity-overview PaginatedComplianceOverviewAttributesList: type: object properties: @@ -10633,6 +10948,7 @@ components: * `aws_security_hub` - AWS Security Hub * `jira` - JIRA * `slack` - Slack + x-spec-enum-id: 6cfd0ff9cf4d6dcc readOnly: true configuration: oneOf: @@ -10668,6 +10984,25 @@ components: default: false description: If true, archives findings that are not present in the current execution. + - type: object + title: JIRA + properties: + project_key: + type: string + description: The JIRA project key where issues will be created + (e.g., 'PROJ', 'SEC'). + issue_types: + type: array + items: + type: string + description: List of JIRA issue types to create for findings. + issue_labels: + type: array + items: + type: string + description: List of labels to apply to created JIRA issues.. + required: + - project_key credentials: oneOf: - type: object @@ -10708,6 +11043,24 @@ components: - User_Session-1 - Test.Session@2 pattern: ^[a-zA-Z0-9=,.@_-]+$ + - type: object + title: JIRA Credentials + properties: + user_mail: + type: string + format: email + description: The email address of the JIRA user account. + domain: + type: string + description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net'). + api_token: + type: string + description: The API token for authentication with JIRA. This + can be generated from your Atlassian account settings. + required: + - user_mail + - domain + - api_token writeOnly: true relationships: type: object @@ -10785,6 +11138,7 @@ components: * `accepted` - Invitation was accepted by a user * `expired` - Invitation expired after the configured time * `revoked` - Invitation was revoked by a user + x-spec-enum-id: 92a1c1fd12e13e25 readOnly: true token: type: string @@ -10868,6 +11222,7 @@ components: - gpt-5-mini-2025-08-07 - gpt-5-mini type: string + x-spec-enum-id: bd2108eb25b2d472 description: |- Must be one of the supported model names @@ -11174,6 +11529,7 @@ components: * `static` - Key-value pairs * `role` - Role assumption * `service_account` - GCP Service Account Key + x-spec-enum-id: 20c74ebcd6671497 secret: oneOf: - type: object @@ -11806,11 +12162,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/Type552Enum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - processors id: type: string format: uuid @@ -11830,6 +12187,7 @@ components: - mutelist type: string description: '* `mutelist` - Mutelist' + x-spec-enum-id: 53cda4a99fe3d655 configuration: oneOf: - type: object @@ -11907,11 +12265,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/Type552Enum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - processors attributes: type: object properties: @@ -11928,6 +12287,7 @@ components: - mutelist type: string description: '* `mutelist` - Mutelist' + x-spec-enum-id: 53cda4a99fe3d655 configuration: oneOf: - type: object @@ -12030,6 +12390,7 @@ components: - mutelist type: string description: '* `mutelist` - Mutelist' + x-spec-enum-id: 53cda4a99fe3d655 configuration: oneOf: - type: object @@ -12124,11 +12485,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/Type552Enum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - processors id: {} attributes: type: object @@ -12225,11 +12587,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/Type227Enum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - providers id: type: string format: uuid @@ -12260,6 +12623,7 @@ components: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + x-spec-enum-id: 4c1e219dad1cc0e7 uid: type: string title: Unique identifier for the provider, set by the provider @@ -12347,11 +12711,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/Type227Enum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - providers attributes: type: object properties: @@ -12376,6 +12741,7 @@ components: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + x-spec-enum-id: 4c1e219dad1cc0e7 uid: type: string title: Unique identifier for the provider, set by the provider @@ -12423,6 +12789,7 @@ components: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub + x-spec-enum-id: 4c1e219dad1cc0e7 uid: type: string minLength: 3 @@ -12447,11 +12814,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/Type34dEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - provider-groups id: type: string format: uuid @@ -12537,11 +12905,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/Type34dEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - provider-groups attributes: type: object properties: @@ -12828,11 +13197,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/Type049Enum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - provider-secrets id: type: string format: uuid @@ -12862,6 +13232,7 @@ components: * `static` - Key-value pairs * `role` - Role assumption * `service_account` - GCP Service Account Key + x-spec-enum-id: 20c74ebcd6671497 required: - secret_type relationships: @@ -12900,11 +13271,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/Type049Enum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - provider-secrets attributes: type: object properties: @@ -12931,6 +13303,7 @@ components: * `static` - Key-value pairs * `role` - Role assumption * `service_account` - GCP Service Account Key + x-spec-enum-id: 20c74ebcd6671497 secret: oneOf: - type: object @@ -13177,6 +13550,7 @@ components: * `static` - Key-value pairs * `role` - Role assumption * `service_account` - GCP Service Account Key + x-spec-enum-id: 20c74ebcd6671497 secret: oneOf: - type: object @@ -13406,11 +13780,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/Type049Enum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - provider-secrets id: type: string format: uuid @@ -13440,6 +13815,7 @@ components: * `static` - Key-value pairs * `role` - Role assumption * `service_account` - GCP Service Account Key + x-spec-enum-id: 20c74ebcd6671497 secret: oneOf: - type: object @@ -13657,11 +14033,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/ResourceTypeEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - resources id: type: string format: uuid @@ -13767,11 +14144,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/ResourceMetadataTypeEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - resources-metadata id: {} attributes: type: object @@ -13799,10 +14177,6 @@ components: $ref: '#/components/schemas/ResourceMetadata' required: - data - ResourceMetadataTypeEnum: - type: string - enum: - - resources-metadata ResourceResponse: type: object properties: @@ -13810,10 +14184,6 @@ components: $ref: '#/components/schemas/Resource' required: - data - ResourceTypeEnum: - type: string - enum: - - resources Role: type: object required: @@ -13822,11 +14192,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/Type6bbEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - roles id: type: string format: uuid @@ -13956,11 +14327,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/Type6bbEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - roles attributes: type: object properties: @@ -14299,11 +14671,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/SAMLConfigurationTypeEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - saml-configurations id: type: string format: uuid @@ -14378,10 +14751,6 @@ components: $ref: '#/components/schemas/SAMLConfiguration' required: - data - SAMLConfigurationTypeEnum: - type: string - enum: - - saml-configurations Scan: type: object required: @@ -14390,11 +14759,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/TypeE53Enum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - scans id: type: string format: uuid @@ -14414,6 +14784,7 @@ components: description: |- * `scheduled` - Scheduled * `manual` - Manual + x-spec-enum-id: 2e52c981d1c89bdf readOnly: true state: enum: @@ -14431,6 +14802,7 @@ components: * `completed` - Completed * `failed` - Failed * `cancelled` - Cancelled + x-spec-enum-id: d38ba07264e1ed34 readOnly: true unique_resource_count: type: integer @@ -14614,11 +14986,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/TypeE53Enum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - scans id: type: string format: uuid @@ -14678,11 +15051,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/TaskTypeEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - tasks id: type: string format: uuid @@ -14723,10 +15097,6 @@ components: $ref: '#/components/schemas/Task' required: - data - TaskTypeEnum: - type: string - enum: - - tasks Tenant: type: object required: @@ -14735,11 +15105,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/TenantTypeEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - tenants id: type: string format: uuid @@ -14849,10 +15220,6 @@ components: $ref: '#/components/schemas/Tenant' required: - data - TenantTypeEnum: - type: string - enum: - - tenants Token: type: object required: @@ -14860,11 +15227,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/TokenTypeEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - tokens attributes: type: object properties: @@ -14896,11 +15264,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/TokenRefreshTypeEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - tokens-refresh attributes: type: object properties: @@ -14948,10 +15317,6 @@ components: $ref: '#/components/schemas/TokenRefresh' required: - data - TokenRefreshTypeEnum: - type: string - enum: - - tokens-refresh TokenRequest: type: object properties: @@ -15012,11 +15377,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/TokenSwitchTenantTypeEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - tokens-switch-tenant attributes: type: object properties: @@ -15076,54 +15442,6 @@ components: $ref: '#/components/schemas/TokenSwitchTenant' required: - data - TokenSwitchTenantTypeEnum: - type: string - enum: - - tokens-switch-tenant - TokenTypeEnum: - type: string - enum: - - tokens - Type049Enum: - type: string - enum: - - provider-secrets - Type227Enum: - type: string - enum: - - providers - Type34dEnum: - type: string - enum: - - provider-groups - Type4bfEnum: - type: string - enum: - - lighthouse-configurations - Type552Enum: - type: string - enum: - - processors - Type6bbEnum: - type: string - enum: - - roles - Type8cdEnum: - type: string - enum: - - users - TypeC82Enum: - type: string - enum: - - integrations - TypeD4dEnum: - type: string - enum: - - invitations - TypeE53Enum: - type: string - enum: - - scans User: type: object required: @@ -15132,11 +15450,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/Type8cdEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - users id: type: string format: uuid @@ -15230,11 +15549,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/Type8cdEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - users attributes: type: object properties: @@ -15348,11 +15668,12 @@ components: additionalProperties: false properties: type: - allOf: - - $ref: '#/components/schemas/Type8cdEnum' + type: string description: The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships. + enum: + - users id: type: string format: uuid @@ -15400,27 +15721,36 @@ tags: - name: Invitation 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...). - 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 retrieval and filtering of resource information. - name: Finding description: Endpoints for managing findings, allowing retrieval and filtering of 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 description: Endpoints for checking the compliance overview, allowing filtering by scan, provider or 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 description: Endpoints for managing third-party integrations, including registration, configuration, retrieval, and deletion of integrations such as S3, JIRA, or other @@ -15429,6 +15759,6 @@ tags: description: Endpoints for managing Lighthouse configurations, including creation, retrieval, 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. diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py index 352c8262fa..853bb58ddb 100644 --- a/api/src/backend/api/tests/test_utils.py +++ b/api/src/backend/api/tests/test_utils.py @@ -6,16 +6,18 @@ from rest_framework.exceptions import NotFound, ValidationError from api.db_router import MainRouter from api.exceptions import InvitationTokenExpiredException -from api.models import Invitation, Provider +from api.models import Integration, Invitation, Provider from api.utils import ( get_prowler_provider_kwargs, initialize_prowler_provider, merge_dicts, + prowler_integration_connection_test, prowler_provider_connection_test, return_prowler_provider, validate_invitation, ) from prowler.providers.aws.aws_provider import AwsProvider +from prowler.providers.aws.lib.security_hub.security_hub import SecurityHubConnection from prowler.providers.azure.azure_provider import AzureProvider from prowler.providers.gcp.gcp_provider import GcpProvider from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider @@ -197,6 +199,10 @@ class TestGetProwlerProviderKwargs: Provider.ProviderChoices.M365.value, {}, ), + ( + Provider.ProviderChoices.GITHUB.value, + {"organizations": ["provider_uid"]}, + ), ], ) def test_get_prowler_provider_kwargs(self, provider_type, expected_extra_kwargs): @@ -390,3 +396,168 @@ class TestValidateInvitation: mock_db.get.assert_called_once_with( token="VALID_TOKEN", email__iexact="user@example.com" ) + + +class TestProwlerIntegrationConnectionTest: + """Test prowler_integration_connection_test function for SecurityHub regions reset.""" + + @patch("api.utils.SecurityHub") + def test_security_hub_connection_failure_resets_regions( + self, mock_security_hub_class + ): + """Test that SecurityHub connection failure resets regions to empty dict.""" + # Create integration with existing regions configuration + integration = MagicMock() + integration.integration_type = Integration.IntegrationChoices.AWS_SECURITY_HUB + integration.credentials = { + "aws_access_key_id": "test_key", + "aws_secret_access_key": "test_secret", + } + integration.configuration = { + "send_only_fails": True, + "regions": { + "us-east-1": True, + "us-west-2": True, + "eu-west-1": False, + "ap-south-1": False, + }, + } + + # Mock provider relationship + mock_provider = MagicMock() + mock_provider.uid = "123456789012" + mock_relationship = MagicMock() + mock_relationship.provider = mock_provider + integration.integrationproviderrelationship_set.first.return_value = ( + mock_relationship + ) + + # Mock failed SecurityHub connection + mock_connection = SecurityHubConnection( + is_connected=False, + error=Exception("SecurityHub testing"), + enabled_regions=set(), + disabled_regions=set(), + ) + mock_security_hub_class.test_connection.return_value = mock_connection + + # Call the function + result = prowler_integration_connection_test(integration) + + # Assertions + assert result.is_connected is False + assert str(result.error) == "SecurityHub testing" + + # Verify regions were completely reset to empty dict + assert integration.configuration["regions"] == {} + + # Verify save was called to persist the change + integration.save.assert_called_once() + + # Verify test_connection was called with correct parameters + mock_security_hub_class.test_connection.assert_called_once_with( + aws_account_id="123456789012", + raise_on_exception=False, + aws_access_key_id="test_key", + aws_secret_access_key="test_secret", + ) + + @patch("api.utils.SecurityHub") + def test_security_hub_connection_success_saves_regions( + self, mock_security_hub_class + ): + """Test that successful SecurityHub connection saves regions correctly.""" + integration = MagicMock() + integration.integration_type = Integration.IntegrationChoices.AWS_SECURITY_HUB + integration.credentials = { + "aws_access_key_id": "valid_key", + "aws_secret_access_key": "valid_secret", + } + integration.configuration = {"send_only_fails": False} + + # Mock provider relationship + mock_provider = MagicMock() + mock_provider.uid = "123456789012" + mock_relationship = MagicMock() + mock_relationship.provider = mock_provider + integration.integrationproviderrelationship_set.first.return_value = ( + mock_relationship + ) + + # Mock successful SecurityHub connection with regions + mock_connection = SecurityHubConnection( + is_connected=True, + error=None, + enabled_regions={"us-east-1", "eu-west-1"}, + disabled_regions={"ap-south-1"}, + ) + mock_security_hub_class.test_connection.return_value = mock_connection + + result = prowler_integration_connection_test(integration) + + assert result.is_connected is True + + # Verify regions were saved correctly + assert integration.configuration["regions"]["us-east-1"] is True + 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.Jira") + def test_jira_connection_success_basic_auth(self, mock_jira_class): + integration = MagicMock() + integration.integration_type = Integration.IntegrationChoices.JIRA + integration.credentials = { + "user_mail": "test@example.com", + "api_token": "test_api_token", + } + integration.configuration = { + "domain": "example.atlassian.net", + } + + mock_connection = MagicMock() + mock_connection.is_connected = True + mock_connection.error = None + mock_jira_class.test_connection.return_value = mock_connection + + result = prowler_integration_connection_test(integration) + + assert result.is_connected is True + assert result.error is None + + 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, + ) + + @patch("api.utils.Jira") + def test_jira_connection_failure_invalid_credentials(self, mock_jira_class): + integration = MagicMock() + integration.integration_type = Integration.IntegrationChoices.JIRA + integration.credentials = { + "user_mail": "invalid@example.com", + "api_token": "invalid_token", + } + integration.configuration = { + "domain": "invalid.atlassian.net", + } + + # Mock failed JIRA connection + mock_connection = MagicMock() + mock_connection.is_connected = False + mock_connection.error = Exception("Authentication failed: Invalid credentials") + mock_jira_class.test_connection.return_value = mock_connection + + result = prowler_integration_connection_test(integration) + + assert result.is_connected is False + assert "Authentication failed: Invalid credentials" in str(result.error) + + 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, + ) diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 0518cd446a..2e3fb0afcc 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -5660,6 +5660,18 @@ class TestIntegrationViewSet: }, {}, ), + # JIRA + ( + Integration.IntegrationChoices.JIRA, + { + "project_key": "JIRA", + "domain": "prowlerdomain", + }, + { + "api_token": "this-is-an-api-token-for-jira-that-works-for-sure", + "user_mail": "testing@prowler.com", + }, + ), ], ) def test_integrations_create_valid( @@ -5806,6 +5818,40 @@ class TestIntegrationViewSet: "invalid", None, ), + ( + { + "integration_type": "jira", + "configuration": { + "project_key": "JIRA", + }, + "credentials": {}, + }, + "required", + "domain", + ), + ( + { + "integration_type": "jira", + "configuration": { + "project_key": "JIRA", + "domain": "prowlerdomain", + }, + }, + "required", + "credentials", + ), + ( + { + "integration_type": "jira", + "configuration": { + "project_key": "JIRA", + "domain": "prowlerdomain", + }, + "credentials": {"api_token": "api-token"}, + }, + "invalid", + "credentials", + ), ] ), ) @@ -5995,6 +6041,110 @@ 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, providers_fixture + ): + provider = providers_fixture[0] + + # Create first JIRA integration + data = { + "data": { + "type": "integrations", + "attributes": { + "integration_type": Integration.IntegrationChoices.JIRA, + "configuration": { + "project_key": "TEST", + "domain": "test.atlassian.net", + }, + "credentials": { + "user_mail": "test@example.com", + "api_token": "test-api-token", + }, + "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" + ) + @pytest.mark.django_db class TestSAMLTokenValidation: diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index 7cbb7a0247..1901c7972a 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -9,6 +9,7 @@ from api.db_router import MainRouter 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 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 @@ -120,6 +121,12 @@ def get_prowler_provider_kwargs( } elif provider.provider == Provider.ProviderChoices.KUBERNETES.value: prowler_provider_kwargs = {**prowler_provider_kwargs, "context": provider.uid} + elif provider.provider == Provider.ProviderChoices.GITHUB.value: + if provider.uid: + prowler_provider_kwargs = { + **prowler_provider_kwargs, + "organizations": [provider.uid], + } if mutelist_processor: mutelist_content = mutelist_processor.configuration.get("Mutelist", {}) @@ -193,7 +200,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 ): @@ -223,10 +231,18 @@ def prowler_integration_connection_test(integration: Integration) -> Connection: # Save regions information in the integration configuration integration.configuration["regions"] = regions_status integration.save() + else: + # Reset regions information if connection fails + integration.configuration["regions"] = {} + integration.save() return connection elif integration.integration_type == Integration.IntegrationChoices.JIRA: - pass + return Jira.test_connection( + **integration.credentials, + domain=integration.configuration["domain"], + raise_on_exception=False, + ) elif integration.integration_type == Integration.IntegrationChoices.SLACK: pass else: diff --git a/api/src/backend/api/v1/serializer_utils/integrations.py b/api/src/backend/api/v1/serializer_utils/integrations.py index d15eee88ff..2d443ddfec 100644 --- a/api/src/backend/api/v1/serializer_utils/integrations.py +++ b/api/src/backend/api/v1/serializer_utils/integrations.py @@ -67,6 +67,16 @@ class SecurityHubConfigSerializer(BaseValidateSerializer): resource_name = "integrations" +class JiraConfigSerializer(BaseValidateSerializer): + project_key = serializers.CharField(required=True) + domain = serializers.CharField(required=True) + issue_types = serializers.ListField(required=False, child=serializers.CharField()) + issue_labels = serializers.ListField(required=False, child=serializers.CharField()) + + class Meta: + resource_name = "integrations" + + class AWSCredentialSerializer(BaseValidateSerializer): role_arn = serializers.CharField(required=False) external_id = serializers.CharField(required=False) @@ -82,6 +92,14 @@ class AWSCredentialSerializer(BaseValidateSerializer): resource_name = "integrations" +class JiraCredentialSerializer(BaseValidateSerializer): + user_mail = serializers.EmailField(required=True) + api_token = serializers.CharField(required=True) + + class Meta: + resource_name = "integrations" + + @extend_schema_field( { "oneOf": [ @@ -133,6 +151,23 @@ 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.", + }, + }, + "required": ["user_mail", "api_token"], + }, ] } ) @@ -153,7 +188,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 +215,31 @@ class IntegrationCredentialField(serializers.JSONField): }, }, }, + { + "type": "object", + "title": "JIRA", + "properties": { + "project_key": { + "type": "string", + "description": "The JIRA project key where issues will be created (e.g., 'PROJ', 'SEC').", + }, + "domain": { + "type": "string", + "description": "The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').", + }, + "issue_types": { + "type": "array", + "items": {"type": "string"}, + "description": "List of JIRA issue types to create for findings.", + }, + "issue_labels": { + "type": "array", + "items": {"type": "string"}, + "description": "List of labels to apply to created JIRA issues..", + }, + }, + "required": ["project_key", "domain"], + }, ] } ) diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 30f942b04d..ec97c2719d 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -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,34 @@ 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"), + "project_key": attrs.get("configuration").get("project_key"), + } + ).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 +2003,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 +2037,9 @@ class BaseWriteIntegrationSerializer(BaseWriteSerializer): ) config_serializer = SecurityHubConfigSerializer credentials_serializers = [AWSCredentialSerializer] + elif integration_type == Integration.IntegrationChoices.JIRA: + config_serializer = JiraConfigSerializer + credentials_serializers = [JiraCredentialSerializer] else: raise serializers.ValidationError( { @@ -2122,9 +2144,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( diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 28cbc3ab5f..f6d4e31ab8 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -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" ), diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 514dcfc6ff..37d905ba58 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -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", } diff --git a/api/src/backend/tasks/jobs/integrations.py b/api/src/backend/tasks/jobs/integrations.py index 5cc25d09a8..4f7d649714 100644 --- a/api/src/backend/tasks/jobs/integrations.py +++ b/api/src/backend/tasks/jobs/integrations.py @@ -220,6 +220,11 @@ def get_security_hub_client_from_integration( **credentials, ) return True, security_hub + else: + # Reset regions information if connection fails + with rls_transaction(tenant_id): + integration.configuration["regions"] = {} + integration.save() return False, connection @@ -325,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() @@ -333,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 diff --git a/api/src/backend/tasks/tests/test_integrations.py b/api/src/backend/tasks/tests/test_integrations.py index 4dd68b9f95..1a55f14f2c 100644 --- a/api/src/backend/tasks/tests/test_integrations.py +++ b/api/src/backend/tasks/tests/test_integrations.py @@ -492,14 +492,17 @@ class TestProwlerIntegrationConnectionTest: def test_aws_security_hub_integration_connection_failure( self, mock_security_hub_class ): - """Test AWS Security Hub integration connection failure.""" + """Test AWS Security Hub integration connection failure resets regions.""" integration = MagicMock() integration.integration_type = Integration.IntegrationChoices.AWS_SECURITY_HUB integration.credentials = { "aws_access_key_id": "invalid_key", "aws_secret_access_key": "invalid_secret", } - integration.configuration = {"send_only_fails": False} + integration.configuration = { + "send_only_fails": False, + "regions": {"us-east-1": True, "us-west-2": False}, # Existing regions + } # Mock integration provider relationship mock_provider = MagicMock() @@ -524,8 +527,9 @@ class TestProwlerIntegrationConnectionTest: assert result.is_connected is False assert result.error == test_exception - # Verify regions were not saved when connection failed - integration.save.assert_not_called() + # Verify regions were reset to empty dict when connection failed + assert integration.configuration["regions"] == {} + integration.save.assert_called_once() @patch("api.utils.SecurityHub") def test_aws_security_hub_integration_with_provider_credentials( @@ -575,6 +579,72 @@ class TestProwlerIntegrationConnectionTest: assert not integration.configuration["regions"]["ap-south-1"] integration.save.assert_called_once() + @patch("api.utils.SecurityHub") + def test_aws_security_hub_connection_failure_with_multiple_regions_clears_all( + self, mock_security_hub_class + ): + """Test that SecurityHub connection failure clears all existing regions data.""" + integration = MagicMock() + integration.integration_type = Integration.IntegrationChoices.AWS_SECURITY_HUB + integration.credentials = { + "aws_access_key_id": "test_key", + "aws_secret_access_key": "test_secret", + } + # Start with complex regions configuration + integration.configuration = { + "send_only_fails": True, + "regions": { + "us-east-1": True, + "us-east-2": False, + "us-west-1": True, + "us-west-2": True, + "eu-west-1": False, + "eu-west-2": True, + "eu-central-1": True, + "ap-northeast-1": False, + "ap-southeast-1": True, + "ap-southeast-2": False, + }, + } + + # Mock integration provider relationship + mock_provider = MagicMock() + mock_provider.uid = "987654321098" + mock_relationship = MagicMock() + mock_relationship.provider = mock_provider + integration.integrationproviderrelationship_set.first.return_value = ( + mock_relationship + ) + + # Mock failed SecurityHub connection + mock_connection = SecurityHubConnection( + is_connected=False, + error=Exception("Invalid credentials or permissions"), + enabled_regions=set(), + disabled_regions=set(), + ) + mock_security_hub_class.test_connection.return_value = mock_connection + + result = prowler_integration_connection_test(integration) + + assert result.is_connected is False + assert str(result.error) == "Invalid credentials or permissions" + + # Verify all regions were completely cleared + assert integration.configuration["regions"] == {} + assert len(integration.configuration["regions"]) == 0 + + # Verify save was called to persist the cleared regions + integration.save.assert_called_once() + + # Verify the test_connection was called with correct parameters + mock_security_hub_class.test_connection.assert_called_once_with( + aws_account_id="987654321098", + raise_on_exception=False, + aws_access_key_id="test_key", + aws_secret_access_key="test_secret", + ) + def test_unsupported_integration_type(self): """Test unsupported integration type raises ValueError.""" integration = MagicMock() @@ -674,15 +744,19 @@ class TestSecurityHubIntegrationUploads: "us-west-2", ] + @patch("tasks.jobs.integrations.rls_transaction") @patch("tasks.jobs.integrations.SecurityHub.test_connection") @patch("tasks.jobs.integrations.initialize_prowler_provider") def test_get_security_hub_client_from_integration_failure( - self, mock_initialize_provider, mock_test_connection + self, mock_initialize_provider, mock_test_connection, mock_rls ): - """Test SecurityHub client creation failure.""" + """Test SecurityHub client creation failure resets regions.""" # Mock integration mock_integration = MagicMock() - mock_integration.configuration = {"send_only_fails": False} + mock_integration.configuration = { + "send_only_fails": False, + "regions": {"us-east-1": True, "us-west-2": False}, # Existing regions + } mock_integration.credentials = {} # Empty credentials, use provider # Mock tenant_id @@ -710,6 +784,9 @@ class TestSecurityHubIntegrationUploads: # Mock findings mock_findings = [{"finding": "test"}] + # Mock RLS context manager + mock_rls.return_value.__enter__.return_value = None + connected, connection = get_security_hub_client_from_integration( mock_integration, tenant_id, mock_findings ) @@ -725,6 +802,93 @@ class TestSecurityHubIntegrationUploads: aws_secret_access_key="test_secret_key", ) + # Verify regions were reset to empty when connection failed + assert mock_integration.configuration["regions"] == {} + mock_integration.save.assert_called_once() + # Verify RLS transaction was used for the reset + assert ( + mock_rls.call_count == 2 + ) # Once for getting provider, once for resetting regions + + @patch("tasks.jobs.integrations.rls_transaction") + @patch("tasks.jobs.integrations.SecurityHub.test_connection") + def test_get_security_hub_client_from_integration_failure_clears_existing_regions( + self, mock_test_connection, mock_rls + ): + """Test that SecurityHub client creation failure clears existing regions configuration.""" + # Mock integration with pre-existing regions configuration + mock_integration = MagicMock() + mock_integration.configuration = { + "send_only_fails": True, + "regions": { + "us-east-1": True, + "us-west-2": True, + "eu-west-1": False, + "ap-south-1": False, + }, # Pre-existing regions configuration + } + mock_integration.credentials = { + "aws_access_key_id": "test_key_id", + "aws_secret_access_key": "test_secret_key", + } + + # Mock tenant_id + tenant_id = "550e8400-e29b-41d4-a716-446655440000" + + # Mock provider relationship + mock_provider = MagicMock() + mock_provider.uid = "123456789012" + mock_provider.secret.secret = { + "aws_access_key_id": "provider_key", + "aws_secret_access_key": "provider_secret", + } + mock_relationship = MagicMock() + mock_relationship.provider = mock_provider + mock_integration.integrationproviderrelationship_set.first.return_value = ( + mock_relationship + ) + + # Mock failed connection with specific error + mock_connection = MagicMock() + mock_connection.is_connected = False + mock_connection.error = "Access denied: SecurityHub not enabled in region" + mock_test_connection.return_value = mock_connection + + # Mock findings + mock_findings = [{"finding": "test1"}, {"finding": "test2"}] + + # Mock RLS context manager + mock_rls.return_value.__enter__.return_value = None + + # Call the function + connected, connection = get_security_hub_client_from_integration( + mock_integration, tenant_id, mock_findings + ) + + # Assertions + assert connected is False + assert connection == mock_connection + assert connection.error == "Access denied: SecurityHub not enabled in region" + + # Verify that regions configuration was completely cleared + assert mock_integration.configuration["regions"] == {} + + # Verify save was called to persist the change + mock_integration.save.assert_called_once() + + # Verify RLS transaction was used correctly + # Should be called twice: once for getting provider info, once for resetting regions + assert mock_rls.call_count == 2 + mock_rls.assert_any_call(tenant_id) + + # Verify test_connection was called with integration credentials (not provider's) + mock_test_connection.assert_called_once_with( + aws_account_id="123456789012", + raise_on_exception=False, + aws_access_key_id="test_key_id", + aws_secret_access_key="test_secret_key", + ) + @patch("tasks.jobs.integrations.AwsProvider") @patch("tasks.jobs.integrations.SecurityHub.test_connection") @patch("tasks.jobs.integrations.initialize_prowler_provider") diff --git a/docs/basic-usage/prowler-app.md b/docs/basic-usage/prowler-app.md index 8ed7625577..0bea33007b 100644 --- a/docs/basic-usage/prowler-app.md +++ b/docs/basic-usage/prowler-app.md @@ -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. - Overview + Overview - **Compliance** – Displays compliance insights based on security frameworks. Compliance diff --git a/docs/tutorials/aws/securityhub.md b/docs/tutorials/aws/securityhub.md index 1ece9e61e5..050896763b 100644 --- a/docs/tutorials/aws/securityhub.md +++ b/docs/tutorials/aws/securityhub.md @@ -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 diff --git a/docs/tutorials/configuration_file.md b/docs/tutorials/configuration_file.md index b99e5064d0..bdd0254f41 100644 --- a/docs/tutorials/configuration_file.md +++ b/docs/tutorials/configuration_file.md @@ -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: diff --git a/docs/tutorials/github/getting-started-github.md b/docs/tutorials/github/getting-started-github.md index 48be3859bc..dca5ef584e 100644 --- a/docs/tutorials/github/getting-started-github.md +++ b/docs/tutorials/github/getting-started-github.md @@ -44,13 +44,13 @@ Personal Access Tokens provide the simplest GitHub authentication method, but it To enable Prowler functionality, configure the following permissions: - **Repository permissions:** + - **Administration**: Read-only access - **Contents**: Read-only access - **Metadata**: Read-only access - **Pull requests**: Read-only access - - **Security advisories**: Read-only access - - **Statuses**: Read-only access - **Organization permissions:** + - **Administration**: Read-only access - **Members**: Read-only access - **Account permissions:** diff --git a/docs/tutorials/img/security-hub/create-integration.png b/docs/tutorials/img/security-hub/create-integration.png new file mode 100644 index 0000000000..145e68d201 Binary files /dev/null and b/docs/tutorials/img/security-hub/create-integration.png differ diff --git a/docs/tutorials/img/security-hub/integration-settings.png b/docs/tutorials/img/security-hub/integration-settings.png new file mode 100644 index 0000000000..3a32bf0830 Binary files /dev/null and b/docs/tutorials/img/security-hub/integration-settings.png differ diff --git a/docs/tutorials/img/security-hub/integrations-tab.png b/docs/tutorials/img/security-hub/integrations-tab.png new file mode 100644 index 0000000000..9d11cae33a Binary files /dev/null and b/docs/tutorials/img/security-hub/integrations-tab.png differ diff --git a/docs/tutorials/microsoft365/authentication.md b/docs/tutorials/microsoft365/authentication.md index c8f78c1c78..fde711d4ff 100644 --- a/docs/tutorials/microsoft365/authentication.md +++ b/docs/tutorials/microsoft365/authentication.md @@ -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`) diff --git a/docs/tutorials/microsoft365/getting-started-m365.md b/docs/tutorials/microsoft365/getting-started-m365.md index 988f58a1b5..8c3037f728 100644 --- a/docs/tutorials/microsoft365/getting-started-m365.md +++ b/docs/tutorials/microsoft365/getting-started-m365.md @@ -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 don’t complete this step, user authentication will fail because Microsoft marks the initial password as expired. diff --git a/docs/tutorials/prowler-app-security-hub-integration.md b/docs/tutorials/prowler-app-security-hub-integration.md new file mode 100644 index 0000000000..0cc4193b98 --- /dev/null +++ b/docs/tutorials/prowler-app-security-hub-integration.md @@ -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
• Check Security Hub accessibility
• Detect enabled regions automatically
• Validate finding export capability | Results displayed in notification message | +| **Config** | Modify integration settings | • Update AWS provider selection
• Change finding filter settings
• Modify archive preferences | Click "Update Configuration" to save changes | +| **Credentials** | Update authentication settings | • Switch between provider/custom credentials
• Update AWS access keys
• Change IAM role configuration | Click "Update Credentials" to save changes | +| **Enable/Disable** | Toggle integration status | • Enable integration to start exporting findings
• Disable integration to pause exports | Status change takes effect immediately | +| **Delete** | Remove integration permanently | • Permanently delete integration
• 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 diff --git a/mkdocs.yml b/mkdocs.yml index 68aa34bcc6..96622f0201 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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: diff --git a/poetry.lock b/poetry.lock index ab1e7d5e4c..9d4fb31e02 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 94cb8ccc05..14b9efbfd5 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -1,8 +1,28 @@ # Prowler SDK Changelog All notable changes to the **Prowler SDK** are documented in this file. +## [v5.12.0] (Prowler UNRELEASED) -## [v5.11.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) + +### Changed + +### Fixed +- Renamed `AdditionalUrls` to `AdditionalURLs` field in CheckMetadata [(#8639)](https://github.com/prowler-cloud/prowler/pull/8639) + +--- + +## [v5.11.1] (Prowler UNRELEASED) + +### Fixed + +--- + +## [v5.11.0] (Prowler v5.11.0) ### Added - Certificate authentication for M365 provider [(#8404)](https://github.com/prowler-cloud/prowler/pull/8404) @@ -15,7 +35,8 @@ 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 - Refine kisa isms-p compliance mapping [(#8479)](https://github.com/prowler-cloud/prowler/pull/8479) @@ -24,12 +45,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### Fixed - Resource metadata error in `s3_bucket_shadow_resource_vulnerability` check [(#8572)](https://github.com/prowler-cloud/prowler/pull/8572) - ---- - -## [v5.10.3] (Prowler UNRELEASED) - -### Fixed +- GitHub App authentication through API fails with auth_method validation error [(#8587)](https://github.com/prowler-cloud/prowler/pull/8587) - AWS resource-arn filtering [(#8533)](https://github.com/prowler-cloud/prowler/pull/8533) - GitHub App authentication for GitHub provider [(#8529)](https://github.com/prowler-cloud/prowler/pull/8529) - List all accessible organizations in GitHub provider [(#8535)](https://github.com/prowler-cloud/prowler/pull/8535) diff --git a/prowler/config/config.py b/prowler/config/config.py index 46dd6f8c1c..6f596ee9ea 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -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" diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index 7932131af6..33da81eaab 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -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 diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index de38162cbc..02813edc76 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -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"]: """ diff --git a/prowler/lib/outputs/jira/exceptions/exceptions.py b/prowler/lib/outputs/jira/exceptions/exceptions.py index ed138a73a4..0f8aef1efd 100644 --- a/prowler/lib/outputs/jira/exceptions/exceptions.py +++ b/prowler/lib/outputs/jira/exceptions/exceptions.py @@ -90,6 +90,10 @@ class JiraBaseException(ProwlerException): "message": "Missing parameters on Jira Init function.", "remediation": "Please check the parameters and try again.", }, + (9021, "JiraRequiredCustomFieldsError"): { + "message": "Jira project requires custom fields that are not supported.", + "remediation": "Please configure the Jira project to not require custom fields, or use a different project.", + }, } def __init__(self, code, file=None, original_exception=None, message=None): @@ -251,3 +255,10 @@ class JiraInvalidParameterError(JiraBaseException): super().__init__( 9020, file=file, original_exception=original_exception, message=message ) + + +class JiraRequiredCustomFieldsError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 9021, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index 616f97b820..93ce81f9b0 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -1,5 +1,6 @@ import base64 import os +from dataclasses import dataclass from datetime import datetime, timedelta from typing import Dict @@ -28,12 +29,24 @@ from prowler.lib.outputs.jira.exceptions.exceptions import ( JiraNoTokenError, JiraRefreshTokenError, JiraRefreshTokenResponseError, + JiraRequiredCustomFieldsError, JiraSendFindingsResponseError, JiraTestConnectionError, ) 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 @@ -462,7 +475,7 @@ class Jira: api_token: str = None, domain: str = None, raise_on_exception: bool = True, - ) -> Connection: + ) -> JiraConnection: """Test the connection to Jira Args: @@ -475,7 +488,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 @@ -483,6 +496,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( @@ -493,60 +508,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: @@ -554,7 +560,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 @@ -599,7 +605,7 @@ class Jira: return projects else: logger.error( - f"Failed to get projects: {response.status_code} - {response.json()}" + f"Failed to get projects: {response.status_code} - {response.text}" ) raise JiraGetProjectsResponseError( message="Failed to get projects from Jira", @@ -665,7 +671,7 @@ class Jira: issue_types = response.json()["projects"][0]["issuetypes"] return [issue_type["name"] for issue_type in issue_types] else: - response_error = f"Failed to get available issue types: {response.status_code} - {response.json()}" + response_error = f"Failed to get available issue types: {response.status_code} - {response.text}" logger.warning(response_error) raise JiraGetAvailableIssueTypesResponseError( message=response_error, file=os.path.basename(__file__) @@ -681,6 +687,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 @@ -698,11 +790,34 @@ class Jira: if status == "MUTED": return "#FFA500" + @staticmethod + def get_severity_color(severity: str) -> str: + """Get the color from the severity + + Args: + - severity: The severity of the finding + + Returns: + - str: The color of the severity + """ + if severity == "critical": + return "#FF0000" + if severity == "high": + return "#FFA500" + if severity == "medium": + return "#FFFF00" + if severity == "low": + return "#008000" + if severity == "informational": + return "#0000FF" + return "#000000" # Default black color for unknown severities + @staticmethod def get_adf_description( check_id: str = None, check_title: str = None, severity: str = None, + severity_color: str = None, status: str = None, status_color: str = None, status_extended: str = None, @@ -713,7 +828,589 @@ class Jira: risk: str = None, recommendation_text: str = None, recommendation_url: str = None, + remediation_code_native_iac: str = None, + remediation_code_terraform: str = None, + remediation_code_cli: str = None, + remediation_code_other: str = None, + resource_tags: dict = None, + compliance: dict = None, ) -> dict: + table_rows = [ + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Check Id", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": check_id, + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Check Title", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": check_title, + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Severity", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": severity, + "marks": [ + {"type": "strong"}, + { + "type": "backgroundColor", + "attrs": { + "color": severity_color, + }, + }, + ], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Status", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": status, + "marks": [ + {"type": "strong"}, + { + "type": "textColor", + "attrs": {"color": status_color}, + }, + ], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Status Extended", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": status_extended, + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Provider", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": provider, + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Region", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": region, + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Resource UID", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": resource_uid, + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Resource Name", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": resource_name, + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Risk", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": risk, + } + ], + } + ], + }, + ], + }, + ] + + # Add resource tags row only if there are tags + if resource_tags: + tags_text = ", ".join([f"{k}={v}" for k, v in resource_tags.items()]) + table_rows.append( + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Resource Tags", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": tags_text, + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + } + ) + + # Add compliance row only if there are compliance mappings + if compliance: + compliance_text = [] + for framework, requirements in compliance.items(): + if requirements: + requirements_str = ", ".join(requirements) + compliance_text.append(f"{framework}: {requirements_str}") + + if compliance_text: + table_rows.append( + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Compliance", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "; ".join(compliance_text), + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + } + ) + + # Add recommendation row + table_rows.append( + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Recommendation", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": recommendation_text + " ", + }, + { + "type": "text", + "text": recommendation_url, + "marks": [ + { + "type": "link", + "attrs": {"href": recommendation_url}, + } + ], + }, + ], + } + ], + }, + ], + } + ) + + # Add remediation code rows only if they have content + remediation_codes = [ + ("Native IaC", remediation_code_native_iac), + ("Terraform", remediation_code_terraform), + ("CLI", remediation_code_cli), + ("Other", remediation_code_other), + ] + + for code_type, code_value in remediation_codes: + if code_value and code_value.strip(): + table_rows.append( + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": f"Remediation {code_type}", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": code_value, + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + } + ) + return { "type": "doc", "version": 1, @@ -730,430 +1427,7 @@ class Jira: { "type": "table", "attrs": {"layout": "full-width"}, - "content": [ - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Check Id", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": check_id, - "marks": [{"type": "code"}], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Check Title", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": check_title, - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Severity", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": severity, - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Status", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": status, - "marks": [ - {"type": "strong"}, - { - "type": "textColor", - "attrs": { - "color": status_color - }, - }, - ], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Status Extended", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": status_extended, - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Provider", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": provider, - "marks": [{"type": "code"}], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Region", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": region, - "marks": [{"type": "code"}], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Resource UID", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": resource_uid, - "marks": [{"type": "code"}], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Resource Name", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": resource_name, - "marks": [{"type": "code"}], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Risk", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": risk, - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Recommendation", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": recommendation_text + " ", - }, - { - "type": "text", - "text": recommendation_url, - "marks": [ - { - "type": "link", - "attrs": { - "href": recommendation_url - }, - } - ], - }, - ], - } - ], - }, - ], - }, - ], + "content": table_rows, }, ], } @@ -1177,6 +1451,7 @@ class Jira: - JiraRefreshTokenResponseError: Failed to refresh the access token, response code did not match 200 - JiraCreateIssueError: Failed to create an issue in Jira - JiraSendFindingsResponseError: Failed to send the findings to Jira + - JiraRequiredCustomFieldsError: Jira project requires custom fields that are not supported """ try: access_token = self.get_access_token() @@ -1217,10 +1492,14 @@ class Jira: for finding in findings: status_color = self.get_color_from_status(finding.status.value) + severity_color = self.get_severity_color( + finding.metadata.Severity.value.lower() + ) adf_description = self.get_adf_description( check_id=finding.metadata.CheckID, check_title=finding.metadata.CheckTitle, severity=finding.metadata.Severity.value.upper(), + severity_color=severity_color, status=finding.status.value, status_color=status_color, status_extended=finding.status_extended, @@ -1231,6 +1510,12 @@ class Jira: risk=finding.metadata.Risk, recommendation_text=finding.metadata.Remediation.Recommendation.Text, recommendation_url=finding.metadata.Remediation.Recommendation.Url, + remediation_code_native_iac=finding.metadata.Remediation.Code.NativeIaC, + remediation_code_terraform=finding.metadata.Remediation.Code.Terraform, + remediation_code_cli=finding.metadata.Remediation.Code.CLI, + remediation_code_other=finding.metadata.Remediation.Code.Other, + resource_tags=finding.resource_tags, + compliance=finding.compliance, ) payload = { "fields": { @@ -1248,13 +1533,37 @@ class Jira: ) if response.status_code != 201: - response_error = f"Failed to send finding: {response.status_code} - {response.json()}" + response_json = response.json() + # Check if the error is due to required custom fields + if response.status_code == 400 and "errors" in response_json: + errors = response_json.get("errors", {}) + # Look for custom field errors (fields starting with "customfield_") + custom_field_errors = { + k: v + for k, v in errors.items() + if k.startswith("customfield_") + } + if custom_field_errors: + custom_fields_formatted = ", ".join( + [ + f"'{k}': '{v}'" + for k, v in custom_field_errors.items() + ] + ) + raise JiraRequiredCustomFieldsError( + message=f"Jira project requires custom fields that are not supported: {custom_fields_formatted}", + file=os.path.basename(__file__), + ) + + response_error = f"Failed to send finding: {response.status_code} - {response_json}" logger.warning(response_error) raise JiraSendFindingsResponseError( message=response_error, file=os.path.basename(__file__) ) else: logger.info(f"Finding sent successfully: {response.json()}") + except JiraRequiredCustomFieldsError as custom_fields_error: + raise custom_fields_error except JiraRefreshTokenError as refresh_error: raise refresh_error except JiraRefreshTokenResponseError as response_error: diff --git a/prowler/providers/azure/lib/service/service.py b/prowler/providers/azure/lib/service/service.py index 83ed9031f4..b91fd51f56 100644 --- a/prowler/providers/azure/lib/service/service.py +++ b/prowler/providers/azure/lib/service/service.py @@ -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( diff --git a/prowler/providers/azure/services/apim/__init__.py b/prowler/providers/azure/services/apim/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/apim/apim_client.py b/prowler/providers/azure/services/apim/apim_client.py new file mode 100644 index 0000000000..d435153f3a --- /dev/null +++ b/prowler/providers/azure/services/apim/apim_client.py @@ -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()) diff --git a/prowler/providers/azure/services/apim/apim_service.py b/prowler/providers/azure/services/apim/apim_service.py new file mode 100644 index 0000000000..793eb727c3 --- /dev/null +++ b/prowler/providers/azure/services/apim/apim_service.py @@ -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) diff --git a/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/__init__.py b/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.metadata.json b/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.metadata.json new file mode 100644 index 0000000000..1673f9cf64 --- /dev/null +++ b/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.metadata.json @@ -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" +} diff --git a/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.py b/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.py new file mode 100644 index 0000000000..e511b9859b --- /dev/null +++ b/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.py @@ -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 diff --git a/prowler/providers/azure/services/logs/__init__.py b/prowler/providers/azure/services/logs/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/logs/loganalytics_client.py b/prowler/providers/azure/services/logs/loganalytics_client.py new file mode 100644 index 0000000000..dc7ef16f68 --- /dev/null +++ b/prowler/providers/azure/services/logs/loganalytics_client.py @@ -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()) diff --git a/prowler/providers/azure/services/logs/logs_service.py b/prowler/providers/azure/services/logs/logs_service.py new file mode 100644 index 0000000000..e0a2cf07e5 --- /dev/null +++ b/prowler/providers/azure/services/logs/logs_service.py @@ -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) diff --git a/prowler/providers/azure/services/logs/logsquery_client.py b/prowler/providers/azure/services/logs/logsquery_client.py new file mode 100644 index 0000000000..0aabde0c24 --- /dev/null +++ b/prowler/providers/azure/services/logs/logsquery_client.py @@ -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()) diff --git a/prowler/providers/azure/services/monitor/monitor_service.py b/prowler/providers/azure/services/monitor/monitor_service.py index b4542a7b14..948b0cceec 100644 --- a/prowler/providers/azure/services/monitor/monitor_service.py +++ b/prowler/providers/azure/services/monitor/monitor_service.py @@ -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 diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 1b2de71148..c09c5113d9 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -202,6 +202,7 @@ class Provider(ABC): config_path=arguments.config_file, mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, + skip_api_check=arguments.skip_api_check, ) elif "kubernetes" in provider_class_name.lower(): provider_class( diff --git a/prowler/providers/gcp/gcp_provider.py b/prowler/providers/gcp/gcp_provider.py index 7cdbba4401..c9e7c25501 100644 --- a/prowler/providers/gcp/gcp_provider.py +++ b/prowler/providers/gcp/gcp_provider.py @@ -86,6 +86,7 @@ class GcpProvider(Provider): client_secret: str = None, refresh_token: str = None, service_account_key: dict = None, + skip_api_check: bool = False, ): """ GCP Provider constructor @@ -107,6 +108,7 @@ class GcpProvider(Provider): client_secret: str refresh_token: str service_account_key: dict + skip_api_check: bool Raises: GCPNoAccesibleProjectsError if no project IDs can be accessed via Google Credentials @@ -174,6 +176,10 @@ class GcpProvider(Provider): gcp_config.DEFAULT_RETRY_ATTEMPTS = retries_max_attempts logger.info(f"GCP retry attempts set to {retries_max_attempts}") + if skip_api_check: + logger.info("Skipping API active check for each service") + self.skip_api_check = skip_api_check + self._impersonated_service_account = impersonate_service_account # Set the GCP credentials using the provided client_id, client_secret and refresh_token gcp_credentials = None diff --git a/prowler/providers/gcp/lib/arguments/arguments.py b/prowler/providers/gcp/lib/arguments/arguments.py index 6866898caa..8172630132 100644 --- a/prowler/providers/gcp/lib/arguments/arguments.py +++ b/prowler/providers/gcp/lib/arguments/arguments.py @@ -57,3 +57,10 @@ def init_parser(self): type=int, help="Set the maximum attempts for the Google Cloud SDK retry config (Default: 3)", ) + + gcp_config_subparser.add_argument( + "--skip-api-check", + action="store_true", + default=False, + help="Assume all APIs are active and skip the active API check for each service", + ) diff --git a/prowler/providers/gcp/lib/service/service.py b/prowler/providers/gcp/lib/service/service.py index f32b29c2af..746952542f 100644 --- a/prowler/providers/gcp/lib/service/service.py +++ b/prowler/providers/gcp/lib/service/service.py @@ -29,7 +29,10 @@ class GCPService: self.service, api_version, self.credentials ) # Only project ids that have their API enabled will be scanned - self.project_ids = self.__is_api_active__(provider.project_ids) + if provider.skip_api_check: + self.project_ids = provider.project_ids + else: + self.project_ids = self.__is_api_active__(provider.project_ids) self.projects = provider.projects self.default_project_id = provider.default_project_id self.audit_config = provider.audit_config diff --git a/prowler/providers/github/github_provider.py b/prowler/providers/github/github_provider.py index 46dff4ab09..384c0b9de0 100644 --- a/prowler/providers/github/github_provider.py +++ b/prowler/providers/github/github_provider.py @@ -156,7 +156,7 @@ class GithubProvider(Provider): self._auth_method = "Personal Access Token" elif oauth_app_token: self._auth_method = "OAuth App Token" - elif github_app_id and github_app_key: + elif github_app_id and (github_app_key or github_app_key_content): self._auth_method = "GitHub App Token" elif environ.get("GITHUB_PERSONAL_ACCESS_TOKEN", ""): self._auth_method = "Environment Variable for Personal Access Token" diff --git a/prowler/providers/github/services/repository/repository_service.py b/prowler/providers/github/services/repository/repository_service.py index 501bd72601..4ca18813c5 100644 --- a/prowler/providers/github/services/repository/repository_service.py +++ b/prowler/providers/github/services/repository/repository_service.py @@ -153,9 +153,10 @@ class Repository(GithubService): self._handle_github_api_error( error, "processing organization", org_name ) - if ( + elif ( isinstance(self.provider.identity, GithubAppIdentityInfo) and self.provider.identity.installations + and not self.provider.repositories ): logger.info( f"Filtering for repositories in the organizations or accounts that the GitHub App is installed in: {', '.join(self.provider.identity.installations)}" diff --git a/prowler/providers/m365/lib/powershell/m365_powershell.py b/prowler/providers/m365/lib/powershell/m365_powershell.py index 26380ae2a2..be36964b11 100644 --- a/prowler/providers/m365/lib/powershell/m365_powershell.py +++ b/prowler/providers/m365/lib/powershell/m365_powershell.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 4308b55f91..d1381c298f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/lib/check/models_test.py b/tests/lib/check/models_test.py index 83b294f171..a992da946c 100644 --- a/tests/lib/check/models_test.py +++ b/tests/lib/check/models_test.py @@ -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) diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index e4cc7fbc0b..a9fee84365 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -13,8 +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 @@ -292,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, @@ -318,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, @@ -345,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, @@ -365,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" @@ -587,6 +681,20 @@ class TestJiraIntegration: finding.metadata.Risk = "risk" finding.metadata.Remediation.Recommendation.Text = "remediation_text" finding.metadata.Remediation.Recommendation.Url = "remediation_url" + finding.metadata.Remediation.Code.NativeIaC = ( + 'resource "aws_s3_bucket" "example" { bucket = "my-bucket" }' + ) + finding.metadata.Remediation.Code.Terraform = ( + "terraform apply -target=aws_s3_bucket.example" + ) + finding.metadata.Remediation.Code.CLI = ( + "aws s3api create-bucket --bucket my-bucket --region us-east-1" + ) + finding.metadata.Remediation.Code.Other = ( + "Manual configuration required in AWS Console" + ) + finding.resource_tags = {"Environment": "Production", "Owner": "SecurityTeam"} + finding.compliance = {"CIS": ["2.1.1", "2.1.2"], "NIST": ["AC-3", "AC-6"]} self.jira_integration.cloud_id = "valid_cloud_id" @@ -594,483 +702,115 @@ class TestJiraIntegration: findings=[finding], project_key="TEST-1", issue_type="Bug" ) + 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" ) - expected_json = { - "fields": { - "project": {"key": "TEST-1"}, - "summary": "[Prowler] HIGH - CHECK-1 - resource-1", - "description": { - "type": "doc", - "version": 1, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Prowler has discovered the following finding:", - } - ], - }, - { - "type": "table", - "attrs": {"layout": "full-width"}, - "content": [ - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Check Id", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "CHECK-1", - "marks": [{"type": "code"}], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Check Title", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Check Title", - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Severity", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - {"type": "text", "text": "HIGH"} - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Status", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "FAIL", - "marks": [ - {"type": "strong"}, - { - "type": "textColor", - "attrs": { - "color": "#FF0000" - }, - }, - ], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Status Extended", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "status_extended", - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Provider", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "aws", - "marks": [{"type": "code"}], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Region", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "region", - "marks": [{"type": "code"}], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Resource UID", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "resource-1", - "marks": [{"type": "code"}], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Resource Name", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "resource_name", - "marks": [{"type": "code"}], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Risk", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - {"type": "text", "text": "risk"} - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Recommendation", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "remediation_text ", - }, - { - "type": "text", - "text": "remediation_url", - "marks": [ - { - "type": "link", - "attrs": { - "href": "remediation_url" - }, - } - ], - }, - ], - } - ], - }, - ], - }, - ], - }, - ], - }, - "issuetype": {"name": "Bug"}, - } - } expected_headers = { "Authorization": "Bearer valid_access_token", "Content-Type": "application/json", } - mock_post.assert_called_once_with( - expected_url, json=expected_json, headers=expected_headers + assert call_args[0][0] == expected_url + assert call_args.kwargs["headers"] == expected_headers + + payload = call_args.kwargs["json"] + assert payload["fields"]["project"]["key"] == "TEST-1" + assert payload["fields"]["summary"] == "[Prowler] HIGH - CHECK-1 - resource-1" + assert payload["fields"]["issuetype"]["name"] == "Bug" + assert payload["fields"]["description"]["type"] == "doc" + + description_content = payload["fields"]["description"]["content"] + + intro_paragraph = description_content[0] + assert intro_paragraph["type"] == "paragraph" + intro_text = intro_paragraph["content"][0] + assert intro_text["type"] == "text" + assert intro_text["text"] == "Prowler has discovered the following finding:" + + table = description_content[1] + assert table["type"] == "table" + + table_rows = table["content"] + row_texts = [] + for row in table_rows: + if row["type"] == "tableRow": + cells = row["content"] + if len(cells) == 2: + key_cell = cells[0]["content"][0]["content"][0]["text"] + + value_content = cells[1]["content"][0]["content"] + if len(value_content) > 1: + value_texts = [] + for content_item in value_content: + if content_item["type"] == "text": + value_texts.append(content_item["text"]) + value_cell = "".join(value_texts) + else: + value_cell = value_content[0]["text"] + + row_texts.append((key_cell, value_cell)) + + expected_keys = [ + "Check Id", + "Check Title", + "Severity", + "Status", + "Status Extended", + "Provider", + "Region", + "Resource UID", + "Resource Name", + "Risk", + "Resource Tags", + "Compliance", + "Recommendation", + "Remediation Native IaC", + "Remediation Terraform", + "Remediation CLI", + "Remediation Other", + ] + + actual_keys = [key for key, _ in row_texts] + + for expected_key in expected_keys: + assert expected_key in actual_keys, f"Missing row key: {expected_key}" + + row_dict = dict(row_texts) + assert row_dict["Check Id"] == "CHECK-1" + assert row_dict["Check Title"] == "Check Title" + assert row_dict["Status"] == "FAIL" + assert row_dict["Severity"] == "HIGH" + assert row_dict["Resource UID"] == "resource-1" + assert row_dict["Resource Name"] == "resource_name" + assert row_dict["Provider"] == "aws" + assert row_dict["Region"] == "region" + assert row_dict["Risk"] == "risk" + assert "remediation_text" in row_dict["Recommendation"] + assert "remediation_url" in row_dict["Recommendation"] + assert ( + row_dict["Remediation Native IaC"] + == 'resource "aws_s3_bucket" "example" { bucket = "my-bucket" }' ) + assert ( + row_dict["Remediation Terraform"] + == "terraform apply -target=aws_s3_bucket.example" + ) + assert ( + row_dict["Remediation CLI"] + == "aws s3api create-bucket --bucket my-bucket --region us-east-1" + ) + assert ( + row_dict["Remediation Other"] + == "Manual configuration required in AWS Console" + ) + assert "Environment=Production" in row_dict["Resource Tags"] + assert "Owner=SecurityTeam" in row_dict["Resource Tags"] + assert "CIS: 2.1.1, 2.1.2" in row_dict["Compliance"] + assert "NIST: AC-3, AC-6" in row_dict["Compliance"] @patch.object(Jira, "get_access_token", return_value="valid_access_token") @patch.object( @@ -1084,6 +824,7 @@ class TestJiraIntegration: # To disable vulture mock_get_available_issue_types = mock_get_available_issue_types mock_get_access_token = mock_get_access_token + mock_post = mock_post with pytest.raises(JiraCreateIssueError): self.jira_integration.send_findings( @@ -1119,6 +860,120 @@ class TestJiraIntegration: findings=[finding], project_key="TEST", issue_type="Bug" ) + @patch.object(Jira, "get_access_token", return_value="valid_access_token") + @patch.object( + Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"] + ) + @patch.object(Jira, "get_projects", return_value={"TEST-1": "Test Project"}) + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_send_findings_custom_fields_required_error( + self, + mock_post, + mock_get_projects, + mock_get_available_issue_types, + mock_get_access_token, + ): + """Test that send_findings raises JiraRequiredCustomFieldsError when custom fields are required.""" + # To disable vulture + mock_get_available_issue_types = mock_get_available_issue_types + mock_get_access_token = mock_get_access_token + mock_get_projects = mock_get_projects + + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = { + "errorMessages": [], + "errors": { + "customfield_10148": "Team is required.", + "customfield_10088": "Component is required.", + }, + } + mock_post.return_value = mock_response + + finding = MagicMock() + finding.status.value = "FAIL" + finding.status_extended = "status_extended" + finding.metadata.Severity.value = "HIGH" + finding.metadata.CheckID = "CHECK-1" + finding.metadata.CheckTitle = "Check Title" + finding.resource_uid = "resource-1" + finding.resource_name = "resource_name" + finding.metadata.Provider = "aws" + finding.region = "region" + finding.metadata.Risk = "risk" + finding.metadata.Remediation.Recommendation.Text = "remediation_text" + finding.metadata.Remediation.Recommendation.Url = "remediation_url" + finding.metadata.Remediation.Code.NativeIaC = "" + finding.metadata.Remediation.Code.Terraform = "" + finding.metadata.Remediation.Code.CLI = "" + finding.metadata.Remediation.Code.Other = "" + finding.resource_tags = {} + finding.compliance = {} + + self.jira_integration.cloud_id = "valid_cloud_id" + + with pytest.raises(JiraRequiredCustomFieldsError): + self.jira_integration.send_findings( + findings=[finding], project_key="TEST-1", issue_type="Bug" + ) + + @patch.object(Jira, "get_access_token", return_value="valid_access_token") + @patch.object( + Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"] + ) + @patch.object(Jira, "get_projects", return_value={"TEST-1": "Test Project"}) + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_send_findings_non_custom_field_400_error( + self, + mock_post, + mock_get_projects, + mock_get_available_issue_types, + mock_get_access_token, + ): + """Test that send_findings raises JiraCreateIssueError for non-custom field 400 errors.""" + # To disable vulture + mock_get_available_issue_types = mock_get_available_issue_types + mock_get_access_token = mock_get_access_token + mock_get_projects = mock_get_projects + + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = { + "errorMessages": [], + "errors": { + "summary": "Summary is required.", + "description": "Description is required.", + }, + } + mock_post.return_value = mock_response + + finding = MagicMock() + finding.status.value = "FAIL" + finding.status_extended = "status_extended" + finding.metadata.Severity.value = "HIGH" + finding.metadata.CheckID = "CHECK-1" + finding.metadata.CheckTitle = "Check Title" + finding.resource_uid = "resource-1" + finding.resource_name = "resource_name" + finding.metadata.Provider = "aws" + finding.region = "region" + finding.metadata.Risk = "risk" + finding.metadata.Remediation.Recommendation.Text = "remediation_text" + finding.metadata.Remediation.Recommendation.Url = "remediation_url" + finding.metadata.Remediation.Code.NativeIaC = "" + finding.metadata.Remediation.Code.Terraform = "" + finding.metadata.Remediation.Code.CLI = "" + finding.metadata.Remediation.Code.Other = "" + finding.resource_tags = {} + finding.compliance = {} + + self.jira_integration.cloud_id = "valid_cloud_id" + + with pytest.raises(JiraCreateIssueError): + self.jira_integration.send_findings( + findings=[finding], project_key="TEST-1", issue_type="Bug" + ) + @pytest.mark.parametrize( "status, expected_color", [("FAIL", "#FF0000"), ("PASS", "#008000"), ("MUTED", "#FFA500")], @@ -1126,3 +981,285 @@ class TestJiraIntegration: def test_get_color_from_status(self, status, expected_color): """Test that get_color_from_status returns the correct color for a status.""" assert self.jira_integration.get_color_from_status(status) == expected_color + + @pytest.mark.parametrize( + "severity, expected_color", + [ + ("critical", "#FF0000"), + ("high", "#FFA500"), + ("medium", "#FFFF00"), + ("low", "#008000"), + ("informational", "#0000FF"), + ("unknown", "#000000"), + ], + ) + 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 diff --git a/tests/providers/azure/azure_provider_test.py b/tests/providers/azure/azure_provider_test.py index 5796b15ada..a0c8a5e5d4 100644 --- a/tests/providers/azure/azure_provider_test.py +++ b/tests/providers/azure/azure_provider_test.py @@ -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): diff --git a/tests/providers/azure/services/apim/__init__.py b/tests/providers/azure/services/apim/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/azure/services/apim/apim_service_test.py b/tests/providers/azure/services/apim/apim_service_test.py new file mode 100644 index 0000000000..3b1a061293 --- /dev/null +++ b/tests/providers/azure/services/apim/apim_service_test.py @@ -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"}]) diff --git a/tests/providers/azure/services/apim/apim_threat_detection_llm_jacking/__init__.py b/tests/providers/azure/services/apim/apim_threat_detection_llm_jacking/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking_test.py b/tests/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking_test.py new file mode 100644 index 0000000000..d37385f585 --- /dev/null +++ b/tests/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking_test.py @@ -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 + ) diff --git a/tests/providers/gcp/gcp_provider_test.py b/tests/providers/gcp/gcp_provider_test.py index e2196dddbf..63741ed647 100644 --- a/tests/providers/gcp/gcp_provider_test.py +++ b/tests/providers/gcp/gcp_provider_test.py @@ -987,3 +987,90 @@ class TestGCPProvider: from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS assert DEFAULT_RETRY_ATTEMPTS == 3 + + def test_skip_api_check_argument(self): + """Test that skip_api_check argument is set correctly in GcpProvider""" + + mocked_credentials = MagicMock() + + mocked_credentials.refresh.return_value = None + mocked_credentials._service_account_email = "test-service-account-email" + + arguments = Namespace() + arguments.project_id = [] + arguments.excluded_project_id = [] + arguments.organization_id = None + arguments.list_project_id = False + arguments.credentials_file = "test_credentials_file" + arguments.impersonate_service_account = "" + arguments.config_file = default_config_file_path + arguments.fixer_config = default_fixer_config_file_path + arguments.skip_api_check = True + + projects = { + "test-project": GCPProject( + number="55555555", + id="project/55555555", + name="test-project", + labels={"test": "value"}, + lifecycle_state="ACTIVE", + ), + } + + mocked_service = MagicMock() + + mocked_service.projects.list.return_value = MagicMock( + execute=MagicMock(return_value={"projects": projects}) + ) + + mocked_is_api_active = MagicMock() + mocked_is_api_active.return_value = projects.keys() + + with ( + patch( + "prowler.providers.gcp.gcp_provider.GcpProvider.get_projects", + return_value=projects, + ), + patch( + "prowler.providers.gcp.gcp_provider.GcpProvider.update_projects_with_organizations", + return_value=None, + ), + patch( + "os.path.abspath", + return_value="test_credentials_file", + ), + patch( + "prowler.providers.gcp.gcp_provider.default", + return_value=(mocked_credentials, MagicMock()), + ), + patch( + "prowler.providers.gcp.gcp_provider.discovery.build", + return_value=mocked_service, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + mocked_is_api_active, + ), + ): + gcp_provider = GcpProvider( + retries_max_attempts=None, + organization_id=arguments.organization_id, + project_ids=arguments.project_id, + excluded_project_ids=arguments.excluded_project_id, + credentials_file=arguments.credentials_file, + impersonate_service_account=arguments.impersonate_service_account, + list_project_ids=arguments.list_project_id, + config_path=arguments.config_file, + fixer_config=arguments.fixer_config, + client_id=None, + client_secret=None, + refresh_token=None, + skip_api_check=arguments.skip_api_check, + ) + + from prowler.providers.gcp.lib.service.service import GCPService + + GCPService("testservice", gcp_provider) + + assert gcp_provider.skip_api_check is True + mocked_is_api_active.assert_not_called() diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 9badc5216a..080d67c917 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,7 +2,21 @@ 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) + +## [1.11.0] (Prowler v5.11.0) ### 🚀 Added @@ -11,6 +25,7 @@ All notable changes to the **Prowler UI** are documented in this file. - New menu item under Configuration section for quick access to the Mutelist [(#8444)](https://github.com/prowler-cloud/prowler/pull/8444) - Resource agent to Lighthouse for querying resource information [(#8509)](https://github.com/prowler-cloud/prowler/pull/8509) - Lighthouse support for OpenAI GPT-5 [(#8527)](https://github.com/prowler-cloud/prowler/pull/8527) +- Link to the configured S3 bucket and folder in each integration [(#8554)](https://github.com/prowler-cloud/prowler/pull/8554) ### 🔄 Changed @@ -24,8 +39,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) diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index cdc947fd8c..c29b771984 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -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; } - // */ }; diff --git a/ui/actions/findings/findings.ts b/ui/actions/findings/findings.ts index 438459b4d2..f37e48637c 100644 --- a/ui/actions/findings/findings.ts +++ b/ui/actions/findings/findings.ts @@ -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; diff --git a/ui/actions/integrations/index.ts b/ui/actions/integrations/index.ts index ab4160c009..1e05de036c 100644 --- a/ui/actions/integrations/index.ts +++ b/ui/actions/integrations/index.ts @@ -1,7 +1,6 @@ export { createIntegration, deleteIntegration, - getIntegration, getIntegrations, pollConnectionTestStatus, testIntegrationConnection, diff --git a/ui/actions/integrations/integrations.ts b/ui/actions/integrations/integrations.ts index 2121810a50..32de22a8cc 100644 --- a/ui/actions/integrations/integrations.ts +++ b/ui/actions/integrations/integrations.ts @@ -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 }> => { diff --git a/ui/actions/integrations/saml.ts b/ui/actions/integrations/saml.ts index c5bc6d18bb..782f896143 100644 --- a/ui/actions/integrations/saml.ts +++ b/ui/actions/integrations/saml.ts @@ -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; diff --git a/ui/actions/invitations/invitation.ts b/ui/actions/invitations/invitation.ts index 44cbd1a455..a46b60bf11 100644 --- a/ui/actions/invitations/invitation.ts +++ b/ui/actions/invitations/invitation.ts @@ -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); } }; diff --git a/ui/actions/manage-groups/manage-groups.ts b/ui/actions/manage-groups/manage-groups.ts index 3b12f619f9..3ac89e29ad 100644 --- a/ui/actions/manage-groups/manage-groups.ts +++ b/ui/actions/manage-groups/manage-groups.ts @@ -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 }] }; } }; diff --git a/ui/actions/overview/overview.ts b/ui/actions/overview/overview.ts index 363a2b9df3..052d0f0322 100644 --- a/ui/actions/overview/overview.ts +++ b/ui/actions/overview/overview.ts @@ -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; } diff --git a/ui/actions/providers/providers.ts b/ui/actions/providers/providers.ts index 19bf794de1..58c4ccd470 100644 --- a/ui/actions/providers/providers.ts +++ b/ui/actions/providers/providers.ts @@ -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); } }; diff --git a/ui/actions/resources/resources.ts b/ui/actions/resources/resources.ts index 4fa78104de..31c7ae21d6 100644 --- a/ui/actions/resources/resources.ts +++ b/ui/actions/resources/resources.ts @@ -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; diff --git a/ui/actions/roles/roles.ts b/ui/actions/roles/roles.ts index e69300ab26..36295fe462 100644 --- a/ui/actions/roles/roles.ts +++ b/ui/actions/roles/roles.ts @@ -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); } }; diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index 80505b3ca1..73602215ad 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -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); } }; diff --git a/ui/actions/task/tasks.ts b/ui/actions/task/tasks.ts index 13cff62345..cd72884566 100644 --- a/ui/actions/task/tasks.ts +++ b/ui/actions/task/tasks.ts @@ -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); } }; diff --git a/ui/actions/users/tenants.ts b/ui/actions/users/tenants.ts index 9063fc9e1b..0cadab0444 100644 --- a/ui/actions/users/tenants.ts +++ b/ui/actions/users/tenants.ts @@ -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); } } diff --git a/ui/actions/users/users.ts b/ui/actions/users/users.ts index c47c58ebbf..bc0a204d30 100644 --- a/ui/actions/users/users.ts +++ b/ui/actions/users/users.ts @@ -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: [] }; } diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index 5a1e7fcca9..e9cd26ac23 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -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; diff --git a/ui/app/(prowler)/error.tsx b/ui/app/(prowler)/error.tsx index f1813e0ede..a2ef4650c1 100644 --- a/ui/app/(prowler)/error.tsx +++ b/ui/app/(prowler)/error.tsx @@ -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 ( - - - An unexpected error occurred - - We're sorry for the inconvenience. Please try again or contact - support if the problem persists. - - - Go to the homepage - - +

+ + + + {is500Error + ? "Server temporarily unavailable" + : "An unexpected error occurred"} + + + {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."} + +
+ } + ariaLabel="Try Again" + > + Try Again + + + Go to Overview + +
+
+
); } diff --git a/ui/app/(prowler)/lighthouse/page.tsx b/ui/app/(prowler)/lighthouse/page.tsx index 96e5ecbfb7..1bf8551f74 100644 --- a/ui/app/(prowler)/lighthouse/page.tsx +++ b/ui/app/(prowler)/lighthouse/page.tsx @@ -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 ( diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index 68ce8b8e29..a364f7b494 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -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({ <> - {thereIsNoProvidersConnected ? ( + {!hasManageScansPermission ? ( + + ) : thereIsNoProvidersConnected ? ( <> @@ -90,6 +101,7 @@ export default async function Scans({ ) : ( )} + ); } diff --git a/ui/components/integrations/s3/s3-integrations-manager.tsx b/ui/components/integrations/s3/s3-integrations-manager.tsx index 1b3061dbc3..a50276b951 100644 --- a/ui/components/integrations/s3/s3-integrations-manager.tsx +++ b/ui/components/integrations/s3/s3-integrations-manager.tsx @@ -322,8 +322,10 @@ export const S3IntegrationsManager = ({ connectionStatus={{ connected: integration.attributes.connected, }} + navigationUrl={`https://console.aws.amazon.com/s3/buckets/${integration.attributes.configuration.bucket_name}`} /> +
diff --git a/ui/components/integrations/security-hub/security-hub-integration-form.tsx b/ui/components/integrations/security-hub/security-hub-integration-form.tsx index 1d5a0c4399..4db090a766 100644 --- a/ui/components/integrations/security-hub/security-hub-integration-form.tsx +++ b/ui/components/integrations/security-hub/security-hub-integration-form.tsx @@ -285,6 +285,7 @@ export const SecurityHubIntegrationForm = ({ "aws_security_hub", )} type="integrations" + integrationType="aws_security_hub" /> )} @@ -308,6 +309,7 @@ export const SecurityHubIntegrationForm = ({ externalId={externalId} templateLinks={templateLinks} type="integrations" + integrationType="aws_security_hub" /> ); } @@ -346,7 +348,9 @@ export const SecurityHubIntegrationForm = ({ onValueChange={field.onChange} size="sm" > - Send only Failed Findings + + Send only findings with status FAIL + )} @@ -379,7 +383,10 @@ export const SecurityHubIntegrationForm = ({ onValueChange={field.onChange} size="sm" > - Use custom credentials + + Use custom credentials (By default, AWS account ones + will be used) + )} diff --git a/ui/components/integrations/shared/integration-card-header.tsx b/ui/components/integrations/shared/integration-card-header.tsx index a56473f410..0e7d46bd81 100644 --- a/ui/components/integrations/shared/integration-card-header.tsx +++ b/ui/components/integrations/shared/integration-card-header.tsx @@ -1,6 +1,7 @@ "use client"; import { Chip } from "@nextui-org/react"; +import { ExternalLink } from "lucide-react"; import { ReactNode } from "react"; interface IntegrationCardHeaderProps { @@ -22,6 +23,7 @@ interface IntegrationCardHeaderProps { connected: boolean; label?: string; }; + navigationUrl?: string; } export const IntegrationCardHeader = ({ @@ -30,13 +32,27 @@ export const IntegrationCardHeader = ({ subtitle, chips = [], connectionStatus, + navigationUrl, }: IntegrationCardHeaderProps) => { return (
{icon}
-

{title}

+
+

{title}

+ {navigationUrl && ( + + + + )} +
{subtitle && (

{subtitle} diff --git a/ui/components/providers/workflow/credentials-role-helper.tsx b/ui/components/providers/workflow/credentials-role-helper.tsx index 52d5952530..c50a87afed 100644 --- a/ui/components/providers/workflow/credentials-role-helper.tsx +++ b/ui/components/providers/workflow/credentials-role-helper.tsx @@ -3,6 +3,7 @@ import { IdIcon } from "@/components/icons"; import { CustomButton } from "@/components/ui/custom"; import { SnippetChip } from "@/components/ui/entities"; +import { IntegrationType } from "@/types/integrations"; interface CredentialsRoleHelperProps { externalId: string; @@ -11,20 +12,22 @@ interface CredentialsRoleHelperProps { cloudformationQuickLink: string; terraform: string; }; - type?: "providers" | "integrations"; + integrationType?: IntegrationType; } export const CredentialsRoleHelper = ({ externalId, templateLinks, - type = "providers", + integrationType, }: CredentialsRoleHelperProps) => { + const isAmazonS3 = integrationType === "amazon_s3"; + return (

A read-only IAM role must be manually created - {type === "integrations" ? " or updated" : ""}. + {isAmazonS3 ? " or updated" : ""}

- {type === "providers" - ? "Use one of the following templates to create the IAM role" - : "Refer to the documentation"} + {isAmazonS3 + ? "Refer to the documentation" + : "Use one of the following templates to create the IAM role"}

@@ -59,7 +62,7 @@ export const CredentialsRoleHelper = ({ asLink={templateLinks.cloudformation} target="_blank" > - CloudFormation {type === "providers" ? "Template" : ""} + CloudFormation {integrationType ? "" : "Template"} - Terraform {type === "providers" ? "Code" : ""} + Terraform {integrationType ? "" : "Code"}
diff --git a/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form.tsx b/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form.tsx index 15b7c3700d..c84fd39c85 100644 --- a/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form.tsx @@ -6,6 +6,7 @@ import { CredentialsRoleHelper } from "@/components/providers/workflow"; import { CustomInput } from "@/components/ui/custom"; import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; import { AWSCredentialsRole } from "@/types"; +import { IntegrationType } from "@/types/integrations"; export const AWSRoleCredentialsForm = ({ control, @@ -13,6 +14,7 @@ export const AWSRoleCredentialsForm = ({ externalId, templateLinks, type = "providers", + integrationType, }: { control: Control; setValue: UseFormSetValue; @@ -23,6 +25,7 @@ export const AWSRoleCredentialsForm = ({ terraform: string; }; type?: "providers" | "integrations"; + integrationType?: IntegrationType; }) => { const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; const defaultCredentialsType = isCloudEnv @@ -187,7 +190,7 @@ export const AWSRoleCredentialsForm = ({ diff --git a/ui/components/providers/workflow/forms/via-credentials/m365-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials/m365-credentials-form.tsx index 13eb61710d..4031536fac 100644 --- a/ui/components/providers/workflow/forms/via-credentials/m365-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/via-credentials/m365-credentials-form.tsx @@ -69,7 +69,7 @@ export const M365CredentialsForm = ({

- By September 2025, MFA will be mandatory. + By October 2025, MFA will be mandatory.

diff --git a/ui/components/ui/custom/custom-banner.tsx b/ui/components/ui/custom/custom-banner.tsx new file mode 100644 index 0000000000..96c3b438ad --- /dev/null +++ b/ui/components/ui/custom/custom-banner.tsx @@ -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 ( +

+
+
+
+ +

+ {title} +

+
+

{message}

+
+
+ + {buttonLabel} + +
+
+
+ ); +}; diff --git a/ui/components/users/forms/edit-tenant-form.tsx b/ui/components/users/forms/edit-tenant-form.tsx index 71aabb654c..affc03c95f 100644 --- a/ui/components/users/forms/edit-tenant-form.tsx +++ b/ui/components/users/forms/edit-tenant-form.tsx @@ -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 */} diff --git a/ui/lib/external-urls.ts b/ui/lib/external-urls.ts index 125a227b87..6ec84ef46c 100644 --- a/ui/lib/external-urls.ts +++ b/ui/lib/external-urls.ts @@ -51,7 +51,7 @@ export const getAWSCredentialsTemplateLinks = ( } => { let links = {}; - if (integrationType === undefined) { + if (integrationType === undefined || integrationType === "aws_security_hub") { links = { cloudformation: "https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/cloudformation/prowler-scan-role.yml", @@ -60,15 +60,6 @@ export const getAWSCredentialsTemplateLinks = ( }; } - if (integrationType === "aws_security_hub") { - links = { - cloudformation: - "https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-s3-integration/", - terraform: - "https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-s3-integration/#terraform", - }; - } - if (integrationType === "amazon_s3") { links = { cloudformation: diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts index 61b431c7c9..016201c5bd 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -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), diff --git a/ui/package-lock.json b/ui/package-lock.json index 0d53f26376..984c9a0895 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -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", diff --git a/ui/package.json b/ui/package.json index 05484fe77d..e6b8a95641 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/types/integrations.ts b/ui/types/integrations.ts index a95174cb45..033a57f4b4 100644 --- a/ui/types/integrations.ts +++ b/ui/types/integrations.ts @@ -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); - }); \ No newline at end of file +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); + }); diff --git a/ui/types/server-actions.ts b/ui/types/server-actions.ts new file mode 100644 index 0000000000..e69de29bb2