Compare commits

..

39 Commits

Author SHA1 Message Date
Víctor Fernández Poyatos 07e82bde56 feat(attack-surfaces): add new endpoints to retrieve overview data (#9309) 2025-12-02 12:12:47 +01:00
Hugo Pereira Brito 4661e01c26 chore(changelog): update for 5.14.2 release (#9404) 2025-12-02 11:22:01 +01:00
Alan Buscaglia dda0a2567d fix(ui): skip Sentry initialization when DSN is not configured (#9368) 2025-12-01 18:05:45 +01:00
StylusFrost 56ea498cca test(ui): Add e2e test for OCI Provider (#9347)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
2025-12-01 16:13:12 +01:00
Hugo Pereira Brito f9e1e29631 fix(dashboard): typo and format errors (#9361) 2025-12-01 14:29:22 +01:00
lydiavilchez 3dadb264cc feat(gcp): add check for VM instance deletion protection (#9358) 2025-12-01 13:20:32 +01:00
Víctor Fernández Poyatos 495aee015e build: add gevent to API deps (#9359) 2025-12-01 13:11:38 +01:00
Pedro Martín d3a000cbc4 fix(report): update logic for threatscore (#9348) 2025-12-01 09:11:08 +01:00
lydiavilchez b2abdbeb60 feat(gcp-compute): add check to ensure VMs are not preemptible or spot (#9342)
Co-authored-by: HugoPBrito <hugopbrit@gmail.com>
2025-11-28 12:49:19 +01:00
lydiavilchez dc852b4595 feat(gcp-compute): add automatic restart check for VM instances (#9271) 2025-11-28 12:21:58 +01:00
Hugo Pereira Brito 1250f582a5 fix(check): custom check folder validation (#9335) 2025-11-28 12:19:47 +01:00
Pedro Martín bb43e924ee fix(report): use pagina for ENS in footer (#9345) 2025-11-28 12:04:30 +01:00
Andoni Alonso 0225627a98 fix(docs): fix image paths (#9341) 2025-11-28 11:20:54 +01:00
Alan Buscaglia 3097513525 fix(ui): filter Risk Pipeline chart by selected providers and show zero-data legends (#9340) 2025-11-27 17:39:01 +01:00
Alan Buscaglia 6af9ff4b4b feat(ui): add interactive charts with filter navigation (#9333) 2025-11-27 16:04:55 +01:00
Hugo Pereira Brito 06fa57a949 fix(docs): info warning format (#9339) 2025-11-27 09:57:05 -05:00
mattkeeler dc9e91ac4e fix(m365): Support multiple Exchange mailbox policies (#9241)
Co-authored-by: HugoPBrito <hugopbrit@gmail.com>
2025-11-27 14:10:15 +01:00
Shafkat Rahman 59f8dfe5ae feat(github): add immutable releases check (#9162)
Co-authored-by: Andoni Alonso <14891798+andoniaf@users.noreply.github.com>
2025-11-27 13:40:15 +01:00
Adrián Jesús Peña Rodríguez 7e0c5540bb feat(api): restore compliance overview endpoint (#9330) 2025-11-27 13:31:15 +01:00
Daniel Barranquero 79ec53bfc5 fix(ui): update changelog (#9334) 2025-11-27 13:16:50 +01:00
Daniel Barranquero ed5f6b3af6 feat(ui): add MongoDB Atlas provider support (#9253)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
2025-11-27 12:37:20 +01:00
Andoni Alonso 6e135abaa0 fix(iac): ignore mutelist in IaC scans (#9331) 2025-11-27 11:08:58 +01:00
Hugo Pereira Brito 65b054f798 feat: enhance m365 documentation (#9287) 2025-11-26 16:17:43 +01:00
Alan Buscaglia 28d5b2bb6c feat(ui): integrate threat map with regions API endpoint (#9324)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2025-11-26 16:12:31 +01:00
Prowler Bot c8d9f37e70 feat(aws): Update regions for AWS services (#9294)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2025-11-26 09:42:40 -05:00
lydiavilchez 9d7b9c3327 feat(gcp): Add VPC Service Controls check for Cloud Storage (#9256)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2025-11-26 14:45:27 +01:00
Hugo Pereira Brito 127b8d8e56 fix: typo in pdf report generation (#9322)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2025-11-26 13:58:40 +01:00
Alan Buscaglia 4e9dd46a5e feat(ui): add Risk Pipeline View with Sankey chart to Overview page (#9320)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2025-11-26 13:33:58 +01:00
Hugo Pereira Brito 880345bebe fix(sharepoint): false positives on disabled external sharing (#9298) 2025-11-26 12:23:04 +01:00
Andoni Alonso 1259713fd6 docs: remove AMD-only docker images warning (#9315) 2025-11-26 10:26:39 +01:00
Prowler Bot 26088868a2 chore(release): Bump version to v5.15.0 (#9318)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2025-11-26 10:19:25 +01:00
César Arroba e58574e2a4 chore(github): fix container actions (#9321) 2025-11-26 10:16:26 +01:00
Alan Buscaglia a07e599cfc feat(ui): add service watchlist component with real API integration (#9316)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2025-11-25 17:03:24 +01:00
Alejandro Bailo e020b3f74b feat: add watchlist component (#9199)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
2025-11-25 16:01:38 +01:00
Alan Buscaglia 8e7e376e4f feat(ui): hide new overview route and filter mongo providers (#9314) 2025-11-25 14:22:03 +01:00
Alan Buscaglia a63a3d3f68 fix: add filters for mongo providers and findings (#9311) 2025-11-25 13:19:49 +01:00
Andoni Alonso 10838de636 docs: refactor Lighthouse AI pages (#9310)
Co-authored-by: Chandrapal Badshah <12944530+Chan9390@users.noreply.github.com>
2025-11-25 13:10:29 +01:00
Chandrapal Badshah 5ebf455e04 docs: Lighthouse multi LLM provider support (#9306)
Co-authored-by: Chandrapal Badshah <12944530+Chan9390@users.noreply.github.com>
Co-authored-by: Andoni A. <14891798+andoniaf@users.noreply.github.com>
2025-11-25 13:04:30 +01:00
Daniel Barranquero 0d59441c5f fix(api): add alter to mongodbatlas migration (#9308) 2025-11-25 11:29:07 +01:00
177 changed files with 9248 additions and 2120 deletions
+40 -1
View File
@@ -10,6 +10,7 @@ on:
- 'ui/**'
jobs:
e2e-tests:
if: github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
@@ -33,12 +34,50 @@ jobs:
E2E_M365_SECRET_ID: ${{ secrets.E2E_M365_SECRET_ID }}
E2E_M365_TENANT_ID: ${{ secrets.E2E_M365_TENANT_ID }}
E2E_M365_CERTIFICATE_CONTENT: ${{ secrets.E2E_M365_CERTIFICATE_CONTENT }}
E2E_NEW_PASSWORD: ${{ secrets.E2E_NEW_PASSWORD }}
E2E_KUBERNETES_CONTEXT: 'kind-kind'
E2E_KUBERNETES_KUBECONFIG_PATH: /home/runner/.kube/config
E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY: ${{ secrets.E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY }}
E2E_GCP_PROJECT_ID: ${{ secrets.E2E_GCP_PROJECT_ID }}
E2E_GITHUB_APP_ID: ${{ secrets.E2E_GITHUB_APP_ID }}
E2E_GITHUB_BASE64_APP_PRIVATE_KEY: ${{ secrets.E2E_GITHUB_BASE64_APP_PRIVATE_KEY }}
E2E_GITHUB_USERNAME: ${{ secrets.E2E_GITHUB_USERNAME }}
E2E_GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.E2E_GITHUB_PERSONAL_ACCESS_TOKEN }}
E2E_GITHUB_ORGANIZATION: ${{ secrets.E2E_GITHUB_ORGANIZATION }}
E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN: ${{ secrets.E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN }}
E2E_ORGANIZATION_ID: ${{ secrets.E2E_ORGANIZATION_ID }}
E2E_OCI_TENANCY_ID: ${{ secrets.E2E_OCI_TENANCY_ID }}
E2E_OCI_USER_ID: ${{ secrets.E2E_OCI_USER_ID }}
E2E_OCI_FINGERPRINT: ${{ secrets.E2E_OCI_FINGERPRINT }}
E2E_OCI_KEY_CONTENT: ${{ secrets.E2E_OCI_KEY_CONTENT }}
E2E_OCI_REGION: ${{ secrets.E2E_OCI_REGION }}
E2E_NEW_USER_PASSWORD: ${{ secrets.E2E_NEW_USER_PASSWORD }}
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Create k8s Kind Cluster
uses: helm/kind-action@v1
with:
cluster_name: kind
- name: Modify kubeconfig
run: |
# Modify the kubeconfig to use the kind cluster server to https://kind-control-plane:6443
# from worker service into docker-compose.yml
kubectl config set-cluster kind-kind --server=https://kind-control-plane:6443
kubectl config view
- name: Add network kind to docker compose
run: |
# Add the network kind to the docker compose to interconnect to kind cluster
yq -i '.networks.kind.external = true' docker-compose.yml
# Add network kind to worker service and default network too
yq -i '.services.worker.networks = ["kind","default"]' docker-compose.yml
- name: Fix API data directory permissions
run: docker run --rm -v $(pwd)/_data/api:/data alpine chown -R 1000:1000 /data
- name: Add AWS credentials for testing AWS SDK Default Adding Provider
run: |
echo "Adding AWS credentials for testing AWS SDK Default Adding Provider..."
echo "AWS_ACCESS_KEY_ID=${{ secrets.E2E_AWS_PROVIDER_ACCESS_KEY }}" >> .env
echo "AWS_SECRET_ACCESS_KEY=${{ secrets.E2E_AWS_PROVIDER_SECRET_KEY }}" >> .env
- name: Start API services
run: |
# Override docker-compose image tag to use latest instead of stable
+1 -1
View File
@@ -90,7 +90,7 @@ prowler dashboard
| M365 | 70 | 7 | 3 | 2 | Official | UI, API, CLI |
| OCI | 51 | 13 | 1 | 10 | Official | UI, API, CLI |
| IaC | [See `trivy` docs.](https://trivy.dev/latest/docs/coverage/iac/) | N/A | N/A | N/A | Official | UI, API, CLI |
| MongoDB Atlas | 10 | 3 | 0 | 0 | Official | CLI, API |
| MongoDB Atlas | 10 | 3 | 0 | 0 | Official | UI, API, CLI |
| LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI |
| NHN | 6 | 2 | 1 | 0 | Unofficial | CLI |
+12 -2
View File
@@ -2,11 +2,21 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.16.0] (Unreleased)
### Added
- New endpoint to retrieve an overview of the attack surfaces [(#9309)](https://github.com/prowler-cloud/prowler/pull/9309)
### Changed
- Restore the compliance overview endpoint's mandatory filters [(#9330)](https://github.com/prowler-cloud/prowler/pull/9330)
---
## [1.15.1] (Prowler v5.14.1)
### Fixed
- Typo in PDF reporting [(#9322)](https://github.com/prowler-cloud/prowler/pull/9322)
- IaC provider initialization failure when mutelist processor is configured [(#9331)](https://github.com/prowler-cloud/prowler/pull/9331)
- Fix typo in PDF reporting [(#9345)](https://github.com/prowler-cloud/prowler/pull/9345)
- Fix IaC provider initialization failure when mutelist processor is configured [(#9331)](https://github.com/prowler-cloud/prowler/pull/9331)
- Match logic for ThreatScore when counting findings [(#9348)](https://github.com/prowler-cloud/prowler/pull/9348)
---
+4 -9
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
[[package]]
name = "about-time"
@@ -4852,8 +4852,8 @@ tzlocal = "5.3.1"
[package.source]
type = "git"
url = "https://github.com/prowler-cloud/prowler.git"
reference = "v5.14"
resolved_reference = "3b05a1430e016cee92b60973705cba400255d9e5"
reference = "master"
resolved_reference = "de5aba6d4db54eed4c95cb7629443da186c17afd"
[[package]]
name = "psutil"
@@ -6065,7 +6065,6 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"},
@@ -6074,7 +6073,6 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"},
@@ -6083,7 +6081,6 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"},
@@ -6092,7 +6089,6 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"},
@@ -6101,7 +6097,6 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"},
{file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"},
@@ -7069,4 +7064,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<3.13"
content-hash = "6dcdbbed2a46ab0111f4e32979fb7e5c7e3f6a80c4d293ac21b8c1f73c555204"
content-hash = "77ef098291cb8631565a1ab5027ce33e7fcb5a04883dc7160bf373eac9e1fb49"
+2 -2
View File
@@ -24,7 +24,7 @@ dependencies = [
"drf-spectacular-jsonapi==0.5.1",
"gunicorn==23.0.0",
"lxml==5.3.2",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.14",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (>=1.0.1,<2.0.0)",
"sentry-sdk[django] (>=2.20.0,<3.0.0)",
@@ -44,7 +44,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.15.1"
version = "1.16.0"
[project.scripts]
celery = "src.backend.config.settings.celery"
+11
View File
@@ -40,6 +40,7 @@ class ApiConfig(AppConfig):
self._ensure_crypto_keys()
load_prowler_compliance()
self._initialize_attack_surface_mapping()
def _ensure_crypto_keys(self):
"""
@@ -167,3 +168,13 @@ class ApiConfig(AppConfig):
f"Error generating JWT keys: {e}. Please set '{SIGNING_KEY_ENV}' and '{VERIFYING_KEY_ENV}' manually."
)
raise e
def _initialize_attack_surface_mapping(self):
from tasks.jobs.scan import ( # noqa: F401
_get_attack_surface_mapping_from_provider,
)
from api.models import Provider # noqa: F401
for provider_type, _label in Provider.ProviderChoices.choices:
_get_attack_surface_mapping_from_provider(provider_type)
+20 -8
View File
@@ -23,6 +23,7 @@ from api.db_utils import (
StatusEnumField,
)
from api.models import (
AttackSurfaceOverview,
ComplianceRequirementOverview,
Finding,
Integration,
@@ -761,14 +762,6 @@ class RoleFilter(FilterSet):
class ComplianceOverviewFilter(FilterSet):
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
scan_id = UUIDFilter(field_name="scan_id")
provider_id = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact")
provider_id__in = UUIDInFilter(field_name="scan__provider__id", lookup_expr="in")
provider_type = ChoiceFilter(
field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices
)
provider_type__in = ChoiceInFilter(
field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices
)
region = CharFilter(field_name="region")
class Meta:
@@ -1021,3 +1014,22 @@ class ThreatScoreSnapshotFilter(FilterSet):
"inserted_at": ["date", "gte", "lte"],
"overall_score": ["exact", "gte", "lte"],
}
class AttackSurfaceOverviewFilter(FilterSet):
"""Filter for attack surface overview aggregations by provider."""
provider_id = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact")
provider_id__in = UUIDInFilter(field_name="scan__provider__id", lookup_expr="in")
provider_type = ChoiceFilter(
field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices
)
provider_type__in = ChoiceInFilter(
field_name="scan__provider__provider",
choices=Provider.ProviderChoices.choices,
lookup_expr="in",
)
class Meta:
model = AttackSurfaceOverview
fields = {}
@@ -0,0 +1,89 @@
# Generated by Django 5.1.14 on 2025-11-19 13:03
import uuid
import django.db.models.deletion
from django.db import migrations, models
import api.rls
class Migration(migrations.Migration):
dependencies = [
("api", "0059_compliance_overview_summary"),
]
operations = [
migrations.CreateModel(
name="AttackSurfaceOverview",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
(
"attack_surface_type",
models.CharField(
choices=[
("internet-exposed", "Internet Exposed"),
("secrets", "Exposed Secrets"),
("privilege-escalation", "Privilege Escalation"),
("ec2-imdsv1", "EC2 IMDSv1 Enabled"),
],
max_length=50,
),
),
("total_findings", models.IntegerField(default=0)),
("failed_findings", models.IntegerField(default=0)),
("muted_failed_findings", models.IntegerField(default=0)),
],
options={
"db_table": "attack_surface_overviews",
"abstract": False,
},
),
migrations.AddField(
model_name="attacksurfaceoverview",
name="scan",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="attack_surface_overviews",
related_query_name="attack_surface_overview",
to="api.scan",
),
),
migrations.AddField(
model_name="attacksurfaceoverview",
name="tenant",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
migrations.AddIndex(
model_name="attacksurfaceoverview",
index=models.Index(
fields=["tenant_id", "scan_id"], name="attack_surf_tenant_scan_idx"
),
),
migrations.AddConstraint(
model_name="attacksurfaceoverview",
constraint=models.UniqueConstraint(
fields=("tenant_id", "scan_id", "attack_surface_type"),
name="unique_attack_surface_per_scan",
),
),
migrations.AddConstraint(
model_name="attacksurfaceoverview",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_attacksurfaceoverview",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
]
+60
View File
@@ -2405,3 +2405,63 @@ class ThreatScoreSnapshot(RowLevelSecurityProtectedModel):
class JSONAPIMeta:
resource_name = "threatscore-snapshots"
class AttackSurfaceOverview(RowLevelSecurityProtectedModel):
"""
Pre-aggregated attack surface metrics per scan.
Stores counts for each attack surface type (internet-exposed, secrets,
privilege-escalation, ec2-imdsv1) to enable fast overview queries.
"""
class AttackSurfaceTypeChoices(models.TextChoices):
INTERNET_EXPOSED = "internet-exposed", _("Internet Exposed")
SECRETS = "secrets", _("Exposed Secrets")
PRIVILEGE_ESCALATION = "privilege-escalation", _("Privilege Escalation")
EC2_IMDSV1 = "ec2-imdsv1", _("EC2 IMDSv1 Enabled")
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
scan = models.ForeignKey(
Scan,
on_delete=models.CASCADE,
related_name="attack_surface_overviews",
related_query_name="attack_surface_overview",
)
attack_surface_type = models.CharField(
max_length=50,
choices=AttackSurfaceTypeChoices.choices,
)
# Finding counts
total_findings = models.IntegerField(default=0) # All findings (PASS + FAIL)
failed_findings = models.IntegerField(default=0) # Non-muted failed findings
muted_failed_findings = models.IntegerField(default=0) # Muted failed findings
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "attack_surface_overviews"
constraints = [
models.UniqueConstraint(
fields=("tenant_id", "scan_id", "attack_surface_type"),
name="unique_attack_surface_per_scan",
),
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
indexes = [
models.Index(
fields=["tenant_id", "scan_id"],
name="attack_surf_tenant_scan_idx",
),
]
class JSONAPIMeta:
resource_name = "attack-surface-overviews"
+2 -2
View File
@@ -65,11 +65,11 @@ def get_providers(role: Role) -> QuerySet[Provider]:
A QuerySet of Provider objects filtered by the role's provider groups.
If the role has no provider groups, returns an empty queryset.
"""
tenant = role.tenant
tenant_id = role.tenant_id
provider_groups = role.provider_groups.all()
if not provider_groups.exists():
return Provider.objects.none()
return Provider.objects.filter(
tenant=tenant, provider_groups__in=provider_groups
tenant_id=tenant_id, provider_groups__in=provider_groups
).distinct()
+221 -212
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.15.1
version: 1.16.0
description: |-
Prowler API specification.
@@ -283,11 +283,8 @@ paths:
/api/v1/compliance-overviews:
get:
operationId: compliance_overviews_list
description: Retrieve an overview of all compliance frameworks. If scan_id is
provided, returns compliance data for that specific scan. If scan_id is omitted,
returns compliance data aggregated from the latest completed scan of each
provider.
summary: List compliance overviews
description: Retrieve an overview of all the compliance in a given scan.
summary: List compliance overviews for a scan
parameters:
- in: query
name: fields[compliance-overviews]
@@ -346,32 +343,6 @@ paths:
schema:
type: string
format: date-time
- in: query
name: filter[provider_id]
schema:
type: string
format: uuid
description: Filter by specific provider ID.
- in: query
name: filter[provider_id__in]
schema:
type: array
items:
type: string
format: uuid
description: Filter by multiple provider IDs (comma-separated).
- in: query
name: filter[provider_type]
schema:
type: string
description: Filter by provider type (e.g., aws, azure, gcp).
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
description: Filter by multiple provider types (comma-separated).
- in: query
name: filter[region]
schema:
@@ -394,8 +365,8 @@ paths:
schema:
type: string
format: uuid
description: Optional scan ID. If provided, returns compliance for that scan.
If omitted, returns compliance for the latest completed scan per provider.
description: Related scan ID.
required: true
- name: filter[search]
required: false
in: query
@@ -635,77 +606,6 @@ paths:
schema:
type: string
format: date-time
- in: query
name: filter[provider_id]
schema:
type: string
format: uuid
- in: query
name: filter[provider_id__in]
schema:
type: array
items:
type: string
format: uuid
description: Multiple values may be separated by commas.
explode: false
style: form
- in: query
name: filter[provider_type]
schema:
type: string
x-spec-enum-id: eca8c51e6bd28935
enum:
- aws
- azure
- gcp
- github
- iac
- kubernetes
- m365
- mongodbatlas
- oraclecloud
description: |-
* `aws` - AWS
* `azure` - Azure
* `gcp` - GCP
* `kubernetes` - Kubernetes
* `m365` - M365
* `github` - GitHub
* `mongodbatlas` - MongoDB Atlas
* `iac` - IaC
* `oraclecloud` - Oracle Cloud Infrastructure
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
x-spec-enum-id: eca8c51e6bd28935
enum:
- aws
- azure
- gcp
- github
- iac
- kubernetes
- m365
- mongodbatlas
- oraclecloud
description: |-
Multiple values may be separated by commas.
* `aws` - AWS
* `azure` - Azure
* `gcp` - GCP
* `kubernetes` - Kubernetes
* `m365` - M365
* `github` - GitHub
* `mongodbatlas` - MongoDB Atlas
* `iac` - IaC
* `oraclecloud` - Oracle Cloud Infrastructure
explode: false
style: form
- in: query
name: filter[region]
schema:
@@ -4597,6 +4497,60 @@ paths:
responses:
'204':
description: No response body
/api/v1/overviews/attack-surfaces:
get:
operationId: overviews_attack_surfaces_retrieve
description: Retrieve aggregated attack surface metrics from latest completed
scans per provider.
summary: Get attack surface overview
parameters:
- in: query
name: fields[attack-surface-overviews]
schema:
type: array
items:
type: string
enum:
- id
- total_findings
- failed_findings
- muted_failed_findings
- check_ids
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
- in: query
name: filter[provider_id.in]
schema:
type: string
description: Filter by multiple provider IDs (comma-separated UUIDs)
- in: query
name: filter[provider_id]
schema:
type: string
format: uuid
description: Filter by specific provider ID
- in: query
name: filter[provider_type.in]
schema:
type: string
description: Filter by multiple provider types (comma-separated)
- in: query
name: filter[provider_type]
schema:
type: string
description: Filter by provider type (aws, azure, gcp, etc.)
tags:
- Overview
security:
- JWT or API Key: []
responses:
'200':
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/AttackSurfaceOverviewResponse'
description: ''
/api/v1/overviews/findings:
get:
operationId: overviews_findings_retrieve
@@ -5068,6 +5022,8 @@ paths:
type: string
enum:
- id
- provider_type
- region
- total
- fail
- muted
@@ -5200,6 +5156,10 @@ paths:
enum:
- id
- -id
- provider_type
- -provider_type
- region
- -region
- total
- -total
- fail
@@ -8984,116 +8944,12 @@ paths:
description: CSV file containing the compliance report
'404':
description: Compliance report not found
/api/v1/scans/{id}/report:
get:
operationId: scans_report_retrieve
description: Returns a ZIP file containing the requested report
summary: Download ZIP report
parameters:
- in: query
name: fields[scan-reports]
schema:
type: array
items:
type: string
enum:
- id
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
- in: path
name: id
schema:
type: string
format: uuid
description: A UUID string identifying this scan.
required: true
tags:
- Scan
security:
- JWT or API Key: []
responses:
'200':
description: Report obtained successfully
'202':
description: The task is in progress
'403':
description: There is a problem with credentials
'404':
description: The scan has no reports, or the report generation task has
not started yet
/api/v1/scans/{id}/threatscore:
get:
operationId: scans_threatscore_retrieve
description: Download a specific threatscore report (e.g., 'prowler_threatscore_aws')
as a PDF file.
summary: Retrieve threatscore report
parameters:
- in: query
name: fields[scans]
schema:
type: array
items:
type: string
enum:
- name
- trigger
- state
- unique_resource_count
- progress
- duration
- provider
- task
- inserted_at
- started_at
- completed_at
- scheduled_at
- next_scan_at
- processor
- url
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
- in: path
name: id
schema:
type: string
format: uuid
description: A UUID string identifying this scan.
required: true
- in: query
name: include
schema:
type: array
items:
type: string
enum:
- provider
description: include query parameter to allow the client to customize which
related resources should be returned.
explode: false
tags:
- Scan
security:
- JWT or API Key: []
responses:
'200':
description: PDF file containing the threatscore report
'202':
description: The task is in progress
'401':
description: API key missing or user not Authenticated
'403':
description: There is a problem with credentials
'404':
description: The scan has no threatscore reports, or the threatscore report
generation task has not started yet
/api/v1/scans/{id}/ens:
get:
operationId: scans_ens_retrieve
description: Download a specific ENS compliance report (e.g., 'prowler_ens_aws')
description: Download ENS RD2022 compliance report (e.g., 'ens_rd2022_aws')
as a PDF file.
summary: Retrieve ENS compliance report
summary: Retrieve ENS RD2022 compliance report
parameters:
- in: query
name: fields[scans]
@@ -9220,6 +9076,110 @@ paths:
'404':
description: The scan has no NIS2 reports, or the NIS2 report generation
task has not started yet
/api/v1/scans/{id}/report:
get:
operationId: scans_report_retrieve
description: Returns a ZIP file containing the requested report
summary: Download ZIP report
parameters:
- in: query
name: fields[scan-reports]
schema:
type: array
items:
type: string
enum:
- id
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
- in: path
name: id
schema:
type: string
format: uuid
description: A UUID string identifying this scan.
required: true
tags:
- Scan
security:
- JWT or API Key: []
responses:
'200':
description: Report obtained successfully
'202':
description: The task is in progress
'403':
description: There is a problem with credentials
'404':
description: The scan has no reports, or the report generation task has
not started yet
/api/v1/scans/{id}/threatscore:
get:
operationId: scans_threatscore_retrieve
description: Download a specific threatscore report (e.g., 'prowler_threatscore_aws')
as a PDF file.
summary: Retrieve threatscore report
parameters:
- in: query
name: fields[scans]
schema:
type: array
items:
type: string
enum:
- name
- trigger
- state
- unique_resource_count
- progress
- duration
- provider
- task
- inserted_at
- started_at
- completed_at
- scheduled_at
- next_scan_at
- processor
- url
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
- in: path
name: id
schema:
type: string
format: uuid
description: A UUID string identifying this scan.
required: true
- in: query
name: include
schema:
type: array
items:
type: string
enum:
- provider
description: include query parameter to allow the client to customize which
related resources should be returned.
explode: false
tags:
- Scan
security:
- JWT or API Key: []
responses:
'200':
description: PDF file containing the threatscore report
'202':
description: The task is in progress
'401':
description: API key missing or user not Authenticated
'403':
description: There is a problem with credentials
'404':
description: The scan has no threatscore reports, or the threatscore report
generation task has not started yet
/api/v1/schedules/daily:
post:
operationId: schedules_daily_create
@@ -10712,6 +10672,49 @@ paths:
description: ''
components:
schemas:
AttackSurfaceOverview:
type: object
required:
- type
- id
additionalProperties: false
properties:
type:
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:
- attack-surface-overviews
id: {}
attributes:
type: object
properties:
id:
type: string
total_findings:
type: integer
failed_findings:
type: integer
muted_failed_findings:
type: integer
check_ids:
type: array
items:
type: string
readOnly: true
required:
- id
- total_findings
- failed_findings
- muted_failed_findings
AttackSurfaceOverviewResponse:
type: object
properties:
data:
$ref: '#/components/schemas/AttackSurfaceOverview'
required:
- data
ComplianceOverview:
type: object
required:
@@ -13558,6 +13561,11 @@ components:
properties:
id:
type: string
readOnly: true
provider_type:
type: string
region:
type: string
total:
type: integer
fail:
@@ -13567,7 +13575,8 @@ components:
pass:
type: integer
required:
- id
- provider_type
- region
- total
- fail
- muted
+391 -153
View File
@@ -35,8 +35,7 @@ from rest_framework.response import Response
from api.compliance import get_compliance_frameworks
from api.db_router import MainRouter
from api.models import (
ComplianceOverviewSummary,
ComplianceRequirementOverview,
AttackSurfaceOverview,
Finding,
Integration,
Invitation,
@@ -57,7 +56,6 @@ from api.models import (
Scan,
ScanSummary,
StateChoices,
StatusChoices,
Task,
TenantAPIKey,
ThreatScoreSnapshot,
@@ -5315,9 +5313,11 @@ class TestUserRoleRelationshipViewSet:
def test_create_relationship_already_exists(
self, authenticated_client, roles_fixture, create_test_user
):
# Only add Role One (which has manage_account=True) to ensure
# the second request has permission to add roles
data = {
"data": [
{"type": "roles", "id": str(role.id)} for role in roles_fixture[:2]
{"type": "roles", "id": str(roles_fixture[0].id)},
]
}
authenticated_client.post(
@@ -5820,44 +5820,16 @@ class TestProviderGroupMembershipViewSet:
@pytest.mark.django_db
class TestComplianceOverviewViewSet:
@pytest.fixture(autouse=True)
def mock_backfill_task(self):
with patch("api.v1.views.backfill_compliance_summaries_task.delay") as mock:
yield mock
def test_compliance_overview_list_none(
self,
authenticated_client,
tenants_fixture,
providers_fixture,
mock_backfill_task,
):
tenant = tenants_fixture[0]
provider = providers_fixture[0]
scan = Scan.objects.create(
name="empty-compliance-scan",
provider=provider,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant=tenant,
)
def test_compliance_overview_list_none(self, authenticated_client):
response = authenticated_client.get(
reverse("complianceoverview-list"),
{"filter[scan_id]": str(scan.id)},
{"filter[scan_id]": "8d20ac7d-4cbc-435e-85f4-359be37af821"},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 0
mock_backfill_task.assert_called_once()
_, kwargs = mock_backfill_task.call_args
assert kwargs["scan_id"] == str(scan.id)
assert str(kwargs["tenant_id"]) == str(tenant.id)
def test_compliance_overview_list(
self,
authenticated_client,
compliance_requirements_overviews_fixture,
mock_backfill_task,
self, authenticated_client, compliance_requirements_overviews_fixture
):
# List compliance overviews with existing data
requirement_overview1 = compliance_requirements_overviews_fixture[0]
@@ -5887,112 +5859,6 @@ class TestComplianceOverviewViewSet:
assert "requirements_failed" in attributes
assert "requirements_manual" in attributes
assert "total_requirements" in attributes
mock_backfill_task.assert_called_once()
_, kwargs = mock_backfill_task.call_args
assert kwargs["scan_id"] == scan_id
def test_compliance_overview_list_uses_preaggregated_summaries(
self,
authenticated_client,
tenants_fixture,
providers_fixture,
mock_backfill_task,
):
tenant = tenants_fixture[0]
provider = providers_fixture[0]
scan = Scan.objects.create(
name="preaggregated-scan",
provider=provider,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant=tenant,
)
ComplianceRequirementOverview.objects.create(
tenant=tenant,
scan=scan,
compliance_id="cis_1.4_aws",
framework="CIS-1.4-AWS",
version="1.4",
description="CIS AWS Foundations Benchmark v1.4.0",
region="eu-west-1",
requirement_id="framework-metadata",
requirement_status=StatusChoices.PASS,
passed_checks=1,
failed_checks=0,
total_checks=1,
)
ComplianceOverviewSummary.objects.create(
tenant=tenant,
scan=scan,
compliance_id="cis_1.4_aws",
requirements_passed=5,
requirements_failed=1,
requirements_manual=2,
total_requirements=8,
)
response = authenticated_client.get(
reverse("complianceoverview-list"),
{"filter[scan_id]": str(scan.id)},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 1
overview = data[0]
assert overview["id"] == "cis_1.4_aws"
assert overview["attributes"]["requirements_passed"] == 5
assert overview["attributes"]["requirements_failed"] == 1
assert overview["attributes"]["requirements_manual"] == 2
assert overview["attributes"]["total_requirements"] == 8
assert "framework" in overview["attributes"]
assert "version" in overview["attributes"]
mock_backfill_task.assert_not_called()
def test_compliance_overview_region_filter_skips_backfill(
self,
authenticated_client,
compliance_requirements_overviews_fixture,
mock_backfill_task,
):
requirement_overview = compliance_requirements_overviews_fixture[0]
scan_id = str(requirement_overview.scan.id)
response = authenticated_client.get(
reverse("complianceoverview-list"),
{
"filter[scan_id]": scan_id,
"filter[region]": requirement_overview.region,
},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) >= 1
mock_backfill_task.assert_not_called()
def test_compliance_overview_list_without_scan_id(
self, authenticated_client, compliance_requirements_overviews_fixture
):
# Ensure the endpoint works without passing a scan filter
response = authenticated_client.get(reverse("complianceoverview-list"))
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 3
# Validate payload structure
first_item = data[0]
assert "id" in first_item
assert "attributes" in first_item
attributes = first_item["attributes"]
assert "framework" in attributes
assert "version" in attributes
assert "requirements_passed" in attributes
assert "requirements_failed" in attributes
assert "requirements_manual" in attributes
assert "total_requirements" in attributes
def test_compliance_overview_metadata(
self, authenticated_client, compliance_requirements_overviews_fixture
@@ -6146,11 +6012,6 @@ class TestComplianceOverviewViewSet:
requirement_overview1 = compliance_requirements_overviews_fixture[0]
scan_id = str(requirement_overview1.scan.id)
# Remove existing compliance data so the view falls back to task checks
scan = requirement_overview1.scan
ComplianceOverviewSummary.objects.filter(scan=scan).delete()
ComplianceRequirementOverview.objects.filter(scan=scan).delete()
# Mock a running task
with patch.object(
ComplianceOverviewViewSet, "get_task_response_if_running"
@@ -6178,11 +6039,6 @@ class TestComplianceOverviewViewSet:
requirement_overview1 = compliance_requirements_overviews_fixture[0]
scan_id = str(requirement_overview1.scan.id)
# Remove existing compliance data so the view falls back to task checks
scan = requirement_overview1.scan
ComplianceOverviewSummary.objects.filter(scan=scan).delete()
ComplianceRequirementOverview.objects.filter(scan=scan).delete()
# Mock a failed task
with patch.object(
ComplianceOverviewViewSet, "get_task_response_if_running"
@@ -6206,8 +6062,6 @@ class TestComplianceOverviewViewSet:
("framework", "framework", 1),
("version", "version", 1),
("region", "region", 1),
("region__in", "region", 1),
("region.in", "region", 1),
],
)
def test_compliance_overview_filters(
@@ -7003,6 +6857,390 @@ class TestOverviewViewSet:
assert combined_attributes["medium"] == 4
assert combined_attributes["critical"] == 3
def test_overview_attack_surface_no_data(self, authenticated_client):
response = authenticated_client.get(reverse("overview-attack-surface"))
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 4
for item in data:
assert item["attributes"]["total_findings"] == 0
assert item["attributes"]["failed_findings"] == 0
assert item["attributes"]["muted_failed_findings"] == 0
assert item["attributes"]["check_ids"] == []
def test_overview_attack_surface_with_data(
self,
authenticated_client,
tenants_fixture,
providers_fixture,
create_attack_surface_overview,
):
tenant = tenants_fixture[0]
provider = providers_fixture[0]
mapping = {
"internet-exposed": {"aws-check-1", "aws-check-2"},
"secrets": {"aws-secret-check"},
"privilege-escalation": {"aws-priv-check"},
"ec2-imdsv1": {"aws-imdsv1-check"},
}
scan = Scan.objects.create(
name="attack-surface-scan",
provider=provider,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant=tenant,
)
create_attack_surface_overview(
tenant,
scan,
AttackSurfaceOverview.AttackSurfaceTypeChoices.INTERNET_EXPOSED,
total=20,
failed=10,
muted_failed=3,
)
create_attack_surface_overview(
tenant,
scan,
AttackSurfaceOverview.AttackSurfaceTypeChoices.SECRETS,
total=15,
failed=8,
muted_failed=2,
)
with patch(
"api.v1.views._get_attack_surface_mapping_from_provider",
return_value=mapping,
):
response = authenticated_client.get(reverse("overview-attack-surface"))
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 4
results_by_type = {item["id"]: item["attributes"] for item in data}
assert results_by_type["internet-exposed"]["total_findings"] == 20
assert results_by_type["internet-exposed"]["failed_findings"] == 10
assert set(results_by_type["internet-exposed"]["check_ids"]) == {
"aws-check-1",
"aws-check-2",
}
assert results_by_type["secrets"]["total_findings"] == 15
assert results_by_type["secrets"]["failed_findings"] == 8
assert set(results_by_type["secrets"]["check_ids"]) == {"aws-secret-check"}
assert results_by_type["privilege-escalation"]["total_findings"] == 0
assert set(results_by_type["privilege-escalation"]["check_ids"]) == {
"aws-priv-check"
}
assert results_by_type["ec2-imdsv1"]["total_findings"] == 0
assert set(results_by_type["ec2-imdsv1"]["check_ids"]) == {"aws-imdsv1-check"}
def test_overview_attack_surface_provider_filter(
self,
authenticated_client,
tenants_fixture,
providers_fixture,
create_attack_surface_overview,
):
tenant = tenants_fixture[0]
provider1, provider2, *_ = providers_fixture
scan1 = Scan.objects.create(
name="attack-surface-scan-1",
provider=provider1,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant=tenant,
)
scan2 = Scan.objects.create(
name="attack-surface-scan-2",
provider=provider2,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant=tenant,
)
mapping = {
"internet-exposed": {"shared-check", "shared-check"},
"secrets": set(),
"privilege-escalation": {"priv-check"},
"ec2-imdsv1": {"imdsv1-check"},
}
create_attack_surface_overview(
tenant,
scan1,
AttackSurfaceOverview.AttackSurfaceTypeChoices.INTERNET_EXPOSED,
total=10,
failed=5,
muted_failed=1,
)
create_attack_surface_overview(
tenant,
scan2,
AttackSurfaceOverview.AttackSurfaceTypeChoices.INTERNET_EXPOSED,
total=20,
failed=15,
muted_failed=3,
)
with patch(
"api.v1.views._get_attack_surface_mapping_from_provider",
return_value=mapping,
):
response = authenticated_client.get(
reverse("overview-attack-surface"),
{"filter[provider_id]": str(provider1.id)},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
results_by_type = {item["id"]: item["attributes"] for item in data}
assert results_by_type["internet-exposed"]["total_findings"] == 10
assert results_by_type["internet-exposed"]["failed_findings"] == 5
assert results_by_type["internet-exposed"]["check_ids"] == ["shared-check"]
def test_overview_services_region_filter(
self, authenticated_client, scan_summaries_fixture
):
response = authenticated_client.get(
reverse("overview-services"),
{"filter[region]": "region1"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 2
service_ids = {item["id"] for item in data}
assert service_ids == {"service1", "service2"}
def test_overview_services_provider_type_filter(
self, authenticated_client, tenants_fixture, providers_fixture
):
tenant = tenants_fixture[0]
aws_provider, _, gcp_provider, *_ = providers_fixture
aws_scan = Scan.objects.create(
name="aws-scan",
provider=aws_provider,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant=tenant,
)
gcp_scan = Scan.objects.create(
name="gcp-scan",
provider=gcp_provider,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant=tenant,
)
ScanSummary.objects.create(
tenant=tenant,
scan=aws_scan,
check_id="aws-check",
service="aws-service",
severity="high",
region="us-east-1",
_pass=5,
fail=2,
muted=1,
total=8,
)
ScanSummary.objects.create(
tenant=tenant,
scan=gcp_scan,
check_id="gcp-check",
service="gcp-service",
severity="medium",
region="us-central1",
_pass=3,
fail=1,
muted=0,
total=4,
)
response = authenticated_client.get(
reverse("overview-services"),
{"filter[provider_type]": "aws"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
service_ids = [item["id"] for item in data]
assert "aws-service" in service_ids
assert "gcp-service" not in service_ids
@pytest.mark.parametrize(
"status_filter,field_to_check",
[
("FAIL", "fail"),
("PASS", "_pass"),
],
)
def test_overview_findings_severity_status_filter(
self,
authenticated_client,
tenants_fixture,
providers_fixture,
status_filter,
field_to_check,
):
tenant = tenants_fixture[0]
provider = providers_fixture[0]
scan = Scan.objects.create(
name="status-filter-scan",
provider=provider,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant=tenant,
)
ScanSummary.objects.create(
tenant=tenant,
scan=scan,
check_id="status-check-high",
service="service-a",
severity="high",
region="us-east-1",
_pass=10,
fail=5,
muted=3,
total=18,
)
ScanSummary.objects.create(
tenant=tenant,
scan=scan,
check_id="status-check-medium",
service="service-a",
severity="medium",
region="us-east-1",
_pass=8,
fail=2,
muted=1,
total=11,
)
response = authenticated_client.get(
reverse("overview-findings_severity"),
{
"filter[provider_id]": str(provider.id),
"filter[status]": status_filter,
},
)
assert response.status_code == status.HTTP_200_OK
attrs = response.json()["data"]["attributes"]
if status_filter == "FAIL":
assert attrs["high"] == 5
assert attrs["medium"] == 2
else:
assert attrs["high"] == 10
assert attrs["medium"] == 8
def test_overview_threatscore_compliance_id_filter(
self, authenticated_client, tenants_fixture, providers_fixture
):
tenant = tenants_fixture[0]
provider = providers_fixture[0]
scan = self._create_scan(tenant, provider, "compliance-filter-scan")
self._create_threatscore_snapshot(
tenant,
scan,
provider,
compliance_id="prowler_threatscore_aws",
overall_score="75.00",
score_delta="2.00",
section_scores={"1. IAM": "70.00"},
critical_requirements=[],
total_requirements=50,
passed_requirements=35,
failed_requirements=15,
manual_requirements=0,
total_findings=30,
passed_findings=20,
failed_findings=10,
)
self._create_threatscore_snapshot(
tenant,
scan,
provider,
compliance_id="cis_1.4_aws",
overall_score="65.00",
score_delta="1.00",
section_scores={"1. IAM": "60.00"},
critical_requirements=[],
total_requirements=40,
passed_requirements=25,
failed_requirements=15,
manual_requirements=0,
total_findings=25,
passed_findings=15,
failed_findings=10,
)
response = authenticated_client.get(
reverse("overview-threatscore"),
{"filter[compliance_id]": "prowler_threatscore_aws"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 1
assert data[0]["attributes"]["overall_score"] == "75.00"
assert data[0]["attributes"]["compliance_id"] == "prowler_threatscore_aws"
def test_overview_threatscore_provider_type_filter(
self, authenticated_client, tenants_fixture, providers_fixture
):
tenant = tenants_fixture[0]
aws_provider, _, gcp_provider, *_ = providers_fixture
aws_scan = self._create_scan(tenant, aws_provider, "aws-threatscore-scan")
gcp_scan = self._create_scan(tenant, gcp_provider, "gcp-threatscore-scan")
self._create_threatscore_snapshot(
tenant,
aws_scan,
aws_provider,
compliance_id="prowler_threatscore_aws",
overall_score="80.00",
score_delta="3.00",
section_scores={"1. IAM": "75.00"},
critical_requirements=[],
total_requirements=60,
passed_requirements=45,
failed_requirements=15,
manual_requirements=0,
total_findings=40,
passed_findings=30,
failed_findings=10,
)
self._create_threatscore_snapshot(
tenant,
gcp_scan,
gcp_provider,
compliance_id="prowler_threatscore_gcp",
overall_score="70.00",
score_delta="2.00",
section_scores={"1. IAM": "65.00"},
critical_requirements=[],
total_requirements=50,
passed_requirements=35,
failed_requirements=15,
manual_requirements=0,
total_findings=35,
passed_findings=25,
failed_findings=10,
)
response = authenticated_client.get(
reverse("overview-threatscore"),
{"filter[provider_type]": "aws"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 1
assert data[0]["attributes"]["overall_score"] == "80.00"
@pytest.mark.django_db
class TestScheduleViewSet:
+75 -70
View File
@@ -72,6 +72,42 @@ from api.v1.serializer_utils.processors import ProcessorConfigField
from api.v1.serializer_utils.providers import ProviderSecretField
from prowler.lib.mutelist.mutelist import Mutelist
# Base
class BaseModelSerializerV1(serializers.ModelSerializer):
def get_root_meta(self, _resource, _many):
return {"version": "v1"}
class BaseSerializerV1(serializers.Serializer):
def get_root_meta(self, _resource, _many):
return {"version": "v1"}
class BaseWriteSerializer(BaseModelSerializerV1):
def validate(self, data):
if hasattr(self, "initial_data"):
initial_data = set(self.initial_data.keys()) - {"id", "type"}
unknown_keys = initial_data - set(self.fields.keys())
if unknown_keys:
raise ValidationError(f"Invalid fields: {unknown_keys}")
return data
class RLSSerializer(BaseModelSerializerV1):
def create(self, validated_data):
tenant_id = self.context.get("tenant_id")
validated_data["tenant_id"] = tenant_id
return super().create(validated_data)
class StateEnumSerializerField(serializers.ChoiceField):
def __init__(self, **kwargs):
kwargs["choices"] = StateChoices.choices
super().__init__(**kwargs)
# Tokens
@@ -179,7 +215,7 @@ class TokenSocialLoginSerializer(BaseTokenSerializer):
# TODO: Check if we can change the parent class to TokenRefreshSerializer from rest_framework_simplejwt.serializers
class TokenRefreshSerializer(serializers.Serializer):
class TokenRefreshSerializer(BaseSerializerV1):
refresh = serializers.CharField()
# Output token
@@ -213,7 +249,7 @@ class TokenRefreshSerializer(serializers.Serializer):
raise ValidationError({"refresh": "Invalid or expired token"})
class TokenSwitchTenantSerializer(serializers.Serializer):
class TokenSwitchTenantSerializer(BaseSerializerV1):
tenant_id = serializers.UUIDField(
write_only=True, help_text="The tenant ID for which to request a new token."
)
@@ -237,41 +273,10 @@ class TokenSwitchTenantSerializer(serializers.Serializer):
return generate_tokens(user, tenant_id)
# Base
class BaseSerializerV1(serializers.ModelSerializer):
def get_root_meta(self, _resource, _many):
return {"version": "v1"}
class BaseWriteSerializer(BaseSerializerV1):
def validate(self, data):
if hasattr(self, "initial_data"):
initial_data = set(self.initial_data.keys()) - {"id", "type"}
unknown_keys = initial_data - set(self.fields.keys())
if unknown_keys:
raise ValidationError(f"Invalid fields: {unknown_keys}")
return data
class RLSSerializer(BaseSerializerV1):
def create(self, validated_data):
tenant_id = self.context.get("tenant_id")
validated_data["tenant_id"] = tenant_id
return super().create(validated_data)
class StateEnumSerializerField(serializers.ChoiceField):
def __init__(self, **kwargs):
kwargs["choices"] = StateChoices.choices
super().__init__(**kwargs)
# Users
class UserSerializer(BaseSerializerV1):
class UserSerializer(BaseModelSerializerV1):
"""
Serializer for the User model.
"""
@@ -402,7 +407,7 @@ class UserUpdateSerializer(BaseWriteSerializer):
return super().update(instance, validated_data)
class RoleResourceIdentifierSerializer(serializers.Serializer):
class RoleResourceIdentifierSerializer(BaseSerializerV1):
resource_type = serializers.CharField(source="type")
id = serializers.UUIDField()
@@ -585,7 +590,7 @@ class TaskSerializer(RLSSerializer, TaskBase):
# Tenants
class TenantSerializer(BaseSerializerV1):
class TenantSerializer(BaseModelSerializerV1):
"""
Serializer for the Tenant model.
"""
@@ -597,7 +602,7 @@ class TenantSerializer(BaseSerializerV1):
fields = ["id", "name", "memberships"]
class TenantIncludeSerializer(BaseSerializerV1):
class TenantIncludeSerializer(BaseModelSerializerV1):
class Meta:
model = Tenant
fields = ["id", "name"]
@@ -773,7 +778,7 @@ class ProviderGroupUpdateSerializer(ProviderGroupSerializer):
return super().update(instance, validated_data)
class ProviderResourceIdentifierSerializer(serializers.Serializer):
class ProviderResourceIdentifierSerializer(BaseSerializerV1):
resource_type = serializers.CharField(source="type")
id = serializers.UUIDField()
@@ -1110,7 +1115,7 @@ class ScanTaskSerializer(RLSSerializer):
]
class ScanReportSerializer(serializers.Serializer):
class ScanReportSerializer(BaseSerializerV1):
id = serializers.CharField(source="scan")
class Meta:
@@ -1118,7 +1123,7 @@ class ScanReportSerializer(serializers.Serializer):
fields = ["id"]
class ScanComplianceReportSerializer(serializers.Serializer):
class ScanComplianceReportSerializer(BaseSerializerV1):
id = serializers.CharField(source="scan")
name = serializers.CharField()
@@ -1267,7 +1272,7 @@ class ResourceIncludeSerializer(RLSSerializer):
return fields
class ResourceMetadataSerializer(serializers.Serializer):
class ResourceMetadataSerializer(BaseSerializerV1):
services = serializers.ListField(child=serializers.CharField(), allow_empty=True)
regions = serializers.ListField(child=serializers.CharField(), allow_empty=True)
types = serializers.ListField(child=serializers.CharField(), allow_empty=True)
@@ -1337,7 +1342,7 @@ class FindingIncludeSerializer(RLSSerializer):
# To be removed when the related endpoint is removed as well
class FindingDynamicFilterSerializer(serializers.Serializer):
class FindingDynamicFilterSerializer(BaseSerializerV1):
services = serializers.ListField(child=serializers.CharField(), allow_empty=True)
regions = serializers.ListField(child=serializers.CharField(), allow_empty=True)
@@ -1345,7 +1350,7 @@ class FindingDynamicFilterSerializer(serializers.Serializer):
resource_name = "finding-dynamic-filters"
class FindingMetadataSerializer(serializers.Serializer):
class FindingMetadataSerializer(BaseSerializerV1):
services = serializers.ListField(child=serializers.CharField(), allow_empty=True)
regions = serializers.ListField(child=serializers.CharField(), allow_empty=True)
resource_types = serializers.ListField(
@@ -2039,7 +2044,7 @@ class RoleProviderGroupRelationshipSerializer(RLSSerializer, BaseWriteSerializer
# Compliance overview
class ComplianceOverviewSerializer(serializers.Serializer):
class ComplianceOverviewSerializer(BaseSerializerV1):
"""
Serializer for compliance requirement status aggregated by compliance framework.
@@ -2061,7 +2066,7 @@ class ComplianceOverviewSerializer(serializers.Serializer):
resource_name = "compliance-overviews"
class ComplianceOverviewDetailSerializer(serializers.Serializer):
class ComplianceOverviewDetailSerializer(BaseSerializerV1):
"""
Serializer for detailed compliance requirement information.
@@ -2090,7 +2095,7 @@ class ComplianceOverviewDetailThreatscoreSerializer(ComplianceOverviewDetailSeri
total_findings = serializers.IntegerField()
class ComplianceOverviewAttributesSerializer(serializers.Serializer):
class ComplianceOverviewAttributesSerializer(BaseSerializerV1):
id = serializers.CharField()
compliance_name = serializers.CharField()
framework_description = serializers.CharField()
@@ -2104,7 +2109,7 @@ class ComplianceOverviewAttributesSerializer(serializers.Serializer):
resource_name = "compliance-requirements-attributes"
class ComplianceOverviewMetadataSerializer(serializers.Serializer):
class ComplianceOverviewMetadataSerializer(BaseSerializerV1):
regions = serializers.ListField(child=serializers.CharField(), allow_empty=True)
class JSONAPIMeta:
@@ -2114,7 +2119,7 @@ class ComplianceOverviewMetadataSerializer(serializers.Serializer):
# Overviews
class OverviewProviderSerializer(serializers.Serializer):
class OverviewProviderSerializer(BaseSerializerV1):
id = serializers.CharField(source="provider")
findings = serializers.SerializerMethodField(read_only=True)
resources = serializers.SerializerMethodField(read_only=True)
@@ -2122,9 +2127,6 @@ class OverviewProviderSerializer(serializers.Serializer):
class JSONAPIMeta:
resource_name = "providers-overview"
def get_root_meta(self, _resource, _many):
return {"version": "v1"}
@extend_schema_field(
{
"type": "object",
@@ -2158,18 +2160,15 @@ class OverviewProviderSerializer(serializers.Serializer):
}
class OverviewProviderCountSerializer(serializers.Serializer):
class OverviewProviderCountSerializer(BaseSerializerV1):
id = serializers.CharField(source="provider")
count = serializers.IntegerField()
class JSONAPIMeta:
resource_name = "providers-count-overview"
def get_root_meta(self, _resource, _many):
return {"version": "v1"}
class OverviewFindingSerializer(serializers.Serializer):
class OverviewFindingSerializer(BaseSerializerV1):
id = serializers.CharField(default="n/a")
new = serializers.IntegerField()
changed = serializers.IntegerField()
@@ -2188,15 +2187,12 @@ class OverviewFindingSerializer(serializers.Serializer):
class JSONAPIMeta:
resource_name = "findings-overview"
def get_root_meta(self, _resource, _many):
return {"version": "v1"}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["pass"] = self.fields.pop("_pass")
class OverviewSeveritySerializer(serializers.Serializer):
class OverviewSeveritySerializer(BaseSerializerV1):
id = serializers.CharField(default="n/a")
critical = serializers.IntegerField()
high = serializers.IntegerField()
@@ -2207,11 +2203,8 @@ class OverviewSeveritySerializer(serializers.Serializer):
class JSONAPIMeta:
resource_name = "findings-severity-overview"
def get_root_meta(self, _resource, _many):
return {"version": "v1"}
class OverviewServiceSerializer(serializers.Serializer):
class OverviewServiceSerializer(BaseSerializerV1):
id = serializers.CharField(source="service")
total = serializers.IntegerField()
_pass = serializers.IntegerField()
@@ -2225,8 +2218,20 @@ class OverviewServiceSerializer(serializers.Serializer):
super().__init__(*args, **kwargs)
self.fields["pass"] = self.fields.pop("_pass")
def get_root_meta(self, _resource, _many):
return {"version": "v1"}
class AttackSurfaceOverviewSerializer(BaseSerializerV1):
"""Serializer for attack surface overview aggregations."""
id = serializers.CharField(source="attack_surface_type")
total_findings = serializers.IntegerField()
failed_findings = serializers.IntegerField()
muted_failed_findings = serializers.IntegerField()
check_ids = serializers.ListField(
child=serializers.CharField(), allow_empty=True, default=list, read_only=True
)
class JSONAPIMeta:
resource_name = "attack-surface-overviews"
class OverviewRegionSerializer(serializers.Serializer):
@@ -2256,7 +2261,7 @@ class OverviewRegionSerializer(serializers.Serializer):
# Schedules
class ScheduleDailyCreateSerializer(serializers.Serializer):
class ScheduleDailyCreateSerializer(BaseSerializerV1):
provider_id = serializers.UUIDField(required=True)
class JSONAPIMeta:
@@ -2592,7 +2597,7 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
return representation
class IntegrationJiraDispatchSerializer(serializers.Serializer):
class IntegrationJiraDispatchSerializer(BaseSerializerV1):
"""
Serializer for dispatching findings to JIRA integration.
"""
@@ -2755,14 +2760,14 @@ class ProcessorUpdateSerializer(BaseWriteSerializer):
# SSO
class SamlInitiateSerializer(serializers.Serializer):
class SamlInitiateSerializer(BaseSerializerV1):
email_domain = serializers.CharField()
class JSONAPIMeta:
resource_name = "saml-initiate"
class SamlMetadataSerializer(serializers.Serializer):
class SamlMetadataSerializer(BaseSerializerV1):
class JSONAPIMeta:
resource_name = "saml-meta"
+249 -284
View File
@@ -74,8 +74,8 @@ from rest_framework_json_api.views import RelationshipView, Response
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from tasks.beat import schedule_provider_scan
from tasks.jobs.export import get_s3_client
from tasks.jobs.scan import _get_attack_surface_mapping_from_provider
from tasks.tasks import (
backfill_compliance_summaries_task,
backfill_scan_resource_summaries_task,
check_integration_connection_task,
check_lighthouse_connection_task,
@@ -98,6 +98,7 @@ from api.db_router import MainRouter
from api.db_utils import rls_transaction
from api.exceptions import TaskFailedException
from api.filters import (
AttackSurfaceOverviewFilter,
ComplianceOverviewFilter,
CustomDjangoFilterBackend,
FindingFilter,
@@ -126,7 +127,7 @@ from api.filters import (
UserFilter,
)
from api.models import (
ComplianceOverviewSummary,
AttackSurfaceOverview,
ComplianceRequirementOverview,
Finding,
Integration,
@@ -172,6 +173,7 @@ from api.utils import (
from api.uuid_utils import datetime_to_uuid7, uuid7_start
from api.v1.mixins import DisablePaginationMixin, PaginateByPkMixin, TaskManagementMixin
from api.v1.serializers import (
AttackSurfaceOverviewSerializer,
ComplianceOverviewAttributesSerializer,
ComplianceOverviewDetailSerializer,
ComplianceOverviewDetailThreatscoreSerializer,
@@ -350,7 +352,7 @@ class SchemaView(SpectacularAPIView):
def get(self, request, *args, **kwargs):
spectacular_settings.TITLE = "Prowler API"
spectacular_settings.VERSION = "1.15.1"
spectacular_settings.VERSION = "1.16.0"
spectacular_settings.DESCRIPTION = (
"Prowler API specification.\n\nThis file is auto-generated."
)
@@ -3359,50 +3361,15 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet):
@extend_schema_view(
list=extend_schema(
tags=["Compliance Overview"],
summary="List compliance overviews",
description=(
"Retrieve an overview of all compliance frameworks. "
"If scan_id is provided, returns compliance data for that specific scan. "
"If scan_id is omitted, returns compliance data aggregated from the latest completed scan of each provider."
),
summary="List compliance overviews for a scan",
description="Retrieve an overview of all the compliance in a given scan.",
parameters=[
OpenApiParameter(
name="filter[scan_id]",
required=False,
required=True,
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
description=(
"Optional scan ID. If provided, returns compliance for that scan. "
"If omitted, returns compliance for the latest completed scan per provider."
),
),
OpenApiParameter(
name="filter[provider_id]",
required=False,
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
description="Filter by specific provider ID.",
),
OpenApiParameter(
name="filter[provider_id__in]",
required=False,
type={"type": "array", "items": {"type": "string", "format": "uuid"}},
location=OpenApiParameter.QUERY,
description="Filter by multiple provider IDs (comma-separated).",
),
OpenApiParameter(
name="filter[provider_type]",
required=False,
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by provider type (e.g., aws, azure, gcp).",
),
OpenApiParameter(
name="filter[provider_type__in]",
required=False,
type={"type": "array", "items": {"type": "string"}},
location=OpenApiParameter.QUERY,
description="Filter by multiple provider types (comma-separated).",
description="Related scan ID.",
),
],
responses={
@@ -3559,114 +3526,6 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
def retrieve(self, request, *args, **kwargs):
raise MethodNotAllowed(method="GET")
def _compliance_summaries_queryset(self, scan_id):
"""Return pre-aggregated summaries constrained by RBAC visibility."""
role = get_role(self.request.user)
unlimited_visibility = getattr(
role, Permissions.UNLIMITED_VISIBILITY.value, False
)
summaries = ComplianceOverviewSummary.objects.filter(
tenant_id=self.request.tenant_id,
scan_id=scan_id,
)
if not unlimited_visibility:
providers = Provider.all_objects.filter(
provider_groups__in=role.provider_groups.all()
).distinct()
summaries = summaries.filter(scan__provider__in=providers)
return summaries
def _get_compliance_template(self, *, provider=None, scan_id=None):
"""Return the compliance template for the given provider or scan."""
if provider is None and scan_id is not None:
scan = Scan.all_objects.select_related("provider").get(pk=scan_id)
provider = scan.provider
if not provider:
return {}
return PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE.get(provider.provider, {})
def _aggregate_compliance_overview(self, queryset, template_metadata=None):
"""
Aggregate requirement rows into compliance overview dictionaries.
Args:
queryset: ComplianceRequirementOverview queryset already filtered.
template_metadata: Optional dict mapping compliance_id -> metadata.
"""
template_metadata = template_metadata or {}
requirement_status_subquery = queryset.values(
"compliance_id", "requirement_id"
).annotate(
fail_count=Count("id", filter=Q(requirement_status="FAIL")),
pass_count=Count("id", filter=Q(requirement_status="PASS")),
total_count=Count("id"),
)
compliance_data = {}
fallback_metadata = {
item["compliance_id"]: {
"framework": item["framework"],
"version": item["version"],
}
for item in queryset.values(
"compliance_id", "framework", "version"
).distinct()
}
for item in requirement_status_subquery:
compliance_id = item["compliance_id"]
if item["fail_count"] > 0:
req_status = "FAIL"
elif item["pass_count"] == item["total_count"]:
req_status = "PASS"
else:
req_status = "MANUAL"
compliance_status = compliance_data.setdefault(
compliance_id,
{
"total_requirements": 0,
"requirements_passed": 0,
"requirements_failed": 0,
"requirements_manual": 0,
},
)
compliance_status["total_requirements"] += 1
if req_status == "PASS":
compliance_status["requirements_passed"] += 1
elif req_status == "FAIL":
compliance_status["requirements_failed"] += 1
else:
compliance_status["requirements_manual"] += 1
response_data = []
for compliance_id, data in compliance_data.items():
template = template_metadata.get(compliance_id, {})
fallback = fallback_metadata.get(compliance_id, {})
response_data.append(
{
"id": compliance_id,
"compliance_id": compliance_id,
"framework": template.get("framework")
or fallback.get("framework", ""),
"version": template.get("version") or fallback.get("version", ""),
"requirements_passed": data["requirements_passed"],
"requirements_failed": data["requirements_failed"],
"requirements_manual": data["requirements_manual"],
"total_requirements": data["total_requirements"],
}
)
serializer = self.get_serializer(response_data, many=True)
return serializer.data
def _task_response_if_running(self, scan_id):
"""Check for an in-progress task only when no compliance data exists."""
try:
@@ -3681,135 +3540,95 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
def _list_with_region_filter(self, scan_id, region_filter):
"""
Fall back to detailed ComplianceRequirementOverview query when region filter is applied.
This uses the original aggregation logic across filtered regions.
"""
regions = region_filter.split(",") if "," in region_filter else [region_filter]
queryset = self.filter_queryset(self.get_queryset()).filter(
scan_id=scan_id,
region__in=regions,
)
data = self._aggregate_compliance_overview(queryset)
if data:
return Response(data)
task_response = self._task_response_if_running(scan_id)
if task_response:
return task_response
return Response(data)
def _list_without_region_aggregation(self, scan_id):
"""
Fall back aggregation when compliance summaries don't exist yet.
Aggregates ComplianceRequirementOverview data across ALL regions.
"""
queryset = self.filter_queryset(self.get_queryset()).filter(scan_id=scan_id)
compliance_template = self._get_compliance_template(scan_id=scan_id)
data = self._aggregate_compliance_overview(
queryset, template_metadata=compliance_template
)
if data:
return Response(data)
task_response = self._task_response_if_running(scan_id)
if task_response:
return task_response
return Response(data)
def list(self, request, *args, **kwargs):
scan_id = request.query_params.get("filter[scan_id]")
tenant_id = self.request.tenant_id
if scan_id:
# Specific scan requested - use optimized summaries with region support
region_filter = request.query_params.get(
"filter[region]"
) or request.query_params.get("filter[region__in]")
if region_filter:
# Fall back to detailed query with region filtering
return self._list_with_region_filter(scan_id, region_filter)
summaries = list(self._compliance_summaries_queryset(scan_id))
if not summaries:
# Trigger async backfill for next time
backfill_compliance_summaries_task.delay(
tenant_id=self.request.tenant_id, scan_id=scan_id
)
# Use fallback aggregation for this request
return self._list_without_region_aggregation(scan_id)
# Get compliance template for provider to enrich with framework/version
compliance_template = self._get_compliance_template(scan_id=scan_id)
# Convert to response format with framework/version enrichment
response_data = []
for summary in summaries:
compliance_metadata = compliance_template.get(summary.compliance_id, {})
response_data.append(
if not scan_id:
raise ValidationError(
[
{
"id": summary.compliance_id,
"compliance_id": summary.compliance_id,
"framework": compliance_metadata.get("framework", ""),
"version": compliance_metadata.get("version", ""),
"requirements_passed": summary.requirements_passed,
"requirements_failed": summary.requirements_failed,
"requirements_manual": summary.requirements_manual,
"total_requirements": summary.total_requirements,
"detail": "This query parameter is required.",
"status": 400,
"source": {"pointer": "filter[scan_id]"},
"code": "required",
}
)
]
)
try:
if task := self.get_task_response_if_running(
task_name="scan-compliance-overviews",
task_kwargs={"tenant_id": self.request.tenant_id, "scan_id": scan_id},
raise_on_not_found=False,
):
return task
except TaskFailedException:
return Response(
{"detail": "Task failed to generate compliance overview data."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
queryset = self.filter_queryset(self.filter_queryset(self.get_queryset()))
serializer = self.get_serializer(response_data, many=True)
return Response(serializer.data)
else:
# No scan_id provided - use latest scans per provider
# First, check if provider filters are present
provider_id = request.query_params.get("filter[provider_id]")
provider_id__in = request.query_params.get("filter[provider_id__in]")
provider_type = request.query_params.get("filter[provider_type]")
provider_type__in = request.query_params.get("filter[provider_type__in]")
requirement_status_subquery = queryset.values(
"compliance_id", "requirement_id"
).annotate(
fail_count=Count("id", filter=Q(requirement_status="FAIL")),
pass_count=Count("id", filter=Q(requirement_status="PASS")),
total_count=Count("id"),
)
scan_filters = {"tenant_id": tenant_id, "state": StateChoices.COMPLETED}
compliance_data = {}
framework_info = {}
# Apply provider ID filters
if provider_id:
scan_filters["provider_id"] = provider_id
elif provider_id__in:
# Convert comma-separated string to list
provider_ids = [pid.strip() for pid in provider_id__in.split(",")]
scan_filters["provider_id__in"] = provider_ids
for item in queryset.values("compliance_id", "framework", "version").distinct():
framework_info[item["compliance_id"]] = {
"framework": item["framework"],
"version": item["version"],
}
# Apply provider type filters
if provider_type:
scan_filters["provider__provider"] = provider_type
elif provider_type__in:
# Convert comma-separated string to list
provider_types = [pt.strip() for pt in provider_type__in.split(",")]
scan_filters["provider__provider__in"] = provider_types
for item in requirement_status_subquery:
compliance_id = item["compliance_id"]
latest_scan_ids = (
Scan.all_objects.filter(**scan_filters)
.order_by("provider_id", "-inserted_at")
.distinct("provider_id")
.values_list("id", flat=True)
if item["fail_count"] > 0:
req_status = "FAIL"
elif item["pass_count"] == item["total_count"]:
req_status = "PASS"
else:
req_status = "MANUAL"
if compliance_id not in compliance_data:
compliance_data[compliance_id] = {
"total_requirements": 0,
"requirements_passed": 0,
"requirements_failed": 0,
"requirements_manual": 0,
}
compliance_data[compliance_id]["total_requirements"] += 1
if req_status == "PASS":
compliance_data[compliance_id]["requirements_passed"] += 1
elif req_status == "FAIL":
compliance_data[compliance_id]["requirements_failed"] += 1
else:
compliance_data[compliance_id]["requirements_manual"] += 1
response_data = []
for compliance_id, data in compliance_data.items():
framework = framework_info.get(compliance_id, {})
response_data.append(
{
"id": compliance_id,
"compliance_id": compliance_id,
"framework": framework.get("framework", ""),
"version": framework.get("version", ""),
"requirements_passed": data["requirements_passed"],
"requirements_failed": data["requirements_failed"],
"requirements_manual": data["requirements_manual"],
"total_requirements": data["total_requirements"],
}
)
base_queryset = self.get_queryset()
queryset = self.filter_queryset(
base_queryset.filter(scan_id__in=latest_scan_ids)
)
# Aggregate compliance data across latest scans
compliance_template = self._get_compliance_template()
data = self._aggregate_compliance_overview(
queryset, template_metadata=compliance_template
)
return Response(data)
serializer = self.get_serializer(response_data, many=True)
return Response(serializer.data)
@action(detail=False, methods=["get"], url_name="metadata")
def metadata(self, request):
@@ -4074,6 +3893,37 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
),
filters=True,
),
attack_surface=extend_schema(
summary="Get attack surface overview",
description="Retrieve aggregated attack surface metrics from latest completed scans per provider.",
tags=["Overview"],
parameters=[
OpenApiParameter(
name="filter[provider_id]",
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
description="Filter by specific provider ID",
),
OpenApiParameter(
name="filter[provider_id.in]",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by multiple provider IDs (comma-separated UUIDs)",
),
OpenApiParameter(
name="filter[provider_type]",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by provider type (aws, azure, gcp, etc.)",
),
OpenApiParameter(
name="filter[provider_type.in]",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by multiple provider types (comma-separated)",
),
],
),
)
@method_decorator(CACHE_DECORATOR, name="list")
class OverviewViewSet(BaseRLSViewSet):
@@ -4108,6 +3958,8 @@ class OverviewViewSet(BaseRLSViewSet):
return OverviewRegionSerializer
elif self.action == "threatscore":
return ThreatScoreSnapshotSerializer
elif self.action == "attack_surface":
return AttackSurfaceOverviewSerializer
return super().get_serializer_class()
def get_filterset_class(self):
@@ -4156,6 +4008,68 @@ class OverviewViewSet(BaseRLSViewSet):
tenant_id=tenant_id, scan_id__in=latest_scan_ids
)
def _normalize_jsonapi_params(self, query_params, exclude_keys=None):
"""Convert JSON:API filter params (filter[X]) to flat params (X)."""
exclude_keys = exclude_keys or set()
normalized = QueryDict(mutable=True)
for key, values in query_params.lists():
normalized_key = (
key[7:-1] if key.startswith("filter[") and key.endswith("]") else key
)
if normalized_key not in exclude_keys:
normalized.setlist(normalized_key, values)
return normalized
def _ensure_allowed_providers(self):
"""Populate allowed providers for RBAC-aware queries once per request."""
if getattr(self, "_providers_initialized", False):
return
self.get_queryset()
self._providers_initialized = True
def _get_provider_filter(self, provider_field="provider"):
self._ensure_allowed_providers()
if hasattr(self, "allowed_providers"):
return {f"{provider_field}__in": self.allowed_providers}
return {}
def _apply_provider_filter(self, queryset, provider_field="provider"):
provider_filter = self._get_provider_filter(provider_field)
if provider_filter:
return queryset.filter(**provider_filter)
return queryset
def _apply_filterset(self, queryset, filterset_class, exclude_keys=None):
normalized_params = self._normalize_jsonapi_params(
self.request.query_params, exclude_keys=set(exclude_keys or [])
)
filterset = filterset_class(normalized_params, queryset=queryset)
return filterset.qs
def _latest_scan_ids_for_allowed_providers(self, tenant_id):
provider_filter = self._get_provider_filter()
return (
Scan.all_objects.filter(
tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter
)
.order_by("provider_id", "-inserted_at")
.distinct("provider_id")
.values_list("id", flat=True)
)
def _attack_surface_check_ids_by_provider_types(self, provider_types):
check_ids_by_type = {
attack_surface_type: set()
for attack_surface_type in AttackSurfaceOverview.AttackSurfaceTypeChoices.values
}
for provider_type in provider_types:
attack_surface_mapping = _get_attack_surface_mapping_from_provider(
provider_type=provider_type
)
for attack_surface_type, check_ids in attack_surface_mapping.items():
check_ids_by_type[attack_surface_type].update(check_ids)
return check_ids_by_type
@action(detail=False, methods=["get"], url_name="providers")
def providers(self, request):
tenant_id = self.request.tenant_id
@@ -4385,11 +4299,9 @@ class OverviewViewSet(BaseRLSViewSet):
snapshot_id = request.query_params.get("snapshot_id")
# Base queryset with RLS
base_queryset = ThreatScoreSnapshot.objects.filter(tenant_id=tenant_id)
# Apply RBAC filtering
if hasattr(self, "allowed_providers"):
base_queryset = base_queryset.filter(provider__in=self.allowed_providers)
base_queryset = self._apply_provider_filter(
ThreatScoreSnapshot.objects.filter(tenant_id=tenant_id)
)
# Case 1: Specific snapshot requested
if snapshot_id:
@@ -4405,17 +4317,9 @@ class OverviewViewSet(BaseRLSViewSet):
# Case 2: Latest snapshot per provider (default)
# Apply filters manually: this @action is outside the standard list endpoint flow,
# so DRF's filter backends don't execute and we must flatten JSON:API params ourselves.
normalized_params = QueryDict(mutable=True)
for param_key, values in request.query_params.lists():
normalized_key = param_key
if param_key.startswith("filter[") and param_key.endswith("]"):
normalized_key = param_key[7:-1]
if normalized_key == "snapshot_id":
continue
normalized_params.setlist(normalized_key, values)
filterset = ThreatScoreSnapshotFilter(normalized_params, queryset=base_queryset)
filtered_queryset = filterset.qs
filtered_queryset = self._apply_filterset(
base_queryset, ThreatScoreSnapshotFilter, exclude_keys={"snapshot_id"}
)
# Get distinct provider IDs from filtered queryset
# Pick the latest snapshot per provider using Postgres DISTINCT ON pattern.
@@ -4659,6 +4563,67 @@ class OverviewViewSet(BaseRLSViewSet):
return aggregated_snapshot
@action(
detail=False,
methods=["get"],
url_name="attack-surface",
url_path="attack-surfaces",
)
def attack_surface(self, request):
tenant_id = request.tenant_id
latest_scan_ids = self._latest_scan_ids_for_allowed_providers(tenant_id)
# Build base queryset and apply user filters via FilterSet
base_queryset = AttackSurfaceOverview.objects.filter(
tenant_id=tenant_id, scan_id__in=latest_scan_ids
)
filtered_queryset = self._apply_filterset(
base_queryset, AttackSurfaceOverviewFilter
)
provider_types = list(
filtered_queryset.values_list(
"scan__provider__provider", flat=True
).distinct()
)
attack_surface_check_ids = self._attack_surface_check_ids_by_provider_types(
provider_types
)
# Aggregate attack surface data
aggregation = filtered_queryset.values("attack_surface_type").annotate(
total_findings=Coalesce(Sum("total_findings"), 0),
failed_findings=Coalesce(Sum("failed_findings"), 0),
muted_failed_findings=Coalesce(Sum("muted_failed_findings"), 0),
)
results = {
attack_surface_type: {
"total_findings": 0,
"failed_findings": 0,
"muted_failed_findings": 0,
}
for attack_surface_type in AttackSurfaceOverview.AttackSurfaceTypeChoices.values
}
for item in aggregation:
results[item["attack_surface_type"]] = {
"total_findings": item["total_findings"],
"failed_findings": item["failed_findings"],
"muted_failed_findings": item["muted_failed_findings"],
}
response_data = [
{
"attack_surface_type": key,
**value,
"check_ids": attack_surface_check_ids.get(key, []),
}
for key, value in results.items()
]
return Response(
self.get_serializer(response_data, many=True).data,
status=status.HTTP_200_OK,
)
@extend_schema(tags=["Schedule"])
@extend_schema_view(
+16
View File
@@ -15,6 +15,7 @@ from tasks.jobs.backfill import backfill_resource_scan_summaries
from api.db_utils import rls_transaction
from api.models import (
AttackSurfaceOverview,
ComplianceOverview,
ComplianceRequirementOverview,
Finding,
@@ -1469,6 +1470,21 @@ def mute_rules_fixture(tenants_fixture, create_test_user, findings_fixture):
return mute_rule1, mute_rule2
@pytest.fixture
def create_attack_surface_overview():
def _create(tenant, scan, attack_surface_type, total=10, failed=5, muted_failed=2):
return AttackSurfaceOverview.objects.create(
tenant=tenant,
scan=scan,
attack_surface_type=attack_surface_type,
total_findings=total,
failed_findings=failed,
muted_failed_findings=muted_failed,
)
return _create
def get_authorization_header(access_token: str) -> dict:
return {"Authorization": f"Bearer {access_token}"}
+22 -5
View File
@@ -1,6 +1,7 @@
import io
import os
from collections import defaultdict
from functools import partial
from pathlib import Path
from shutil import rmtree
@@ -772,7 +773,9 @@ def _create_section_score_chart(
return buffer
def _add_pdf_footer(canvas_obj: canvas.Canvas, doc: SimpleDocTemplate) -> None:
def _add_pdf_footer(
canvas_obj: canvas.Canvas, doc: SimpleDocTemplate, compliance_name: str
) -> None:
"""
Add footer with page number and branding to each page of the PDF.
@@ -782,7 +785,9 @@ def _add_pdf_footer(canvas_obj: canvas.Canvas, doc: SimpleDocTemplate) -> None:
"""
canvas_obj.saveState()
width, height = doc.pagesize
page_num_text = f"Page {doc.page}"
page_num_text = (
f"{'Página' if 'ens' in compliance_name.lower() else 'Page'} {doc.page}"
)
canvas_obj.setFont("PlusJakartaSans", 9)
canvas_obj.setFillColorRGB(0.4, 0.4, 0.4)
canvas_obj.drawString(30, 20, page_num_text)
@@ -1595,7 +1600,11 @@ def generate_threatscore_report(
elements.append(PageBreak())
# Build the PDF
doc.build(elements, onFirstPage=_add_pdf_footer, onLaterPages=_add_pdf_footer)
doc.build(
elements,
onFirstPage=partial(_add_pdf_footer, compliance_name=compliance_name),
onLaterPages=partial(_add_pdf_footer, compliance_name=compliance_name),
)
except Exception as e:
tb_lineno = e.__traceback__.tb_lineno if e.__traceback__ else "unknown"
logger.info(f"Error building the document, line {tb_lineno} -- {e}")
@@ -2818,7 +2827,11 @@ def generate_ens_report(
# Build the PDF
logger.info("Building PDF...")
doc.build(elements, onFirstPage=_add_pdf_footer, onLaterPages=_add_pdf_footer)
doc.build(
elements,
onFirstPage=partial(_add_pdf_footer, compliance_name=compliance_name),
onLaterPages=partial(_add_pdf_footer, compliance_name=compliance_name),
)
except Exception as e:
tb_lineno = e.__traceback__.tb_lineno if e.__traceback__ else "unknown"
logger.error(f"Error building ENS report, line {tb_lineno} -- {e}")
@@ -3365,7 +3378,11 @@ def generate_nis2_report(
# Build the PDF
logger.info("Building NIS2 PDF...")
doc.build(elements, onFirstPage=_add_pdf_footer, onLaterPages=_add_pdf_footer)
doc.build(
elements,
onFirstPage=partial(_add_pdf_footer, compliance_name=compliance_name),
onLaterPages=partial(_add_pdf_footer, compliance_name=compliance_name),
)
logger.info(f"NIS2 report successfully generated at {output_path}")
except Exception as e:
+153 -1
View File
@@ -12,7 +12,7 @@ from celery.utils.log import get_task_logger
from config.env import env
from config.settings.celery import CELERY_DEADLOCK_ATTEMPTS
from django.db import IntegrityError, OperationalError
from django.db.models import Case, Count, IntegerField, Prefetch, Sum, When
from django.db.models import Case, Count, IntegerField, Prefetch, Q, Sum, When
from tasks.utils import CustomEncoder
from api.compliance import PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE
@@ -26,6 +26,7 @@ from api.db_utils import (
)
from api.exceptions import ProviderConnectionError
from api.models import (
AttackSurfaceOverview,
ComplianceOverviewSummary,
ComplianceRequirementOverview,
Finding,
@@ -43,6 +44,7 @@ from api.models import (
from api.models import StatusChoices as FindingStatus
from api.utils import initialize_prowler_provider, return_prowler_provider
from api.v1.serializers import ScanTaskSerializer
from prowler.lib.check.models import CheckMetadata
from prowler.lib.outputs.finding import Finding as ProwlerFinding
from prowler.lib.scan.scan import Scan as ProwlerScan
@@ -75,6 +77,44 @@ FINDINGS_MICRO_BATCH_SIZE = env.int("DJANGO_FINDINGS_MICRO_BATCH_SIZE", default=
SCAN_DB_BATCH_SIZE = env.int("DJANGO_SCAN_DB_BATCH_SIZE", default=500)
ATTACK_SURFACE_PROVIDER_COMPATIBILITY = {
"internet-exposed": None, # Compatible with all providers
"secrets": None, # Compatible with all providers
"privilege-escalation": ["aws", "kubernetes"],
"ec2-imdsv1": ["aws"],
}
_ATTACK_SURFACE_MAPPING_CACHE: dict[str, dict] = {}
def _get_attack_surface_mapping_from_provider(provider_type: str) -> dict:
global _ATTACK_SURFACE_MAPPING_CACHE
if provider_type in _ATTACK_SURFACE_MAPPING_CACHE:
return _ATTACK_SURFACE_MAPPING_CACHE[provider_type]
attack_surface_check_mappings = {
"internet-exposed": None,
"secrets": None,
"privilege-escalation": {
"iam_policy_allows_privilege_escalation",
"iam_inline_policy_allows_privilege_escalation",
},
"ec2-imdsv1": {
"ec2_instance_imdsv2_enabled"
}, # AWS only - IMDSv1 enabled findings
}
for category_name, check_ids in attack_surface_check_mappings.items():
if check_ids is None:
sdk_check_ids = CheckMetadata.list(
provider=provider_type, category=category_name
)
attack_surface_check_mappings[category_name] = sdk_check_ids
_ATTACK_SURFACE_MAPPING_CACHE[provider_type] = attack_surface_check_mappings
return attack_surface_check_mappings
def _create_finding_delta(
last_status: FindingStatus | None | str, new_status: FindingStatus | None
) -> Finding.DeltaChoices:
@@ -1196,3 +1236,115 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
except Exception as e:
logger.error(f"Error creating compliance requirements for scan {scan_id}: {e}")
raise e
def aggregate_attack_surface(tenant_id: str, scan_id: str):
"""
Aggregate findings into attack surface overview records.
Creates one AttackSurfaceOverview record per attack surface type
for the given scan, based on check_id mappings.
Args:
tenant_id: Tenant that owns the scan.
scan_id: Scan UUID whose findings should be aggregated.
"""
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
scan_instance = Scan.all_objects.select_related("provider").get(pk=scan_id)
provider_type = scan_instance.provider.provider
provider_attack_surface_mapping = _get_attack_surface_mapping_from_provider(
provider_type=provider_type
)
# Filter out attack surfaces that are not compatible or have no resolved check IDs
supported_mappings: dict[str, list[str]] = {}
for attack_surface_type, check_ids in provider_attack_surface_mapping.items():
compatible_providers = ATTACK_SURFACE_PROVIDER_COMPATIBILITY.get(
attack_surface_type
)
if (
compatible_providers is not None
and provider_type not in compatible_providers
):
logger.info(
f"Skipping {attack_surface_type} - not supported for {provider_type}"
)
continue
if not check_ids:
logger.info(
f"Skipping {attack_surface_type} - no check IDs resolved for {provider_type}"
)
continue
supported_mappings[attack_surface_type] = list(check_ids)
if not supported_mappings:
logger.info(
f"No attack surface mappings available for scan {scan_id} and provider {provider_type}"
)
logger.info(f"No attack surface overview records created for scan {scan_id}")
return
# Map every check_id to its attack surface, so we can aggregate with a single query
check_id_to_surface: dict[str, str] = {}
for attack_surface_type, check_ids in supported_mappings.items():
for check_id in check_ids:
check_id_to_surface[check_id] = attack_surface_type
aggregated_counts = {
attack_surface_type: {"total": 0, "failed": 0, "muted": 0}
for attack_surface_type in supported_mappings.keys()
}
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
finding_stats = (
Finding.all_objects.filter(
tenant_id=tenant_id,
scan_id=scan_id,
check_id__in=list(check_id_to_surface.keys()),
)
.values("check_id")
.annotate(
total=Count("id"),
failed=Count("id", filter=Q(status="FAIL", muted=False)),
muted=Count("id", filter=Q(status="FAIL", muted=True)),
)
)
for stats in finding_stats:
attack_surface_type = check_id_to_surface.get(stats["check_id"])
if not attack_surface_type:
continue
aggregated_counts[attack_surface_type]["total"] += stats["total"] or 0
aggregated_counts[attack_surface_type]["failed"] += stats["failed"] or 0
aggregated_counts[attack_surface_type]["muted"] += stats["muted"] or 0
overview_objects = []
for attack_surface_type, counts in aggregated_counts.items():
total = counts["total"]
if not total:
continue
overview_objects.append(
AttackSurfaceOverview(
tenant_id=tenant_id,
scan_id=scan_id,
attack_surface_type=attack_surface_type,
total_findings=total,
failed_findings=counts["failed"],
muted_failed_findings=counts["muted"],
)
)
# Bulk create overview records
if overview_objects:
with rls_transaction(tenant_id):
AttackSurfaceOverview.objects.bulk_create(overview_objects, batch_size=500)
logger.info(
f"Created {len(overview_objects)} attack surface overview records for scan {scan_id}"
)
else:
logger.info(f"No attack surface overview records created for scan {scan_id}")
+19
View File
@@ -37,6 +37,7 @@ from tasks.jobs.lighthouse_providers import (
from tasks.jobs.muting import mute_historical_findings
from tasks.jobs.report import generate_compliance_reports_job
from tasks.jobs.scan import (
aggregate_attack_surface,
aggregate_findings,
create_compliance_requirements,
perform_prowler_scan,
@@ -69,6 +70,9 @@ def _perform_scan_complete_tasks(tenant_id: str, scan_id: str, provider_id: str)
create_compliance_requirements_task.apply_async(
kwargs={"tenant_id": tenant_id, "scan_id": scan_id}
)
aggregate_attack_surface_task.apply_async(
kwargs={"tenant_id": tenant_id, "scan_id": scan_id}
)
chain(
perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id),
generate_outputs_task.si(
@@ -529,6 +533,21 @@ def create_compliance_requirements_task(tenant_id: str, scan_id: str):
return create_compliance_requirements(tenant_id=tenant_id, scan_id=scan_id)
@shared_task(name="scan-attack-surface-overviews", queue="overview")
def aggregate_attack_surface_task(tenant_id: str, scan_id: str):
"""
Creates attack surface overview records for a scan.
This task processes findings and aggregates them into attack surface categories
(internet-exposed, secrets, privilege-escalation, ec2-imdsv1) for quick overview queries.
Args:
tenant_id (str): The tenant ID for which to create records.
scan_id (str): The ID of the scan for which to create records.
"""
return aggregate_attack_surface(tenant_id=tenant_id, scan_id=scan_id)
@shared_task(base=RLSTask, name="lighthouse-connection-check")
@set_tenant
def check_lighthouse_connection_task(lighthouse_config_id: str, tenant_id: str = None):
+282
View File
@@ -9,14 +9,17 @@ from unittest.mock import MagicMock, patch
import pytest
from tasks.jobs.scan import (
_ATTACK_SURFACE_MAPPING_CACHE,
_aggregate_findings_by_region,
_copy_compliance_requirement_rows,
_create_compliance_summaries,
_create_finding_delta,
_get_attack_surface_mapping_from_provider,
_normalized_compliance_key,
_persist_compliance_requirement_rows,
_process_finding_micro_batch,
_store_resources,
aggregate_attack_surface,
aggregate_findings,
create_compliance_requirements,
perform_prowler_scan,
@@ -3474,3 +3477,282 @@ class TestAggregateFindingsByRegion:
assert check_status_by_region == {}
assert findings_count_by_compliance == {}
@pytest.mark.django_db
class TestAggregateAttackSurface:
"""Test aggregate_attack_surface function and related caching."""
def setup_method(self):
"""Clear cache before each test."""
_ATTACK_SURFACE_MAPPING_CACHE.clear()
def teardown_method(self):
"""Clear cache after each test."""
_ATTACK_SURFACE_MAPPING_CACHE.clear()
@patch("tasks.jobs.scan.CheckMetadata.list")
def test_get_attack_surface_mapping_caches_result(self, mock_check_metadata_list):
"""Test that _get_attack_surface_mapping_from_provider caches results."""
mock_check_metadata_list.return_value = {"check_internet_exposed_1"}
# First call should hit CheckMetadata.list
result1 = _get_attack_surface_mapping_from_provider("aws")
assert mock_check_metadata_list.call_count == 2 # internet-exposed, secrets
# Second call should use cache
result2 = _get_attack_surface_mapping_from_provider("aws")
assert mock_check_metadata_list.call_count == 2 # No additional calls
assert result1 is result2
assert "aws" in _ATTACK_SURFACE_MAPPING_CACHE
@patch("tasks.jobs.scan.CheckMetadata.list")
def test_get_attack_surface_mapping_different_providers(
self, mock_check_metadata_list
):
"""Test caching works independently for different providers."""
mock_check_metadata_list.return_value = {"check_1"}
_get_attack_surface_mapping_from_provider("aws")
aws_call_count = mock_check_metadata_list.call_count
_get_attack_surface_mapping_from_provider("gcp")
gcp_call_count = mock_check_metadata_list.call_count
# Both providers should have made calls
assert gcp_call_count > aws_call_count
assert "aws" in _ATTACK_SURFACE_MAPPING_CACHE
assert "gcp" in _ATTACK_SURFACE_MAPPING_CACHE
@patch("tasks.jobs.scan.CheckMetadata.list")
def test_get_attack_surface_mapping_returns_hardcoded_checks(
self, mock_check_metadata_list
):
"""Test that hardcoded check IDs are returned for privilege-escalation and ec2-imdsv1."""
mock_check_metadata_list.return_value = set()
result = _get_attack_surface_mapping_from_provider("aws")
# Hardcoded checks should be present
assert (
"iam_policy_allows_privilege_escalation" in result["privilege-escalation"]
)
assert (
"iam_inline_policy_allows_privilege_escalation"
in result["privilege-escalation"]
)
assert "ec2_instance_imdsv2_enabled" in result["ec2-imdsv1"]
@patch("tasks.jobs.scan.AttackSurfaceOverview.objects.bulk_create")
@patch("tasks.jobs.scan.Finding.all_objects.filter")
@patch("tasks.jobs.scan._get_attack_surface_mapping_from_provider")
@patch("tasks.jobs.scan.rls_transaction")
def test_aggregate_attack_surface_creates_overview_records(
self,
mock_rls_transaction,
mock_get_mapping,
mock_findings_filter,
mock_bulk_create,
tenants_fixture,
scans_fixture,
):
"""Test that aggregate_attack_surface creates AttackSurfaceOverview records."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
scan.provider.provider = "aws"
scan.provider.save()
mock_get_mapping.return_value = {
"internet-exposed": {"check_internet_1", "check_internet_2"},
"secrets": {"check_secrets_1"},
"privilege-escalation": {"check_privesc_1"},
"ec2-imdsv1": {"check_imdsv1_1"},
}
# Mock findings aggregation
mock_queryset = MagicMock()
mock_queryset.values.return_value = mock_queryset
mock_queryset.annotate.return_value = [
{"check_id": "check_internet_1", "total": 10, "failed": 3, "muted": 1},
{"check_id": "check_secrets_1", "total": 5, "failed": 2, "muted": 0},
]
ctx = MagicMock()
ctx.__enter__.return_value = None
ctx.__exit__.return_value = False
mock_rls_transaction.return_value = ctx
mock_findings_filter.return_value = mock_queryset
aggregate_attack_surface(str(tenant.id), str(scan.id))
mock_bulk_create.assert_called_once()
args, kwargs = mock_bulk_create.call_args
objects = args[0]
# Should create records for internet-exposed and secrets (the ones with findings)
assert len(objects) == 2
assert kwargs["batch_size"] == 500
@patch("tasks.jobs.scan.AttackSurfaceOverview.objects.bulk_create")
@patch("tasks.jobs.scan.Finding.all_objects.filter")
@patch("tasks.jobs.scan._get_attack_surface_mapping_from_provider")
@patch("tasks.jobs.scan.rls_transaction")
def test_aggregate_attack_surface_skips_unsupported_provider(
self,
mock_rls_transaction,
mock_get_mapping,
mock_findings_filter,
mock_bulk_create,
tenants_fixture,
scans_fixture,
):
"""Test that ec2-imdsv1 is skipped for non-AWS providers."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
scan.provider.provider = "gcp"
scan.provider.uid = "gcp-test-project-id"
scan.provider.save()
mock_get_mapping.return_value = {
"internet-exposed": {"check_internet_1"},
"secrets": {"check_secrets_1"},
"privilege-escalation": set(), # Not supported for GCP
"ec2-imdsv1": {"check_imdsv1_1"}, # Should be skipped for GCP
}
mock_queryset = MagicMock()
mock_queryset.values.return_value = mock_queryset
mock_queryset.annotate.return_value = [
{"check_id": "check_internet_1", "total": 5, "failed": 1, "muted": 0},
]
ctx = MagicMock()
ctx.__enter__.return_value = None
ctx.__exit__.return_value = False
mock_rls_transaction.return_value = ctx
mock_findings_filter.return_value = mock_queryset
aggregate_attack_surface(str(tenant.id), str(scan.id))
# ec2-imdsv1 check_ids should not be in the filter
filter_call = mock_findings_filter.call_args
check_ids_in_filter = filter_call[1]["check_id__in"]
assert "check_imdsv1_1" not in check_ids_in_filter
@patch("tasks.jobs.scan.AttackSurfaceOverview.objects.bulk_create")
@patch("tasks.jobs.scan.Finding.all_objects.filter")
@patch("tasks.jobs.scan._get_attack_surface_mapping_from_provider")
@patch("tasks.jobs.scan.rls_transaction")
def test_aggregate_attack_surface_no_findings(
self,
mock_rls_transaction,
mock_get_mapping,
mock_findings_filter,
mock_bulk_create,
tenants_fixture,
scans_fixture,
):
"""Test that no records are created when there are no findings."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
mock_get_mapping.return_value = {
"internet-exposed": {"check_1"},
"secrets": {"check_2"},
"privilege-escalation": set(),
"ec2-imdsv1": set(),
}
mock_queryset = MagicMock()
mock_queryset.values.return_value = mock_queryset
mock_queryset.annotate.return_value = [] # No findings
ctx = MagicMock()
ctx.__enter__.return_value = None
ctx.__exit__.return_value = False
mock_rls_transaction.return_value = ctx
mock_findings_filter.return_value = mock_queryset
aggregate_attack_surface(str(tenant.id), str(scan.id))
mock_bulk_create.assert_not_called()
@patch("tasks.jobs.scan.AttackSurfaceOverview.objects.bulk_create")
@patch("tasks.jobs.scan.Finding.all_objects.filter")
@patch("tasks.jobs.scan._get_attack_surface_mapping_from_provider")
@patch("tasks.jobs.scan.rls_transaction")
def test_aggregate_attack_surface_aggregates_counts_correctly(
self,
mock_rls_transaction,
mock_get_mapping,
mock_findings_filter,
mock_bulk_create,
tenants_fixture,
scans_fixture,
):
"""Test that counts from multiple check_ids are aggregated per attack surface type."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
scan.provider.provider = "aws"
scan.provider.save()
mock_get_mapping.return_value = {
"internet-exposed": {"check_internet_1", "check_internet_2"},
"secrets": set(),
"privilege-escalation": set(),
"ec2-imdsv1": set(),
}
mock_queryset = MagicMock()
mock_queryset.values.return_value = mock_queryset
mock_queryset.annotate.return_value = [
{"check_id": "check_internet_1", "total": 10, "failed": 3, "muted": 1},
{"check_id": "check_internet_2", "total": 5, "failed": 2, "muted": 0},
]
ctx = MagicMock()
ctx.__enter__.return_value = None
ctx.__exit__.return_value = False
mock_rls_transaction.return_value = ctx
mock_findings_filter.return_value = mock_queryset
aggregate_attack_surface(str(tenant.id), str(scan.id))
args, kwargs = mock_bulk_create.call_args
objects = args[0]
assert len(objects) == 1
overview = objects[0]
assert overview.attack_surface_type == "internet-exposed"
assert overview.total_findings == 15 # 10 + 5
assert overview.failed_findings == 5 # 3 + 2
assert overview.muted_failed_findings == 1 # 1 + 0
@patch("tasks.jobs.scan.Scan.all_objects.select_related")
@patch("tasks.jobs.scan.rls_transaction")
def test_aggregate_attack_surface_uses_select_related(
self, mock_rls_transaction, mock_select_related, tenants_fixture, scans_fixture
):
"""Test that select_related is used to avoid N+1 query."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
mock_scan = MagicMock()
mock_scan.provider.provider = "aws"
mock_select_related.return_value.get.return_value = mock_scan
ctx = MagicMock()
ctx.__enter__.return_value = None
ctx.__exit__.return_value = False
mock_rls_transaction.return_value = ctx
with patch(
"tasks.jobs.scan._get_attack_surface_mapping_from_provider"
) as mock_map:
mock_map.return_value = {}
aggregate_attack_surface(str(tenant.id), str(scan.id))
mock_select_related.assert_called_once_with("provider")
@@ -529,6 +529,7 @@ class TestGenerateOutputs:
class TestScanCompleteTasks:
@patch("tasks.tasks.aggregate_attack_surface_task.apply_async")
@patch("tasks.tasks.create_compliance_requirements_task.apply_async")
@patch("tasks.tasks.perform_scan_summary_task.si")
@patch("tasks.tasks.generate_outputs_task.si")
@@ -541,6 +542,7 @@ class TestScanCompleteTasks:
mock_outputs_task,
mock_scan_summary_task,
mock_compliance_requirements_task,
mock_attack_surface_task,
):
"""Test that scan complete tasks are properly orchestrated with optimized reports."""
_perform_scan_complete_tasks("tenant-id", "scan-id", "provider-id")
@@ -550,6 +552,11 @@ class TestScanCompleteTasks:
kwargs={"tenant_id": "tenant-id", "scan_id": "scan-id"},
)
# Verify attack surface task is called
mock_attack_surface_task.assert_called_once_with(
kwargs={"tenant_id": "tenant-id", "scan_id": "scan-id"},
)
# Verify scan summary task is called
mock_scan_summary_task.assert_called_once_with(
scan_id="scan-id",
+4 -4
View File
@@ -76,8 +76,8 @@ def load_csv_files(csv_files):
result = result.replace("_AZURE", " - AZURE")
if "KUBERNETES" in result:
result = result.replace("_KUBERNETES", " - KUBERNETES")
if "M65" in result:
result = result.replace("_M65", " - M65")
if "M365" in result:
result = result.replace("_M365", " - M365")
results.append(result)
unique_results = set(results)
@@ -125,7 +125,7 @@ if data is None:
)
else:
data["ASSESSMENTDATE"] = pd.to_datetime(data["ASSESSMENTDATE"])
data["ASSESSMENTDATE"] = pd.to_datetime(data["ASSESSMENTDATE"], format="mixed")
data["ASSESSMENT_TIME"] = data["ASSESSMENTDATE"].dt.strftime("%Y-%m-%d %H:%M:%S")
data_values = data["ASSESSMENT_TIME"].unique()
@@ -280,7 +280,7 @@ def display_data(
].apply(lambda x: x.split(" - ")[0])
# Filter the chosen level of the CIS
if is_level_1:
data = data[data["REQUIREMENTS_ATTRIBUTES_PROFILE"] == "Level 1"]
data = data[data["REQUIREMENTS_ATTRIBUTES_PROFILE"].str.contains("Level 1")]
# Rename the column PROJECTID to ACCOUNTID for GCP
if data.columns.str.contains("PROJECTID").any():
@@ -4,12 +4,12 @@ title: "Installation"
### Installation
Prowler App supports multiple installation methods based on your environment.
Prowler App offers flexible installation methods tailored to various environments.
Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detailed usage instructions.
<Warning>
Prowler configuration is based in `.env` files. Every version of Prowler can have differences on that file, so, please, use the file that corresponds with that version or repository branch or tag.
Prowler configuration is based on `.env` files. Every version of Prowler can have differences on that file, so, please, use the file that corresponds with that version or repository branch or tag.
</Warning>
<Tabs>
@@ -26,8 +26,6 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai
curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/.env"
docker compose up -d
```
> Containers are built for `linux/amd64`. If your workstation's architecture is different, please set `DOCKER_DEFAULT_PLATFORM=linux/amd64` in your environment or use the `--platform linux/amd64` flag in the docker command.
</Tab>
<Tab title="GitHub">
_Requirements_:
@@ -106,11 +104,13 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai
</Tab>
</Tabs>
### Update Prowler App
### Updating Prowler App
Upgrade Prowler App installation using one of two options:
#### Option 1: Update Environment File
#### Option 1: Updating the Environment File
To update the environment file:
Edit the `.env` file and change version values:
@@ -119,7 +119,7 @@ PROWLER_UI_VERSION="5.9.0"
PROWLER_API_VERSION="5.9.0"
```
#### Option 2: Use Docker Compose Pull
#### Option 2: Using Docker Compose Pull
```bash
docker compose pull --policy always
@@ -133,7 +133,7 @@ The `--policy always` flag ensures that Docker pulls the latest images even if t
Everything is preserved, nothing will be deleted after the update.
</Note>
### Troubleshooting
### Troubleshooting Installation Issues
If containers don't start, check logs for errors:
@@ -145,16 +145,16 @@ docker compose logs
docker images | grep prowler
```
If you encounter issues, you can rollback to the previous version by changing the `.env` file back to your previous version and running:
If issues are encountered, rollback to the previous version by changing the `.env` file back to the previous version and running:
```bash
docker compose pull
docker compose up -d
```
### Container versions
### Container Versions
The available versions of Prowler CLI are the following:
The available versions of Prowler App are the following:
- `latest`: in sync with `master` branch (please note that it is not a stable version)
- `v4-latest`: in sync with `v4` branch (please note that it is not a stable version)
@@ -4,7 +4,7 @@ title: 'Installation'
## Installation
Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/). Install it as a Python package with `Python >= 3.9, <= 3.12`:
To install Prowler as a Python package, use `Python >= 3.9, <= 3.12`. Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/):
<Tabs>
<Tab title="pipx">
@@ -41,7 +41,7 @@ Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/).
prowler -v
```
Upgrade Prowler to the latest version:
To upgrade Prowler to the latest version:
``` bash
pip install --upgrade prowler
@@ -54,8 +54,6 @@ Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/).
* In the command below, change `-v` to your local directory path in order to access the reports.
* AWS, GCP, Azure and/or Kubernetes credentials
> Containers are built for `linux/amd64`. If your workstation's architecture is different, please set `DOCKER_DEFAULT_PLATFORM=linux/amd64` in your environment or use the `--platform linux/amd64` flag in the docker command.
_Commands_:
``` bash
@@ -75,7 +73,7 @@ Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/).
_Commands_:
```
```bash
git clone https://github.com/prowler-cloud/prowler
cd prowler
poetry install
@@ -94,7 +92,7 @@ Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/).
_Commands_:
```
```bash
python3 -m pip install --user pipx
python3 -m pipx ensurepath
pipx install prowler
@@ -104,7 +102,7 @@ Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/).
<Tab title="Ubuntu">
_Requirements_:
* `Ubuntu 23.04` or above, if you are using an older version of Ubuntu check [pipx installation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#__tabbed_1_1) and ensure you have `Python >= 3.9, <= 3.12`.
* `Ubuntu 23.04` or above. For older Ubuntu versions, check [pipx installation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#__tabbed_1_1) and ensure `Python >= 3.9, <= 3.12` is installed.
* `Python >= 3.9, <= 3.12`
* AWS, GCP, Azure and/or Kubernetes credentials
@@ -121,7 +119,7 @@ Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/).
<Tab title="Brew">
_Requirements_:
* `Brew` installed in your Mac or Linux
* `Brew` installed on Mac or Linux
* AWS, GCP, Azure and/or Kubernetes credentials
_Commands_:
@@ -171,7 +169,8 @@ Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/).
```
</Tab>
</Tabs>
## Container versions
## Container Versions
The available versions of Prowler CLI are the following:
+1 -1
View File
@@ -33,7 +33,7 @@ The supported providers right now are:
| [Github](/user-guide/providers/github/getting-started-github) | Official | UI, API, CLI |
| [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | UI, API, CLI |
| [Infra as Code](/user-guide/providers/iac/getting-started-iac) | Official | UI, API, CLI |
| [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | CLI, API |
| [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | UI, API, CLI |
| [LLM](/user-guide/providers/llm/getting-started-llm) | Official | CLI |
| **NHN** | Unofficial | CLI |
@@ -49,8 +49,9 @@ This method grants permanent access and is the recommended setup for production
![External ID](/images/providers/prowler-cloud-external-id.png)
![Stack Data](/images/providers/fill-stack-data.png)
!!! info
An **External ID** is required when assuming the *ProwlerScan* role to comply with AWS [confused deputy prevention](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html).
<Info>
An **External ID** is required when assuming the *ProwlerScan* role to prevent the [confused deputy problem](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html).
</Info>
6. Acknowledge the IAM resource creation warning and proceed
@@ -37,7 +37,7 @@ title: 'Getting Started With AWS on Prowler'
6. Choose the preferred authentication method (next step)
![Select auth method](/images/providers/select-auth-method.png)
![Select auth method](./img/select-auth-method.png)
### Step 3: Set Up AWS Authentication
@@ -76,7 +76,7 @@ For Google Cloud, first enter your `GCP Project ID` and then select the authenti
7. Click "Next", then "Launch Scan"
![Launch Scan GCP](/images/providers/launch-scan.png)
![Launch Scan GCP](./img/launch-scan.png)
---
@@ -2,25 +2,38 @@
title: "Microsoft 365 Authentication in Prowler"
---
Prowler for Microsoft 365 supports multiple authentication types. Authentication methods vary between Prowler App and Prowler CLI:
Prowler for Microsoft 365 supports multiple authentication types across Prowler Cloud and Prowler CLI.
**Prowler App:**
## Navigation
- [Common Setup](#common-setup)
- [Prowler Cloud Authentication](#prowler-cloud-authentication)
- [Prowler CLI Authentication](#prowler-cli-authentication)
- [Supported PowerShell Versions](#supported-powershell-versions)
- [Required PowerShell Modules](#required-powershell-modules)
- [**Application Certificate Authentication**](#certificate-based-authentication) (**Recommended**)
- [**Application Client Secret Authentication**](#client-secret-authentication)
## Common Setup
### Authentication Methods Overview
Prowler Cloud uses app-only authentication. Prowler CLI supports the same app-only options and two delegated flows.
**Prowler Cloud:**
- [**Application Certificate Authentication**](#application-certificate-authentication-recommended) (**Recommended**)
- [**Application Client Secret Authentication**](#application-client-secret-authentication)
**Prowler CLI:**
- [**Application Certificate Authentication**](#certificate-based-authentication) (**Recommended**)
- [**Application Client Secret Authentication**](#client-secret-authentication)
- [**Application Certificate Authentication**](#application-certificate-authentication-recommended) (**Recommended**)
- [**Application Client Secret Authentication**](#application-client-secret-authentication)
- [**Azure CLI Authentication**](#azure-cli-authentication)
- [**Interactive Browser Authentication**](#interactive-browser-authentication)
## Required Permissions
### Required Permissions
To run the full Prowler provider, including PowerShell checks, two types of permission scopes must be set in **Microsoft Entra ID**.
### Application Permissions for App-Only Authentication
#### Application Permissions for App-Only Authentication
When using service principal authentication, add these **Application Permissions**:
@@ -44,6 +57,7 @@ When using service principal authentication, add these **Application Permissions
These permissions enable application-based authentication methods (client secret and certificate). Using certificate-based authentication is the recommended way to run the full M365 provider, including PowerShell checks.
</Note>
### Browser Authentication Permissions
When using browser authentication, permissions are delegated to the user, so the user must have the appropriate permissions rather than the application.
@@ -52,37 +66,38 @@ When using browser authentication, permissions are delegated to the user, so the
Browser and Azure CLI authentication methods limit scanning capabilities to checks that operate through Microsoft Graph API. Checks requiring PowerShell modules will not execute, as they need application-level permissions that cannot be delegated through browser authentication.
</Warning>
### Step-by-Step Permission Assignment
#### Create Application Registration
1. Access **Microsoft Entra ID**
1. Access **Microsoft Entra ID**.
![Overview of Microsoft Entra ID](/images/providers/microsoft-entra-id.png)
2. Navigate to "Applications" > "App registrations"
2. Navigate to "Applications" > "App registrations".
![App Registration nav](/images/providers/app-registration-menu.png)
3. Click "+ New registration", complete the form, and click "Register"
3. Click "+ New registration", complete the form, and click "Register".
![New Registration](/images/providers/new-registration.png)
4. Go to "Certificates & secrets" > "Client secrets" > "+ New client secret"
4. Go to "Certificates & secrets" > "Client secrets" > "+ New client secret".
![Certificate & Secrets nav](/images/providers/certificates-and-secrets.png)
5. Fill in the required fields and click "Add", then copy the generated value (this will be `AZURE_CLIENT_SECRET`)
5. Fill in the required fields and click "Add", then copy the generated value (this will be `AZURE_CLIENT_SECRET`).
![New Client Secret](/images/providers/new-client-secret.png)
#### Grant Microsoft Graph API Permissions
1. Go to App Registration > Select your Prowler App > click on "API permissions"
1. Open **API permissions** for the Prowler application registration.
![API Permission Page](/images/providers/api-permissions-page.png)
2. Click "+ Add a permission" > "Microsoft Graph" > "Application permissions"
2. Click "+ Add a permission" > "Microsoft Graph" > "Application permissions".
![Add API Permission](/images/providers/add-app-api-permission.png)
@@ -97,38 +112,39 @@ Browser and Azure CLI authentication methods limit scanning capabilities to chec
![Application Permissions](/images/providers/app-permissions.png)
4. Click "Add permissions", then click "Grant admin consent for `<your-tenant-name>`"
4. Click "Add permissions", then click "Grant admin consent for `<your-tenant-name>`".
<a id="grant-powershell-module-permissions-for-app-only-authentication"></a>
#### Grant PowerShell Module Permissions
1. **Add Exchange API:**
- Search and select "Office 365 Exchange Online" API in **APIs my organization uses**
- Search and select "Office 365 Exchange Online" API in **APIs my organization uses**.
![Office 365 Exchange Online API](/images/providers/search-exchange-api.png)
- Select "Exchange.ManageAsApp" permission and click "Add permissions"
- Select "Exchange.ManageAsApp" permission and click "Add permissions".
![Exchange.ManageAsApp Permission](/images/providers/exchange-permission.png)
- Assign `Global Reader` role to the app: Go to `Roles and administrators` > click `here` for directory level assignment
- Assign `Global Reader` role to the app: Go to `Roles and administrators` > click `here` for directory level assignment.
![Roles and administrators](/images/providers/here.png)
- Search for `Global Reader` and assign it to your application
- Search for `Global Reader` and assign it to the application.
![Global Reader Role](/images/providers/global-reader-role.png)
2. **Add Teams API:**
- Search and select "Skype and Teams Tenant Admin API" in **APIs my organization uses**
- Search and select "Skype and Teams Tenant Admin API" in **APIs my organization uses**.
![Skype and Teams Tenant Admin API](/images/providers/search-skype-teams-tenant-admin-api.png)
- Select "application_access" permission and click "Add permissions"
- Select "application_access" permission and click "Add permissions".
![application_access Permission](/images/providers/teams-permission.png)
3. Click "Grant admin consent for `<your-tenant-name>`" to grant admin consent
3. Click "Grant admin consent for `<your-tenant-name>`" to grant admin consent.
![Grant Admin Consent](/images/providers/grant-external-api-permissions.png)
@@ -136,11 +152,13 @@ Final permissions should look like this:
![Final Permissions](/images/providers/final-permissions.png)
Use the same application registration for both Prowler Cloud and Prowler CLI while switching authentication methods as needed.
<a id="client-secret-authentication"></a>
<a id="certificate-based-authentication"></a>
## Application Certificate Authentication (Recommended)
_Available for both Prowler App and Prowler CLI_
_Available for both Prowler Cloud and Prowler CLI_
**Authentication flag for CLI:** `--certificate-auth`
@@ -173,11 +191,11 @@ Guard `prowlerm365.key` and `prowlerm365.pfx`. Only upload the `.cer` file to th
</Warning>
If your organization uses a certificate authority, you can replace step 2 with a CSR workflow and import the signed certificate instead.
If an internal certificate authority is preferred, replace step 2 with a CSR workflow and import the signed certificate instead.
### Upload the Certificate to Microsoft Entra ID
1. Open **Microsoft Entra ID** > **App registrations** > your application.
1. Open **Microsoft Entra ID** > **App registrations** > the Prowler application.
2. Go to **Certificates & secrets** > **Certificates**.
3. Select **Upload certificate** and choose `prowlerm365.cer`.
4. Confirm the certificate appears with the expected expiration date.
@@ -189,45 +207,37 @@ base64 -i prowlerm365.pfx -o prowlerm365.pfx.b64
cat prowlerm365.pfx.b64 | tr -d '\n'
```
Copy the resulting single-line Base64 string (or the contents of `prowlerm365.pfx.b64`)—you will use it in the next step.
Copy the resulting single-line Base64 string (or the contents of `prowlerm365.pfx.b64`) for the next step.
### Provide the Certificate to Prowler
You can supply the private certificate to Prowler in two ways:
- **Prowler Cloud:** Paste the Base64-encoded PFX in the `certificate_content` field when configuring the Microsoft 365 provider in Prowler Cloud.
- **Prowler CLI:** Export credential variables or pass the local file path when running Prowler.
- **Environment variables (recommended for headless execution)**
```console
export AZURE_CLIENT_ID="00000000-0000-0000-0000-000000000000"
export AZURE_TENANT_ID="11111111-1111-1111-1111-111111111111"
export M365_CERTIFICATE_CONTENT="$(base64 < prowlerm365.pfx | tr -d '\n')"
```
```console
export AZURE_CLIENT_ID="00000000-0000-0000-0000-000000000000"
export AZURE_TENANT_ID="11111111-1111-1111-1111-111111111111"
export M365_CERTIFICATE_CONTENT="$(base64 < prowlerm365.pfx | tr -d '\n')"
```
Store the PFX securely and reference it when running the CLI:
The `M365_CERTIFICATE_CONTENT` variable must contain a single-line Base64 string. Remove any line breaks or spaces before exporting.
```console
python3 prowler-cli.py m365 --certificate-auth --certificate-path /secure/path/prowlerm365.pfx
```
- **Local file path**
Store the PFX securely and reference it when you run the CLI:
```console
python3 prowler-cli.py m365 --certificate-auth --certificate-path /secure/path/prowlerm365.pfx
```
The CLI still needs `AZURE_CLIENT_ID` and `AZURE_TENANT_ID` in the environment when you use `--certificate-path`.
For the **Prowler App**, paste the Base64-encoded PFX in the `certificate_content` field when you configure the provider secrets. The platform persists the encrypted certificate and supplies it during scans.
The CLI still needs `AZURE_CLIENT_ID` and `AZURE_TENANT_ID` in the environment when `--certificate-path` is used.
<Note>
Do not mix certificate authentication with a client secret. Provide either a certificate **or** a secret to the application registration and Prowler configuration.
</Note>
<a id="client-secret-authentication"></a>
<a id="service-principal-authentication"></a>
<a id="service-principal-authentication-recommended"></a>
## Application Client Secret Authentication
_Available for both Prowler App and Prowler CLI_
_Available for both Prowler Cloud and Prowler CLI_
**Authentication flag for CLI:** `--sp-env-auth`
@@ -239,35 +249,59 @@ export AZURE_CLIENT_SECRET="XXXXXXXXX"
export AZURE_TENANT_ID="XXXXXXXXX"
```
If these variables are not set or exported, execution using `--sp-env-auth` will fail.
Refer to the [Step-by-Step Permission Assignment](#step-by-step-permission-assignment) section below for setup instructions.
If the external API permissions described in the mentioned section above are not added only checks that work through MS Graph will be executed. This means that the full provider will not be executed.
This workflow is helpful for initial validation or temporary access. Plan to transition to certificate-based authentication to remove long-lived secrets and keep full provider coverage in unattended environments.
If these variables are not set or exported, execution using `--sp-env-auth` will fail. This workflow is helpful for initial validation or temporary access. Plan to transition to certificate-based authentication to remove long-lived secrets and keep full provider coverage in unattended environments.
<Note>
To scan every M365 check, ensure the required permissions are added to the application registration. Refer to the [PowerShell Module Permissions](#grant-powershell-module-permissions-for-app-only-authentication) section for more information.
</Note>
### Run Prowler with Certificate Authentication
If the external API permissions described above are not added, only checks that work through Microsoft Graph will be executed. This means that the full provider will not be executed.
After the variables or path are in place, run the Microsoft 365 provider as usual:
## Prowler Cloud Authentication
Use the shared permissions and credentials above, then complete the Microsoft 365 provider form in Prowler Cloud. The platform persists the encrypted credentials and supplies them during scans.
### Application Certificate Authentication (Recommended)
1. Select **Application Certificate Authentication**.
2. Enter the **tenant ID** and **application (client) ID**.
3. Paste the Base64-encoded certificate content.
This method keeps all Microsoft 365 checks available, including PowerShell-based checks.
### Application Client Secret Authentication
1. Select **Application Client Secret Authentication**.
2. Enter the **tenant ID** and **application (client) ID**.
3. Enter the **client secret**.
## Prowler CLI Authentication
### Certificate Authentication
**Authentication flag for CLI:** `--certificate-auth`
After credentials are exported, launch the Microsoft 365 provider with certificate authentication:
```console
python3 prowler-cli.py m365 --certificate-auth --init-modules --log-level ERROR
```
The command above initializes PowerShell modules if needed. You can combine other standard flags (for example, `--region M365USGovernment` or custom outputs) with `--certificate-auth`.
Prowler prints the certificate thumbprint during execution so the correct credential can be verified.
Prowler prints the certificate thumbprint during execution so you can confirm the correct credential is in use.
### Client Secret Authentication
**Authentication flag for CLI:** `--sp-env-auth`
After exporting the secret-based variables, run:
```console
python3 prowler-cli.py m365 --sp-env-auth --init-modules --log-level ERROR
```
<a id="azure-cli-authentication"></a>
## Azure CLI Authentication
_Available only for Prowler CLI_
### Azure CLI Authentication
**Authentication flag for CLI:** `--az-cli-auth`
@@ -279,7 +313,7 @@ az login --tenant <TENANT_ID>
az account set --tenant <TENANT_ID>
```
If you prefer to reuse the same service principal that powers certificate-based authentication, authenticate it through Azure CLI instead of exporting environment variables. Azure CLI expects the certificate in PEM format; convert the PFX produced earlier and sign in:
If reusing the same service principal that powers certificate-based authentication, authenticate it through Azure CLI instead of exporting environment variables. Azure CLI expects the certificate in PEM format; convert the PFX produced earlier and sign in:
```console
openssl pkcs12 -in prowlerm365.pfx -out prowlerm365.pem -nodes
@@ -297,11 +331,9 @@ python3 prowler-cli.py m365 --az-cli-auth
The Azure CLI identity must hold the same Microsoft Graph and external API permissions required for the full provider. Signing in with a user account limits the scan to delegated Microsoft Graph endpoints and skips PowerShell-based checks. Use a service principal with the necessary application permissions to keep complete coverage.
## Interactive Browser Authentication
### Interactive Browser Authentication
_Available only for Prowler CLI_
**Authentication flag:** `--browser-auth`
**Authentication flag for CLI:** `--browser-auth`
Authenticate against Azure using the default browser to start the scan. The `--tenant-id` flag is also required.
@@ -8,73 +8,81 @@ title: 'Getting Started With Microsoft 365 on Prowler'
Government cloud accounts or tenants (Microsoft 365 Government) are currently unsupported, but we expect to add support for them in the near future.
</Note>
## Prerequisites
Configure authentication for Microsoft 365 by following the [Microsoft 365 Authentication](/user-guide/providers/microsoft365/authentication) guide. This includes:
Set up authentication for Microsoft 365 with the [Microsoft 365 Authentication](/user-guide/providers/microsoft365/authentication) guide before starting either path:
- Registering an application in Microsoft Entra ID
- Granting all required Microsoft Graph and external API permissions
- Generating the application certificate (recommended) or client secret
- Setting up PowerShell module permissions (for full security coverage)
- Register an application in Microsoft Entra ID
- Grant the Microsoft Graph and external API permissions listed for the provider
- Generate an application certificate (recommended) or client secret
- Prepare PowerShell module permissions to enable every check
## Prowler App
<CardGroup cols={2}>
<Card title="Prowler Cloud" icon="cloud" href="#prowler-cloud">
Onboard Microsoft 365 using Prowler Cloud
</Card>
<Card title="Prowler CLI" icon="terminal" href="#prowler-cli">
Onboard Microsoft 365 using Prowler CLI
</Card>
</CardGroup>
### Step 1: Obtain Domain ID
## Prowler Cloud
1. Go to the Entra ID portal, then search for "Domain" or go to Identity > Settings > Domain Names
### Step 1: Locate the Domain ID
1. Open the Entra ID portal, then search for "Domain" or go to Identity > Settings > Domain Names.
![Search Domain Names](/images/providers/search-domain-names.png)
![Custom Domain Names](/images/providers/custom-domain-names.png)
2. Select the domain to use as unique identifier for the Microsoft 365 account in Prowler App
2. Select the domain that acts as the unique identifier for the Microsoft 365 account in Prowler Cloud.
### Step 2: Access Prowler App
### Step 2: Open Prowler Cloud
1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app)
2. Navigate to "Configuration" > "Cloud Providers"
1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app).
2. Navigate to "Configuration" > "Cloud Providers".
![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png)
3. Click on "Add Cloud Provider"
3. Click "Add Cloud Provider".
![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png)
4. Select "Microsoft 365"
4. Select "Microsoft 365".
![Select Microsoft 365](/images/providers/select-m365-prowler-cloud.png)
5. Add the Domain ID and an optional alias, then click "Next"
5. Add the Domain ID and an optional alias, then click "Next".
![Add Domain ID](/images/providers/add-domain-id.png)
### Step 3: Select Authentication Method and Provide Credentials
### Step 3: Choose and Provide Authentication
Prowler App now separates Microsoft 365 authentication into two app-only options. After adding the Domain ID (primary tenant domain), choose the method that matches your setup:
After the Domain ID is in place, select the app-only authentication option that matches the Microsoft Entra ID setup:
<img src="/images/providers/m365-auth-selection-form.png" alt="M365 authentication method selection" width="700" />
#### Application Certificate Authentication (Recommended)
1. Enter your **tenant ID**: This is the unique identifier for your Microsoft Entra ID directory.
2. Enter your **application (client) ID**: This is the unique identifier assigned to your app registration in Microsoft Entra ID.
3. Upload your **certificate file content**: This is the Base64 encoded certificate content used to authenticate your application.
1. Enter the **tenant ID**, the unique identifier for the Microsoft Entra ID directory.
2. Enter the **application (client) ID**, the identifier for the Entra application registration.
3. Upload the **certificate file content** (Base64-encoded PFX).
<img src="/images/providers/certificate-form.png" alt="M365 certificate authentication form" width="700" />
Use this method whenever possible to avoid managing client secrets and to unlock every Microsoft 365 check, including those that require PowerShell modules.
For detailed instructions on how to setup Application Certificate Authentication, see the [Authentication](/user-guide/providers/microsoft365/authentication#application-certificate-authentication-recommended) page.
Use this method to avoid managing secrets and to unlock all Microsoft 365 checks, including the PowerShell-based ones. Full setup steps are in the [Authentication guide](/user-guide/providers/microsoft365/authentication#application-certificate-authentication-recommended).
#### Application Client Secret Authentication
1. Enter your **tenant ID**: This is the unique identifier for your Microsoft Entra ID directory.
2. Enter your **application (client) ID**: This is the unique identifier assigned to your app registration in Microsoft Entra ID.
3. Enter your **client secret**: This is the secret key used to authenticate your application.
1. Enter the **tenant ID**.
2. Enter the **application (client) ID**.
3. Enter the **client secret**.
<img src="/images/providers/secret-form.png" alt="M365 client secret authentication form" width="700" />
For detailed instructions on how to setup Application Client Secret Authentication, see the [Authentication](/user-guide/providers/microsoft365/authentication#application-client-secret-authentication) page.
For the complete setup workflow, follow the [Authentication guide](/user-guide/providers/microsoft365/authentication#application-client-secret-authentication).
### Step 4: Launch the Scan
@@ -90,30 +98,30 @@ For detailed instructions on how to setup Application Client Secret Authenticati
## Prowler CLI
Use Prowler CLI to scan Microsoft 365 environments.
### Step 1: Confirm PowerShell Coverage
### PowerShell Requirements
PowerShell 7.4+ keeps the full Microsoft 365 coverage. Installation options are listed in the [Authentication guide](/user-guide/providers/microsoft365/authentication#supported-powershell-versions).
PowerShell 7.4+ is required for comprehensive Microsoft 365 security coverage. Installation instructions are available in the [Authentication guide](/user-guide/providers/microsoft365/authentication#supported-powershell-versions).
### Step 2: Select an Authentication Method
### Authentication Options
Select an authentication method from the [Microsoft 365 Authentication](/user-guide/providers/microsoft365/authentication) guide:
Choose the matching flag from the [Microsoft 365 Authentication](/user-guide/providers/microsoft365/authentication) guide:
- **Application Certificate Authentication** (recommended): `--certificate-auth`
- **Application Client Secret Authentication**: `--sp-env-auth`
- **Azure CLI Authentication**: `--az-cli-auth`
- **Interactive Browser Authentication**: `--browser-auth`
### Basic Usage
### Step 3: Run the First Scan
After configuring authentication, run a basic scan:
Run a baseline scan after credentials are configured:
```console
prowler m365 --sp-env-auth
```
For comprehensive scans including PowerShell checks:
### Step 4: Enable Full Coverage
Include PowerShell module initialization to run every check:
```console
prowler m365 --sp-env-auth --init-modules
+18
View File
@@ -2,6 +2,24 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [v5.15.0] (Prowler UNRELEASED)
### Added
- `cloudstorage_uses_vpc_service_controls` check for GCP provider [(#9256)](https://github.com/prowler-cloud/prowler/pull/9256)
- `repository_immutable_releases_enabled` check for GitHub provider [(#9162)](https://github.com/prowler-cloud/prowler/pull/9162)
- `compute_instance_preemptible_vm_disabled` check for GCP provider [(#9342)](https://github.com/prowler-cloud/prowler/pull/9342)
- `compute_instance_automatic_restart_enabled` check for GCP provider [(#9271)](https://github.com/prowler-cloud/prowler/pull/9271)
- `compute_instance_deletion_protection_enabled` check for GCP provider [(#9358)](https://github.com/prowler-cloud/prowler/pull/9358)
---
## [v5.14.2] (Prowler UNRELEASED)
### Fixed
- Custom check folder metadata validation [(#9335)](https://github.com/prowler-cloud/prowler/pull/9335)
---
## [v5.14.1] (Prowler v5.14.1)
### Fixed
+6
View File
@@ -24,6 +24,7 @@ from prowler.lib.check.check import (
list_checks_json,
list_fixers,
list_services,
load_custom_checks_metadata,
parse_checks_from_file,
parse_checks_from_folder,
print_categories,
@@ -185,6 +186,11 @@ def prowler():
logger.debug("Loading checks metadata from .metadata.json files")
bulk_checks_metadata = CheckMetadata.get_bulk(provider)
# Load custom checks metadata before validation
if checks_folder:
custom_folder_metadata = load_custom_checks_metadata(checks_folder)
bulk_checks_metadata.update(custom_folder_metadata)
if args.list_categories:
print_categories(list_categories(bulk_checks_metadata))
sys.exit()
+1 -1
View File
@@ -38,7 +38,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.14.1"
prowler_version = "5.15.0"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
+43 -1
View File
@@ -14,7 +14,7 @@ from colorama import Fore, Style
import prowler
from prowler.config.config import orange_color
from prowler.lib.check.custom_checks_metadata import update_check_metadata
from prowler.lib.check.models import Check
from prowler.lib.check.models import Check, load_check_metadata
from prowler.lib.check.utils import recover_checks_from_provider
from prowler.lib.logger import logger
from prowler.lib.outputs.outputs import report
@@ -110,6 +110,48 @@ def parse_checks_from_folder(provider, input_folder: str) -> set:
sys.exit(1)
def load_custom_checks_metadata(input_folder: str) -> dict:
"""
Load check metadata from a custom checks folder without copying the checks.
This is used to validate check names before the provider is initialized.
Args:
input_folder (str): Path to the folder containing custom checks.
Returns:
dict: A dictionary with CheckID as key and CheckMetadata as value.
"""
custom_checks_metadata = {}
try:
if not os.path.isdir(input_folder):
return custom_checks_metadata
with os.scandir(input_folder) as checks:
for check in checks:
if check.is_dir():
check_name = check.name
metadata_file = os.path.join(
input_folder, check_name, f"{check_name}.metadata.json"
)
if os.path.isfile(metadata_file):
try:
check_metadata = load_check_metadata(metadata_file)
custom_checks_metadata[check_metadata.CheckID] = (
check_metadata
)
except Exception as error:
logger.warning(
f"Could not load metadata from {metadata_file}: {error}"
)
return custom_checks_metadata
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
)
return custom_checks_metadata
# Load checks from custom folder
def remove_custom_checks_module(input_folder: str, provider: str):
# Check if input folder is a S3 URI
@@ -761,11 +761,15 @@
"ap-south-1",
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-5",
"ca-central-1",
"eu-central-1",
"eu-south-1",
"eu-south-2",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"il-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -1379,6 +1383,7 @@
"bedrock": {
"regions": {
"aws": [
"af-south-1",
"ap-east-2",
"ap-northeast-1",
"ap-northeast-2",
@@ -1392,6 +1397,7 @@
"ap-southeast-5",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
"eu-central-1",
"eu-central-2",
"eu-north-1",
@@ -1402,6 +1408,8 @@
"eu-west-3",
"il-central-1",
"me-central-1",
"me-south-1",
"mx-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -1418,6 +1426,7 @@
"bedrock-agent": {
"regions": {
"aws": [
"af-south-1",
"ap-east-2",
"ap-northeast-1",
"ap-northeast-2",
@@ -1431,6 +1440,7 @@
"ap-southeast-5",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
"eu-central-1",
"eu-central-2",
"eu-north-1",
@@ -1441,6 +1451,8 @@
"eu-west-3",
"il-central-1",
"me-central-1",
"me-south-1",
"mx-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -3595,6 +3607,7 @@
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-6",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
@@ -5551,6 +5564,7 @@
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-6",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
@@ -8459,6 +8473,7 @@
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-6",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
@@ -10696,7 +10711,9 @@
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-southeast-5",
"ca-central-1",
"ca-west-1",
"eu-central-1",
"eu-central-2",
"eu-north-1",
@@ -10732,7 +10749,9 @@
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-southeast-5",
"ca-central-1",
"ca-west-1",
"eu-central-1",
"eu-central-2",
"eu-north-1",
@@ -11239,6 +11258,7 @@
"aws": [
"af-south-1",
"ap-east-1",
"ap-east-2",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
@@ -0,0 +1,6 @@
from prowler.providers.common.provider import Provider
from prowler.providers.gcp.services.accesscontextmanager.accesscontextmanager_service import (
AccessContextManager,
)
accesscontextmanager_client = AccessContextManager(Provider.get_global_provider())
@@ -0,0 +1,101 @@
from pydantic.v1 import BaseModel
import prowler.providers.gcp.config as config
from prowler.lib.logger import logger
from prowler.providers.gcp.gcp_provider import GcpProvider
from prowler.providers.gcp.lib.service.service import GCPService
from prowler.providers.gcp.services.cloudresourcemanager.cloudresourcemanager_client import (
cloudresourcemanager_client,
)
class AccessContextManager(GCPService):
def __init__(self, provider: GcpProvider):
super().__init__("accesscontextmanager", provider, api_version="v1")
self.service_perimeters = []
self._get_service_perimeters()
def _get_service_perimeters(self):
for org in cloudresourcemanager_client.organizations:
try:
access_policies = []
try:
request = self.client.accessPolicies().list(
parent=f"organizations/{org.id}"
)
while request is not None:
response = request.execute(
num_retries=config.DEFAULT_RETRY_ATTEMPTS
)
access_policies.extend(response.get("accessPolicies", []))
request = self.client.accessPolicies().list_next(
previous_request=request, previous_response=response
)
except Exception as error:
logger.error(
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
continue
for policy in access_policies:
try:
request = (
self.client.accessPolicies()
.servicePerimeters()
.list(parent=policy["name"])
)
while request is not None:
response = request.execute(
num_retries=config.DEFAULT_RETRY_ATTEMPTS
)
for perimeter in response.get("servicePerimeters", []):
status = perimeter.get("status", {})
spec = perimeter.get("spec", {})
perimeter_config = status if status else spec
resources = perimeter_config.get("resources", [])
restricted_services = perimeter_config.get(
"restrictedServices", []
)
self.service_perimeters.append(
ServicePerimeter(
name=perimeter["name"],
title=perimeter.get("title", ""),
perimeter_type=perimeter.get(
"perimeterType", ""
),
resources=resources,
restricted_services=restricted_services,
policy_name=policy["name"],
)
)
request = (
self.client.accessPolicies()
.servicePerimeters()
.list_next(
previous_request=request, previous_response=response
)
)
except Exception as error:
logger.error(
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
logger.error(
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
class ServicePerimeter(BaseModel):
name: str
title: str
perimeter_type: str
resources: list[str]
restricted_services: list[str]
policy_name: str
@@ -19,6 +19,14 @@ class CloudResourceManager(GCPService):
def _get_iam_policy(self):
for project_id in self.project_ids:
try:
# Get project details to obtain project number
project_details = (
self.client.projects()
.get(projectId=project_id)
.execute(num_retries=DEFAULT_RETRY_ATTEMPTS)
)
project_number = project_details.get("projectNumber", "")
policy = (
self.client.projects()
.getIamPolicy(resource=project_id)
@@ -41,6 +49,7 @@ class CloudResourceManager(GCPService):
self.cloud_resource_manager_projects.append(
Project(
id=project_id,
number=project_number,
audit_logging=audit_logging,
audit_configs=audit_configs,
)
@@ -96,6 +105,7 @@ class Binding(BaseModel):
class Project(BaseModel):
id: str
number: str = ""
audit_logging: bool
audit_configs: list[AuditConfig] = []
@@ -0,0 +1,36 @@
{
"Provider": "gcp",
"CheckID": "cloudstorage_uses_vpc_service_controls",
"CheckTitle": "Cloud Storage services are protected by VPC Service Controls",
"CheckType": [],
"ServiceName": "cloudstorage",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "cloudresourcemanager.googleapis.com/Project",
"Description": "**GCP Projects** are evaluated to ensure they have **VPC Service Controls** enabled for Cloud Storage. VPC Service Controls establish security boundaries by restricting access to Cloud Storage resources to specific networks and trusted clients, preventing unauthorized data access and exfiltration.",
"Risk": "Projects without VPC Service Controls protection for Cloud Storage may be vulnerable to unauthorized data access and exfiltration, even with proper IAM policies in place. VPC Service Controls provide an additional layer of network-level security that restricts API access based on the context of the request.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/CloudStorage/use-vpc-service-controls.html",
"https://cloud.google.com/vpc-service-controls/docs/create-service-perimeters"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1) Open Google Cloud Console → Security → VPC Service Controls\n2) Create a new service perimeter or select an existing one\n3) Add the relevant GCP projects to the perimeter's protected resources\n4) Add 'storage.googleapis.com' to the list of restricted services\n5) Configure appropriate ingress and egress rules\n6) Save the perimeter configuration",
"Terraform": ""
},
"Recommendation": {
"Text": "Enable VPC Service Controls for all Cloud Storage buckets by adding their projects to a service perimeter with storage.googleapis.com as a restricted service. This prevents data exfiltration and ensures API calls are only allowed from authorized networks.",
"Url": "https://hub.prowler.com/check/cloudstorage_uses_vpc_service_controls"
}
},
"Categories": [
"internet-exposed"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,53 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.accesscontextmanager.accesscontextmanager_client import (
accesscontextmanager_client,
)
from prowler.providers.gcp.services.cloudresourcemanager.cloudresourcemanager_client import (
cloudresourcemanager_client,
)
class cloudstorage_uses_vpc_service_controls(Check):
"""
Ensure Cloud Storage is protected by VPC Service Controls at project level.
Reports PASS if a project is in a VPC Service Controls perimeter
with storage.googleapis.com as a restricted service, otherwise FAIL.
"""
def execute(self) -> list[Check_Report_GCP]:
findings = []
protected_projects = {}
for perimeter in accesscontextmanager_client.service_perimeters:
if any(
service == "storage.googleapis.com"
for service in perimeter.restricted_services
):
for resource in perimeter.resources:
protected_projects[resource] = perimeter.title
for project in cloudresourcemanager_client.cloud_resource_manager_projects:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=cloudresourcemanager_client.projects[project.id],
project_id=project.id,
location=cloudresourcemanager_client.region,
resource_name=(
cloudresourcemanager_client.projects[project.id].name
if cloudresourcemanager_client.projects[project.id].name
else "GCP Project"
),
)
report.status = "FAIL"
report.status_extended = f"Project {project.id} does not have VPC Service Controls enabled for Cloud Storage."
# GCP stores resources by project number, not project ID
project_resource_id = f"projects/{project.number}"
if project_resource_id in protected_projects:
report.status = "PASS"
report.status_extended = f"Project {project.id} has VPC Service Controls enabled for Cloud Storage in perimeter {protected_projects[project_resource_id]}."
findings.append(report)
return findings
@@ -0,0 +1,36 @@
{
"Provider": "gcp",
"CheckID": "compute_instance_automatic_restart_enabled",
"CheckTitle": "Compute Engine VM instances have Automatic Restart enabled",
"CheckType": [],
"ServiceName": "compute",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "compute.googleapis.com/Instance",
"Description": "**Google Compute Engine virtual machine instances** are evaluated to ensure that **Automatic Restart** is enabled. This feature allows the Google Cloud Compute Engine service to automatically restart VM instances when they are terminated due to non-user-initiated reasons such as maintenance events, hardware failures, or software failures.",
"Risk": "VM instances without Automatic Restart enabled will not recover automatically from host maintenance events or unexpected failures, potentially leading to prolonged service downtime and requiring manual intervention to restore services.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/ComputeEngine/enable-automatic-restart.html",
"https://cloud.google.com/compute/docs/instances/setting-instance-scheduling-options"
],
"Remediation": {
"Code": {
"CLI": "gcloud compute instances update <INSTANCE_NAME> --restart-on-failure --zone=<ZONE>",
"NativeIaC": "",
"Other": "1) Open Google Cloud Console → Compute Engine → VM instances\n2) Click on the instance name to view details\n3) Click 'Edit' at the top of the page\n4) Under 'Availability policies', set 'Automatic restart' to 'On (recommended)'\n5) Click 'Save' at the bottom of the page",
"Terraform": "```hcl\n# Example: enable Automatic Restart for a Compute Engine VM instance\nresource \"google_compute_instance\" \"example\" {\n name = var.instance_name\n machine_type = var.machine_type\n zone = var.zone\n\n scheduling {\n automatic_restart = true\n on_host_maintenance = \"MIGRATE\"\n }\n}\n```"
},
"Recommendation": {
"Text": "Enable the Automatic Restart feature for Compute Engine VM instances to enhance system reliability by automatically recovering from crashes or system-initiated terminations. This setting does not interfere with user-initiated shutdowns or stops.",
"Url": "https://hub.prowler.com/check/compute_instance_automatic_restart_enabled"
}
},
"Categories": [
"resilience"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "VM instances missing the 'scheduling.automaticRestart' field are treated as having Automatic Restart enabled (defaults to true). Preemptible instances and instances with provisioning model set to SPOT are automatically marked as PASS, as they cannot have Automatic Restart enabled by design."
}
@@ -0,0 +1,35 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.compute.compute_client import compute_client
class compute_instance_automatic_restart_enabled(Check):
"""
Ensure Compute Engine VM instances have Automatic Restart enabled.
Reports PASS if a VM instance has automatic restart enabled, otherwise FAIL.
"""
def execute(self) -> list[Check_Report_GCP]:
findings = []
for instance in compute_client.instances:
report = Check_Report_GCP(metadata=self.metadata(), resource=instance)
# Preemptible and Spot VMs cannot have automatic restart enabled
if instance.preemptible or instance.provisioning_model == "SPOT":
report.status = "FAIL"
report.status_extended = (
f"VM Instance {instance.name} is a Preemptible or Spot instance, "
"which cannot have Automatic Restart enabled by design."
)
elif instance.automatic_restart:
report.status = "PASS"
report.status_extended = (
f"VM Instance {instance.name} has Automatic Restart enabled."
)
else:
report.status = "FAIL"
report.status_extended = f"VM Instance {instance.name} does not have Automatic Restart enabled."
findings.append(report)
return findings
@@ -0,0 +1,36 @@
{
"Provider": "gcp",
"CheckID": "compute_instance_deletion_protection_enabled",
"CheckTitle": "VM instance has deletion protection enabled",
"CheckType": [],
"ServiceName": "compute",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "compute.googleapis.com/Instance",
"Description": "This check verifies whether GCP Compute Engine VM instances have **deletion protection** enabled to prevent accidental termination of production or critical workloads.",
"Risk": "Without deletion protection enabled, VM instances are vulnerable to **accidental deletion** by users with sufficient permissions.\n\nThis could result in:\n- **Service disruption** and downtime for critical applications\n- **Data loss** if persistent disks are also deleted\n- **Recovery delays** while recreating instances and restoring configurations",
"RelatedUrl": "",
"AdditionalURLs": [
"https://cloud.google.com/compute/docs/instances/preventing-accidental-vm-deletion",
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/ComputeEngine/enable-deletion-protection.html"
],
"Remediation": {
"Code": {
"CLI": "gcloud compute instances update INSTANCE_NAME --deletion-protection --zone=ZONE",
"NativeIaC": "",
"Other": "1. Open the Google Cloud Console\n2. Navigate to Compute Engine > VM instances\n3. Select the target VM instance\n4. Click Edit\n5. Under Deletion protection, check the box to enable\n6. Click Save",
"Terraform": "```hcl\nresource \"google_compute_instance\" \"example_resource\" {\n name = \"example-instance\"\n machine_type = \"e2-medium\"\n zone = \"us-central1-a\"\n\n # Enable deletion protection\n deletion_protection = true\n\n boot_disk {\n initialize_params {\n image = \"debian-cloud/debian-11\"\n }\n }\n\n network_interface {\n network = \"default\"\n }\n}\n```"
},
"Recommendation": {
"Text": "Enable deletion protection on all production and business-critical VM instances to prevent accidental termination. Regularly review instances to ensure critical workloads are protected.",
"Url": "https://hub.prowler.com/check/compute_instance_deletion_protection_enabled"
}
},
"Categories": [
"resilience"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,29 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.compute.compute_client import compute_client
class compute_instance_deletion_protection_enabled(Check):
"""
Ensure that VM instance has deletion protection enabled.
This check verifies whether GCP Compute Engine VM instances have deletion protection
enabled to prevent accidental termination of production or critical workloads.
- PASS: VM instance has deletion protection enabled.
- FAIL: VM instance does not have deletion protection enabled.
"""
def execute(self) -> list[Check_Report_GCP]:
findings = []
for instance in compute_client.instances:
report = Check_Report_GCP(metadata=self.metadata(), resource=instance)
report.status = "PASS"
report.status_extended = (
f"VM Instance {instance.name} has deletion protection enabled."
)
if not instance.deletion_protection:
report.status = "FAIL"
report.status_extended = f"VM Instance {instance.name} does not have deletion protection enabled."
findings.append(report)
return findings
@@ -0,0 +1,37 @@
{
"Provider": "gcp",
"CheckID": "compute_instance_preemptible_vm_disabled",
"CheckTitle": "VM instance is not configured as preemptible or Spot VM",
"CheckType": [],
"ServiceName": "compute",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "compute.googleapis.com/Instance",
"Description": "This check verifies that VM instances are not configured as **preemptible** or **Spot VMs**.\n\nBoth preemptible and Spot VMs can be terminated by Google at any time when resources are needed elsewhere, making them unsuitable for production and business-critical workloads. Spot VMs are the newer version of preemptible VMs and are Google's recommended approach for interruptible workloads.",
"Risk": "Preemptible and Spot VMs may be **terminated at any time** by Google Cloud, causing:\n\n- **Service disruptions** for production workloads\n- **Data loss** if workloads are not fault-tolerant\n- **Availability issues** for business-critical applications\n\nThey are designed for batch jobs and fault-tolerant workloads only.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://cloud.google.com/compute/docs/instances/preemptible",
"https://cloud.google.com/compute/docs/instances/spot",
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/ComputeEngine/disable-preemptibility.html"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Go to Compute Engine console\n2. Select the preemptible or Spot VM instance\n3. Create a machine image from the instance\n4. Create a new instance from the machine image\n5. During creation, set **VM provisioning model** to **Standard** (not Spot)\n6. Delete the original preemptible or Spot VM instance",
"Terraform": "```hcl\nresource \"google_compute_instance\" \"example_resource\" {\n name = \"example-instance\"\n machine_type = \"e2-medium\"\n zone = \"us-central1-a\"\n\n scheduling {\n # Use standard provisioning model for production workloads (not Spot)\n provisioning_model = \"STANDARD\"\n # Also ensure preemptible is false (legacy field)\n preemptible = false\n }\n}\n```"
},
"Recommendation": {
"Text": "Use standard provisioning model for production and business-critical VM instances. Preemptible and Spot VMs should only be used for fault-tolerant, batch processing, or non-critical workloads that can handle interruptions.",
"Url": "https://hub.prowler.com/checks/compute_instance_preemptible_vm_disabled"
}
},
"Categories": [
"resilience"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,32 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.compute.compute_client import compute_client
class compute_instance_preemptible_vm_disabled(Check):
"""
Ensure GCP Compute Engine VM instances are not preemptible or Spot VMs.
- PASS: VM instance is not preemptible (preemptible=False) and not Spot
(provisioningModel != "SPOT").
- FAIL: VM instance is preemptible (preemptible=True) or Spot
(provisioningModel="SPOT").
"""
def execute(self) -> list[Check_Report_GCP]:
findings = []
for instance in compute_client.instances:
report = Check_Report_GCP(metadata=self.metadata(), resource=instance)
report.status = "PASS"
report.status_extended = (
f"VM Instance {instance.name} is not preemptible or Spot VM."
)
if instance.preemptible or instance.provisioning_model == "SPOT":
report.status = "FAIL"
vm_type = "preemptible" if instance.preemptible else "Spot VM"
report.status_extended = (
f"VM Instance {instance.name} is configured as {vm_type}."
)
findings.append(report)
return findings
@@ -133,7 +133,19 @@ class Compute(GCPService):
)
for disk in instance.get("disks", [])
],
automatic_restart=instance.get("scheduling", {}).get(
"automaticRestart", False
),
provisioning_model=instance.get("scheduling", {}).get(
"provisioningModel", "STANDARD"
),
project_id=project_id,
preemptible=instance.get("scheduling", {}).get(
"preemptible", False
),
deletion_protection=instance.get(
"deletionProtection", False
),
)
)
@@ -365,6 +377,10 @@ class Instance(BaseModel):
service_accounts: list
ip_forward: bool
disks_encryption: list
automatic_restart: bool = False
preemptible: bool = False
provisioning_model: str = "STANDARD"
deletion_protection: bool = False
class Network(BaseModel):
@@ -0,0 +1,33 @@
{
"Provider": "github",
"CheckID": "repository_immutable_releases_enabled",
"CheckTitle": "Repository has immutable releases enabled",
"CheckType": [],
"ServiceName": "repository",
"SubServiceName": "",
"ResourceIdTemplate": "github:user-id:repository/repository-name",
"Severity": "high",
"ResourceType": "GitHubRepository",
"Description": "Immutable releases prevent modification or replacement of published artifacts after publication. When enabled, release assets and binaries become tamper-proof, ensuring artifact integrity throughout the software supply chain.",
"Risk": "If immutable releases are disabled, release assets can be tampered with after publication, allowing attackers to substitute malicious binaries and undermining supply chain integrity.",
"RelatedUrl": "https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository#preventing-changes-to-releases",
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": ""
},
"Recommendation": {
"Text": "Enable immutable releases in the repository settings so release artifacts cannot be altered once published.",
"Url": "https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/immutable-releases"
}
},
"Categories": [
"software-supply-chain"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,41 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGithub
from prowler.providers.github.services.repository.repository_client import (
repository_client,
)
class repository_immutable_releases_enabled(Check):
"""Ensure immutable releases are enabled for GitHub repositories.
Immutable releases prevent post-publication tampering of binaries and release assets.
"""
def execute(self) -> List[CheckReportGithub]:
"""Run the immutable releases verification for each discovered repository.
Returns:
List[CheckReportGithub]: Collection of check reports describing the immutable releases status.
"""
findings: List[CheckReportGithub] = []
for repo in repository_client.repositories.values():
if repo.immutable_releases_enabled is None:
continue
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
if repo.immutable_releases_enabled:
report.status = "PASS"
report.status_extended = (
f"Repository {repo.name} has immutable releases enabled."
)
else:
report.status = "FAIL"
report.status_extended = (
f"Repository {repo.name} does not have immutable releases enabled."
)
findings.append(report)
return findings
@@ -341,6 +341,9 @@ class Repository(GithubService):
name=repo.name,
owner=repo.owner.login,
full_name=repo.full_name,
immutable_releases_enabled=self._get_repository_immutable_releases_status(
repo
),
default_branch=Branch(
name=default_branch,
protected=branch_protection,
@@ -370,6 +373,54 @@ class Repository(GithubService):
f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _get_repository_immutable_releases_status(self, repo) -> Optional[bool]:
"""Retrieve the immutable releases status for the provided repository.
The API returns a response in the format:
{
"enabled": true,
"enforced_by_owner": false
}
Args:
repo: The PyGithub repository instance to query.
Returns:
Optional[bool]: True when immutable releases are enabled, False when they are disabled, and None when the status cannot be determined.
"""
try:
_, response = repo._requester.requestJsonAndCheck( # type: ignore[attr-defined]
"GET",
f"/repos/{repo.full_name}/immutable-releases",
headers={
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
if isinstance(response, dict) and "enabled" in response:
return response.get("enabled")
return None
except github.GithubException as error:
status_code = getattr(error, "status", None)
if status_code == 404:
logger.info(
f"{repo.full_name}: immutable releases endpoint not available for this repository."
)
return None
if status_code == 403:
logger.warning(
f"{repo.full_name}: insufficient permissions to query immutable releases endpoint."
)
return None
self._handle_github_api_error(
error, "fetching immutable releases status", repo.full_name
)
except Exception as error:
logger.error(
f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return None
class Branch(BaseModel):
"""Model for Github Branch"""
@@ -396,6 +447,7 @@ class Repo(BaseModel):
name: str
owner: str
full_name: str
immutable_releases_enabled: Optional[bool] = None
default_branch: Branch
private: bool
archived: bool
+1 -1
View File
@@ -77,7 +77,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">3.9.1,<3.13"
version = "5.14.1"
version = "5.15.0"
[project.scripts]
prowler = "prowler.__main__:prowler"
+44
View File
@@ -18,6 +18,7 @@ from prowler.lib.check.check import (
list_categories,
list_checks_json,
list_services,
load_custom_checks_metadata,
parse_checks_from_file,
parse_checks_from_folder,
remove_custom_checks_module,
@@ -483,6 +484,49 @@ class TestCheck:
)
remove_custom_checks_module(check_folder, provider)
def test_load_custom_checks_metadata(self, tmp_path):
"""Test loading check metadata from a custom checks folder."""
check_name = "custom_test_check"
check_folder = tmp_path / check_name
check_folder.mkdir()
metadata = {
"Provider": "aws",
"CheckID": check_name,
"CheckTitle": "Test Custom Check",
"CheckType": [],
"ServiceName": "custom",
"SubServiceName": "",
"ResourceIdTemplate": "arn:aws:custom:::resource",
"Severity": "low",
"ResourceType": "AwsCustomResource",
"Description": "A test custom check",
"Risk": "Test risk",
"RelatedUrl": "https://example.com",
"Remediation": {
"Code": {"CLI": "", "NativeIaC": "", "Other": "", "Terraform": ""},
"Recommendation": {"Text": "", "Url": ""},
},
"Categories": [],
"DependsOn": [],
"RelatedTo": [],
"Notes": "",
}
metadata_file = check_folder / f"{check_name}.metadata.json"
metadata_file.write_text(json.dumps(metadata))
result = load_custom_checks_metadata(str(tmp_path))
assert check_name in result
assert result[check_name].CheckID == check_name
assert result[check_name].Provider == "aws"
assert result[check_name].Severity == "low"
def test_load_custom_checks_metadata_nonexistent_path(self):
"""Test that nonexistent paths return empty dict."""
result = load_custom_checks_metadata("/nonexistent/path/to/checks")
assert result == {}
def test_exclude_checks_to_run(self):
test_cases = [
{
+86 -2
View File
@@ -56,6 +56,7 @@ def mock_api_client(GCPService, service, api_version, _):
mock_api_policies_calls(client)
mock_api_sink_calls(client)
mock_api_services_calls(client)
mock_api_access_policies_calls(client)
return client
@@ -117,8 +118,9 @@ def mock_api_projects_calls(client: MagicMock):
"etag": "BwWWja0YfJA=",
"version": 3,
}
# Used by compute client
# Used by compute client and cloudresourcemanager
client.projects().get().execute.return_value = {
"projectNumber": "123456789012",
"commonInstanceMetadata": {
"items": [
{
@@ -134,7 +136,7 @@ def mock_api_projects_calls(client: MagicMock):
"value": "TRUE",
},
]
}
},
}
client.projects().list_next.return_value = None
# Used by dataproc client
@@ -757,6 +759,11 @@ def mock_api_instances_calls(client: MagicMock, service: str):
"diskType": "disk_type",
}
],
"scheduling": {
"automaticRestart": False,
"preemptible": False,
"provisioningModel": "STANDARD",
},
},
{
"name": "instance2",
@@ -783,6 +790,11 @@ def mock_api_instances_calls(client: MagicMock, service: str):
"diskType": "disk_type",
}
],
"scheduling": {
"automaticRestart": False,
"preemptible": False,
"provisioningModel": "STANDARD",
},
},
]
}
@@ -1100,3 +1112,75 @@ def mock_api_services_calls(client: MagicMock):
]
}
client.services().list_next.return_value = None
def mock_api_access_policies_calls(client: MagicMock):
# Mock access policies list based on parent organization
def mock_list_access_policies(parent):
return_value = MagicMock()
# Only return policies for the first organization (123456789)
if parent == "organizations/123456789":
return_value.execute.return_value = {
"accessPolicies": [
{
"name": "accessPolicies/123456",
"title": "Test Access Policy 1",
},
{
"name": "accessPolicies/789012",
"title": "Test Access Policy 2",
},
]
}
elif parent == "organizations/987654321":
# No policies for the second organization
return_value.execute.return_value = {"accessPolicies": []}
else:
return_value.execute.return_value = {"accessPolicies": []}
return return_value
client.accessPolicies().list = mock_list_access_policies
client.accessPolicies().list_next.return_value = None
# Mock service perimeters list based on parent access policy
def mock_list_service_perimeters(parent):
return_value = MagicMock()
if parent == "accessPolicies/123456":
return_value.execute.return_value = {
"servicePerimeters": [
{
"name": "accessPolicies/123456/servicePerimeters/perimeter1",
"title": "Test Perimeter 1",
"perimeterType": "PERIMETER_TYPE_REGULAR",
"status": {
"resources": [
f"projects/{GCP_PROJECT_ID}",
],
"restrictedServices": [
"storage.googleapis.com",
"bigquery.googleapis.com",
],
},
},
{
"name": "accessPolicies/123456/servicePerimeters/perimeter2",
"title": "Test Perimeter 2",
"perimeterType": "PERIMETER_TYPE_BRIDGE",
"spec": {
"resources": [],
"restrictedServices": [
"compute.googleapis.com",
],
},
},
]
}
elif parent == "accessPolicies/789012":
# No perimeters for the second policy
return_value.execute.return_value = {"servicePerimeters": []}
else:
return_value.execute.return_value = {"servicePerimeters": []}
return return_value
client.accessPolicies().servicePerimeters().list = mock_list_service_perimeters
client.accessPolicies().servicePerimeters().list_next.return_value = None
@@ -0,0 +1,196 @@
from unittest.mock import MagicMock, patch
from tests.providers.gcp.gcp_fixtures import (
GCP_PROJECT_ID,
mock_api_client,
mock_is_api_active,
set_mocked_gcp_provider,
)
class TestAccessContextManagerService:
def test_service(self):
# Mock cloudresourcemanager_client before importing accesscontextmanager
mock_crm_client = MagicMock()
mock_crm_client.organizations = [
MagicMock(id="123456789", name="Organization 1"),
]
with (
patch(
"prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__",
new=mock_is_api_active,
),
patch(
"prowler.providers.gcp.lib.service.service.GCPService.__generate_client__",
new=mock_api_client,
),
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.accesscontextmanager.accesscontextmanager_service.cloudresourcemanager_client",
new=mock_crm_client,
),
):
from prowler.providers.gcp.services.accesscontextmanager.accesscontextmanager_service import (
AccessContextManager,
)
accesscontextmanager_client = AccessContextManager(
set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])
)
assert accesscontextmanager_client.service == "accesscontextmanager"
assert accesscontextmanager_client.project_ids == [GCP_PROJECT_ID]
# Should have 2 service perimeters from the first access policy
assert len(accesscontextmanager_client.service_perimeters) == 2
# First service perimeter
assert (
accesscontextmanager_client.service_perimeters[0].name
== "accessPolicies/123456/servicePerimeters/perimeter1"
)
assert (
accesscontextmanager_client.service_perimeters[0].title
== "Test Perimeter 1"
)
assert (
accesscontextmanager_client.service_perimeters[0].perimeter_type
== "PERIMETER_TYPE_REGULAR"
)
assert accesscontextmanager_client.service_perimeters[0].resources == [
f"projects/{GCP_PROJECT_ID}"
]
assert accesscontextmanager_client.service_perimeters[
0
].restricted_services == [
"storage.googleapis.com",
"bigquery.googleapis.com",
]
assert (
accesscontextmanager_client.service_perimeters[0].policy_name
== "accessPolicies/123456"
)
# Second service perimeter
assert (
accesscontextmanager_client.service_perimeters[1].name
== "accessPolicies/123456/servicePerimeters/perimeter2"
)
assert (
accesscontextmanager_client.service_perimeters[1].title
== "Test Perimeter 2"
)
assert (
accesscontextmanager_client.service_perimeters[1].perimeter_type
== "PERIMETER_TYPE_BRIDGE"
)
assert accesscontextmanager_client.service_perimeters[1].resources == []
assert accesscontextmanager_client.service_perimeters[
1
].restricted_services == [
"compute.googleapis.com",
]
assert (
accesscontextmanager_client.service_perimeters[1].policy_name
== "accessPolicies/123456"
)
def test_get_service_perimeters_access_policies_error(self):
"""Test error handling when listing access policies fails."""
mock_crm_client = MagicMock()
mock_crm_client.organizations = [
MagicMock(id="123456789", name="Organization 1"),
]
mock_client = MagicMock()
def mock_list_access_policies_error(parent):
return_value = MagicMock()
return_value.execute.side_effect = Exception("Access denied")
return return_value
mock_client.accessPolicies().list = mock_list_access_policies_error
with (
patch(
"prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__",
new=mock_is_api_active,
),
patch(
"prowler.providers.gcp.lib.service.service.GCPService.__generate_client__",
return_value=mock_client,
),
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.accesscontextmanager.accesscontextmanager_service.cloudresourcemanager_client",
new=mock_crm_client,
),
):
from prowler.providers.gcp.services.accesscontextmanager.accesscontextmanager_service import (
AccessContextManager,
)
accesscontextmanager_client = AccessContextManager(
set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])
)
assert len(accesscontextmanager_client.service_perimeters) == 0
def test_get_service_perimeters_list_perimeters_error(self):
"""Test error handling when listing service perimeters fails."""
mock_crm_client = MagicMock()
mock_crm_client.organizations = [
MagicMock(id="123456789", name="Organization 1"),
]
mock_client = MagicMock()
def mock_list_access_policies(parent):
return_value = MagicMock()
return_value.execute.return_value = {
"accessPolicies": [{"name": "accessPolicies/123456"}]
}
return return_value
def mock_list_perimeters_error(parent):
return_value = MagicMock()
return_value.execute.side_effect = Exception("Permission denied")
return return_value
mock_client.accessPolicies().list = mock_list_access_policies
mock_client.accessPolicies().list_next.return_value = None
mock_client.accessPolicies().servicePerimeters().list = (
mock_list_perimeters_error
)
with (
patch(
"prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__",
new=mock_is_api_active,
),
patch(
"prowler.providers.gcp.lib.service.service.GCPService.__generate_client__",
return_value=mock_client,
),
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.accesscontextmanager.accesscontextmanager_service.cloudresourcemanager_client",
new=mock_crm_client,
),
):
from prowler.providers.gcp.services.accesscontextmanager.accesscontextmanager_service import (
AccessContextManager,
)
accesscontextmanager_client = AccessContextManager(
set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])
)
assert len(accesscontextmanager_client.service_perimeters) == 0
@@ -33,6 +33,10 @@ class TestCloudResourceManagerService:
assert (
api_keys_client.cloud_resource_manager_projects[0].id == GCP_PROJECT_ID
)
assert (
api_keys_client.cloud_resource_manager_projects[0].number
== "123456789012"
)
assert api_keys_client.cloud_resource_manager_projects[0].audit_logging
assert len(api_keys_client.bindings) == 2
@@ -0,0 +1,317 @@
from unittest import mock
from prowler.providers.gcp.models import GCPProject
from tests.providers.gcp.gcp_fixtures import (
GCP_PROJECT_ID,
GCP_US_CENTER1_LOCATION,
set_mocked_gcp_provider,
)
class TestCloudStorageUsesVPCServiceControls:
def test_project_protected_by_vpc_sc(self):
cloudresourcemanager_client = mock.MagicMock()
accesscontextmanager_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.cloudstorage.cloudstorage_uses_vpc_service_controls.cloudstorage_uses_vpc_service_controls.cloudresourcemanager_client",
new=cloudresourcemanager_client,
),
mock.patch(
"prowler.providers.gcp.services.cloudstorage.cloudstorage_uses_vpc_service_controls.cloudstorage_uses_vpc_service_controls.accesscontextmanager_client",
new=accesscontextmanager_client,
),
):
from prowler.providers.gcp.services.accesscontextmanager.accesscontextmanager_service import (
ServicePerimeter,
)
from prowler.providers.gcp.services.cloudresourcemanager.cloudresourcemanager_service import (
Project,
)
from prowler.providers.gcp.services.cloudstorage.cloudstorage_uses_vpc_service_controls.cloudstorage_uses_vpc_service_controls import (
cloudstorage_uses_vpc_service_controls,
)
project1 = Project(
id=GCP_PROJECT_ID, number="123456789012", audit_logging=True
)
cloudresourcemanager_client.project_ids = [GCP_PROJECT_ID]
cloudresourcemanager_client.cloud_resource_manager_projects = [project1]
cloudresourcemanager_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="test-project",
labels={},
lifecycle_state="ACTIVE",
)
}
cloudresourcemanager_client.region = GCP_US_CENTER1_LOCATION
accesscontextmanager_client.service_perimeters = [
ServicePerimeter(
name="accessPolicies/123456/servicePerimeters/test_perimeter",
title="Test Perimeter",
perimeter_type="PERIMETER_TYPE_REGULAR",
resources=["projects/123456789012"],
restricted_services=[
"storage.googleapis.com",
"bigquery.googleapis.com",
],
policy_name="accessPolicies/123456",
)
]
check = cloudstorage_uses_vpc_service_controls()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Project {GCP_PROJECT_ID} has VPC Service Controls enabled for Cloud Storage in perimeter Test Perimeter."
)
assert result[0].resource_id == GCP_PROJECT_ID
assert result[0].resource_name == "test-project"
assert result[0].location == GCP_US_CENTER1_LOCATION
assert result[0].project_id == GCP_PROJECT_ID
def test_project_not_protected_no_perimeters(self):
cloudresourcemanager_client = mock.MagicMock()
accesscontextmanager_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.cloudstorage.cloudstorage_uses_vpc_service_controls.cloudstorage_uses_vpc_service_controls.cloudresourcemanager_client",
new=cloudresourcemanager_client,
),
mock.patch(
"prowler.providers.gcp.services.cloudstorage.cloudstorage_uses_vpc_service_controls.cloudstorage_uses_vpc_service_controls.accesscontextmanager_client",
new=accesscontextmanager_client,
),
):
from prowler.providers.gcp.services.cloudresourcemanager.cloudresourcemanager_service import (
Project,
)
from prowler.providers.gcp.services.cloudstorage.cloudstorage_uses_vpc_service_controls.cloudstorage_uses_vpc_service_controls import (
cloudstorage_uses_vpc_service_controls,
)
project1 = Project(
id=GCP_PROJECT_ID, number="123456789012", audit_logging=True
)
cloudresourcemanager_client.project_ids = [GCP_PROJECT_ID]
cloudresourcemanager_client.cloud_resource_manager_projects = [project1]
cloudresourcemanager_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="test-project",
labels={},
lifecycle_state="ACTIVE",
)
}
cloudresourcemanager_client.region = GCP_US_CENTER1_LOCATION
# No service perimeters configured
accesscontextmanager_client.service_perimeters = []
check = cloudstorage_uses_vpc_service_controls()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Project {GCP_PROJECT_ID} does not have VPC Service Controls enabled for Cloud Storage."
)
assert result[0].resource_id == GCP_PROJECT_ID
assert result[0].resource_name == "test-project"
assert result[0].location == GCP_US_CENTER1_LOCATION
assert result[0].project_id == GCP_PROJECT_ID
def test_project_in_perimeter_but_storage_not_restricted(self):
cloudresourcemanager_client = mock.MagicMock()
accesscontextmanager_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.cloudstorage.cloudstorage_uses_vpc_service_controls.cloudstorage_uses_vpc_service_controls.cloudresourcemanager_client",
new=cloudresourcemanager_client,
),
mock.patch(
"prowler.providers.gcp.services.cloudstorage.cloudstorage_uses_vpc_service_controls.cloudstorage_uses_vpc_service_controls.accesscontextmanager_client",
new=accesscontextmanager_client,
),
):
from prowler.providers.gcp.services.accesscontextmanager.accesscontextmanager_service import (
ServicePerimeter,
)
from prowler.providers.gcp.services.cloudresourcemanager.cloudresourcemanager_service import (
Project,
)
from prowler.providers.gcp.services.cloudstorage.cloudstorage_uses_vpc_service_controls.cloudstorage_uses_vpc_service_controls import (
cloudstorage_uses_vpc_service_controls,
)
project1 = Project(
id=GCP_PROJECT_ID, number="123456789012", audit_logging=True
)
cloudresourcemanager_client.project_ids = [GCP_PROJECT_ID]
cloudresourcemanager_client.cloud_resource_manager_projects = [project1]
cloudresourcemanager_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="test-project",
labels={},
lifecycle_state="ACTIVE",
)
}
cloudresourcemanager_client.region = GCP_US_CENTER1_LOCATION
# Perimeter exists but storage.googleapis.com is NOT in restricted services
accesscontextmanager_client.service_perimeters = [
ServicePerimeter(
name="accessPolicies/123456/servicePerimeters/test_perimeter",
title="Test Perimeter",
perimeter_type="PERIMETER_TYPE_REGULAR",
resources=["projects/123456789012"],
restricted_services=[
"bigquery.googleapis.com",
"compute.googleapis.com",
],
policy_name="accessPolicies/123456",
)
]
check = cloudstorage_uses_vpc_service_controls()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Project {GCP_PROJECT_ID} does not have VPC Service Controls enabled for Cloud Storage."
)
assert result[0].resource_id == GCP_PROJECT_ID
assert result[0].resource_name == "test-project"
assert result[0].location == GCP_US_CENTER1_LOCATION
assert result[0].project_id == GCP_PROJECT_ID
def test_project_not_in_perimeter(self):
cloudresourcemanager_client = mock.MagicMock()
accesscontextmanager_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.cloudstorage.cloudstorage_uses_vpc_service_controls.cloudstorage_uses_vpc_service_controls.cloudresourcemanager_client",
new=cloudresourcemanager_client,
),
mock.patch(
"prowler.providers.gcp.services.cloudstorage.cloudstorage_uses_vpc_service_controls.cloudstorage_uses_vpc_service_controls.accesscontextmanager_client",
new=accesscontextmanager_client,
),
):
from prowler.providers.gcp.services.accesscontextmanager.accesscontextmanager_service import (
ServicePerimeter,
)
from prowler.providers.gcp.services.cloudresourcemanager.cloudresourcemanager_service import (
Project,
)
from prowler.providers.gcp.services.cloudstorage.cloudstorage_uses_vpc_service_controls.cloudstorage_uses_vpc_service_controls import (
cloudstorage_uses_vpc_service_controls,
)
project1 = Project(
id=GCP_PROJECT_ID, number="123456789012", audit_logging=True
)
cloudresourcemanager_client.project_ids = [GCP_PROJECT_ID]
cloudresourcemanager_client.cloud_resource_manager_projects = [project1]
cloudresourcemanager_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="test-project",
labels={},
lifecycle_state="ACTIVE",
)
}
cloudresourcemanager_client.region = GCP_US_CENTER1_LOCATION
# Perimeter exists with storage restricted, but different project
accesscontextmanager_client.service_perimeters = [
ServicePerimeter(
name="accessPolicies/123456/servicePerimeters/test_perimeter",
title="Test Perimeter",
perimeter_type="PERIMETER_TYPE_REGULAR",
resources=["projects/999999999999"],
restricted_services=["storage.googleapis.com"],
policy_name="accessPolicies/123456",
)
]
check = cloudstorage_uses_vpc_service_controls()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Project {GCP_PROJECT_ID} does not have VPC Service Controls enabled for Cloud Storage."
)
assert result[0].resource_id == GCP_PROJECT_ID
assert result[0].resource_name == "test-project"
assert result[0].location == GCP_US_CENTER1_LOCATION
assert result[0].project_id == GCP_PROJECT_ID
def test_no_projects(self):
cloudresourcemanager_client = mock.MagicMock()
accesscontextmanager_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.cloudstorage.cloudstorage_uses_vpc_service_controls.cloudstorage_uses_vpc_service_controls.cloudresourcemanager_client",
new=cloudresourcemanager_client,
),
mock.patch(
"prowler.providers.gcp.services.cloudstorage.cloudstorage_uses_vpc_service_controls.cloudstorage_uses_vpc_service_controls.accesscontextmanager_client",
new=accesscontextmanager_client,
),
):
from prowler.providers.gcp.services.cloudstorage.cloudstorage_uses_vpc_service_controls.cloudstorage_uses_vpc_service_controls import (
cloudstorage_uses_vpc_service_controls,
)
cloudresourcemanager_client.cloud_resource_manager_projects = []
cloudresourcemanager_client.region = GCP_US_CENTER1_LOCATION
check = cloudstorage_uses_vpc_service_controls()
result = check.execute()
assert len(result) == 0
@@ -0,0 +1,323 @@
from unittest import mock
from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_provider
class TestComputeInstanceAutomaticRestartEnabled:
def test_compute_no_instances(self):
compute_client = mock.MagicMock()
compute_client.instances = []
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.compute.compute_instance_automatic_restart_enabled.compute_instance_automatic_restart_enabled.compute_client",
new=compute_client,
),
):
from prowler.providers.gcp.services.compute.compute_instance_automatic_restart_enabled.compute_instance_automatic_restart_enabled import (
compute_instance_automatic_restart_enabled,
)
check = compute_instance_automatic_restart_enabled()
result = check.execute()
assert len(result) == 0
def test_instance_with_automatic_restart_enabled(self):
compute_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.compute.compute_instance_automatic_restart_enabled.compute_instance_automatic_restart_enabled.compute_client",
new=compute_client,
),
):
from prowler.providers.gcp.services.compute.compute_instance_automatic_restart_enabled.compute_instance_automatic_restart_enabled import (
compute_instance_automatic_restart_enabled,
)
from prowler.providers.gcp.services.compute.compute_service import Instance
compute_client.project_ids = [GCP_PROJECT_ID]
compute_client.instances = [
Instance(
name="test-instance",
id="1234567890",
zone="us-central1-a",
region="us-central1",
public_ip=True,
metadata={},
shielded_enabled_vtpm=True,
shielded_enabled_integrity_monitoring=True,
confidential_computing=False,
service_accounts=[],
ip_forward=False,
disks_encryption=[("disk1", False)],
automatic_restart=True,
project_id=GCP_PROJECT_ID,
)
]
check = compute_instance_automatic_restart_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"VM Instance {compute_client.instances[0].name} has Automatic Restart enabled."
)
assert result[0].resource_id == compute_client.instances[0].id
assert result[0].resource_name == compute_client.instances[0].name
assert result[0].location == "us-central1"
assert result[0].project_id == GCP_PROJECT_ID
def test_instance_without_automatic_restart(self):
compute_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.compute.compute_instance_automatic_restart_enabled.compute_instance_automatic_restart_enabled.compute_client",
new=compute_client,
),
):
from prowler.providers.gcp.services.compute.compute_instance_automatic_restart_enabled.compute_instance_automatic_restart_enabled import (
compute_instance_automatic_restart_enabled,
)
from prowler.providers.gcp.services.compute.compute_service import Instance
compute_client.project_ids = [GCP_PROJECT_ID]
compute_client.instances = [
Instance(
name="test-instance-disabled",
id="0987654321",
zone="us-west1-b",
region="us-west1",
public_ip=False,
metadata={},
shielded_enabled_vtpm=False,
shielded_enabled_integrity_monitoring=False,
confidential_computing=False,
service_accounts=[],
ip_forward=False,
disks_encryption=[],
automatic_restart=False,
project_id=GCP_PROJECT_ID,
)
]
check = compute_instance_automatic_restart_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"VM Instance {compute_client.instances[0].name} does not have Automatic Restart enabled."
)
assert result[0].resource_id == compute_client.instances[0].id
assert result[0].resource_name == compute_client.instances[0].name
assert result[0].location == "us-west1"
assert result[0].project_id == GCP_PROJECT_ID
def test_multiple_instances_mixed(self):
compute_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.compute.compute_instance_automatic_restart_enabled.compute_instance_automatic_restart_enabled.compute_client",
new=compute_client,
),
):
from prowler.providers.gcp.services.compute.compute_instance_automatic_restart_enabled.compute_instance_automatic_restart_enabled import (
compute_instance_automatic_restart_enabled,
)
from prowler.providers.gcp.services.compute.compute_service import Instance
compute_client.project_ids = [GCP_PROJECT_ID]
compute_client.instances = [
Instance(
name="compliant-instance",
id="1111111111",
zone="us-central1-a",
region="us-central1",
public_ip=True,
metadata={},
shielded_enabled_vtpm=True,
shielded_enabled_integrity_monitoring=True,
confidential_computing=False,
service_accounts=[],
ip_forward=False,
disks_encryption=[],
automatic_restart=True,
project_id=GCP_PROJECT_ID,
),
Instance(
name="non-compliant-instance",
id="2222222222",
zone="us-west1-b",
region="us-west1",
public_ip=False,
metadata={},
shielded_enabled_vtpm=False,
shielded_enabled_integrity_monitoring=False,
confidential_computing=False,
service_accounts=[],
ip_forward=False,
disks_encryption=[],
automatic_restart=False,
project_id=GCP_PROJECT_ID,
),
]
check = compute_instance_automatic_restart_enabled()
result = check.execute()
assert len(result) == 2
compliant_result = next(r for r in result if r.resource_id == "1111111111")
non_compliant_result = next(
r for r in result if r.resource_id == "2222222222"
)
assert compliant_result.status == "PASS"
assert (
compliant_result.status_extended
== "VM Instance compliant-instance has Automatic Restart enabled."
)
assert compliant_result.resource_id == "1111111111"
assert compliant_result.resource_name == "compliant-instance"
assert compliant_result.location == "us-central1"
assert compliant_result.project_id == GCP_PROJECT_ID
assert non_compliant_result.status == "FAIL"
assert (
non_compliant_result.status_extended
== "VM Instance non-compliant-instance does not have Automatic Restart enabled."
)
assert non_compliant_result.resource_id == "2222222222"
assert non_compliant_result.resource_name == "non-compliant-instance"
assert non_compliant_result.location == "us-west1"
assert non_compliant_result.project_id == GCP_PROJECT_ID
def test_preemptible_instance_fails(self):
compute_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.compute.compute_instance_automatic_restart_enabled.compute_instance_automatic_restart_enabled.compute_client",
new=compute_client,
),
):
from prowler.providers.gcp.services.compute.compute_instance_automatic_restart_enabled.compute_instance_automatic_restart_enabled import (
compute_instance_automatic_restart_enabled,
)
from prowler.providers.gcp.services.compute.compute_service import Instance
compute_client.project_ids = [GCP_PROJECT_ID]
compute_client.instances = [
Instance(
name="preemptible-instance",
id="3333333333",
zone="us-central1-a",
region="us-central1",
public_ip=False,
metadata={},
shielded_enabled_vtpm=False,
shielded_enabled_integrity_monitoring=False,
confidential_computing=False,
service_accounts=[],
ip_forward=False,
disks_encryption=[],
automatic_restart=False,
preemptible=True,
provisioning_model="STANDARD",
project_id=GCP_PROJECT_ID,
)
]
check = compute_instance_automatic_restart_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"VM Instance {compute_client.instances[0].name} is a Preemptible or Spot instance, which cannot have Automatic Restart enabled by design."
)
assert result[0].resource_id == compute_client.instances[0].id
assert result[0].resource_name == compute_client.instances[0].name
assert result[0].location == "us-central1"
assert result[0].project_id == GCP_PROJECT_ID
def test_spot_instance_fails(self):
compute_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.compute.compute_instance_automatic_restart_enabled.compute_instance_automatic_restart_enabled.compute_client",
new=compute_client,
),
):
from prowler.providers.gcp.services.compute.compute_instance_automatic_restart_enabled.compute_instance_automatic_restart_enabled import (
compute_instance_automatic_restart_enabled,
)
from prowler.providers.gcp.services.compute.compute_service import Instance
compute_client.project_ids = [GCP_PROJECT_ID]
compute_client.instances = [
Instance(
name="spot-instance",
id="4444444444",
zone="us-west1-b",
region="us-west1",
public_ip=False,
metadata={},
shielded_enabled_vtpm=False,
shielded_enabled_integrity_monitoring=False,
confidential_computing=False,
service_accounts=[],
ip_forward=False,
disks_encryption=[],
automatic_restart=False,
preemptible=False,
provisioning_model="SPOT",
project_id=GCP_PROJECT_ID,
)
]
check = compute_instance_automatic_restart_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"VM Instance {compute_client.instances[0].name} is a Preemptible or Spot instance, which cannot have Automatic Restart enabled by design."
)
assert result[0].resource_id == compute_client.instances[0].id
assert result[0].resource_name == compute_client.instances[0].name
assert result[0].location == "us-west1"
assert result[0].project_id == GCP_PROJECT_ID
@@ -0,0 +1,145 @@
from unittest import mock
from tests.providers.gcp.gcp_fixtures import (
GCP_PROJECT_ID,
GCP_US_CENTER1_LOCATION,
set_mocked_gcp_provider,
)
class TestComputeInstanceDeletionProtectionEnabled:
def test_compute_no_instances(self):
compute_client = mock.MagicMock()
compute_client.instances = []
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.compute.compute_instance_deletion_protection_enabled.compute_instance_deletion_protection_enabled.compute_client",
new=compute_client,
),
):
from prowler.providers.gcp.services.compute.compute_instance_deletion_protection_enabled.compute_instance_deletion_protection_enabled import (
compute_instance_deletion_protection_enabled,
)
check = compute_instance_deletion_protection_enabled()
result = check.execute()
assert len(result) == 0
def test_instance_deletion_protection_enabled(self):
compute_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.compute.compute_instance_deletion_protection_enabled.compute_instance_deletion_protection_enabled.compute_client",
new=compute_client,
),
):
from prowler.providers.gcp.services.compute.compute_instance_deletion_protection_enabled.compute_instance_deletion_protection_enabled import (
compute_instance_deletion_protection_enabled,
)
from prowler.providers.gcp.services.compute.compute_service import Instance
compute_client.project_ids = [GCP_PROJECT_ID]
compute_client.region = GCP_US_CENTER1_LOCATION
compute_client.instances = [
Instance(
name="test-instance",
id="1234567890",
zone=f"{GCP_US_CENTER1_LOCATION}-a",
region=GCP_US_CENTER1_LOCATION,
public_ip=False,
metadata={},
shielded_enabled_vtpm=True,
shielded_enabled_integrity_monitoring=True,
confidential_computing=False,
service_accounts=[
{"email": "123-compute@developer.gserviceaccount.com"}
],
ip_forward=False,
disks_encryption=[],
project_id=GCP_PROJECT_ID,
deletion_protection=True,
)
]
check = compute_instance_deletion_protection_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"VM Instance {compute_client.instances[0].name} has deletion protection enabled."
)
assert result[0].resource_id == "1234567890"
assert result[0].resource_name == "test-instance"
assert result[0].location == GCP_US_CENTER1_LOCATION
assert result[0].project_id == GCP_PROJECT_ID
def test_instance_deletion_protection_disabled(self):
compute_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.compute.compute_instance_deletion_protection_enabled.compute_instance_deletion_protection_enabled.compute_client",
new=compute_client,
),
):
from prowler.providers.gcp.services.compute.compute_instance_deletion_protection_enabled.compute_instance_deletion_protection_enabled import (
compute_instance_deletion_protection_enabled,
)
from prowler.providers.gcp.services.compute.compute_service import Instance
compute_client.project_ids = [GCP_PROJECT_ID]
compute_client.region = GCP_US_CENTER1_LOCATION
compute_client.instances = [
Instance(
name="test-instance",
id="1234567890",
zone=f"{GCP_US_CENTER1_LOCATION}-a",
region=GCP_US_CENTER1_LOCATION,
public_ip=False,
metadata={},
shielded_enabled_vtpm=True,
shielded_enabled_integrity_monitoring=True,
confidential_computing=False,
service_accounts=[
{
"email": f"{GCP_PROJECT_ID}-compute@developer.gserviceaccount.com"
}
],
ip_forward=False,
disks_encryption=[],
project_id=GCP_PROJECT_ID,
deletion_protection=False,
)
]
check = compute_instance_deletion_protection_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"VM Instance {compute_client.instances[0].name} does not have deletion protection enabled."
)
assert result[0].resource_id == "1234567890"
assert result[0].resource_name == "test-instance"
assert result[0].location == GCP_US_CENTER1_LOCATION
assert result[0].project_id == GCP_PROJECT_ID
@@ -0,0 +1,394 @@
from unittest import mock
from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_provider
class TestComputeInstancePreemptibleVmDisabled:
def test_no_instances(self):
compute_client = mock.MagicMock()
compute_client.project_ids = [GCP_PROJECT_ID]
compute_client.instances = []
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.compute.compute_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled.compute_client",
new=compute_client,
),
):
from prowler.providers.gcp.services.compute.compute_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled import (
compute_instance_preemptible_vm_disabled,
)
check = compute_instance_preemptible_vm_disabled()
result = check.execute()
assert len(result) == 0
def test_instance_not_preemptible(self):
compute_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.compute.compute_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled.compute_client",
new=compute_client,
),
):
from prowler.providers.gcp.services.compute.compute_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled import (
compute_instance_preemptible_vm_disabled,
)
from prowler.providers.gcp.services.compute.compute_service import Instance
compute_client.project_ids = [GCP_PROJECT_ID]
compute_client.instances = [
Instance(
name="test",
id="1234567890",
zone="us-central1-a",
region="us-central1",
public_ip=True,
metadata={},
shielded_enabled_vtpm=True,
shielded_enabled_integrity_monitoring=True,
confidential_computing=True,
service_accounts=[],
ip_forward=False,
disks_encryption=[("disk1", False), ("disk2", False)],
project_id=GCP_PROJECT_ID,
preemptible=False,
provisioning_model="",
)
]
check = compute_instance_preemptible_vm_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"VM Instance {compute_client.instances[0].name} is not preemptible or Spot VM."
)
assert result[0].resource_id == "1234567890"
assert result[0].resource_name == "test"
assert result[0].location == "us-central1"
assert result[0].project_id == GCP_PROJECT_ID
def test_instance_preemptible(self):
compute_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.compute.compute_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled.compute_client",
new=compute_client,
),
):
from prowler.providers.gcp.services.compute.compute_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled import (
compute_instance_preemptible_vm_disabled,
)
from prowler.providers.gcp.services.compute.compute_service import Instance
compute_client.project_ids = [GCP_PROJECT_ID]
compute_client.instances = [
Instance(
name="test",
id="1234567890",
zone="us-central1-a",
region="us-central1",
public_ip=True,
metadata={},
shielded_enabled_vtpm=False,
shielded_enabled_integrity_monitoring=True,
confidential_computing=False,
service_accounts=[],
ip_forward=False,
disks_encryption=[("disk1", False), ("disk2", False)],
project_id=GCP_PROJECT_ID,
preemptible=True,
provisioning_model="",
)
]
check = compute_instance_preemptible_vm_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"VM Instance {compute_client.instances[0].name} is configured as preemptible."
)
assert result[0].resource_id == "1234567890"
assert result[0].resource_name == "test"
assert result[0].location == "us-central1"
assert result[0].project_id == GCP_PROJECT_ID
def test_multiple_instances_mixed_preemptible_and_standard(self):
compute_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.compute.compute_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled.compute_client",
new=compute_client,
),
):
from prowler.providers.gcp.services.compute.compute_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled import (
compute_instance_preemptible_vm_disabled,
)
from prowler.providers.gcp.services.compute.compute_service import Instance
compute_client.project_ids = [GCP_PROJECT_ID]
compute_client.instances = [
Instance(
name="preemptible-instance",
id="111111111",
zone="us-central1-a",
region="us-central1",
public_ip=False,
metadata={},
shielded_enabled_vtpm=True,
shielded_enabled_integrity_monitoring=True,
confidential_computing=False,
service_accounts=[],
ip_forward=False,
disks_encryption=[],
project_id=GCP_PROJECT_ID,
preemptible=True,
provisioning_model="",
),
Instance(
name="standard-instance",
id="222222222",
zone="europe-west1-b",
region="europe-west1",
public_ip=True,
metadata={},
shielded_enabled_vtpm=True,
shielded_enabled_integrity_monitoring=True,
confidential_computing=True,
service_accounts=[],
ip_forward=False,
disks_encryption=[],
project_id=GCP_PROJECT_ID,
preemptible=False,
provisioning_model="",
),
]
check = compute_instance_preemptible_vm_disabled()
result = check.execute()
assert len(result) == 2
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "VM Instance preemptible-instance is configured as preemptible."
)
assert result[0].resource_id == "111111111"
assert result[0].resource_name == "preemptible-instance"
assert result[1].status == "PASS"
assert (
result[1].status_extended
== "VM Instance standard-instance is not preemptible or Spot VM."
)
assert result[1].resource_id == "222222222"
assert result[1].resource_name == "standard-instance"
def test_instance_spot_vm(self):
compute_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.compute.compute_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled.compute_client",
new=compute_client,
),
):
from prowler.providers.gcp.services.compute.compute_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled import (
compute_instance_preemptible_vm_disabled,
)
from prowler.providers.gcp.services.compute.compute_service import Instance
compute_client.project_ids = [GCP_PROJECT_ID]
compute_client.instances = [
Instance(
name="spot-vm",
id="3333333333",
zone="us-west1-a",
region="us-west1",
public_ip=False,
metadata={},
shielded_enabled_vtpm=True,
shielded_enabled_integrity_monitoring=True,
confidential_computing=False,
service_accounts=[],
ip_forward=False,
disks_encryption=[],
project_id=GCP_PROJECT_ID,
preemptible=False,
provisioning_model="SPOT",
)
]
check = compute_instance_preemptible_vm_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "VM Instance spot-vm is configured as Spot VM."
)
assert result[0].resource_id == "3333333333"
assert result[0].resource_name == "spot-vm"
assert result[0].location == "us-west1"
assert result[0].project_id == GCP_PROJECT_ID
def test_instance_standard_provisioning_model(self):
compute_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.compute.compute_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled.compute_client",
new=compute_client,
),
):
from prowler.providers.gcp.services.compute.compute_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled import (
compute_instance_preemptible_vm_disabled,
)
from prowler.providers.gcp.services.compute.compute_service import Instance
compute_client.project_ids = [GCP_PROJECT_ID]
compute_client.instances = [
Instance(
name="standard-vm",
id="4444444444",
zone="asia-east1-a",
region="asia-east1",
public_ip=True,
metadata={},
shielded_enabled_vtpm=True,
shielded_enabled_integrity_monitoring=True,
confidential_computing=False,
service_accounts=[],
ip_forward=False,
disks_encryption=[],
project_id=GCP_PROJECT_ID,
preemptible=False,
provisioning_model="STANDARD",
)
]
check = compute_instance_preemptible_vm_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "VM Instance standard-vm is not preemptible or Spot VM."
)
assert result[0].resource_id == "4444444444"
assert result[0].resource_name == "standard-vm"
assert result[0].location == "asia-east1"
assert result[0].project_id == GCP_PROJECT_ID
def test_multiple_instances_spot_and_standard(self):
compute_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.compute.compute_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled.compute_client",
new=compute_client,
),
):
from prowler.providers.gcp.services.compute.compute_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled import (
compute_instance_preemptible_vm_disabled,
)
from prowler.providers.gcp.services.compute.compute_service import Instance
compute_client.project_ids = [GCP_PROJECT_ID]
compute_client.instances = [
Instance(
name="spot-instance",
id="5555555555",
zone="us-central1-c",
region="us-central1",
public_ip=False,
metadata={},
shielded_enabled_vtpm=True,
shielded_enabled_integrity_monitoring=True,
confidential_computing=False,
service_accounts=[],
ip_forward=False,
disks_encryption=[],
project_id=GCP_PROJECT_ID,
preemptible=False,
provisioning_model="SPOT",
),
Instance(
name="standard-instance-2",
id="6666666666",
zone="europe-west2-a",
region="europe-west2",
public_ip=True,
metadata={},
shielded_enabled_vtpm=True,
shielded_enabled_integrity_monitoring=True,
confidential_computing=True,
service_accounts=[],
ip_forward=False,
disks_encryption=[],
project_id=GCP_PROJECT_ID,
preemptible=False,
provisioning_model="STANDARD",
),
]
check = compute_instance_preemptible_vm_disabled()
result = check.execute()
assert len(result) == 2
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "VM Instance spot-instance is configured as Spot VM."
)
assert result[0].resource_id == "5555555555"
assert result[0].resource_name == "spot-instance"
assert result[1].status == "PASS"
assert (
result[1].status_extended
== "VM Instance standard-instance-2 is not preemptible or Spot VM."
)
assert result[1].resource_id == "6666666666"
assert result[1].resource_name == "standard-instance-2"
@@ -57,6 +57,9 @@ class TestComputeService:
]
assert compute_client.instances[0].ip_forward
assert compute_client.instances[0].disks_encryption == [("disk1", True)]
assert not compute_client.instances[0].automatic_restart
assert not compute_client.instances[0].preemptible
assert compute_client.instances[0].provisioning_model == "STANDARD"
assert compute_client.instances[1].name == "instance2"
assert compute_client.instances[1].id.__class__.__name__ == "str"
@@ -78,6 +81,9 @@ class TestComputeService:
]
assert not compute_client.instances[1].ip_forward
assert compute_client.instances[1].disks_encryption == [("disk2", False)]
assert not compute_client.instances[1].automatic_restart
assert not compute_client.instances[1].preemptible
assert compute_client.instances[1].provisioning_model == "STANDARD"
assert len(compute_client.networks) == 3
assert compute_client.networks[0].name == "network1"
@@ -0,0 +1,141 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Branch, Repo
from tests.providers.github.github_fixtures import set_mocked_github_provider
class Test_repository_immutable_releases_enabled:
"""Unit tests for the repository_immutable_releases_enabled check."""
def _build_repo(self, immutable_releases_enabled):
"""Create a Repo instance with the provided immutable releases state."""
default_branch = Branch(
name="main",
protected=True,
default_branch=True,
require_pull_request=True,
approval_count=1,
required_linear_history=True,
allow_force_pushes=False,
branch_deletion=False,
status_checks=True,
enforce_admins=True,
require_code_owner_reviews=True,
require_signed_commits=True,
conversation_resolution=True,
)
return Repo(
id=1,
name="repo1",
owner="account-name",
full_name="account-name/repo1",
immutable_releases_enabled=immutable_releases_enabled,
default_branch=default_branch,
private=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=True,
codeowners_exists=True,
secret_scanning_enabled=True,
dependabot_alerts_enabled=True,
delete_branch_on_merge=False,
)
def test_no_repositories(self):
repository_client = mock.MagicMock
repository_client.repositories = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_github_provider(),
),
mock.patch(
"prowler.providers.github.services.repository.repository_immutable_releases_enabled.repository_immutable_releases_enabled.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_immutable_releases_enabled.repository_immutable_releases_enabled import (
repository_immutable_releases_enabled,
)
check = repository_immutable_releases_enabled()
result = check.execute()
assert len(result) == 0
def test_immutable_releases_enabled(self):
repository_client = mock.MagicMock
repository_client.repositories = {1: self._build_repo(True)}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_github_provider(),
),
mock.patch(
"prowler.providers.github.services.repository.repository_immutable_releases_enabled.repository_immutable_releases_enabled.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_immutable_releases_enabled.repository_immutable_releases_enabled import (
repository_immutable_releases_enabled,
)
check = repository_immutable_releases_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Repository repo1 has immutable releases enabled."
)
def test_immutable_releases_disabled(self):
repository_client = mock.MagicMock
repository_client.repositories = {1: self._build_repo(False)}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_github_provider(),
),
mock.patch(
"prowler.providers.github.services.repository.repository_immutable_releases_enabled.repository_immutable_releases_enabled.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_immutable_releases_enabled.repository_immutable_releases_enabled import (
repository_immutable_releases_enabled,
)
check = repository_immutable_releases_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Repository repo1 does not have immutable releases enabled."
)
def test_immutable_releases_unknown(self):
repository_client = mock.MagicMock
repository_client.repositories = {1: self._build_repo(None)}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_github_provider(),
),
mock.patch(
"prowler.providers.github.services.repository.repository_immutable_releases_enabled.repository_immutable_releases_enabled.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_immutable_releases_enabled.repository_immutable_releases_enabled import (
repository_immutable_releases_enabled,
)
check = repository_immutable_releases_enabled()
result = check.execute()
assert len(result) == 0
@@ -19,6 +19,7 @@ def mock_list_repositories(_):
name="repo1",
owner="account-name",
full_name="account-name/repo1",
immutable_releases_enabled=True,
default_branch=Branch(
name="main",
protected=True,
@@ -88,6 +89,7 @@ class Test_Repository_Service:
)
assert repository_service.repositories[1].archived is False
assert repository_service.repositories[1].pushed_at is not None
assert repository_service.repositories[1].immutable_releases_enabled is True
class Test_Repository_FileExists:
+11 -1
View File
@@ -2,6 +2,16 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.15.0] (Unreleased)
### 🚀 Added
- Compliance Watchlist component to Overview page [(#9199)](https://github.com/prowler-cloud/prowler/pull/9199)
- Service Watchlist component to Overview page [(#9316)](https://github.com/prowler-cloud/prowler/pull/9316)
- Risk Pipeline component with Sankey chart to Overview page [(#9317)](https://github.com/prowler-cloud/prowler/pull/9317)
- Threat Map component to Overview Page [(#9324)](https://github.com/prowler-cloud/prowler/pull/9324)
- MongoDB Atlas provider support [(#9253)](https://github.com/prowler-cloud/prowler/pull/9253)
## [1.14.0] (Prowler v5.14.0)
### 🚀 Added
@@ -25,7 +35,7 @@ All notable changes to the **Prowler UI** are documented in this file.
---
## [1.13.1]
## [1.13.1] (Prolwer v5.13.1)
### 🔄 Changed
@@ -0,0 +1,137 @@
import { StaticImageData } from "next/image";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { MetaDataProps } from "@/types";
import { ComplianceOverviewData } from "@/types/compliance";
/**
* Raw API response from /compliance-overviews endpoint
*/
export interface ComplianceOverviewsResponse {
data: ComplianceOverviewData[];
meta?: {
pagination?: {
page: number;
pages: number;
count: number;
};
};
}
/**
* Enriched compliance overview with computed fields
*/
export interface EnrichedComplianceOverview {
id: string;
framework: string;
version: string;
requirements_passed: number;
requirements_failed: number;
requirements_manual: number;
total_requirements: number;
score: number;
label: string;
icon: string | StaticImageData | undefined;
}
/**
* Formats framework name for display by replacing hyphens with spaces
* e.g., "FedRAMP-20x-KSI-Low" -> "FedRAMP 20x KSI Low"
*/
function formatFrameworkName(framework: string): string {
return framework.replace(/-/g, " ");
}
/**
* Adapts the raw API response to enriched compliance data
* - Computes score percentage (rounded)
* - Formats label (framework + version)
* - Resolves framework icon
* - Preserves pagination metadata
*
* @param response - Raw API response with data and optional pagination
* @returns Object with enriched compliance data and metadata
*/
export function adaptComplianceOverviewsResponse(
response: ComplianceOverviewsResponse | undefined,
): {
data: EnrichedComplianceOverview[];
metadata?: MetaDataProps;
} {
if (!response?.data) {
return { data: [] };
}
const enrichedData = response.data.map((compliance) => {
const { id, attributes } = compliance;
const {
framework,
version,
requirements_passed,
requirements_failed,
requirements_manual,
total_requirements,
} = attributes;
const totalRequirements = Number(total_requirements) || 0;
const passedRequirements = Number(requirements_passed) || 0;
const score =
totalRequirements > 0
? Math.round((passedRequirements / totalRequirements) * 100)
: 0;
const formattedFramework = formatFrameworkName(framework);
const label = version
? `${formattedFramework} - ${version}`
: formattedFramework;
const icon = getComplianceIcon(framework);
return {
id,
framework,
version,
requirements_passed,
requirements_failed,
requirements_manual,
total_requirements,
score,
label,
icon,
};
});
const metadata: MetaDataProps | undefined = response.meta?.pagination
? {
pagination: {
page: response.meta.pagination.page,
pages: response.meta.pagination.pages,
count: response.meta.pagination.count,
itemsPerPage: [10, 25, 50, 100],
},
version: "1.0",
}
: undefined;
return { data: enrichedData, metadata };
}
/**
* Sorts compliances for watchlist display:
* - Excludes ProwlerThreatScore
* - Sorted by score ascending (worst/lowest scores first)
* - Limited to specified count
*
* @param data - Enriched compliance data
* @param limit - Maximum number of items to return (default: 9)
* @returns Sorted and limited compliance data
*/
export function sortCompliancesForWatchlist(
data: EnrichedComplianceOverview[],
limit: number = 9,
): EnrichedComplianceOverview[] {
return [...data]
.filter((item) => item.framework !== "ProwlerThreatScore")
.sort((a, b) => a.score - b.score)
.slice(0, limit);
}
+18 -9
View File
@@ -7,22 +7,31 @@ export const getCompliancesOverview = async ({
scanId,
region,
query,
filters = {},
}: {
scanId: string;
scanId?: string;
region?: string | string[];
query?: string;
}) => {
filters?: Record<string, string | string[] | undefined>;
} = {}) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/compliance-overviews`);
if (scanId) url.searchParams.append("filter[scan_id]", scanId);
if (query) url.searchParams.append("filter[search]", query);
const setParam = (key: string, value?: string | string[]) => {
if (!value) return;
if (region) {
const regionValue = Array.isArray(region) ? region.join(",") : region;
url.searchParams.append("filter[region__in]", regionValue);
}
const serializedValue = Array.isArray(value) ? value.join(",") : value;
if (serializedValue.trim().length > 0) {
url.searchParams.set(key, serializedValue);
}
};
Object.entries(filters).forEach(([key, value]) => setParam(key, value));
setParam("filter[scan_id]", scanId);
setParam("filter[region__in]", region);
if (query) url.searchParams.set("filter[search]", query);
try {
const response = await fetch(url.toString(), {
@@ -31,7 +40,7 @@ export const getCompliancesOverview = async ({
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching providers:", error);
console.error("Error fetching compliances overview:", error);
return undefined;
}
};
+1
View File
@@ -1 +1,2 @@
export * from "./compliances";
export * from "./compliances.adapter";
+2 -62
View File
@@ -4,62 +4,6 @@ import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { FindingsResponse } from "@/types";
interface IncludedItem {
type: string;
id: string;
attributes?: { provider?: string };
relationships?: { provider?: { data?: { id: string } } };
}
type FindingsApiResponse = FindingsResponse & {
included?: IncludedItem[];
};
const filterMongoFindings = <T extends FindingsApiResponse | null | undefined>(
result: T,
): T => {
if (!result?.data) return result;
const included = (result as FindingsApiResponse).included || [];
// Get IDs of providers containing "mongo" in included items
const mongoProviderIds = new Set(
included
.filter(
(item) =>
item.type === "providers" &&
item.attributes?.provider?.toLowerCase().includes("mongo"),
)
.map((item) => item.id),
);
// Filter out findings associated with mongo providers
result.data = result.data.filter((finding) => {
const scanId = finding.relationships?.scan?.data?.id;
// Find the scan in included items
const scan = included.find(
(item) => item.type === "scans" && item.id === scanId,
);
const providerId = scan?.relationships?.provider?.data?.id;
return !providerId || !mongoProviderIds.has(providerId);
});
// Filter out mongo-related included items
if ((result as FindingsApiResponse).included) {
(result as FindingsApiResponse).included = included.filter(
(item) =>
!(
item.type === "providers" &&
item.attributes?.provider?.toLowerCase().includes("mongo")
),
);
}
return result;
};
export const getFindings = async ({
page = 1,
pageSize = 10,
@@ -89,9 +33,7 @@ export const getFindings = async ({
headers,
});
const result = await handleApiResponse(findings);
return filterMongoFindings(result);
return handleApiResponse(findings);
} catch (error) {
console.error("Error fetching findings:", error);
return undefined;
@@ -129,9 +71,7 @@ export const getLatestFindings = async ({
headers,
});
const result = await handleApiResponse(findings);
return filterMongoFindings(result);
return handleApiResponse(findings);
} catch (error) {
console.error("Error fetching findings:", error);
return undefined;
+2
View File
@@ -1,2 +1,4 @@
export * from "./overview";
export * from "./sankey.adapter";
export * from "./threat-map.adapter";
export * from "./types";
+78 -13
View File
@@ -4,12 +4,52 @@ import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
import {
FindingsSeverityOverviewResponse,
ProvidersOverviewResponse,
RegionsOverviewResponse,
ServicesOverviewResponse,
} from "./types";
export const getServicesOverview = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<ServicesOverviewResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/services`);
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]" && value !== undefined) {
url.searchParams.append(key, String(value));
}
});
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching services overview:", error);
return undefined;
}
};
export const getProvidersOverview = async ({
page = 1,
query = "",
sort = "",
filters = {},
}) => {
}: {
page?: number;
query?: string;
sort?: string;
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<ProvidersOverviewResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
if (isNaN(Number(page)) || page < 1) redirect("/providers-overview");
@@ -22,7 +62,7 @@ export const getProvidersOverview = async ({
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]") {
if (key !== "filter[search]" && value !== undefined) {
url.searchParams.append(key, String(value));
}
});
@@ -81,24 +121,21 @@ export const getFindingsByStatus = async ({
};
export const getFindingsBySeverity = async ({
page = 1,
query = "",
sort = "",
filters = {},
}) => {
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<FindingsSeverityOverviewResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
if (isNaN(Number(page)) || page < 1) redirect("/");
const url = new URL(`${apiBaseUrl}/overviews/findings_severity`);
if (page) url.searchParams.append("page[number]", page.toString());
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
// Handle multiple filters, but exclude unsupported filters
// The overviews/findings_severity endpoint does not support status or muted filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]" && key !== "filter[muted]") {
if (
key !== "filter[search]" &&
key !== "filter[muted]" &&
value !== undefined
) {
url.searchParams.append(key, String(value));
}
});
@@ -142,3 +179,31 @@ export const getThreatScore = async ({
return undefined;
}
};
export const getRegionsOverview = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<RegionsOverviewResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/regions`);
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]" && value !== undefined) {
url.searchParams.append(key, String(value));
}
});
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching regions overview:", error);
return undefined;
}
};
+216
View File
@@ -0,0 +1,216 @@
import { getProviderDisplayName } from "@/types/providers";
import {
FindingsSeverityOverviewResponse,
ProviderOverview,
ProvidersOverviewResponse,
} from "./types";
export interface SankeyNode {
name: string;
}
export interface SankeyLink {
source: number;
target: number;
value: number;
}
export interface ZeroDataProvider {
id: string;
displayName: string;
}
export interface SankeyData {
nodes: SankeyNode[];
links: SankeyLink[];
zeroDataProviders: ZeroDataProvider[];
}
export interface SankeyFilters {
providerTypes?: string[];
/** All selected provider types - used to show missing providers in legend */
allSelectedProviderTypes?: string[];
}
interface AggregatedProvider {
id: string;
displayName: string;
pass: number;
fail: number;
}
// API can return multiple entries for the same provider type, so we sum their findings
function aggregateProvidersByType(
providers: ProviderOverview[],
): AggregatedProvider[] {
const aggregated = new Map<string, AggregatedProvider>();
for (const provider of providers) {
const { id, attributes } = provider;
const existing = aggregated.get(id);
if (existing) {
existing.pass += attributes.findings.pass;
existing.fail += attributes.findings.fail;
} else {
aggregated.set(id, {
id,
displayName: getProviderDisplayName(id),
pass: attributes.findings.pass,
fail: attributes.findings.fail,
});
}
}
return Array.from(aggregated.values());
}
const SEVERITY_ORDER = [
"Critical",
"High",
"Medium",
"Low",
"Informational",
] as const;
/**
* Adapts providers overview and findings severity API responses to Sankey chart format.
* Severity distribution is calculated proportionally based on each provider's fail count.
*
* @param providersResponse - The providers overview API response
* @param severityResponse - The findings severity API response
* @param filters - Optional filters to restrict which providers are shown.
* When filters are set, only selected providers are shown.
* When no filters, all providers are shown.
*/
export function adaptProvidersOverviewToSankey(
providersResponse: ProvidersOverviewResponse | undefined,
severityResponse?: FindingsSeverityOverviewResponse | undefined,
filters?: SankeyFilters,
): SankeyData {
if (!providersResponse?.data || providersResponse.data.length === 0) {
return { nodes: [], links: [], zeroDataProviders: [] };
}
const aggregatedProviders = aggregateProvidersByType(providersResponse.data);
// Filter providers based on selection:
// - If providerTypes filter is set: show only those provider types
// - Otherwise: show all providers from the API response
const hasProviderTypeFilter =
filters?.providerTypes && filters.providerTypes.length > 0;
let providersToShow: AggregatedProvider[];
if (hasProviderTypeFilter) {
// Show only selected provider types
providersToShow = aggregatedProviders.filter((p) =>
filters.providerTypes!.includes(p.id.toLowerCase()),
);
} else {
// No provider type filter - show all providers from the API response
// Providers with no findings (pass=0, fail=0) will appear in the legend
providersToShow = aggregatedProviders;
}
if (providersToShow.length === 0) {
return { nodes: [], links: [], zeroDataProviders: [] };
}
// Separate providers with and without failures
const providersWithFailures = providersToShow.filter((p) => p.fail > 0);
const providersWithoutFailures = providersToShow.filter((p) => p.fail === 0);
// Zero-data providers to show as legends below the chart
const zeroDataProviders: ZeroDataProvider[] = providersWithoutFailures.map(
(p) => ({
id: p.id,
displayName: p.displayName,
}),
);
// Add selected provider types that are completely missing from API response
// (these are providers with zero findings - not even in the response)
if (
filters?.allSelectedProviderTypes &&
filters.allSelectedProviderTypes.length > 0
) {
const existingProviderIds = new Set(
aggregatedProviders.map((p) => p.id.toLowerCase()),
);
for (const selectedType of filters.allSelectedProviderTypes) {
const normalizedType = selectedType.toLowerCase();
if (!existingProviderIds.has(normalizedType)) {
// This provider type was selected but has no data at all
zeroDataProviders.push({
id: normalizedType,
displayName: getProviderDisplayName(normalizedType),
});
}
}
}
// If no providers have failures, return empty chart with legends
if (providersWithFailures.length === 0) {
return { nodes: [], links: [], zeroDataProviders };
}
// Only include providers WITH failures in the chart
const providerNodes: SankeyNode[] = providersWithFailures.map((p) => ({
name: p.displayName,
}));
const severityNodes: SankeyNode[] = SEVERITY_ORDER.map((severity) => ({
name: severity,
}));
const nodes = [...providerNodes, ...severityNodes];
const severityStartIndex = providerNodes.length;
const links: SankeyLink[] = [];
if (severityResponse?.data?.attributes) {
const { critical, high, medium, low, informational } =
severityResponse.data.attributes;
const severityValues = [critical, high, medium, low, informational];
const totalSeverity = severityValues.reduce((sum, v) => sum + v, 0);
if (totalSeverity > 0) {
const totalFails = providersWithFailures.reduce(
(sum, p) => sum + p.fail,
0,
);
providersWithFailures.forEach((provider, sourceIndex) => {
const providerRatio = provider.fail / totalFails;
severityValues.forEach((severityValue, severityIndex) => {
const value = Math.round(severityValue * providerRatio);
if (value > 0) {
links.push({
source: sourceIndex,
target: severityStartIndex + severityIndex,
value,
});
}
});
});
}
} else {
// Fallback when no severity data available
const failNode: SankeyNode = { name: "Fail" };
nodes.push(failNode);
const failIndex = nodes.length - 1;
providersWithFailures.forEach((provider, sourceIndex) => {
links.push({
source: sourceIndex,
target: failIndex,
value: provider.fail,
});
});
}
return { nodes, links, zeroDataProviders };
}
+266
View File
@@ -0,0 +1,266 @@
import { getProviderDisplayName } from "@/types/providers";
import { RegionsOverviewResponse } from "./types";
export interface ThreatMapLocation {
id: string;
name: string;
region: string;
regionCode: string;
providerType: string;
coordinates: [number, number];
totalFindings: number;
riskLevel: "low-high" | "high" | "critical";
severityData: Array<{
name: string;
value: number;
percentage?: number;
color?: string;
}>;
change?: number;
}
export interface ThreatMapData {
locations: ThreatMapLocation[];
regions: string[];
}
const AWS_REGION_COORDINATES: Record<string, { lat: number; lng: number }> = {
"us-east-1": { lat: 37.5, lng: -77.5 }, // N. Virginia
"us-east-2": { lat: 40.0, lng: -83.0 }, // Ohio
"us-west-1": { lat: 37.8, lng: -122.4 }, // N. California
"us-west-2": { lat: 45.5, lng: -122.7 }, // Oregon
"af-south-1": { lat: -33.9, lng: 18.4 }, // Cape Town
"ap-east-1": { lat: 22.3, lng: 114.2 }, // Hong Kong
"ap-south-1": { lat: 19.1, lng: 72.9 }, // Mumbai
"ap-south-2": { lat: 17.4, lng: 78.5 }, // Hyderabad
"ap-northeast-1": { lat: 35.7, lng: 139.7 }, // Tokyo
"ap-northeast-2": { lat: 37.6, lng: 127.0 }, // Seoul
"ap-northeast-3": { lat: 34.7, lng: 135.5 }, // Osaka
"ap-southeast-1": { lat: 1.4, lng: 103.8 }, // Singapore
"ap-southeast-2": { lat: -33.9, lng: 151.2 }, // Sydney
"ap-southeast-3": { lat: -6.2, lng: 106.8 }, // Jakarta
"ap-southeast-4": { lat: -37.8, lng: 144.96 }, // Melbourne
"ca-central-1": { lat: 45.5, lng: -73.6 }, // Montreal
"ca-west-1": { lat: 51.0, lng: -114.1 }, // Calgary
"eu-central-1": { lat: 50.1, lng: 8.7 }, // Frankfurt
"eu-central-2": { lat: 47.4, lng: 8.5 }, // Zurich
"eu-west-1": { lat: 53.3, lng: -6.3 }, // Ireland
"eu-west-2": { lat: 51.5, lng: -0.1 }, // London
"eu-west-3": { lat: 48.9, lng: 2.3 }, // Paris
"eu-north-1": { lat: 59.3, lng: 18.1 }, // Stockholm
"eu-south-1": { lat: 45.5, lng: 9.2 }, // Milan
"eu-south-2": { lat: 40.4, lng: -3.7 }, // Spain
"il-central-1": { lat: 32.1, lng: 34.8 }, // Tel Aviv
"me-central-1": { lat: 25.3, lng: 55.3 }, // UAE
"me-south-1": { lat: 26.1, lng: 50.6 }, // Bahrain
"sa-east-1": { lat: -23.5, lng: -46.6 }, // São Paulo
};
const AZURE_REGION_COORDINATES: Record<string, { lat: number; lng: number }> = {
eastus: { lat: 37.5, lng: -79.0 },
eastus2: { lat: 36.7, lng: -78.9 },
westus: { lat: 37.8, lng: -122.4 },
westus2: { lat: 47.6, lng: -122.3 },
westus3: { lat: 33.4, lng: -112.1 },
centralus: { lat: 41.6, lng: -93.6 },
northcentralus: { lat: 41.9, lng: -87.6 },
southcentralus: { lat: 29.4, lng: -98.5 },
westcentralus: { lat: 40.9, lng: -110.0 },
canadacentral: { lat: 43.7, lng: -79.4 },
canadaeast: { lat: 46.8, lng: -71.2 },
brazilsouth: { lat: -23.5, lng: -46.6 },
northeurope: { lat: 53.3, lng: -6.3 },
westeurope: { lat: 52.4, lng: 4.9 },
uksouth: { lat: 51.5, lng: -0.1 },
ukwest: { lat: 53.4, lng: -3.0 },
francecentral: { lat: 46.3, lng: 2.4 },
francesouth: { lat: 43.8, lng: 2.1 },
switzerlandnorth: { lat: 47.5, lng: 8.5 },
switzerlandwest: { lat: 46.2, lng: 6.1 },
germanywestcentral: { lat: 50.1, lng: 8.7 },
germanynorth: { lat: 53.1, lng: 8.8 },
norwayeast: { lat: 59.9, lng: 10.7 },
norwaywest: { lat: 58.97, lng: 5.73 },
swedencentral: { lat: 60.67, lng: 17.14 },
polandcentral: { lat: 52.23, lng: 21.01 },
italynorth: { lat: 45.5, lng: 9.2 },
spaincentral: { lat: 40.4, lng: -3.7 },
australiaeast: { lat: -33.9, lng: 151.2 },
australiasoutheast: { lat: -37.8, lng: 145.0 },
australiacentral: { lat: -35.3, lng: 149.1 },
eastasia: { lat: 22.3, lng: 114.2 },
southeastasia: { lat: 1.3, lng: 103.8 },
japaneast: { lat: 35.7, lng: 139.7 },
japanwest: { lat: 34.7, lng: 135.5 },
koreacentral: { lat: 37.6, lng: 127.0 },
koreasouth: { lat: 35.2, lng: 129.0 },
centralindia: { lat: 18.6, lng: 73.9 },
southindia: { lat: 12.9, lng: 80.2 },
westindia: { lat: 19.1, lng: 72.9 },
uaenorth: { lat: 25.3, lng: 55.3 },
uaecentral: { lat: 24.5, lng: 54.4 },
southafricanorth: { lat: -26.2, lng: 28.0 },
southafricawest: { lat: -34.0, lng: 18.5 },
israelcentral: { lat: 32.1, lng: 34.8 },
qatarcentral: { lat: 25.3, lng: 51.5 },
};
const GCP_REGION_COORDINATES: Record<string, { lat: number; lng: number }> = {
"us-central1": { lat: 41.3, lng: -95.9 }, // Iowa
"us-east1": { lat: 33.2, lng: -80.0 }, // South Carolina
"us-east4": { lat: 39.0, lng: -77.5 }, // Northern Virginia
"us-east5": { lat: 39.96, lng: -82.99 }, // Columbus
"us-south1": { lat: 32.8, lng: -96.8 }, // Dallas
"us-west1": { lat: 45.6, lng: -122.8 }, // Oregon
"us-west2": { lat: 34.1, lng: -118.2 }, // Los Angeles
"us-west3": { lat: 40.8, lng: -111.9 }, // Salt Lake City
"us-west4": { lat: 36.2, lng: -115.1 }, // Las Vegas
"northamerica-northeast1": { lat: 45.5, lng: -73.6 }, // Montreal
"northamerica-northeast2": { lat: 43.7, lng: -79.4 }, // Toronto
"southamerica-east1": { lat: -23.5, lng: -46.6 }, // São Paulo
"southamerica-west1": { lat: -33.4, lng: -70.6 }, // Santiago
"europe-north1": { lat: 60.6, lng: 27.0 }, // Finland
"europe-west1": { lat: 50.4, lng: 3.8 }, // Belgium
"europe-west2": { lat: 51.5, lng: -0.1 }, // London
"europe-west3": { lat: 50.1, lng: 8.7 }, // Frankfurt
"europe-west4": { lat: 53.4, lng: 6.8 }, // Netherlands
"europe-west6": { lat: 47.4, lng: 8.5 }, // Zurich
"europe-west8": { lat: 45.5, lng: 9.2 }, // Milan
"europe-west9": { lat: 48.9, lng: 2.3 }, // Paris
"europe-west10": { lat: 52.5, lng: 13.4 }, // Berlin
"europe-west12": { lat: 45.0, lng: 7.7 }, // Turin
"europe-central2": { lat: 52.2, lng: 21.0 }, // Warsaw
"europe-southwest1": { lat: 40.4, lng: -3.7 }, // Madrid
"asia-east1": { lat: 24.0, lng: 121.0 }, // Taiwan
"asia-east2": { lat: 22.3, lng: 114.2 }, // Hong Kong
"asia-northeast1": { lat: 35.7, lng: 139.7 }, // Tokyo
"asia-northeast2": { lat: 34.7, lng: 135.5 }, // Osaka
"asia-northeast3": { lat: 37.6, lng: 127.0 }, // Seoul
"asia-south1": { lat: 19.1, lng: 72.9 }, // Mumbai
"asia-south2": { lat: 28.6, lng: 77.2 }, // Delhi
"asia-southeast1": { lat: 1.4, lng: 103.8 }, // Singapore
"asia-southeast2": { lat: -6.2, lng: 106.8 }, // Jakarta
"australia-southeast1": { lat: -33.9, lng: 151.2 }, // Sydney
"australia-southeast2": { lat: -37.8, lng: 145.0 }, // Melbourne
"me-central1": { lat: 25.3, lng: 51.5 }, // Doha
"me-central2": { lat: 24.5, lng: 54.4 }, // Dammam
"me-west1": { lat: 32.1, lng: 34.8 }, // Tel Aviv
"africa-south1": { lat: -26.2, lng: 28.0 }, // Johannesburg
};
const PROVIDER_COORDINATES: Record<
string,
Record<string, { lat: number; lng: number }>
> = {
aws: AWS_REGION_COORDINATES,
azure: AZURE_REGION_COORDINATES,
gcp: GCP_REGION_COORDINATES,
};
// Returns [lng, lat] format for D3/GeoJSON compatibility
function getRegionCoordinates(
providerType: string,
region: string,
): [number, number] | null {
const coords =
PROVIDER_COORDINATES[providerType.toLowerCase()]?.[region.toLowerCase()];
return coords ? [coords.lng, coords.lat] : null;
}
function getRiskLevel(failRate: number): "low-high" | "high" | "critical" {
if (failRate >= 0.5) return "critical";
if (failRate >= 0.25) return "high";
return "low-high";
}
// CSS variables are used for Recharts inline styles, not className
function buildSeverityData(fail: number, pass: number) {
const total = fail + pass;
const pct = (value: number) =>
total > 0 ? Math.round((value / total) * 100) : 0;
return [
{
name: "Fail",
value: fail,
percentage: pct(fail),
color: "var(--color-bg-fail)",
},
{
name: "Pass",
value: pass,
percentage: pct(pass),
color: "var(--color-bg-pass)",
},
];
}
// Formats "europe-west10" → "Europe West 10"
function formatRegionCode(region: string): string {
return region
.split(/[-_]/)
.map((part) => {
const match = part.match(/^([a-zA-Z]+)(\d+)$/);
if (match) {
const [, text, number] = match;
return `${text.charAt(0).toUpperCase()}${text.slice(1).toLowerCase()} ${number}`;
}
return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
})
.join(" ");
}
function formatRegionName(providerType: string, region: string): string {
return `${getProviderDisplayName(providerType)} - ${formatRegionCode(region)}`;
}
/**
* Adapts regions overview API response to threat map format.
*/
export function adaptRegionsOverviewToThreatMap(
regionsResponse: RegionsOverviewResponse | undefined,
): ThreatMapData {
if (!regionsResponse?.data || regionsResponse.data.length === 0) {
return {
locations: [],
regions: [],
};
}
const locations: ThreatMapLocation[] = [];
const regionSet = new Set<string>();
for (const regionData of regionsResponse.data) {
const { id, attributes } = regionData;
const coordinates = getRegionCoordinates(
attributes.provider_type,
attributes.region,
);
if (!coordinates) continue;
const providerRegion = getProviderDisplayName(attributes.provider_type);
regionSet.add(providerRegion);
const failRate =
attributes.total > 0 ? attributes.fail / attributes.total : 0;
locations.push({
id,
name: formatRegionName(attributes.provider_type, attributes.region),
region: providerRegion,
regionCode: attributes.region,
providerType: attributes.provider_type,
coordinates,
totalFindings: attributes.fail,
riskLevel: getRiskLevel(failRate),
severityData: buildSeverityData(attributes.fail, attributes.pass),
});
}
return {
locations,
regions: Array.from(regionSet).sort(),
};
}
+5
View File
@@ -0,0 +1,5 @@
// Common types shared across overview endpoints
export interface OverviewResponseMeta {
version: string;
}
@@ -0,0 +1,23 @@
// Findings Severity Overview Types
// Corresponds to the /overviews/findings_severity endpoint
import { OverviewResponseMeta } from "./common";
export interface FindingsSeverityAttributes {
critical: number;
high: number;
medium: number;
low: number;
informational: number;
}
export interface FindingsSeverityOverview {
type: "findings-severity-overview";
id: string;
attributes: FindingsSeverityAttributes;
}
export interface FindingsSeverityOverviewResponse {
data: FindingsSeverityOverview;
meta: OverviewResponseMeta;
}
+6
View File
@@ -0,0 +1,6 @@
export * from "./common";
export * from "./findings-severity";
export * from "./providers";
export * from "./regions";
export * from "./services";
export * from "./threat-score";
+31
View File
@@ -0,0 +1,31 @@
// Providers Overview Types
// Corresponds to the /overviews/providers endpoint
import { OverviewResponseMeta } from "./common";
export interface ProviderOverviewFindings {
pass: number;
fail: number;
muted: number;
total: number;
}
export interface ProviderOverviewResources {
total: number;
}
export interface ProviderOverviewAttributes {
findings: ProviderOverviewFindings;
resources: ProviderOverviewResources;
}
export interface ProviderOverview {
type: "providers-overview";
id: string;
attributes: ProviderOverviewAttributes;
}
export interface ProvidersOverviewResponse {
data: ProviderOverview[];
meta: OverviewResponseMeta;
}
+24
View File
@@ -0,0 +1,24 @@
// Regions Overview Types
// Corresponds to the /overviews/regions endpoint
import { OverviewResponseMeta } from "./common";
export interface RegionOverviewAttributes {
provider_type: string;
region: string;
total: number;
fail: number;
muted: number;
pass: number;
}
export interface RegionOverview {
type: "regions-overview";
id: string;
attributes: RegionOverviewAttributes;
}
export interface RegionsOverviewResponse {
data: RegionOverview[];
meta: OverviewResponseMeta;
}
+22
View File
@@ -0,0 +1,22 @@
// Services Overview Types
// Corresponds to the /overviews/services endpoint
import { OverviewResponseMeta } from "./common";
export interface ServiceOverviewAttributes {
total: number;
fail: number;
muted: number;
pass: number;
}
export interface ServiceOverview {
type: "services-overview";
id: string;
attributes: ServiceOverviewAttributes;
}
export interface ServicesOverviewResponse {
data: ServiceOverview[];
meta: OverviewResponseMeta;
}
+1 -18
View File
@@ -39,26 +39,9 @@ export const getProviders = async ({
headers,
});
const result = (await handleApiResponse(response)) as
return (await handleApiResponse(response)) as
| ProvidersApiResponse
| undefined;
if (result?.data) {
// Filter out providers with provider type containing "mongo"
result.data = result.data.filter(
(provider) =>
!provider.attributes?.provider?.toLowerCase().includes("mongo"),
);
// Also filter out mongo-related included items if present
if (result.included) {
result.included = result.included.filter(
(item) => !item.type.toLowerCase().includes("mongo"),
);
}
}
return result;
} catch (error) {
console.error("Error fetching providers:", error);
return undefined;
+1 -45
View File
@@ -9,47 +9,6 @@ import {
} from "@/lib/compliance/compliance-report-types";
import { addScanOperation } from "@/lib/sentry-breadcrumbs";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import { ScansApiResponse } from "@/types";
const filterMongoScans = (result: ScansApiResponse | undefined) => {
if (!result?.data) return result;
const included = result.included || [];
// Get IDs of providers containing "mongo"
const mongoProviderIds = new Set(
included
.filter(
(item) =>
item.type === "providers" &&
item.attributes?.provider?.toLowerCase().includes("mongo"),
)
.map((item) => item.id),
);
// If no mongo providers found, return as-is
if (mongoProviderIds.size === 0) return result;
// Filter out scans associated with mongo providers
result.data = result.data.filter((scan) => {
const providerId = scan.relationships?.provider?.data?.id;
return !providerId || !mongoProviderIds.has(providerId);
});
// Filter out mongo-related included items
if (result.included) {
result.included = included.filter(
(item) =>
!(
item.type === "providers" &&
item.attributes?.provider?.toLowerCase().includes("mongo")
),
);
}
return result;
};
export const getScans = async ({
page = 1,
query = "",
@@ -84,10 +43,7 @@ export const getScans = async ({
try {
const response = await fetch(url.toString(), { headers });
const result = await handleApiResponse(response);
// Filter out mongo-related scans when provider is included
return filterMongoScans(result);
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching scans:", error);
return undefined;
@@ -11,6 +11,7 @@ import {
IacProviderBadge,
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OracleCloudProviderBadge,
} from "@/components/icons/providers-badge";
import {
@@ -31,6 +32,7 @@ const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
github: <GitHubProviderBadge width={18} height={18} />,
iac: <IacProviderBadge width={18} height={18} />,
oraclecloud: <OracleCloudProviderBadge width={18} height={18} />,
mongodbatlas: <MongoDBAtlasProviderBadge width={18} height={18} />,
};
interface AccountsSelectorProps {
@@ -126,23 +128,41 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
</MultiSelectTrigger>
<MultiSelectContent search={false}>
{visibleProviders.length > 0 ? (
visibleProviders.map((p) => {
const id = p.id;
const displayName = p.attributes.alias || p.attributes.uid;
const providerType = p.attributes.provider as ProviderType;
const icon = PROVIDER_ICON[providerType];
return (
<MultiSelectItem
key={id}
value={id}
badgeLabel={displayName}
aria-label={`${displayName} account (${providerType.toUpperCase()})`}
>
<span aria-hidden="true">{icon}</span>
<span className="truncate">{displayName}</span>
</MultiSelectItem>
);
})
<>
<div
role="option"
aria-selected={selectedIds.length === 0}
aria-label="Select all accounts (clears current selection to show all)"
tabIndex={0}
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 dark:hover:bg-slate-700/50"
onClick={() => handleMultiValueChange([])}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleMultiValueChange([]);
}
}}
>
Select All
</div>
{visibleProviders.map((p) => {
const id = p.id;
const displayName = p.attributes.alias || p.attributes.uid;
const providerType = p.attributes.provider as ProviderType;
const icon = PROVIDER_ICON[providerType];
return (
<MultiSelectItem
key={id}
value={id}
badgeLabel={displayName}
aria-label={`${displayName} account (${providerType.toUpperCase()})`}
>
<span aria-hidden="true">{icon}</span>
<span className="truncate">{displayName}</span>
</MultiSelectItem>
);
})}
</>
) : (
<div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
{selectedTypesList.length > 0
@@ -1,5 +1,4 @@
import { getFindingsByStatus } from "@/actions/overview/overview";
import { getProviders } from "@/actions/providers";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
@@ -12,10 +11,7 @@ export const CheckFindingsSSR = async ({
}) => {
const filters = pickFilterParams(searchParams);
const [findingsByStatus, providersData] = await Promise.all([
getFindingsByStatus({ filters }),
getProviders({ page: 1, pageSize: 200 }),
]);
const findingsByStatus = await getFindingsByStatus({ filters });
if (!findingsByStatus) {
return (
@@ -39,7 +35,6 @@ export const CheckFindingsSSR = async ({
total: pass,
new: pass_new,
}}
providers={providersData?.data}
/>
);
};
@@ -34,7 +34,7 @@ export const FindingSeverityOverTimeSSR = async ({
}
return (
<Card variant="base" className="flex h-full flex-col">
<Card variant="base" className="flex h-full flex-1 flex-col">
<CardHeader className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<CardTitle>Finding Severity Over Time</CardTitle>
@@ -6,6 +6,7 @@ import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends
import { LineChart } from "@/components/graphs/line-chart";
import { LineConfig, LineDataPoint } from "@/components/graphs/types";
import { Skeleton } from "@/components/shadcn";
import { SEVERITY_LINE_CONFIGS } from "@/types/severities";
import { type TimeRange, TimeRangeSelector } from "./time-range-selector";
@@ -70,36 +71,10 @@ export const FindingSeverityOverTime = ({
};
});
// Define line configurations for each severity level
const lines: LineConfig[] = [
{
dataKey: "informational",
color: "var(--color-bg-data-info)",
label: "Informational",
},
{
dataKey: "low",
color: "var(--color-bg-data-low)",
label: "Low",
},
{
dataKey: "medium",
color: "var(--color-bg-data-medium)",
label: "Medium",
},
{
dataKey: "high",
color: "var(--color-bg-data-high)",
label: "High",
},
{
dataKey: "critical",
color: "var(--color-bg-data-critical)",
label: "Critical",
},
];
// Build line configurations from shared severity configs
const lines: LineConfig[] = [...SEVERITY_LINE_CONFIGS];
// Only add muted line if data contains it
// Only add muted line if data contains it (CSS var for Recharts inline styles)
if (data.some((item) => item.muted !== undefined)) {
lines.push({
dataKey: "muted",
@@ -0,0 +1,105 @@
"use server";
import { Spacer } from "@heroui/spacer";
import { getLatestFindings } from "@/actions/findings/findings";
import { LinkToFindings } from "@/components/overview";
import { ColumnNewFindingsToDate } from "@/components/overview/new-findings-table/table/column-new-findings-to-date";
import { DataTable } from "@/components/ui/table";
import { createDict } from "@/lib/helper";
import { FindingProps, SearchParamsProps } from "@/types";
import { LighthouseBanner } from "../../../../../../components/lighthouse/banner";
const FILTER_PREFIX = "filter[";
function pickFilterParams(
params: SearchParamsProps | undefined | null,
): Record<string, string | string[] | undefined> {
if (!params) return {};
return Object.fromEntries(
Object.entries(params).filter(([key]) => key.startsWith(FILTER_PREFIX)),
);
}
interface FindingsViewSSRProps {
searchParams: SearchParamsProps;
}
export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) {
const page = 1;
const sort = "severity,-inserted_at";
const defaultFilters = {
"filter[status]": "FAIL",
"filter[delta]": "new",
};
const filters = pickFilterParams(searchParams);
// Map provider_id__in to provider__in for findings API
const mappedFilters = { ...filters };
if (mappedFilters["filter[provider_id__in]"]) {
mappedFilters["filter[provider__in]"] =
mappedFilters["filter[provider_id__in]"];
delete mappedFilters["filter[provider_id__in]"];
}
const combinedFilters = { ...defaultFilters, ...mappedFilters };
const findingsData = await getLatestFindings({
query: undefined,
page,
sort,
filters: combinedFilters,
});
const resourceDict = createDict("resources", findingsData);
const scanDict = createDict("scans", findingsData);
const providerDict = createDict("providers", findingsData);
const expandedFindings = findingsData?.data
? (findingsData.data as FindingProps[]).map((finding) => {
const scan = scanDict[finding.relationships?.scan?.data?.id];
const resource =
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
const provider = providerDict[scan?.relationships?.provider?.data?.id];
return {
...finding,
relationships: { scan, resource, provider },
};
})
: [];
const expandedResponse = {
...findingsData,
data: expandedFindings,
};
return (
<div className="flex w-full flex-col">
<LighthouseBanner />
<div className="relative flex w-full">
<div className="flex w-full items-center gap-2">
<h3 className="text-sm font-bold uppercase">
Latest new failing findings
</h3>
<p className="text-text-neutral-tertiary text-xs">
Showing the latest 10 new failing findings by severity.
</p>
</div>
<div className="absolute -top-6 right-0">
<LinkToFindings />
</div>
</div>
<Spacer y={4} />
<DataTable
key={`dashboard-findings-${Date.now()}`}
columns={ColumnNewFindingsToDate}
data={(expandedResponse?.data || []) as FindingProps[]}
/>
</div>
);
}
@@ -0,0 +1 @@
export { FindingsViewSSR } from "./findings-view.ssr";
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn";
@@ -11,10 +11,19 @@ interface GraphsTabsClientProps {
}
export const GraphsTabsClient = ({ tabsContent }: GraphsTabsClientProps) => {
const [activeTab, setActiveTab] = useState<TabId>("threat-map");
const [activeTab, setActiveTab] = useState<TabId>("findings");
const contentRef = useRef<HTMLDivElement>(null);
const handleValueChange = (value: string) => {
setActiveTab(value as TabId);
// Scroll to the end of the tab content after a short delay for render
setTimeout(() => {
contentRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
});
}, 100);
};
return (
@@ -35,17 +44,19 @@ export const GraphsTabsClient = ({ tabsContent }: GraphsTabsClientProps) => {
))}
</TabsList>
{GRAPH_TABS.map((tab) =>
activeTab === tab.id ? (
<TabsContent
key={tab.id}
value={tab.id}
className="mt-4 flex flex-1 overflow-visible"
>
{tabsContent[tab.id]}
</TabsContent>
) : null,
)}
<div ref={contentRef}>
{GRAPH_TABS.map((tab) =>
activeTab === tab.id ? (
<TabsContent
key={tab.id}
value={tab.id}
className="mt-10 flex flex-1 overflow-visible"
>
{tabsContent[tab.id]}
</TabsContent>
) : null,
)}
</div>
</Tabs>
);
};
@@ -1,20 +1,25 @@
export const GRAPH_TABS = [
{
id: "threat-map",
label: "Threat Map",
},
{
id: "risk-radar",
label: "Risk Radar",
id: "findings",
label: "New Findings",
},
{
id: "risk-pipeline",
label: "Risk Pipeline",
},
{
id: "risk-plot",
label: "Risk Plot",
id: "threat-map",
label: "Threat Map",
},
// TODO: Uncomment when ready to enable other tabs
// {
// id: "risk-radar",
// label: "Risk Radar",
// },
// {
// id: "risk-plot",
// label: "Risk Plot",
// },
] as const;
export type TabId = (typeof GRAPH_TABS)[number]["id"];
@@ -3,39 +3,31 @@ import { Suspense } from "react";
import { SearchParamsProps } from "@/types";
import { FindingsViewSSR } from "./findings-view";
import { GraphsTabsClient } from "./graphs-tabs-client";
import { GRAPH_TABS, type TabId } from "./graphs-tabs-config";
import { RiskPipelineViewSSR } from "./risk-pipeline-view/risk-pipeline-view.ssr";
import { RiskPlotView } from "./risk-plot/risk-plot-view";
import { RiskRadarViewSSR } from "./risk-radar-view/risk-radar-view.ssr";
import { ThreatMapViewSSR } from "./threat-map-view/threat-map-view.ssr";
// TODO: Uncomment when ready to enable other tabs
// import { RiskPlotView } from "./risk-plot/risk-plot-view";
// import { RiskRadarViewSSR } from "./risk-radar-view/risk-radar-view.ssr";
const LoadingFallback = () => (
<div
className="flex w-full flex-col space-y-4 rounded-lg border p-4"
style={{
borderColor: "var(--border-neutral-primary)",
backgroundColor: "var(--bg-neutral-secondary)",
}}
>
<Skeleton
className="h-6 w-1/3 rounded"
style={{ backgroundColor: "var(--bg-neutral-tertiary)" }}
/>
<Skeleton
className="h-[457px] w-full rounded"
style={{ backgroundColor: "var(--bg-neutral-tertiary)" }}
/>
<div className="border-border-neutral-primary bg-bg-neutral-secondary flex w-full flex-col space-y-4 rounded-lg border p-4">
<Skeleton className="bg-bg-neutral-tertiary h-6 w-1/3 rounded" />
<Skeleton className="bg-bg-neutral-tertiary h-[457px] w-full rounded" />
</div>
);
type GraphComponent = React.ComponentType<{ searchParams: SearchParamsProps }>;
const GRAPH_COMPONENTS: Record<TabId, GraphComponent> = {
"threat-map": ThreatMapViewSSR as GraphComponent,
"risk-radar": RiskRadarViewSSR as GraphComponent,
findings: FindingsViewSSR as GraphComponent,
"risk-pipeline": RiskPipelineViewSSR as GraphComponent,
"risk-plot": RiskPlotView as GraphComponent,
"threat-map": ThreatMapViewSSR as GraphComponent,
// TODO: Uncomment when ready to enable other tabs
// "risk-radar": RiskRadarViewSSR as GraphComponent,
// "risk-plot": RiskPlotView as GraphComponent,
};
interface GraphsTabsWrapperProps {
@@ -0,0 +1,2 @@
export { RiskPipelineViewSSR } from "./risk-pipeline-view.ssr";
export { RiskPipelineViewSkeleton } from "./risk-pipeline-view-skeleton";
@@ -0,0 +1,12 @@
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
export function RiskPipelineViewSkeleton() {
return (
<div className="border-border-neutral-primary bg-bg-neutral-secondary flex h-[460px] w-full flex-col space-y-4 rounded-lg border p-4">
<Skeleton className="h-6 w-1/4 rounded" />
<div className="flex flex-1 items-center justify-center">
<Skeleton className="h-[380px] w-full rounded" />
</div>
</div>
);
}
@@ -1,39 +1,114 @@
import {
adaptProvidersOverviewToSankey,
getFindingsBySeverity,
getProvidersOverview,
SankeyFilters,
} from "@/actions/overview";
import { getProviders } from "@/actions/providers";
import { SankeyChart } from "@/components/graphs/sankey-chart";
import { SearchParamsProps } from "@/types";
// Helper to simulate loading delay
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
import { pickFilterParams } from "../../../lib/filter-params";
// Mock data - replace with actual API call
const mockSankeyData = {
nodes: [
{ name: "AWS" },
{ name: "Azure" },
{ name: "Google Cloud" },
{ name: "Critical" },
{ name: "High" },
{ name: "Medium" },
{ name: "Low" },
],
links: [
{ source: 0, target: 3, value: 45 },
{ source: 0, target: 4, value: 120 },
{ source: 0, target: 5, value: 85 },
{ source: 1, target: 3, value: 28 },
{ source: 1, target: 4, value: 95 },
{ source: 1, target: 5, value: 62 },
{ source: 2, target: 3, value: 18 },
{ source: 2, target: 4, value: 72 },
{ source: 2, target: 5, value: 48 },
],
};
export async function RiskPipelineViewSSR({
searchParams,
}: {
searchParams: SearchParamsProps;
}) {
const filters = pickFilterParams(searchParams);
export async function RiskPipelineViewSSR() {
// TODO: Call server action to fetch sankey chart data
await delay(3000); // Simulating server action fetch time
// Check if any provider/account filter is active
const providerTypeFilter = filters["filter[provider_type__in]"];
const providerIdFilter = filters["filter[provider_id__in]"];
// Fetch data in parallel
const [providersResponse, severityResponse, providersListResponse] =
await Promise.all([
getProvidersOverview({ filters }),
getFindingsBySeverity({ filters }),
// Only fetch providers list if we need to look up account IDs
providerIdFilter && !providerTypeFilter
? getProviders({ pageSize: 200 })
: Promise.resolve(null),
]);
// Determine provider types to show
let providerTypesToShow: string[] | undefined;
if (providerTypeFilter) {
// Provider type filter is set - use it directly
providerTypesToShow = String(providerTypeFilter)
.split(",")
.map((t) => t.trim().toLowerCase());
} else if (providerIdFilter && providersListResponse?.data) {
// Account filter is set - look up provider types from account IDs
const selectedAccountIds = String(providerIdFilter)
.split(",")
.map((id) => id.trim());
const providerTypesSet = new Set<string>();
for (const accountId of selectedAccountIds) {
const provider = providersListResponse.data.find(
(p) => p.id === accountId,
);
if (provider) {
providerTypesSet.add(provider.attributes.provider.toLowerCase());
}
}
providerTypesToShow = Array.from(providerTypesSet);
}
// Build sankey filters
const sankeyFilters: SankeyFilters = {
providerTypes: providerTypesToShow,
allSelectedProviderTypes: providerTypesToShow,
};
const sankeyData = adaptProvidersOverviewToSankey(
providersResponse,
severityResponse,
sankeyFilters,
);
// If no chart data and no zero-data providers, show empty state message
if (
sankeyData.nodes.length === 0 &&
sankeyData.zeroDataProviders.length === 0
) {
return (
<div className="flex h-[460px] w-full items-center justify-center">
<p className="text-text-neutral-tertiary text-sm">
No findings data available for the selected filters
</p>
</div>
);
}
// If no chart data but there are zero-data providers, show only the legend
if (sankeyData.nodes.length === 0) {
return (
<div className="flex h-[460px] w-full items-center justify-center">
<div className="text-center">
<p className="text-text-neutral-tertiary mb-4 text-sm">
No failed findings for the selected accounts
</p>
<SankeyChart
data={sankeyData}
zeroDataProviders={sankeyData.zeroDataProviders}
height={100}
/>
</div>
</div>
);
}
return (
<div className="w-full flex-1 overflow-visible">
<SankeyChart data={mockSankeyData} height={460} />
<SankeyChart
data={sankeyData}
zeroDataProviders={sankeyData.zeroDataProviders}
height={460}
/>
</div>
);
}
@@ -1,98 +1,34 @@
import {
adaptRegionsOverviewToThreatMap,
getRegionsOverview,
} from "@/actions/overview";
import { ThreatMap } from "@/components/graphs/threat-map";
import { SearchParamsProps } from "@/types";
// Mock data - replace with actual API call
const mockThreatMapData = {
locations: [
{
id: "us-east-1",
name: "US East-1",
region: "North America",
coordinates: [-75.1551, 40.2206] as [number, number],
totalFindings: 455,
riskLevel: "critical" as const,
severityData: [
{ name: "Critical", value: 432 },
{ name: "High", value: 1232 },
{ name: "Medium", value: 221 },
{ name: "Low", value: 543 },
{ name: "Info", value: 10 },
],
change: 5,
},
{
id: "eu-west-1",
name: "EU West-1",
region: "Europe",
coordinates: [-6.2597, 53.3498] as [number, number],
totalFindings: 320,
riskLevel: "high" as const,
severityData: [
{ name: "Critical", value: 200 },
{ name: "High", value: 900 },
{ name: "Medium", value: 180 },
{ name: "Low", value: 400 },
{ name: "Info", value: 15 },
],
change: -2,
},
{
id: "ap-southeast-1",
name: "AP Southeast-1",
region: "Asia Pacific",
coordinates: [103.8198, 1.3521] as [number, number],
totalFindings: 280,
riskLevel: "high" as const,
severityData: [
{ name: "Critical", value: 150 },
{ name: "High", value: 800 },
{ name: "Medium", value: 160 },
{ name: "Low", value: 350 },
{ name: "Info", value: 8 },
],
change: 3,
},
{
id: "ca-central-1",
name: "CA Central-1",
region: "North America",
coordinates: [-95.7129, 56.1304] as [number, number],
totalFindings: 190,
riskLevel: "high" as const,
severityData: [
{ name: "Critical", value: 100 },
{ name: "High", value: 600 },
{ name: "Medium", value: 120 },
{ name: "Low", value: 280 },
{ name: "Info", value: 5 },
],
change: 1,
},
{
id: "ap-northeast-1",
name: "AP Northeast-1",
region: "Asia Pacific",
coordinates: [139.6917, 35.6895] as [number, number],
totalFindings: 240,
riskLevel: "high" as const,
severityData: [
{ name: "Critical", value: 120 },
{ name: "High", value: 700 },
{ name: "Medium", value: 140 },
{ name: "Low", value: 320 },
{ name: "Info", value: 12 },
],
change: 4,
},
],
regions: ["North America", "Europe", "Asia Pacific"],
};
import { pickFilterParams } from "../../../lib/filter-params";
export async function ThreatMapViewSSR() {
// TODO: Call server action to fetch threat map data
export async function ThreatMapViewSSR({
searchParams,
}: {
searchParams: SearchParamsProps;
}) {
const filters = pickFilterParams(searchParams);
const regionsResponse = await getRegionsOverview({ filters });
const threatMapData = adaptRegionsOverviewToThreatMap(regionsResponse);
if (threatMapData.locations.length === 0) {
return (
<div className="flex h-[460px] w-full items-center justify-center">
<p className="text-text-neutral-tertiary text-sm">
No region data available
</p>
</div>
);
}
return (
<div className="w-full flex-1 overflow-hidden">
<ThreatMap data={mockThreatMapData} height={350} />
<ThreatMap data={threatMapData} height={460} />
</div>
);
}

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