mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-06-11 05:46:05 +00:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07e82bde56 | |||
| 4661e01c26 | |||
| dda0a2567d | |||
| 56ea498cca | |||
| f9e1e29631 | |||
| 3dadb264cc | |||
| 495aee015e | |||
| d3a000cbc4 | |||
| b2abdbeb60 | |||
| dc852b4595 | |||
| 1250f582a5 | |||
| bb43e924ee | |||
| 0225627a98 | |||
| 3097513525 | |||
| 6af9ff4b4b | |||
| 06fa57a949 | |||
| dc9e91ac4e | |||
| 59f8dfe5ae | |||
| 7e0c5540bb | |||
| 79ec53bfc5 | |||
| ed5f6b3af6 | |||
| 6e135abaa0 | |||
| 65b054f798 | |||
| 28d5b2bb6c | |||
| c8d9f37e70 | |||
| 9d7b9c3327 | |||
| 127b8d8e56 | |||
| 4e9dd46a5e | |||
| 880345bebe | |||
| 1259713fd6 | |||
| 26088868a2 | |||
| e58574e2a4 | |||
| a07e599cfc | |||
| e020b3f74b | |||
| 8e7e376e4f | |||
| a63a3d3f68 | |||
| 10838de636 | |||
| 5ebf455e04 | |||
| 0d59441c5f |
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
Generated
+4
-9
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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}"}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||

|
||||

|
||||
|
||||
!!! 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)
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
### 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"
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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**.
|
||||
|
||||

|
||||
|
||||
2. Navigate to "Applications" > "App registrations"
|
||||
2. Navigate to "Applications" > "App registrations".
|
||||
|
||||

|
||||
|
||||
3. Click "+ New registration", complete the form, and click "Register"
|
||||
3. Click "+ New registration", complete the form, and click "Register".
|
||||
|
||||

|
||||
|
||||
4. Go to "Certificates & secrets" > "Client secrets" > "+ New client secret"
|
||||
4. Go to "Certificates & secrets" > "Client secrets" > "+ New client secret".
|
||||
|
||||

|
||||
|
||||
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`).
|
||||
|
||||

|
||||
|
||||
#### 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.
|
||||
|
||||

|
||||
|
||||
2. Click "+ Add a permission" > "Microsoft Graph" > "Application permissions"
|
||||
2. Click "+ Add a permission" > "Microsoft Graph" > "Application permissions".
|
||||
|
||||

|
||||
|
||||
@@ -97,38 +112,39 @@ Browser and Azure CLI authentication methods limit scanning capabilities to chec
|
||||
|
||||

|
||||
|
||||
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**.
|
||||
|
||||

|
||||
|
||||
- Select "Exchange.ManageAsApp" permission and click "Add permissions"
|
||||
- Select "Exchange.ManageAsApp" permission and click "Add permissions".
|
||||
|
||||

|
||||
|
||||
- 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.
|
||||
|
||||

|
||||
|
||||
- Search for `Global Reader` and assign it to your application
|
||||
- Search for `Global Reader` and assign it to the application.
|
||||
|
||||

|
||||
|
||||
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**.
|
||||
|
||||

|
||||
|
||||
- Select "application_access" permission and click "Add permissions"
|
||||
- Select "application_access" permission and click "Add permissions".
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
@@ -136,11 +152,13 @@ Final permissions should look like this:
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
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".
|
||||
|
||||

|
||||
|
||||
3. Click on "Add Cloud Provider"
|
||||
3. Click "Add Cloud Provider".
|
||||
|
||||

|
||||
|
||||
4. Select "Microsoft 365"
|
||||
4. Select "Microsoft 365".
|
||||
|
||||

|
||||
|
||||
5. Add the Domain ID and an optional alias, then click "Next"
|
||||
5. Add the Domain ID and an optional alias, then click "Next".
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
|
||||
+36
@@ -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": ""
|
||||
}
|
||||
+53
@@ -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
|
||||
+36
@@ -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."
|
||||
}
|
||||
+35
@@ -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
|
||||
+36
@@ -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": ""
|
||||
}
|
||||
+29
@@ -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
|
||||
+37
@@ -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": ""
|
||||
}
|
||||
+32
@@ -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):
|
||||
|
||||
+33
@@ -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": ""
|
||||
}
|
||||
|
||||
+41
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
+196
@@ -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
|
||||
|
||||
+317
@@ -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
|
||||
+323
@@ -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
|
||||
+145
@@ -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
|
||||
+394
@@ -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"
|
||||
|
||||
+141
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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 +1,2 @@
|
||||
export * from "./compliances";
|
||||
export * from "./compliances.adapter";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from "./overview";
|
||||
export * from "./sankey.adapter";
|
||||
export * from "./threat-map.adapter";
|
||||
export * from "./types";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./common";
|
||||
export * from "./findings-severity";
|
||||
export * from "./providers";
|
||||
export * from "./regions";
|
||||
export * from "./services";
|
||||
export * from "./threat-score";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+4
-29
@@ -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",
|
||||
|
||||
+105
@@ -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";
|
||||
+12
@@ -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>
|
||||
);
|
||||
}
|
||||
+104
-29
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+25
-89
@@ -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
Reference in New Issue
Block a user