mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 12:31:54 +00:00
Merge branch 'master' into PROWLER-187-create-new-user
This commit is contained in:
@@ -29,6 +29,12 @@ POSTGRES_ADMIN_PASSWORD=postgres
|
||||
POSTGRES_USER=prowler
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_DB=prowler_db
|
||||
# Read replica settings (optional)
|
||||
# POSTGRES_REPLICA_HOST=postgres-db
|
||||
# POSTGRES_REPLICA_PORT=5432
|
||||
# POSTGRES_REPLICA_USER=prowler
|
||||
# POSTGRES_REPLICA_PASSWORD=postgres
|
||||
# POSTGRES_REPLICA_DB=prowler_db
|
||||
|
||||
# Celery-Prowler task settings
|
||||
TASK_RETRY_DELAY_SECONDS=0.1
|
||||
|
||||
@@ -20,4 +20,5 @@ jobs:
|
||||
id: conventional-commit-check
|
||||
uses: agenthunt/conventional-commit-checker-action@9e552d650d0e205553ec7792d447929fc78e012b # v2.0.0
|
||||
with:
|
||||
pr-title-regex: '^([^\s(]+)(?:\(([^)]+)\))?: (.+)'
|
||||
pr-title-regex: '^(feat|fix|docs|style|refactor|perf|test|chore|build|ci|revert)(\([^)]+\))?!?: .+'
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
name: MCP Server - Build and Push containers
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
paths:
|
||||
- "mcp_server/**"
|
||||
- ".github/workflows/mcp-server-build-push-containers.yml"
|
||||
|
||||
# Uncomment the below code to test this action on PRs
|
||||
# pull_request:
|
||||
# branches:
|
||||
# - "master"
|
||||
# paths:
|
||||
# - "mcp_server/**"
|
||||
# - ".github/workflows/mcp-server-build-push-containers.yml"
|
||||
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
env:
|
||||
# Tags
|
||||
LATEST_TAG: latest
|
||||
|
||||
WORKING_DIRECTORY: ./mcp_server
|
||||
|
||||
# Container Registries
|
||||
PROWLERCLOUD_DOCKERHUB_REPOSITORY: prowlercloud
|
||||
PROWLERCLOUD_DOCKERHUB_IMAGE: prowler-mcp
|
||||
|
||||
jobs:
|
||||
repository-check:
|
||||
name: Repository check
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
is_repo: ${{ steps.repository_check.outputs.is_repo }}
|
||||
steps:
|
||||
- name: Repository check
|
||||
id: repository_check
|
||||
working-directory: /tmp
|
||||
run: |
|
||||
if [[ ${{ github.repository }} == "prowler-cloud/prowler" ]]
|
||||
then
|
||||
echo "is_repo=true" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "This action only runs for prowler-cloud/prowler"
|
||||
echo "is_repo=false" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
container-build-push:
|
||||
needs: repository-check
|
||||
if: needs.repository-check.outputs.is_repo == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ env.WORKING_DIRECTORY }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set short git commit SHA
|
||||
id: vars
|
||||
run: |
|
||||
shortSha=$(git rev-parse --short ${{ github.sha }})
|
||||
echo "SHORT_SHA=${shortSha}" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Build and push container image (latest)
|
||||
# Comment the following line for testing
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: ${{ env.WORKING_DIRECTORY }}
|
||||
# Set push: false for testing
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }}
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.SHORT_SHA }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -7,7 +7,11 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
### Added
|
||||
- Default JWT keys are generated and stored if they are missing from configuration [(#8655)](https://github.com/prowler-cloud/prowler/pull/8655)
|
||||
- `compliance_name` for each compliance [(#7920)](https://github.com/prowler-cloud/prowler/pull/7920)
|
||||
- Support for M365 Certificate authentication [(#8538)](https://github.com/prowler-cloud/prowler/pull/8538)
|
||||
- API Key support [(#8805)](https://github.com/prowler-cloud/prowler/pull/8805)
|
||||
- SAML role mapping protection for single-admin tenants to prevent accidental lockout [(#8882)](https://github.com/prowler-cloud/prowler/pull/8882)
|
||||
- Support for `passed_findings` and `total_findings` fields in compliance requirement overview for accurate Prowler ThreatScore calculation [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582)
|
||||
- Database read replica support [(#8869)](https://github.com/prowler-cloud/prowler/pull/8869)
|
||||
|
||||
### Changed
|
||||
- Now the MANAGE_ACCOUNT permission is required to modify or read user permissions instead of MANAGE_USERS [(#8281)](https://github.com/prowler-cloud/prowler/pull/8281)
|
||||
|
||||
@@ -10,6 +10,7 @@ from rest_framework.exceptions import AuthenticationFailed
|
||||
from rest_framework.request import Request
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
|
||||
from api.db_router import MainRouter
|
||||
from api.models import TenantAPIKey, TenantAPIKeyManager
|
||||
|
||||
|
||||
@@ -19,6 +20,22 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
def __init__(self):
|
||||
self.key_crypto = get_crypto()
|
||||
|
||||
def _authenticate_credentials(self, request, key):
|
||||
"""
|
||||
Override to use admin connection, bypassing RLS during authentication.
|
||||
Delegates to parent after temporarily routing model queries to admin DB.
|
||||
"""
|
||||
# Temporarily point the model's manager to admin database
|
||||
original_objects = self.model.objects
|
||||
self.model.objects = self.model.objects.using(MainRouter.admin_db)
|
||||
|
||||
try:
|
||||
# Call parent method which will now use admin database
|
||||
return super()._authenticate_credentials(request, key)
|
||||
finally:
|
||||
# Restore original manager
|
||||
self.model.objects = original_objects
|
||||
|
||||
def authenticate(self, request: Request):
|
||||
prefixed_key = self.get_key(request)
|
||||
|
||||
@@ -43,13 +60,15 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
api_key_pk = UUID(api_key_pk)
|
||||
|
||||
try:
|
||||
api_key_instance = TenantAPIKey.objects.get(id=api_key_pk, prefix=prefix)
|
||||
api_key_instance = TenantAPIKey.objects.using(MainRouter.admin_db).get(
|
||||
id=api_key_pk, prefix=prefix
|
||||
)
|
||||
except TenantAPIKey.DoesNotExist:
|
||||
raise AuthenticationFailed("Invalid API Key.")
|
||||
|
||||
# Update last_used_at
|
||||
api_key_instance.last_used_at = timezone.now()
|
||||
api_key_instance.save(update_fields=["last_used_at"])
|
||||
api_key_instance.save(update_fields=["last_used_at"], using=MainRouter.admin_db)
|
||||
|
||||
return entity, {
|
||||
"tenant_id": str(api_key_instance.tenant_id),
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import transaction
|
||||
from rest_framework import permissions
|
||||
from rest_framework.exceptions import NotAuthenticated
|
||||
from rest_framework.filters import SearchFilter
|
||||
from rest_framework.permissions import SAFE_METHODS
|
||||
from rest_framework_json_api import filters
|
||||
from rest_framework_json_api.views import ModelViewSet
|
||||
|
||||
from api.authentication import CombinedJWTOrAPIKeyAuthentication
|
||||
from api.db_router import MainRouter
|
||||
from api.db_router import MainRouter, reset_read_db_alias, set_read_db_alias
|
||||
from api.db_utils import POSTGRES_USER_VAR, rls_transaction
|
||||
from api.filters import CustomDjangoFilterBackend
|
||||
from api.models import Role, Tenant
|
||||
@@ -31,6 +33,20 @@ class BaseViewSet(ModelViewSet):
|
||||
ordering_fields = "__all__"
|
||||
ordering = ["id"]
|
||||
|
||||
def _get_request_db_alias(self, request):
|
||||
if request is None:
|
||||
return MainRouter.default_db
|
||||
|
||||
read_alias = (
|
||||
MainRouter.replica_db
|
||||
if request.method in SAFE_METHODS
|
||||
and MainRouter.replica_db in settings.DATABASES
|
||||
else None
|
||||
)
|
||||
if read_alias:
|
||||
return read_alias
|
||||
return MainRouter.default_db
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
"""
|
||||
Sets required_permissions before permissions are checked.
|
||||
@@ -48,8 +64,21 @@ class BaseViewSet(ModelViewSet):
|
||||
|
||||
class BaseRLSViewSet(BaseViewSet):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
with transaction.atomic():
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
self.db_alias = self._get_request_db_alias(request)
|
||||
alias_token = None
|
||||
try:
|
||||
if self.db_alias != MainRouter.default_db:
|
||||
alias_token = set_read_db_alias(self.db_alias)
|
||||
|
||||
if request is not None:
|
||||
request.db_alias = self.db_alias
|
||||
|
||||
with transaction.atomic(using=self.db_alias):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
finally:
|
||||
if alias_token is not None:
|
||||
reset_read_db_alias(alias_token)
|
||||
self.db_alias = MainRouter.default_db
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
# Ideally, this logic would be in the `.setup()` method but DRF view sets don't call it
|
||||
@@ -61,7 +90,9 @@ class BaseRLSViewSet(BaseViewSet):
|
||||
if tenant_id is None:
|
||||
raise NotAuthenticated("Tenant ID is not present in token")
|
||||
|
||||
with rls_transaction(tenant_id):
|
||||
with rls_transaction(
|
||||
tenant_id, using=getattr(self, "db_alias", MainRouter.default_db)
|
||||
):
|
||||
self.request.tenant_id = tenant_id
|
||||
return super().initial(request, *args, **kwargs)
|
||||
|
||||
@@ -73,18 +104,33 @@ class BaseRLSViewSet(BaseViewSet):
|
||||
|
||||
class BaseTenantViewset(BaseViewSet):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
with transaction.atomic():
|
||||
tenant = super().dispatch(request, *args, **kwargs)
|
||||
|
||||
self.db_alias = self._get_request_db_alias(request)
|
||||
alias_token = None
|
||||
try:
|
||||
# If the request is a POST, create the admin role
|
||||
if request.method == "POST":
|
||||
isinstance(tenant, dict) and self._create_admin_role(tenant.data["id"])
|
||||
except Exception as e:
|
||||
self._handle_creation_error(e, tenant)
|
||||
raise
|
||||
if self.db_alias != MainRouter.default_db:
|
||||
alias_token = set_read_db_alias(self.db_alias)
|
||||
|
||||
return tenant
|
||||
if request is not None:
|
||||
request.db_alias = self.db_alias
|
||||
|
||||
with transaction.atomic(using=self.db_alias):
|
||||
tenant = super().dispatch(request, *args, **kwargs)
|
||||
|
||||
try:
|
||||
# If the request is a POST, create the admin role
|
||||
if request.method == "POST":
|
||||
isinstance(tenant, dict) and self._create_admin_role(
|
||||
tenant.data["id"]
|
||||
)
|
||||
except Exception as e:
|
||||
self._handle_creation_error(e, tenant)
|
||||
raise
|
||||
|
||||
return tenant
|
||||
finally:
|
||||
if alias_token is not None:
|
||||
reset_read_db_alias(alias_token)
|
||||
self.db_alias = MainRouter.default_db
|
||||
|
||||
def _create_admin_role(self, tenant_id):
|
||||
Role.objects.using(MainRouter.admin_db).create(
|
||||
@@ -117,14 +163,31 @@ class BaseTenantViewset(BaseViewSet):
|
||||
raise NotAuthenticated("Tenant ID is not present in token")
|
||||
|
||||
user_id = str(request.user.id)
|
||||
with rls_transaction(value=user_id, parameter=POSTGRES_USER_VAR):
|
||||
with rls_transaction(
|
||||
value=user_id,
|
||||
parameter=POSTGRES_USER_VAR,
|
||||
using=getattr(self, "db_alias", MainRouter.default_db),
|
||||
):
|
||||
return super().initial(request, *args, **kwargs)
|
||||
|
||||
|
||||
class BaseUserViewset(BaseViewSet):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
with transaction.atomic():
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
self.db_alias = self._get_request_db_alias(request)
|
||||
alias_token = None
|
||||
try:
|
||||
if self.db_alias != MainRouter.default_db:
|
||||
alias_token = set_read_db_alias(self.db_alias)
|
||||
|
||||
if request is not None:
|
||||
request.db_alias = self.db_alias
|
||||
|
||||
with transaction.atomic(using=self.db_alias):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
finally:
|
||||
if alias_token is not None:
|
||||
reset_read_db_alias(alias_token)
|
||||
self.db_alias = MainRouter.default_db
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
# TODO refactor after improving RLS on users
|
||||
@@ -137,6 +200,8 @@ class BaseUserViewset(BaseViewSet):
|
||||
if tenant_id is None:
|
||||
raise NotAuthenticated("Tenant ID is not present in token")
|
||||
|
||||
with rls_transaction(tenant_id):
|
||||
with rls_transaction(
|
||||
tenant_id, using=getattr(self, "db_alias", MainRouter.default_db)
|
||||
):
|
||||
self.request.tenant_id = tenant_id
|
||||
return super().initial(request, *args, **kwargs)
|
||||
|
||||
@@ -150,12 +150,16 @@ def generate_scan_compliance(
|
||||
requirement["checks"][check_id] = status
|
||||
requirement["checks_status"][status.lower()] += 1
|
||||
|
||||
if requirement["status"] != "FAIL" and any(
|
||||
value == "FAIL" for value in requirement["checks"].values()
|
||||
):
|
||||
requirement["status"] = "FAIL"
|
||||
compliance_overview[compliance_id]["requirements_status"]["passed"] -= 1
|
||||
compliance_overview[compliance_id]["requirements_status"]["failed"] += 1
|
||||
if requirement["status"] != "FAIL" and any(
|
||||
value == "FAIL" for value in requirement["checks"].values()
|
||||
):
|
||||
requirement["status"] = "FAIL"
|
||||
compliance_overview[compliance_id]["requirements_status"][
|
||||
"passed"
|
||||
] -= 1
|
||||
compliance_overview[compliance_id]["requirements_status"][
|
||||
"failed"
|
||||
] += 1
|
||||
|
||||
|
||||
def generate_compliance_overview_template(prowler_compliance: dict):
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
from contextvars import ContextVar
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
ALLOWED_APPS = ("django", "socialaccount", "account", "authtoken", "silk")
|
||||
|
||||
_read_db_alias = ContextVar("read_db_alias", default=None)
|
||||
|
||||
|
||||
def set_read_db_alias(alias: str | None):
|
||||
if not alias:
|
||||
return None
|
||||
return _read_db_alias.set(alias)
|
||||
|
||||
|
||||
def get_read_db_alias() -> str | None:
|
||||
return _read_db_alias.get()
|
||||
|
||||
|
||||
def reset_read_db_alias(token) -> None:
|
||||
if token is not None:
|
||||
_read_db_alias.reset(token)
|
||||
|
||||
|
||||
class MainRouter:
|
||||
default_db = "default"
|
||||
admin_db = "admin"
|
||||
replica_db = "replica"
|
||||
|
||||
def db_for_read(self, model, **hints): # noqa: F841
|
||||
model_table_name = model._meta.db_table
|
||||
@@ -11,6 +33,9 @@ class MainRouter:
|
||||
model_table_name.startswith(f"{app}_") for app in ALLOWED_APPS
|
||||
):
|
||||
return self.admin_db
|
||||
read_alias = get_read_db_alias()
|
||||
if read_alias:
|
||||
return read_alias
|
||||
return None
|
||||
|
||||
def db_for_write(self, model, **hints): # noqa: F841
|
||||
@@ -27,3 +52,8 @@ class MainRouter:
|
||||
if {obj1._state.db, obj2._state.db} <= {self.default_db, self.admin_db}:
|
||||
return True
|
||||
return None
|
||||
|
||||
|
||||
READ_REPLICA_ALIAS = (
|
||||
MainRouter.replica_db if MainRouter.replica_db in settings.DATABASES else None
|
||||
)
|
||||
|
||||
@@ -6,12 +6,14 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import BaseUserManager
|
||||
from django.db import connection, models, transaction
|
||||
from django.db import DEFAULT_DB_ALIAS, connection, connections, models, transaction
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
from psycopg2 import connect as psycopg2_connect
|
||||
from psycopg2.extensions import AsIs, new_type, register_adapter, register_type
|
||||
from rest_framework_json_api.serializers import ValidationError
|
||||
|
||||
from api.db_router import get_read_db_alias, reset_read_db_alias, set_read_db_alias
|
||||
|
||||
DB_USER = settings.DATABASES["default"]["USER"] if not settings.TESTING else "test"
|
||||
DB_PASSWORD = (
|
||||
settings.DATABASES["default"]["PASSWORD"] if not settings.TESTING else "test"
|
||||
@@ -49,7 +51,11 @@ def psycopg_connection(database_alias: str):
|
||||
|
||||
|
||||
@contextmanager
|
||||
def rls_transaction(value: str, parameter: str = POSTGRES_TENANT_VAR):
|
||||
def rls_transaction(
|
||||
value: str,
|
||||
parameter: str = POSTGRES_TENANT_VAR,
|
||||
using: str | None = None,
|
||||
):
|
||||
"""
|
||||
Creates a new database transaction setting the given configuration value for Postgres RLS. It validates the
|
||||
if the value is a valid UUID.
|
||||
@@ -57,16 +63,32 @@ def rls_transaction(value: str, parameter: str = POSTGRES_TENANT_VAR):
|
||||
Args:
|
||||
value (str): Database configuration parameter value.
|
||||
parameter (str): Database configuration parameter name, by default is 'api.tenant_id'.
|
||||
using (str | None): Optional database alias to run the transaction against. Defaults to the
|
||||
active read alias (if any) or Django's default connection.
|
||||
"""
|
||||
with transaction.atomic():
|
||||
with connection.cursor() as cursor:
|
||||
try:
|
||||
# just in case the value is a UUID object
|
||||
uuid.UUID(str(value))
|
||||
except ValueError:
|
||||
raise ValidationError("Must be a valid UUID")
|
||||
cursor.execute(SET_CONFIG_QUERY, [parameter, value])
|
||||
yield cursor
|
||||
requested_alias = using or get_read_db_alias()
|
||||
db_alias = requested_alias or DEFAULT_DB_ALIAS
|
||||
if db_alias not in connections:
|
||||
db_alias = DEFAULT_DB_ALIAS
|
||||
|
||||
router_token = None
|
||||
try:
|
||||
if db_alias != DEFAULT_DB_ALIAS:
|
||||
router_token = set_read_db_alias(db_alias)
|
||||
|
||||
with transaction.atomic(using=db_alias):
|
||||
conn = connections[db_alias]
|
||||
with conn.cursor() as cursor:
|
||||
try:
|
||||
# just in case the value is a UUID object
|
||||
uuid.UUID(str(value))
|
||||
except ValueError:
|
||||
raise ValidationError("Must be a valid UUID")
|
||||
cursor.execute(SET_CONFIG_QUERY, [parameter, value])
|
||||
yield cursor
|
||||
finally:
|
||||
if router_token is not None:
|
||||
reset_read_db_alias(router_token)
|
||||
|
||||
|
||||
class CustomUserManager(BaseUserManager):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import uuid
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import drf_simple_apikey.models
|
||||
from django.conf import settings
|
||||
@@ -20,7 +21,13 @@ class Migration(migrations.Migration):
|
||||
migrations.CreateModel(
|
||||
name="TenantAPIKey",
|
||||
fields=[
|
||||
("name", models.CharField(blank=True, max_length=255, null=True)),
|
||||
(
|
||||
"name",
|
||||
models.CharField(
|
||||
max_length=255,
|
||||
validators=[django.core.validators.MinLengthValidator(3)],
|
||||
),
|
||||
),
|
||||
(
|
||||
"expiry_date",
|
||||
models.DateTimeField(
|
||||
@@ -110,7 +117,11 @@ class Migration(migrations.Migration):
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("tenant_id", "prefix"), name="unique_api_key_prefixes"
|
||||
)
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=("tenant_id", "name"),
|
||||
name="unique_api_key_name_per_tenant",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.1.12 on 2025-10-07 10:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0048_api_key"),
|
||||
]
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="compliancerequirementoverview",
|
||||
name="passed_findings",
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="compliancerequirementoverview",
|
||||
name="total_findings",
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
]
|
||||
@@ -220,6 +220,7 @@ class Membership(models.Model):
|
||||
|
||||
class TenantAPIKey(AbstractAPIKey, RowLevelSecurityProtectedModel):
|
||||
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
name = models.CharField(max_length=100, validators=[MinLengthValidator(3)])
|
||||
prefix = models.CharField(
|
||||
max_length=11,
|
||||
unique=True,
|
||||
@@ -255,6 +256,10 @@ class TenantAPIKey(AbstractAPIKey, RowLevelSecurityProtectedModel):
|
||||
fields=("tenant_id", "prefix"),
|
||||
name="unique_api_key_prefixes",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=("tenant_id", "name"),
|
||||
name="unique_api_key_name_per_tenant",
|
||||
),
|
||||
]
|
||||
|
||||
indexes = [
|
||||
@@ -1293,6 +1298,8 @@ class ComplianceRequirementOverview(RowLevelSecurityProtectedModel):
|
||||
passed_checks = models.IntegerField(default=0)
|
||||
failed_checks = models.IntegerField(default=0)
|
||||
total_checks = models.IntegerField(default=0)
|
||||
passed_findings = models.IntegerField(default=0)
|
||||
total_findings = models.IntegerField(default=0)
|
||||
|
||||
scan = models.ForeignKey(
|
||||
Scan,
|
||||
|
||||
@@ -11,11 +11,12 @@ class APIJSONRenderer(JSONRenderer):
|
||||
def render(self, data, accepted_media_type=None, renderer_context=None):
|
||||
request = renderer_context.get("request")
|
||||
tenant_id = getattr(request, "tenant_id", None) if request else None
|
||||
db_alias = getattr(request, "db_alias", None) if request else None
|
||||
include_param_present = "include" in request.query_params if request else False
|
||||
|
||||
# Use rls_transaction if needed for included resources, otherwise do nothing
|
||||
context_manager = (
|
||||
rls_transaction(tenant_id)
|
||||
rls_transaction(tenant_id, using=db_alias)
|
||||
if tenant_id and include_param_present
|
||||
else nullcontext()
|
||||
)
|
||||
|
||||
@@ -86,6 +86,17 @@ paths:
|
||||
description: A search term.
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: include
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- entity
|
||||
description: include query parameter to allow the client to customize which
|
||||
related resources should be returned.
|
||||
explode: false
|
||||
- name: page[number]
|
||||
required: false
|
||||
in: query
|
||||
@@ -186,6 +197,17 @@ paths:
|
||||
format: uuid
|
||||
description: A UUID string identifying this tenant api key.
|
||||
required: true
|
||||
- in: query
|
||||
name: include
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- entity
|
||||
description: include query parameter to allow the client to customize which
|
||||
related resources should be returned.
|
||||
explode: false
|
||||
tags:
|
||||
- API Keys
|
||||
security:
|
||||
@@ -4706,6 +4728,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 4c1e219dad1cc0e7
|
||||
enum:
|
||||
- aws
|
||||
- azure
|
||||
@@ -11995,6 +12018,33 @@ components:
|
||||
type: string
|
||||
description: The Azure tenant ID, representing the directory
|
||||
where the application is registered.
|
||||
user:
|
||||
type: email
|
||||
description: 'Deprecated: User microsoft email address.'
|
||||
password:
|
||||
type: string
|
||||
description: 'Deprecated: User password.'
|
||||
required:
|
||||
- client_id
|
||||
- client_secret
|
||||
- tenant_id
|
||||
- user
|
||||
- password
|
||||
- type: object
|
||||
title: M365 Certificate Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
description: The Azure application (client) ID for authentication
|
||||
in Azure AD.
|
||||
tenant_id:
|
||||
type: string
|
||||
description: The Azure tenant ID, representing the directory
|
||||
where the application is registered.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: The certificate content in base64 format for
|
||||
certificate-based authentication.
|
||||
user:
|
||||
type: email
|
||||
description: User microsoft email address.
|
||||
@@ -12003,8 +12053,8 @@ components:
|
||||
description: User password.
|
||||
required:
|
||||
- client_id
|
||||
- client_secret
|
||||
- tenant_id
|
||||
- certificate_content
|
||||
- user
|
||||
- password
|
||||
- type: object
|
||||
@@ -12410,8 +12460,8 @@ components:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 255
|
||||
minLength: 3
|
||||
maxLength: 100
|
||||
prefix:
|
||||
type: string
|
||||
readOnly: true
|
||||
@@ -12431,6 +12481,8 @@ components:
|
||||
readOnly: true
|
||||
nullable: true
|
||||
description: Last time this API key was used for authentication
|
||||
required:
|
||||
- name
|
||||
relationships:
|
||||
type: object
|
||||
properties:
|
||||
@@ -13859,6 +13911,33 @@ components:
|
||||
type: string
|
||||
description: The Azure tenant ID, representing the directory where
|
||||
the application is registered.
|
||||
user:
|
||||
type: email
|
||||
description: 'Deprecated: User microsoft email address.'
|
||||
password:
|
||||
type: string
|
||||
description: 'Deprecated: User password.'
|
||||
required:
|
||||
- client_id
|
||||
- client_secret
|
||||
- tenant_id
|
||||
- user
|
||||
- password
|
||||
- type: object
|
||||
title: M365 Certificate Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
description: The Azure application (client) ID for authentication
|
||||
in Azure AD.
|
||||
tenant_id:
|
||||
type: string
|
||||
description: The Azure tenant ID, representing the directory where
|
||||
the application is registered.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: The certificate content in base64 format for certificate-based
|
||||
authentication.
|
||||
user:
|
||||
type: email
|
||||
description: User microsoft email address.
|
||||
@@ -13867,8 +13946,8 @@ components:
|
||||
description: User password.
|
||||
required:
|
||||
- client_id
|
||||
- client_secret
|
||||
- tenant_id
|
||||
- certificate_content
|
||||
- user
|
||||
- password
|
||||
- type: object
|
||||
@@ -14107,6 +14186,33 @@ components:
|
||||
type: string
|
||||
description: The Azure tenant ID, representing the directory
|
||||
where the application is registered.
|
||||
user:
|
||||
type: email
|
||||
description: 'Deprecated: User microsoft email address.'
|
||||
password:
|
||||
type: string
|
||||
description: 'Deprecated: User password.'
|
||||
required:
|
||||
- client_id
|
||||
- client_secret
|
||||
- tenant_id
|
||||
- user
|
||||
- password
|
||||
- type: object
|
||||
title: M365 Certificate Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
description: The Azure application (client) ID for authentication
|
||||
in Azure AD.
|
||||
tenant_id:
|
||||
type: string
|
||||
description: The Azure tenant ID, representing the directory
|
||||
where the application is registered.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: The certificate content in base64 format for
|
||||
certificate-based authentication.
|
||||
user:
|
||||
type: email
|
||||
description: User microsoft email address.
|
||||
@@ -14115,8 +14221,8 @@ components:
|
||||
description: User password.
|
||||
required:
|
||||
- client_id
|
||||
- client_secret
|
||||
- tenant_id
|
||||
- certificate_content
|
||||
- user
|
||||
- password
|
||||
- type: object
|
||||
@@ -14371,6 +14477,33 @@ components:
|
||||
type: string
|
||||
description: The Azure tenant ID, representing the directory where
|
||||
the application is registered.
|
||||
user:
|
||||
type: email
|
||||
description: 'Deprecated: User microsoft email address.'
|
||||
password:
|
||||
type: string
|
||||
description: 'Deprecated: User password.'
|
||||
required:
|
||||
- client_id
|
||||
- client_secret
|
||||
- tenant_id
|
||||
- user
|
||||
- password
|
||||
- type: object
|
||||
title: M365 Certificate Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
description: The Azure application (client) ID for authentication
|
||||
in Azure AD.
|
||||
tenant_id:
|
||||
type: string
|
||||
description: The Azure tenant ID, representing the directory where
|
||||
the application is registered.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: The certificate content in base64 format for certificate-based
|
||||
authentication.
|
||||
user:
|
||||
type: email
|
||||
description: User microsoft email address.
|
||||
@@ -14379,8 +14512,8 @@ components:
|
||||
description: User password.
|
||||
required:
|
||||
- client_id
|
||||
- client_secret
|
||||
- tenant_id
|
||||
- certificate_content
|
||||
- user
|
||||
- password
|
||||
- type: object
|
||||
@@ -15636,8 +15769,8 @@ components:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 255
|
||||
maxLength: 100
|
||||
minLength: 3
|
||||
prefix:
|
||||
type: string
|
||||
readOnly: true
|
||||
@@ -15659,6 +15792,8 @@ components:
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: Last time this API key was used for authentication
|
||||
required:
|
||||
- name
|
||||
relationships:
|
||||
type: object
|
||||
properties:
|
||||
@@ -15705,8 +15840,8 @@ components:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 255
|
||||
maxLength: 100
|
||||
minLength: 3
|
||||
prefix:
|
||||
type: string
|
||||
readOnly: true
|
||||
@@ -15732,6 +15867,8 @@ components:
|
||||
api_key:
|
||||
type: string
|
||||
readOnly: true
|
||||
required:
|
||||
- name
|
||||
relationships:
|
||||
type: object
|
||||
properties:
|
||||
@@ -15782,8 +15919,8 @@ components:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 255
|
||||
minLength: 3
|
||||
maxLength: 100
|
||||
prefix:
|
||||
type: string
|
||||
readOnly: true
|
||||
@@ -15810,6 +15947,8 @@ components:
|
||||
api_key:
|
||||
type: string
|
||||
readOnly: true
|
||||
required:
|
||||
- name
|
||||
relationships:
|
||||
type: object
|
||||
properties:
|
||||
@@ -15877,8 +16016,8 @@ components:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 255
|
||||
maxLength: 100
|
||||
minLength: 3
|
||||
prefix:
|
||||
type: string
|
||||
readOnly: true
|
||||
@@ -15897,6 +16036,8 @@ components:
|
||||
readOnly: true
|
||||
nullable: true
|
||||
description: Last time this API key was used for authentication
|
||||
required:
|
||||
- name
|
||||
relationships:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -1003,6 +1003,504 @@ class TestCombinedAuthentication:
|
||||
assert api_key.last_used_at is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAPIKeyRLSBypass:
|
||||
"""Test RLS bypass fix for API key authentication.
|
||||
|
||||
These tests verify that API key authentication works correctly even when
|
||||
RLS context is not set, which is critical since we don't know the tenant_id
|
||||
until we look up the API key (which itself is protected by RLS).
|
||||
|
||||
The fix ensures all database operations during authentication use the admin
|
||||
database, bypassing RLS constraints.
|
||||
"""
|
||||
|
||||
def test_api_key_authentication_without_rls_context(
|
||||
self, create_test_user, tenants_fixture, api_keys_fixture
|
||||
):
|
||||
"""Verify API key authentication works without pre-existing RLS context.
|
||||
|
||||
This is the core fix: authentication must succeed even when prowler.tenant_id
|
||||
is not set, since we need to look up the API key to discover the tenant.
|
||||
"""
|
||||
client = APIClient()
|
||||
api_key = api_keys_fixture[0]
|
||||
|
||||
api_key_headers = get_api_key_header(api_key._raw_key)
|
||||
response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "data" in response.json()
|
||||
|
||||
def test_api_key_lookup_uses_admin_database(
|
||||
self, create_test_user, tenants_fixture
|
||||
):
|
||||
"""Verify API key lookup uses admin database during authentication.
|
||||
|
||||
The TenantAPIKey model is RLS-protected, so queries against it would
|
||||
normally fail without prowler.tenant_id set. The fix routes lookups
|
||||
to the admin database which bypasses RLS.
|
||||
"""
|
||||
client = APIClient()
|
||||
tenant = tenants_fixture[0]
|
||||
|
||||
role = Role.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
name="Admin DB Test Role",
|
||||
unlimited_visibility=True,
|
||||
manage_account=True,
|
||||
)
|
||||
UserRoleRelationship.objects.create(
|
||||
user=create_test_user,
|
||||
role=role,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
|
||||
api_key, raw_key = TenantAPIKey.objects.create_api_key(
|
||||
name="Admin DB Test Key",
|
||||
tenant_id=tenant.id,
|
||||
entity=create_test_user,
|
||||
)
|
||||
|
||||
api_key_headers = get_api_key_header(raw_key)
|
||||
response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
api_key.refresh_from_db()
|
||||
assert api_key.last_used_at is not None
|
||||
|
||||
def test_tenant_context_established_after_authentication(
|
||||
self, create_test_user, tenants_fixture, api_keys_fixture
|
||||
):
|
||||
"""Verify correct tenant context is established after API key auth.
|
||||
|
||||
After authentication, the tenant_id from the API key should be used
|
||||
to set up the proper RLS context for subsequent queries.
|
||||
"""
|
||||
client = APIClient()
|
||||
api_key = api_keys_fixture[0]
|
||||
|
||||
api_key_headers = get_api_key_header(api_key._raw_key)
|
||||
|
||||
# Use tenant-list endpoint to get actual tenant IDs
|
||||
tenant_response = client.get(reverse("tenant-list"), headers=api_key_headers)
|
||||
|
||||
assert tenant_response.status_code == 200
|
||||
tenant_data = tenant_response.json()["data"]
|
||||
tenant_ids = [t["id"] for t in tenant_data]
|
||||
|
||||
# Verify the API key's tenant is in the list of accessible tenants
|
||||
assert str(api_key.tenant_id) in tenant_ids
|
||||
|
||||
def test_concurrent_authentication_different_tenants(self, tenants_fixture):
|
||||
"""Verify multiple API keys from different tenants can authenticate simultaneously.
|
||||
|
||||
This tests that the admin database routing works correctly in concurrent
|
||||
scenarios and doesn't cause tenant isolation issues.
|
||||
"""
|
||||
client = APIClient()
|
||||
|
||||
user1 = User.objects.create_user(
|
||||
name="concurrent_user1",
|
||||
email="concurrent1@test.com",
|
||||
password=TEST_PASSWORD,
|
||||
)
|
||||
user2 = User.objects.create_user(
|
||||
name="concurrent_user2",
|
||||
email="concurrent2@test.com",
|
||||
password=TEST_PASSWORD,
|
||||
)
|
||||
|
||||
tenant1 = tenants_fixture[0]
|
||||
tenant2 = tenants_fixture[1]
|
||||
|
||||
Membership.objects.create(user=user1, tenant=tenant1)
|
||||
Membership.objects.create(user=user2, tenant=tenant2)
|
||||
|
||||
role1 = Role.objects.create(
|
||||
tenant_id=tenant1.id,
|
||||
name="Concurrent Role 1",
|
||||
unlimited_visibility=True,
|
||||
manage_account=True,
|
||||
)
|
||||
role2 = Role.objects.create(
|
||||
tenant_id=tenant2.id,
|
||||
name="Concurrent Role 2",
|
||||
unlimited_visibility=True,
|
||||
manage_account=True,
|
||||
)
|
||||
|
||||
UserRoleRelationship.objects.create(
|
||||
user=user1,
|
||||
role=role1,
|
||||
tenant_id=tenant1.id,
|
||||
)
|
||||
UserRoleRelationship.objects.create(
|
||||
user=user2,
|
||||
role=role2,
|
||||
tenant_id=tenant2.id,
|
||||
)
|
||||
|
||||
api_key1, raw_key1 = TenantAPIKey.objects.create_api_key(
|
||||
name="Concurrent Key 1",
|
||||
tenant_id=tenant1.id,
|
||||
entity=user1,
|
||||
)
|
||||
api_key2, raw_key2 = TenantAPIKey.objects.create_api_key(
|
||||
name="Concurrent Key 2",
|
||||
tenant_id=tenant2.id,
|
||||
entity=user2,
|
||||
)
|
||||
|
||||
headers1 = get_api_key_header(raw_key1)
|
||||
headers2 = get_api_key_header(raw_key2)
|
||||
|
||||
response1 = client.get(reverse("provider-list"), headers=headers1)
|
||||
response2 = client.get(reverse("provider-list"), headers=headers2)
|
||||
|
||||
assert response1.status_code == 200
|
||||
assert response2.status_code == 200
|
||||
|
||||
api_key1.refresh_from_db()
|
||||
api_key2.refresh_from_db()
|
||||
|
||||
assert api_key1.last_used_at is not None
|
||||
assert api_key2.last_used_at is not None
|
||||
assert api_key1.tenant_id == tenant1.id
|
||||
assert api_key2.tenant_id == tenant2.id
|
||||
|
||||
def test_api_key_update_last_used_uses_admin_db(
|
||||
self, create_test_user, tenants_fixture, api_keys_fixture
|
||||
):
|
||||
"""Verify last_used_at update uses admin database.
|
||||
|
||||
The update to last_used_at during authentication must also use the
|
||||
admin database since it occurs before RLS context is established.
|
||||
"""
|
||||
client = APIClient()
|
||||
api_key = api_keys_fixture[0]
|
||||
|
||||
assert api_key.last_used_at is None
|
||||
|
||||
api_key_headers = get_api_key_header(api_key._raw_key)
|
||||
first_response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
|
||||
assert first_response.status_code == 200
|
||||
|
||||
api_key.refresh_from_db()
|
||||
first_timestamp = api_key.last_used_at
|
||||
assert first_timestamp is not None
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
second_response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
assert second_response.status_code == 200
|
||||
|
||||
api_key.refresh_from_db()
|
||||
second_timestamp = api_key.last_used_at
|
||||
assert second_timestamp > first_timestamp
|
||||
|
||||
def test_api_key_prefix_lookup_bypasses_rls(
|
||||
self, create_test_user, tenants_fixture
|
||||
):
|
||||
"""Verify prefix-based API key lookup works without RLS context.
|
||||
|
||||
The authentication process splits the key into prefix and encrypted parts,
|
||||
then looks up by prefix. This lookup must work via admin database.
|
||||
"""
|
||||
client = APIClient()
|
||||
tenant = tenants_fixture[0]
|
||||
|
||||
role = Role.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
name="Prefix Test Role",
|
||||
unlimited_visibility=True,
|
||||
manage_account=True,
|
||||
)
|
||||
UserRoleRelationship.objects.create(
|
||||
user=create_test_user,
|
||||
role=role,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
|
||||
api_key, raw_key = TenantAPIKey.objects.create_api_key(
|
||||
name="Prefix Test Key",
|
||||
tenant_id=tenant.id,
|
||||
entity=create_test_user,
|
||||
)
|
||||
|
||||
prefix = raw_key.split(".")[0]
|
||||
assert prefix == api_key.prefix
|
||||
|
||||
api_key_headers = get_api_key_header(raw_key)
|
||||
response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_expired_api_key_check_uses_admin_db(
|
||||
self, create_test_user, tenants_fixture
|
||||
):
|
||||
"""Verify expired API key validation works via admin database.
|
||||
|
||||
Checking if a key is expired requires reading from TenantAPIKey,
|
||||
which must use admin database during authentication.
|
||||
"""
|
||||
client = APIClient()
|
||||
tenant = tenants_fixture[0]
|
||||
|
||||
expired_key, raw_key = TenantAPIKey.objects.create_api_key(
|
||||
name="Expired Test Key",
|
||||
tenant_id=tenant.id,
|
||||
entity=create_test_user,
|
||||
expiry_date=datetime.now(timezone.utc) - timedelta(days=1),
|
||||
)
|
||||
|
||||
api_key_headers = get_api_key_header(raw_key)
|
||||
response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "expired" in response.json()["errors"][0]["detail"].lower()
|
||||
|
||||
def test_revoked_api_key_check_uses_admin_db(
|
||||
self, create_test_user, tenants_fixture
|
||||
):
|
||||
"""Verify revoked API key validation works via admin database.
|
||||
|
||||
Checking if a key is revoked requires reading from TenantAPIKey,
|
||||
which must use admin database during authentication.
|
||||
"""
|
||||
client = APIClient()
|
||||
tenant = tenants_fixture[0]
|
||||
|
||||
role = Role.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
name="Revoked Test Role",
|
||||
unlimited_visibility=True,
|
||||
manage_account=True,
|
||||
)
|
||||
UserRoleRelationship.objects.create(
|
||||
user=create_test_user,
|
||||
role=role,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
|
||||
api_key, raw_key = TenantAPIKey.objects.create_api_key(
|
||||
name="Revoked Test Key",
|
||||
tenant_id=tenant.id,
|
||||
entity=create_test_user,
|
||||
)
|
||||
|
||||
api_key.revoked = True
|
||||
api_key.save()
|
||||
|
||||
api_key_headers = get_api_key_header(raw_key)
|
||||
response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "revoked" in response.json()["errors"][0]["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAPIKeyMultiTenantWorkflows:
|
||||
"""Test complete multi-tenant workflows using API keys.
|
||||
|
||||
These integration tests verify end-to-end scenarios where API keys
|
||||
are used across different tenants and ensure proper isolation.
|
||||
"""
|
||||
|
||||
def test_user_with_multiple_tenant_memberships_api_keys(self, tenants_fixture):
|
||||
"""User with memberships in multiple tenants can use different API keys.
|
||||
|
||||
Tests that a user can have separate API keys for different tenants
|
||||
and each key only accesses resources in its tenant.
|
||||
"""
|
||||
client = APIClient()
|
||||
|
||||
user = User.objects.create_user(
|
||||
name="multi_tenant_user",
|
||||
email="multitenant@test.com",
|
||||
password=TEST_PASSWORD,
|
||||
)
|
||||
|
||||
tenant1 = tenants_fixture[0]
|
||||
tenant2 = tenants_fixture[1]
|
||||
|
||||
Membership.objects.create(user=user, tenant=tenant1)
|
||||
Membership.objects.create(user=user, tenant=tenant2)
|
||||
|
||||
role1 = Role.objects.create(
|
||||
tenant_id=tenant1.id,
|
||||
name="Multi Tenant Role 1",
|
||||
unlimited_visibility=True,
|
||||
manage_account=True,
|
||||
)
|
||||
role2 = Role.objects.create(
|
||||
tenant_id=tenant2.id,
|
||||
name="Multi Tenant Role 2",
|
||||
unlimited_visibility=True,
|
||||
manage_account=True,
|
||||
)
|
||||
|
||||
UserRoleRelationship.objects.create(
|
||||
user=user,
|
||||
role=role1,
|
||||
tenant_id=tenant1.id,
|
||||
)
|
||||
UserRoleRelationship.objects.create(
|
||||
user=user,
|
||||
role=role2,
|
||||
tenant_id=tenant2.id,
|
||||
)
|
||||
|
||||
key1, raw_key1 = TenantAPIKey.objects.create_api_key(
|
||||
name="Tenant 1 Key",
|
||||
tenant_id=tenant1.id,
|
||||
entity=user,
|
||||
)
|
||||
key2, raw_key2 = TenantAPIKey.objects.create_api_key(
|
||||
name="Tenant 2 Key",
|
||||
tenant_id=tenant2.id,
|
||||
entity=user,
|
||||
)
|
||||
|
||||
headers1 = get_api_key_header(raw_key1)
|
||||
headers2 = get_api_key_header(raw_key2)
|
||||
|
||||
response1 = client.get(reverse("provider-list"), headers=headers1)
|
||||
response2 = client.get(reverse("provider-list"), headers=headers2)
|
||||
|
||||
assert response1.status_code == 200
|
||||
assert response2.status_code == 200
|
||||
|
||||
me_response1 = client.get(reverse("user-me"), headers=headers1)
|
||||
me_response2 = client.get(reverse("user-me"), headers=headers2)
|
||||
|
||||
assert me_response1.status_code == 200
|
||||
assert me_response2.status_code == 200
|
||||
|
||||
assert me_response1.json()["data"]["id"] == str(user.id)
|
||||
assert me_response2.json()["data"]["id"] == str(user.id)
|
||||
|
||||
def test_api_key_cannot_access_different_tenant_resources(
|
||||
self, tenants_fixture, providers_fixture
|
||||
):
|
||||
"""API key from one tenant cannot access resources from another tenant.
|
||||
|
||||
Verifies RLS enforcement after authentication ensures tenant isolation.
|
||||
"""
|
||||
client = APIClient()
|
||||
|
||||
user1 = User.objects.create_user(
|
||||
name="tenant1_user",
|
||||
email="tenant1user@test.com",
|
||||
password=TEST_PASSWORD,
|
||||
)
|
||||
user2 = User.objects.create_user(
|
||||
name="tenant2_user",
|
||||
email="tenant2user@test.com",
|
||||
password=TEST_PASSWORD,
|
||||
)
|
||||
|
||||
tenant1 = tenants_fixture[0]
|
||||
tenant2 = tenants_fixture[1]
|
||||
|
||||
Membership.objects.create(user=user1, tenant=tenant1)
|
||||
Membership.objects.create(user=user2, tenant=tenant2)
|
||||
|
||||
role1 = Role.objects.create(
|
||||
tenant_id=tenant1.id,
|
||||
name="Isolation Test Role 1",
|
||||
unlimited_visibility=True,
|
||||
manage_account=True,
|
||||
)
|
||||
role2 = Role.objects.create(
|
||||
tenant_id=tenant2.id,
|
||||
name="Isolation Test Role 2",
|
||||
unlimited_visibility=True,
|
||||
manage_account=True,
|
||||
)
|
||||
|
||||
UserRoleRelationship.objects.create(
|
||||
user=user1,
|
||||
role=role1,
|
||||
tenant_id=tenant1.id,
|
||||
)
|
||||
UserRoleRelationship.objects.create(
|
||||
user=user2,
|
||||
role=role2,
|
||||
tenant_id=tenant2.id,
|
||||
)
|
||||
|
||||
key1, raw_key1 = TenantAPIKey.objects.create_api_key(
|
||||
name="Isolation Key 1",
|
||||
tenant_id=tenant1.id,
|
||||
entity=user1,
|
||||
)
|
||||
|
||||
headers1 = get_api_key_header(raw_key1)
|
||||
|
||||
provider_response = client.get(reverse("provider-list"), headers=headers1)
|
||||
assert provider_response.status_code == 200
|
||||
|
||||
providers_data = provider_response.json()["data"]
|
||||
|
||||
if providers_data:
|
||||
for provider in providers_data:
|
||||
provider_tenant_id = str(tenants_fixture[0].id)
|
||||
assert str(tenant2.id) != provider_tenant_id
|
||||
|
||||
def test_api_key_workflow_create_authenticate_revoke(
|
||||
self, create_test_user_rbac, tenants_fixture
|
||||
):
|
||||
"""Complete workflow: create API key via JWT, use it, then revoke via JWT.
|
||||
|
||||
Tests the full lifecycle using both JWT and API key authentication.
|
||||
"""
|
||||
client = APIClient()
|
||||
tenants_fixture[0]
|
||||
|
||||
jwt_access_token, _ = get_api_tokens(
|
||||
client, create_test_user_rbac.email, TEST_PASSWORD
|
||||
)
|
||||
jwt_headers = get_authorization_header(jwt_access_token)
|
||||
|
||||
create_response = client.post(
|
||||
reverse("api-key-list"),
|
||||
data={
|
||||
"data": {
|
||||
"type": "api-keys",
|
||||
"attributes": {
|
||||
"name": "Workflow Test Key",
|
||||
},
|
||||
}
|
||||
},
|
||||
format="vnd.api+json",
|
||||
headers=jwt_headers,
|
||||
)
|
||||
|
||||
assert create_response.status_code == 201
|
||||
api_key_data = create_response.json()["data"]
|
||||
api_key_id = api_key_data["id"]
|
||||
raw_api_key = api_key_data["attributes"]["api_key"]
|
||||
|
||||
api_key_headers = get_api_key_header(raw_api_key)
|
||||
auth_response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
assert auth_response.status_code == 200
|
||||
|
||||
revoke_response = client.delete(
|
||||
reverse("api-key-revoke", kwargs={"pk": api_key_id}),
|
||||
headers=jwt_headers,
|
||||
)
|
||||
assert revoke_response.status_code == 200
|
||||
|
||||
revoked_auth_response = client.get(
|
||||
reverse("provider-list"), headers=api_key_headers
|
||||
)
|
||||
assert revoked_auth_response.status_code == 401
|
||||
assert "revoked" in revoked_auth_response.json()["errors"][0]["detail"].lower()
|
||||
|
||||
|
||||
def get_api_key_header(api_key: str) -> dict:
|
||||
"""Helper to create API key authorization header."""
|
||||
return {"Authorization": f"Api-Key {api_key}"}
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from django.test import RequestFactory
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from api.authentication import TenantAPIKeyAuthentication
|
||||
from api.db_router import MainRouter
|
||||
from api.models import TenantAPIKey
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestTenantAPIKeyAuthentication:
|
||||
@pytest.fixture
|
||||
def auth_backend(self):
|
||||
"""Create an instance of TenantAPIKeyAuthentication."""
|
||||
return TenantAPIKeyAuthentication()
|
||||
|
||||
@pytest.fixture
|
||||
def request_factory(self):
|
||||
"""Create a Django request factory."""
|
||||
return RequestFactory()
|
||||
|
||||
def test_authenticate_credentials_uses_admin_database(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test that _authenticate_credentials routes queries to admin database."""
|
||||
api_key = api_keys_fixture[0]
|
||||
raw_key = api_key._raw_key
|
||||
|
||||
# Extract the encrypted key part (after the prefix and separator)
|
||||
_, encrypted_key = raw_key.split(TenantAPIKey.objects.separator, 1)
|
||||
|
||||
# Create a mock request
|
||||
request = request_factory.get("/")
|
||||
|
||||
# Call the method
|
||||
entity, auth_dict = auth_backend._authenticate_credentials(
|
||||
request, encrypted_key
|
||||
)
|
||||
|
||||
# Verify that the entity is the user associated with the API key
|
||||
assert entity == api_key.entity
|
||||
assert entity.id == api_key.entity.id
|
||||
|
||||
def test_authenticate_credentials_restores_manager_on_success(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test that the manager is restored after successful authentication."""
|
||||
api_key = api_keys_fixture[0]
|
||||
raw_key = api_key._raw_key
|
||||
_, encrypted_key = raw_key.split(TenantAPIKey.objects.separator, 1)
|
||||
|
||||
# Store the original manager
|
||||
original_manager = TenantAPIKey.objects
|
||||
|
||||
request = request_factory.get("/")
|
||||
|
||||
# Call the method
|
||||
auth_backend._authenticate_credentials(request, encrypted_key)
|
||||
|
||||
# Verify the manager was restored
|
||||
assert TenantAPIKey.objects == original_manager
|
||||
|
||||
def test_authenticate_credentials_restores_manager_on_exception(
|
||||
self, auth_backend, request_factory
|
||||
):
|
||||
"""Test that the manager is restored even when an exception occurs."""
|
||||
# Store the original manager
|
||||
original_manager = TenantAPIKey.objects
|
||||
|
||||
request = request_factory.get("/")
|
||||
|
||||
# Try to authenticate with an invalid key that will raise an exception
|
||||
with pytest.raises(Exception):
|
||||
auth_backend._authenticate_credentials(request, "invalid_encrypted_key")
|
||||
|
||||
# Verify the manager was restored despite the exception
|
||||
assert TenantAPIKey.objects == original_manager
|
||||
|
||||
def test_authenticate_valid_api_key(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test successful authentication with a valid API key."""
|
||||
api_key = api_keys_fixture[0]
|
||||
raw_key = api_key._raw_key
|
||||
|
||||
# Create a request with the API key in the Authorization header
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
|
||||
|
||||
# Authenticate
|
||||
entity, auth_dict = auth_backend.authenticate(request)
|
||||
|
||||
# Verify the entity and auth dict
|
||||
assert entity == api_key.entity
|
||||
assert auth_dict["tenant_id"] == str(api_key.tenant_id)
|
||||
assert auth_dict["sub"] == str(api_key.entity.id)
|
||||
assert auth_dict["api_key_prefix"] == api_key.prefix
|
||||
|
||||
# Verify that last_used_at was updated
|
||||
api_key.refresh_from_db()
|
||||
assert api_key.last_used_at is not None
|
||||
assert (datetime.now(timezone.utc) - api_key.last_used_at).seconds < 5
|
||||
|
||||
def test_authenticate_valid_api_key_uses_admin_database(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test that authenticate uses admin database for API key lookup."""
|
||||
api_key = api_keys_fixture[0]
|
||||
raw_key = api_key._raw_key
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
|
||||
|
||||
# Mock the manager's using method to verify it's called with admin_db
|
||||
with patch.object(
|
||||
TenantAPIKey.objects, "using", wraps=TenantAPIKey.objects.using
|
||||
) as mock_using:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
# Verify that .using('admin') was called
|
||||
mock_using.assert_called_with(MainRouter.admin_db)
|
||||
|
||||
def test_authenticate_invalid_key_format_missing_separator(
|
||||
self, auth_backend, request_factory
|
||||
):
|
||||
"""Test authentication fails with invalid API key format (no separator)."""
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = "Api-Key invalid_key_no_separator"
|
||||
|
||||
with pytest.raises(AuthenticationFailed) as exc_info:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
assert str(exc_info.value.detail) == "Invalid API Key."
|
||||
|
||||
def test_authenticate_invalid_key_format_empty_prefix(
|
||||
self, auth_backend, request_factory
|
||||
):
|
||||
"""Test authentication fails with empty prefix."""
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = "Api-Key .encrypted_part"
|
||||
|
||||
with pytest.raises(AuthenticationFailed) as exc_info:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
assert str(exc_info.value.detail) == "Invalid API Key."
|
||||
|
||||
def test_authenticate_invalid_encrypted_key(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test authentication fails with invalid encrypted key."""
|
||||
api_key = api_keys_fixture[0]
|
||||
|
||||
# Create an invalid key with valid prefix but invalid encryption
|
||||
invalid_key = f"{api_key.prefix}.invalid_encrypted_data"
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {invalid_key}"
|
||||
|
||||
with pytest.raises(AuthenticationFailed) as exc_info:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
assert str(exc_info.value.detail) == "Invalid API Key."
|
||||
|
||||
def test_authenticate_revoked_api_key(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test authentication fails with a revoked API key."""
|
||||
# Use the revoked API key (index 2 from fixture)
|
||||
api_key = api_keys_fixture[2]
|
||||
raw_key = api_key._raw_key
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
|
||||
|
||||
# The revoked key should fail during credential validation
|
||||
with pytest.raises(AuthenticationFailed) as exc_info:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
assert str(exc_info.value.detail) == "This API Key has been revoked."
|
||||
|
||||
def test_authenticate_expired_api_key(
|
||||
self, auth_backend, create_test_user, tenants_fixture, request_factory
|
||||
):
|
||||
"""Test authentication fails with an expired API key."""
|
||||
tenant = tenants_fixture[0]
|
||||
user = create_test_user
|
||||
|
||||
# Create an expired API key
|
||||
api_key, raw_key = TenantAPIKey.objects.create_api_key(
|
||||
name="Expired API Key",
|
||||
tenant_id=tenant.id,
|
||||
entity=user,
|
||||
expiry_date=datetime.now(timezone.utc) - timedelta(days=1),
|
||||
)
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
|
||||
|
||||
with pytest.raises(AuthenticationFailed) as exc_info:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
assert str(exc_info.value.detail) == "API Key has already expired."
|
||||
|
||||
def test_authenticate_nonexistent_api_key(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test authentication fails when API key doesn't exist in database."""
|
||||
# Create a valid-looking encrypted key with a non-existent UUID
|
||||
api_key = api_keys_fixture[0]
|
||||
non_existent_uuid = str(uuid4())
|
||||
|
||||
# Manually create an encrypted key with a non-existent ID
|
||||
payload = {
|
||||
"_pk": non_existent_uuid,
|
||||
"_exp": (datetime.now(timezone.utc) + timedelta(days=30)).timestamp(),
|
||||
}
|
||||
encrypted_key = auth_backend.key_crypto.generate(payload)
|
||||
fake_key = f"{api_key.prefix}.{encrypted_key}"
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {fake_key}"
|
||||
|
||||
with pytest.raises(AuthenticationFailed) as exc_info:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
assert str(exc_info.value.detail) == "No entity matching this api key."
|
||||
|
||||
def test_authenticate_updates_last_used_at(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test that last_used_at is updated on successful authentication."""
|
||||
api_key = api_keys_fixture[0]
|
||||
raw_key = api_key._raw_key
|
||||
|
||||
# Store the original last_used_at
|
||||
original_last_used = api_key.last_used_at
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
|
||||
|
||||
# Authenticate
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
# Refresh from database
|
||||
api_key.refresh_from_db()
|
||||
|
||||
# Verify last_used_at was updated
|
||||
assert api_key.last_used_at is not None
|
||||
if original_last_used:
|
||||
assert api_key.last_used_at > original_last_used
|
||||
|
||||
def test_authenticate_saves_to_admin_database(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test that the API key save operation uses admin database."""
|
||||
api_key = api_keys_fixture[0]
|
||||
raw_key = api_key._raw_key
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
|
||||
|
||||
# Mock the save method to verify it's called with using='admin'
|
||||
with patch.object(TenantAPIKey, "save") as mock_save:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
# Verify save was called with using=admin_db
|
||||
mock_save.assert_called_once_with(
|
||||
update_fields=["last_used_at"], using=MainRouter.admin_db
|
||||
)
|
||||
|
||||
def test_authenticate_returns_correct_auth_dict(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test that the auth dict contains all required fields."""
|
||||
api_key = api_keys_fixture[0]
|
||||
raw_key = api_key._raw_key
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
|
||||
|
||||
entity, auth_dict = auth_backend.authenticate(request)
|
||||
|
||||
# Verify all required fields are present
|
||||
assert "tenant_id" in auth_dict
|
||||
assert "sub" in auth_dict
|
||||
assert "api_key_prefix" in auth_dict
|
||||
|
||||
# Verify values are correct
|
||||
assert auth_dict["tenant_id"] == str(api_key.tenant_id)
|
||||
assert auth_dict["sub"] == str(api_key.entity.id)
|
||||
assert auth_dict["api_key_prefix"] == api_key.prefix
|
||||
|
||||
def test_authenticate_with_multiple_api_keys_same_tenant(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test that authentication works correctly with multiple API keys for the same tenant."""
|
||||
# Test with first API key
|
||||
api_key1 = api_keys_fixture[0]
|
||||
raw_key1 = api_key1._raw_key
|
||||
|
||||
request1 = request_factory.get("/")
|
||||
request1.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key1}"
|
||||
|
||||
entity1, auth_dict1 = auth_backend.authenticate(request1)
|
||||
|
||||
assert entity1 == api_key1.entity
|
||||
assert auth_dict1["api_key_prefix"] == api_key1.prefix
|
||||
|
||||
# Test with second API key
|
||||
api_key2 = api_keys_fixture[1]
|
||||
raw_key2 = api_key2._raw_key
|
||||
|
||||
request2 = request_factory.get("/")
|
||||
request2.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key2}"
|
||||
|
||||
entity2, auth_dict2 = auth_backend.authenticate(request2)
|
||||
|
||||
assert entity2 == api_key2.entity
|
||||
assert auth_dict2["api_key_prefix"] == api_key2.prefix
|
||||
|
||||
# Verify they're different keys but same tenant
|
||||
assert auth_dict1["api_key_prefix"] != auth_dict2["api_key_prefix"]
|
||||
assert auth_dict1["tenant_id"] == auth_dict2["tenant_id"]
|
||||
|
||||
def test_authenticate_with_wrong_prefix_in_db(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test authentication fails when prefix doesn't match database."""
|
||||
api_key = api_keys_fixture[0]
|
||||
raw_key = api_key._raw_key
|
||||
|
||||
# Extract the encrypted part and combine with wrong prefix
|
||||
_, encrypted_part = raw_key.split(TenantAPIKey.objects.separator, 1)
|
||||
wrong_key = f"pk_wrong123.{encrypted_part}"
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {wrong_key}"
|
||||
|
||||
with pytest.raises(AuthenticationFailed) as exc_info:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
assert str(exc_info.value.detail) == "Invalid API Key."
|
||||
|
||||
def test_authenticate_credentials_exception_handling(
|
||||
self, auth_backend, request_factory
|
||||
):
|
||||
"""Test that exceptions in _authenticate_credentials are properly handled."""
|
||||
request = request_factory.get("/")
|
||||
|
||||
# Test with completely invalid data that will cause InvalidToken
|
||||
with pytest.raises(Exception):
|
||||
auth_backend._authenticate_credentials(request, "completely_invalid")
|
||||
|
||||
def test_authenticate_with_expired_timestamp(
|
||||
self, auth_backend, create_test_user, tenants_fixture, request_factory
|
||||
):
|
||||
"""Test that expired timestamp in encrypted key causes authentication failure."""
|
||||
tenant = tenants_fixture[0]
|
||||
user = create_test_user
|
||||
|
||||
# Create an API key with a very short expiry
|
||||
api_key, raw_key = TenantAPIKey.objects.create_api_key(
|
||||
name="Short-lived API Key",
|
||||
tenant_id=tenant.id,
|
||||
entity=user,
|
||||
expiry_date=datetime.now(timezone.utc) + timedelta(seconds=1),
|
||||
)
|
||||
|
||||
# Wait for the key to expire
|
||||
time.sleep(2)
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
|
||||
|
||||
# Should fail with expired key
|
||||
with pytest.raises(AuthenticationFailed) as exc_info:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
assert str(exc_info.value.detail) == "API Key has already expired."
|
||||
@@ -1870,17 +1870,7 @@ class TestProviderSecretViewSet:
|
||||
"kubeconfig_content": "kubeconfig-content",
|
||||
},
|
||||
),
|
||||
# M365 with STATIC secret - no user or password
|
||||
(
|
||||
Provider.ProviderChoices.M365.value,
|
||||
ProviderSecret.TypeChoices.STATIC,
|
||||
{
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"tenant_id": "tenant-id",
|
||||
},
|
||||
),
|
||||
# M365 with user only
|
||||
# M365 client secret credentials
|
||||
(
|
||||
Provider.ProviderChoices.M365.value,
|
||||
ProviderSecret.TypeChoices.STATIC,
|
||||
@@ -1889,27 +1879,17 @@ class TestProviderSecretViewSet:
|
||||
"client_secret": "client-secret",
|
||||
"tenant_id": "tenant-id",
|
||||
"user": "test@domain.com",
|
||||
},
|
||||
),
|
||||
# M365 with password only
|
||||
(
|
||||
Provider.ProviderChoices.M365.value,
|
||||
ProviderSecret.TypeChoices.STATIC,
|
||||
{
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"tenant_id": "tenant-id",
|
||||
"password": "supersecret",
|
||||
},
|
||||
),
|
||||
# M365 with user and password
|
||||
# M365 certificate credentials (valid base64)
|
||||
(
|
||||
Provider.ProviderChoices.M365.value,
|
||||
ProviderSecret.TypeChoices.STATIC,
|
||||
{
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"tenant_id": "tenant-id",
|
||||
"certificate_content": "VGVzdCBjZXJ0aWZpY2F0ZSBjb250ZW50",
|
||||
"user": "test@domain.com",
|
||||
"password": "supersecret",
|
||||
},
|
||||
@@ -2279,6 +2259,50 @@ class TestProviderSecretViewSet:
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_m365_provider_secrets_invalid_certificate_base64(
|
||||
self, authenticated_client, providers_fixture
|
||||
):
|
||||
"""Test M365 provider secret creation with invalid base64 certificate content"""
|
||||
# Find M365 provider from fixture
|
||||
m365_provider = None
|
||||
for provider in providers_fixture:
|
||||
if provider.provider == Provider.ProviderChoices.M365.value:
|
||||
m365_provider = provider
|
||||
break
|
||||
|
||||
assert m365_provider is not None, "M365 provider not found in fixture"
|
||||
|
||||
data = {
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"attributes": {
|
||||
"name": "M365 Certificate Invalid Base64",
|
||||
"secret_type": "static",
|
||||
"secret": {
|
||||
"client_id": "client-id",
|
||||
"tenant_id": "tenant-id",
|
||||
"certificate_content": "invalid-base64-content!@#$%",
|
||||
"user": "test@domain.com",
|
||||
"password": "supersecret",
|
||||
},
|
||||
},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {"type": "providers", "id": str(m365_provider.id)}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
response = authenticated_client.post(
|
||||
reverse("providersecret-list"),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "certificate content is not valid base64 encoded data" in str(
|
||||
response.json()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestScanViewSet:
|
||||
@@ -5781,10 +5805,12 @@ class TestScheduleViewSet:
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@patch("tasks.beat.perform_scheduled_scan_task.apply_async")
|
||||
@patch("api.v1.views.Task.objects.get")
|
||||
def test_schedule_daily_already_scheduled(
|
||||
self,
|
||||
mock_task_get,
|
||||
mock_apply_async,
|
||||
authenticated_client,
|
||||
providers_fixture,
|
||||
tasks_fixture,
|
||||
@@ -5792,6 +5818,7 @@ class TestScheduleViewSet:
|
||||
provider, *_ = providers_fixture
|
||||
prowler_task = tasks_fixture[0]
|
||||
mock_task_get.return_value = prowler_task
|
||||
mock_apply_async.return_value.id = prowler_task.id
|
||||
json_payload = {
|
||||
"provider_id": str(provider.id),
|
||||
}
|
||||
@@ -6892,6 +6919,188 @@ class TestTenantFinishACSView:
|
||||
assert response.status_code == 302
|
||||
assert "sso_saml_failed=true" in response.url
|
||||
|
||||
def test_dispatch_skips_role_mapping_when_single_manage_account_user(
|
||||
self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch
|
||||
):
|
||||
"""Test that role mapping is skipped when tenant has only one user with MANAGE_ACCOUNT role"""
|
||||
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
|
||||
user = create_test_user
|
||||
tenant = tenants_fixture[0]
|
||||
|
||||
# Create a single role with manage_account=True for the user
|
||||
admin_role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name="admin",
|
||||
tenant=tenant,
|
||||
manage_account=True,
|
||||
manage_users=True,
|
||||
manage_billing=True,
|
||||
manage_providers=True,
|
||||
manage_integrations=True,
|
||||
manage_scans=True,
|
||||
unlimited_visibility=True,
|
||||
)
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=user, role=admin_role, tenant_id=tenant.id
|
||||
)
|
||||
|
||||
social_account = SocialAccount(
|
||||
user=user,
|
||||
provider="saml",
|
||||
extra_data={
|
||||
"firstName": ["John"],
|
||||
"lastName": ["Doe"],
|
||||
"organization": ["testing_company"],
|
||||
"userType": ["no_permissions"], # This should be ignored
|
||||
},
|
||||
)
|
||||
|
||||
request = RequestFactory().get(
|
||||
reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
|
||||
)
|
||||
request.user = user
|
||||
request.session = {}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"allauth.socialaccount.providers.saml.views.get_app_or_404"
|
||||
) as mock_get_app_or_404,
|
||||
patch(
|
||||
"allauth.socialaccount.models.SocialApp.objects.get"
|
||||
) as mock_socialapp_get,
|
||||
patch(
|
||||
"allauth.socialaccount.models.SocialAccount.objects.get"
|
||||
) as mock_sa_get,
|
||||
patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
|
||||
patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
|
||||
patch("api.models.User.objects.get") as mock_user_get,
|
||||
):
|
||||
mock_get_app_or_404.return_value = MagicMock(
|
||||
provider="saml", client_id="testtenant", name="Test App", settings={}
|
||||
)
|
||||
mock_sa_get.return_value = social_account
|
||||
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
|
||||
mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
|
||||
mock_saml_config_get.return_value = MagicMock()
|
||||
mock_user_get.return_value = user
|
||||
|
||||
view = TenantFinishACSView.as_view()
|
||||
response = view(request, organization_slug="testtenant")
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
# Verify the admin role is still assigned (not changed to no_permissions)
|
||||
assert (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user=user, role=admin_role, tenant_id=tenant.id)
|
||||
.exists()
|
||||
)
|
||||
|
||||
# Verify no_permissions role was NOT created in the database
|
||||
assert (
|
||||
not Role.objects.using(MainRouter.admin_db)
|
||||
.filter(name="no_permissions", tenant=tenant)
|
||||
.exists()
|
||||
)
|
||||
|
||||
# Verify no_permissions role was NOT assigned to the user
|
||||
assert not (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user=user, role__name="no_permissions", tenant_id=tenant.id)
|
||||
.exists()
|
||||
)
|
||||
|
||||
def test_dispatch_applies_role_mapping_when_multiple_manage_account_users(
|
||||
self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch
|
||||
):
|
||||
"""Test that role mapping is applied when tenant has multiple users with MANAGE_ACCOUNT role"""
|
||||
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
|
||||
user = create_test_user
|
||||
tenant = tenants_fixture[0]
|
||||
|
||||
# Create a second user with manage_account=True
|
||||
second_admin = User.objects.using(MainRouter.admin_db).create(
|
||||
email="admin2@prowler.com", name="Second Admin"
|
||||
)
|
||||
admin_role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name="admin",
|
||||
tenant=tenant,
|
||||
manage_account=True,
|
||||
manage_users=True,
|
||||
manage_billing=True,
|
||||
manage_providers=True,
|
||||
manage_integrations=True,
|
||||
manage_scans=True,
|
||||
unlimited_visibility=True,
|
||||
)
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=user, role=admin_role, tenant_id=tenant.id
|
||||
)
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=second_admin, role=admin_role, tenant_id=tenant.id
|
||||
)
|
||||
|
||||
social_account = SocialAccount(
|
||||
user=user,
|
||||
provider="saml",
|
||||
extra_data={
|
||||
"firstName": ["John"],
|
||||
"lastName": ["Doe"],
|
||||
"organization": ["testing_company"],
|
||||
"userType": ["viewer"], # This SHOULD be applied
|
||||
},
|
||||
)
|
||||
|
||||
request = RequestFactory().get(
|
||||
reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
|
||||
)
|
||||
request.user = user
|
||||
request.session = {}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"allauth.socialaccount.providers.saml.views.get_app_or_404"
|
||||
) as mock_get_app_or_404,
|
||||
patch(
|
||||
"allauth.socialaccount.models.SocialApp.objects.get"
|
||||
) as mock_socialapp_get,
|
||||
patch(
|
||||
"allauth.socialaccount.models.SocialAccount.objects.get"
|
||||
) as mock_sa_get,
|
||||
patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
|
||||
patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
|
||||
patch("api.models.User.objects.get") as mock_user_get,
|
||||
):
|
||||
mock_get_app_or_404.return_value = MagicMock(
|
||||
provider="saml", client_id="testtenant", name="Test App", settings={}
|
||||
)
|
||||
mock_sa_get.return_value = social_account
|
||||
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
|
||||
mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
|
||||
mock_saml_config_get.return_value = MagicMock()
|
||||
mock_user_get.return_value = user
|
||||
|
||||
view = TenantFinishACSView.as_view()
|
||||
response = view(request, organization_slug="testtenant")
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
# Verify the viewer role was created and assigned (role mapping was applied)
|
||||
viewer_role = Role.objects.using(MainRouter.admin_db).get(
|
||||
name="viewer", tenant=tenant
|
||||
)
|
||||
assert (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user=user, role=viewer_role, tenant_id=tenant.id)
|
||||
.exists()
|
||||
)
|
||||
|
||||
# Verify the admin role was removed (replaced by viewer)
|
||||
assert not (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user=user, role=admin_role, tenant_id=tenant.id)
|
||||
.exists()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestLighthouseConfigViewSet:
|
||||
@@ -7530,8 +7739,6 @@ class TestTenantApiKeyViewSet:
|
||||
(
|
||||
[
|
||||
{"name": "New API Key"},
|
||||
{"name": ""},
|
||||
{},
|
||||
]
|
||||
),
|
||||
)
|
||||
@@ -7572,6 +7779,18 @@ class TestTenantApiKeyViewSet:
|
||||
{"name": "Invalid Expiry", "expires_at": "not-a-date"},
|
||||
"expires_at",
|
||||
),
|
||||
(
|
||||
{"name": ""},
|
||||
"name",
|
||||
),
|
||||
(
|
||||
{},
|
||||
"name",
|
||||
),
|
||||
(
|
||||
{"name": "AB"}, # Too short (min length is 3)
|
||||
"name",
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
@@ -7600,6 +7819,58 @@ class TestTenantApiKeyViewSet:
|
||||
== f"/data/attributes/{error_pointer}"
|
||||
)
|
||||
|
||||
def test_api_keys_create_duplicate_name(
|
||||
self, authenticated_client, api_keys_fixture
|
||||
):
|
||||
"""Test creating an API key with a duplicate name fails."""
|
||||
# Use the name of an existing API key
|
||||
existing_name = api_keys_fixture[0].name
|
||||
data = {
|
||||
"data": {
|
||||
"type": "api-keys",
|
||||
"attributes": {
|
||||
"name": existing_name,
|
||||
},
|
||||
}
|
||||
}
|
||||
response = authenticated_client.post(
|
||||
reverse("api-key-list"),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "errors" in response.json()
|
||||
error_detail = response.json()["errors"][0]["detail"]
|
||||
assert "already exists" in error_detail.lower()
|
||||
|
||||
def test_api_keys_update_duplicate_name(
|
||||
self, authenticated_client, api_keys_fixture
|
||||
):
|
||||
"""Test updating an API key with a duplicate name fails."""
|
||||
# Get two different API keys
|
||||
first_api_key = api_keys_fixture[0]
|
||||
second_api_key = api_keys_fixture[1]
|
||||
|
||||
# Try to update the second API key to have the same name as the first one
|
||||
data = {
|
||||
"data": {
|
||||
"type": "api-keys",
|
||||
"id": str(second_api_key.id),
|
||||
"attributes": {
|
||||
"name": first_api_key.name,
|
||||
},
|
||||
}
|
||||
}
|
||||
response = authenticated_client.patch(
|
||||
reverse("api-key-detail", kwargs={"pk": second_api_key.id}),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "errors" in response.json()
|
||||
error_detail = response.json()["errors"][0]["detail"]
|
||||
assert "already exists" in error_detail.lower()
|
||||
|
||||
def test_api_keys_create_multiple_unique_prefixes(
|
||||
self, authenticated_client, api_keys_fixture
|
||||
):
|
||||
@@ -8024,6 +8295,53 @@ class TestTenantApiKeyViewSet:
|
||||
assert data["relationships"]["entity"]["data"]["type"] == "users"
|
||||
assert data["relationships"]["entity"]["data"]["id"] == str(api_key.entity.id)
|
||||
|
||||
def test_api_keys_retrieve_with_entity_include(
|
||||
self, authenticated_client, api_keys_fixture
|
||||
):
|
||||
"""Test retrieving API key with ?include=entity returns user data without memberships."""
|
||||
api_key = api_keys_fixture[0]
|
||||
response = authenticated_client.get(
|
||||
reverse("api-key-detail", kwargs={"pk": api_key.id}),
|
||||
{"include": "entity"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
response_data = response.json()
|
||||
|
||||
# Verify the main data contains the entity relationship
|
||||
data = response_data["data"]
|
||||
assert "entity" in data["relationships"]
|
||||
assert data["relationships"]["entity"]["data"]["type"] == "users"
|
||||
assert data["relationships"]["entity"]["data"]["id"] == str(api_key.entity.id)
|
||||
|
||||
# Verify included section exists
|
||||
assert "included" in response_data
|
||||
assert len(response_data["included"]) == 1
|
||||
|
||||
# Verify included user data
|
||||
included_user = response_data["included"][0]
|
||||
assert included_user["type"] == "users"
|
||||
assert included_user["id"] == str(api_key.entity.id)
|
||||
|
||||
# Refresh entity from database to get current state
|
||||
# (in case other tests modified the shared session-scoped user fixture)
|
||||
api_key.entity.refresh_from_db()
|
||||
|
||||
# Verify UserIncludeSerializer fields are present
|
||||
user_attrs = included_user["attributes"]
|
||||
assert "name" in user_attrs
|
||||
assert "email" in user_attrs
|
||||
assert "company_name" in user_attrs
|
||||
assert "date_joined" in user_attrs
|
||||
assert user_attrs["name"] == api_key.entity.name
|
||||
assert user_attrs["email"] == api_key.entity.email
|
||||
|
||||
# Verify memberships field is NOT included (excluded by UserIncludeSerializer)
|
||||
assert "memberships" not in user_attrs
|
||||
|
||||
# Verify roles relationship is present
|
||||
assert "relationships" in included_user
|
||||
assert "roles" in included_user["relationships"]
|
||||
|
||||
def test_api_keys_entity_auto_assigned_on_create(
|
||||
self, authenticated_client, create_test_user
|
||||
):
|
||||
|
||||
@@ -115,6 +115,40 @@ from rest_framework_json_api import serializers
|
||||
"description": "The Azure tenant ID, representing the directory where the application is "
|
||||
"registered.",
|
||||
},
|
||||
"user": {
|
||||
"type": "email",
|
||||
"description": "Deprecated: User microsoft email address.",
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"description": "Deprecated: User password.",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"tenant_id",
|
||||
"user",
|
||||
"password",
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "M365 Certificate Credentials",
|
||||
"properties": {
|
||||
"client_id": {
|
||||
"type": "string",
|
||||
"description": "The Azure application (client) ID for authentication in Azure AD.",
|
||||
},
|
||||
"tenant_id": {
|
||||
"type": "string",
|
||||
"description": "The Azure tenant ID, representing the directory where the application is "
|
||||
"registered.",
|
||||
},
|
||||
"certificate_content": {
|
||||
"type": "string",
|
||||
"description": "The certificate content in base64 format for certificate-based authentication.",
|
||||
},
|
||||
"user": {
|
||||
"type": "email",
|
||||
"description": "User microsoft email address.",
|
||||
@@ -126,8 +160,8 @@ from rest_framework_json_api import serializers
|
||||
},
|
||||
"required": [
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"tenant_id",
|
||||
"certificate_content",
|
||||
"user",
|
||||
"password",
|
||||
],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import base64
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -317,6 +318,23 @@ class UserSerializer(BaseSerializerV1):
|
||||
)
|
||||
|
||||
|
||||
class UserIncludeSerializer(UserSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"company_name",
|
||||
"date_joined",
|
||||
"roles",
|
||||
]
|
||||
|
||||
included_serializers = {
|
||||
"roles": "api.v1.serializers.RoleIncludeSerializer",
|
||||
}
|
||||
|
||||
|
||||
class UserCreateSerializer(BaseWriteSerializer):
|
||||
password = serializers.CharField(write_only=True)
|
||||
company_name = serializers.CharField(required=False)
|
||||
@@ -1378,10 +1396,38 @@ class AzureProviderSecret(serializers.Serializer):
|
||||
|
||||
class M365ProviderSecret(serializers.Serializer):
|
||||
client_id = serializers.CharField()
|
||||
client_secret = serializers.CharField()
|
||||
client_secret = serializers.CharField(required=False)
|
||||
tenant_id = serializers.CharField()
|
||||
user = serializers.EmailField(required=False)
|
||||
password = serializers.CharField(required=False)
|
||||
certificate_content = serializers.CharField(required=False)
|
||||
|
||||
def validate(self, attrs):
|
||||
if attrs.get("client_secret") and attrs.get("certificate_content"):
|
||||
raise serializers.ValidationError(
|
||||
"You cannot provide both client_secret and certificate_content."
|
||||
)
|
||||
if not attrs.get("client_secret") and not attrs.get("certificate_content"):
|
||||
raise serializers.ValidationError(
|
||||
"You must provide either client_secret or certificate_content."
|
||||
)
|
||||
return super().validate(attrs)
|
||||
|
||||
def validate_certificate_content(self, certificate_content):
|
||||
"""Validate that M365 certificate content is valid base64 encoded data."""
|
||||
if certificate_content:
|
||||
try:
|
||||
base64.b64decode(certificate_content, validate=True)
|
||||
except Exception as e:
|
||||
raise ValidationError(
|
||||
{
|
||||
"certificate_content": [
|
||||
f"The provided certificate content is not valid base64 encoded data: {str(e)}"
|
||||
]
|
||||
},
|
||||
code="m365-certificate-content",
|
||||
)
|
||||
return certificate_content
|
||||
|
||||
class Meta:
|
||||
resource_name = "provider-secrets"
|
||||
@@ -1974,6 +2020,17 @@ class ComplianceOverviewDetailSerializer(serializers.Serializer):
|
||||
resource_name = "compliance-requirements-details"
|
||||
|
||||
|
||||
class ComplianceOverviewDetailThreatscoreSerializer(ComplianceOverviewDetailSerializer):
|
||||
"""
|
||||
Serializer for detailed compliance requirement information for Threatscore.
|
||||
|
||||
Includes additional fields specific to the Threatscore framework.
|
||||
"""
|
||||
|
||||
passed_findings = serializers.IntegerField()
|
||||
total_findings = serializers.IntegerField()
|
||||
|
||||
|
||||
class ComplianceOverviewAttributesSerializer(serializers.Serializer):
|
||||
id = serializers.CharField()
|
||||
compliance_name = serializers.CharField()
|
||||
@@ -2779,6 +2836,10 @@ class TenantApiKeySerializer(RLSSerializer):
|
||||
"entity",
|
||||
]
|
||||
|
||||
included_serializers = {
|
||||
"entity": "api.v1.serializers.UserIncludeSerializer",
|
||||
}
|
||||
|
||||
|
||||
class TenantApiKeyCreateSerializer(RLSSerializer, BaseWriteSerializer):
|
||||
"""Serializer for creating new API keys."""
|
||||
@@ -2811,6 +2872,13 @@ class TenantApiKeyCreateSerializer(RLSSerializer, BaseWriteSerializer):
|
||||
"api_key": {"read_only": True},
|
||||
}
|
||||
|
||||
def validate_name(self, value):
|
||||
"""Validate that the name is unique within the tenant."""
|
||||
tenant_id = self.context.get("tenant_id")
|
||||
if TenantAPIKey.objects.filter(tenant_id=tenant_id, name=value).exists():
|
||||
raise ValidationError("An API key with this name already exists.")
|
||||
return value
|
||||
|
||||
def get_api_key(self, obj):
|
||||
"""Return the raw API key if it was stored during creation."""
|
||||
return getattr(obj, "_raw_api_key", None)
|
||||
@@ -2852,3 +2920,14 @@ class TenantApiKeyUpdateSerializer(RLSSerializer, BaseWriteSerializer):
|
||||
"inserted_at": {"read_only": True},
|
||||
"last_used_at": {"read_only": True},
|
||||
}
|
||||
|
||||
def validate_name(self, value):
|
||||
"""Validate that the name is unique within the tenant, excluding current instance."""
|
||||
tenant_id = self.context.get("tenant_id")
|
||||
if (
|
||||
TenantAPIKey.objects.filter(tenant_id=tenant_id, name=value)
|
||||
.exclude(id=self.instance.id)
|
||||
.exists()
|
||||
):
|
||||
raise ValidationError("An API key with this name already exists.")
|
||||
return value
|
||||
|
||||
@@ -142,6 +142,7 @@ from api.v1.mixins import PaginateByPkMixin, TaskManagementMixin
|
||||
from api.v1.serializers import (
|
||||
ComplianceOverviewAttributesSerializer,
|
||||
ComplianceOverviewDetailSerializer,
|
||||
ComplianceOverviewDetailThreatscoreSerializer,
|
||||
ComplianceOverviewMetadataSerializer,
|
||||
ComplianceOverviewSerializer,
|
||||
FindingDynamicFilterSerializer,
|
||||
@@ -665,36 +666,48 @@ class TenantFinishACSView(FinishACSView):
|
||||
.get(email_domain=email_domain)
|
||||
.tenant
|
||||
)
|
||||
role_name = (
|
||||
extra.get("userType", ["no_permissions"])[0].strip()
|
||||
if extra.get("userType")
|
||||
else "no_permissions"
|
||||
|
||||
# Check if tenant has only one user with MANAGE_ACCOUNT role
|
||||
users_with_manage_account = (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(role__manage_account=True, tenant_id=tenant.id)
|
||||
.values("user")
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
try:
|
||||
role = Role.objects.using(MainRouter.admin_db).get(
|
||||
name=role_name, tenant=tenant
|
||||
|
||||
# Only apply role mapping from userType if tenant does NOT have exactly one user with MANAGE_ACCOUNT
|
||||
if users_with_manage_account != 1:
|
||||
role_name = (
|
||||
extra.get("userType", ["no_permissions"])[0].strip()
|
||||
if extra.get("userType")
|
||||
else "no_permissions"
|
||||
)
|
||||
except Role.DoesNotExist:
|
||||
role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name=role_name,
|
||||
tenant=tenant,
|
||||
manage_users=False,
|
||||
manage_account=False,
|
||||
manage_billing=False,
|
||||
manage_providers=False,
|
||||
manage_integrations=False,
|
||||
manage_scans=False,
|
||||
unlimited_visibility=False,
|
||||
try:
|
||||
role = Role.objects.using(MainRouter.admin_db).get(
|
||||
name=role_name, tenant=tenant
|
||||
)
|
||||
except Role.DoesNotExist:
|
||||
role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name=role_name,
|
||||
tenant=tenant,
|
||||
manage_users=False,
|
||||
manage_account=False,
|
||||
manage_billing=False,
|
||||
manage_providers=False,
|
||||
manage_integrations=False,
|
||||
manage_scans=False,
|
||||
unlimited_visibility=False,
|
||||
)
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).filter(
|
||||
user=user,
|
||||
tenant_id=tenant.id,
|
||||
).delete()
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=user,
|
||||
role=role,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).filter(
|
||||
user=user,
|
||||
tenant_id=tenant.id,
|
||||
).delete()
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=user,
|
||||
role=role,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
membership, _ = Membership.objects.using(MainRouter.admin_db).get_or_create(
|
||||
user=user,
|
||||
tenant=tenant,
|
||||
@@ -3436,15 +3449,16 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
|
||||
)
|
||||
filtered_queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
all_requirements = (
|
||||
filtered_queryset.values(
|
||||
"requirement_id", "framework", "version", "description"
|
||||
)
|
||||
.distinct()
|
||||
.annotate(
|
||||
total_instances=Count("id"),
|
||||
manual_count=Count("id", filter=Q(requirement_status="MANUAL")),
|
||||
)
|
||||
all_requirements = filtered_queryset.values(
|
||||
"requirement_id",
|
||||
"framework",
|
||||
"version",
|
||||
"description",
|
||||
).annotate(
|
||||
total_instances=Count("id"),
|
||||
manual_count=Count("id", filter=Q(requirement_status="MANUAL")),
|
||||
passed_findings_sum=Sum("passed_findings"),
|
||||
total_findings_sum=Sum("total_findings"),
|
||||
)
|
||||
|
||||
passed_instances = (
|
||||
@@ -3463,6 +3477,8 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
|
||||
total_instances = requirement["total_instances"]
|
||||
passed_count = passed_counts.get(requirement_id, 0)
|
||||
is_manual = requirement["manual_count"] == total_instances
|
||||
passed_findings = requirement["passed_findings_sum"] or 0
|
||||
total_findings = requirement["total_findings_sum"] or 0
|
||||
if is_manual:
|
||||
requirement_status = "MANUAL"
|
||||
elif passed_count == total_instances:
|
||||
@@ -3477,10 +3493,19 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
|
||||
"version": requirement["version"],
|
||||
"description": requirement["description"],
|
||||
"status": requirement_status,
|
||||
"passed_findings": passed_findings,
|
||||
"total_findings": total_findings,
|
||||
}
|
||||
)
|
||||
|
||||
serializer = self.get_serializer(requirements_summary, many=True)
|
||||
# Use different serializer for threatscore framework
|
||||
if "threatscore" not in compliance_id:
|
||||
serializer = self.get_serializer(requirements_summary, many=True)
|
||||
else:
|
||||
serializer = ComplianceOverviewDetailThreatscoreSerializer(
|
||||
requirements_summary, many=True
|
||||
)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=False, methods=["get"], url_name="attributes")
|
||||
|
||||
@@ -5,24 +5,39 @@ DEBUG = env.bool("DJANGO_DEBUG", default=True)
|
||||
ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["*"])
|
||||
|
||||
# Database
|
||||
default_db_name = env("POSTGRES_DB", default="prowler_db")
|
||||
default_db_user = env("POSTGRES_USER", default="prowler_user")
|
||||
default_db_password = env("POSTGRES_PASSWORD", default="prowler")
|
||||
default_db_host = env("POSTGRES_HOST", default="postgres-db")
|
||||
default_db_port = env("POSTGRES_PORT", default="5432")
|
||||
|
||||
DATABASES = {
|
||||
"prowler_user": {
|
||||
"ENGINE": "psqlextra.backend",
|
||||
"NAME": env("POSTGRES_DB", default="prowler_db"),
|
||||
"USER": env("POSTGRES_USER", default="prowler_user"),
|
||||
"PASSWORD": env("POSTGRES_PASSWORD", default="prowler"),
|
||||
"HOST": env("POSTGRES_HOST", default="postgres-db"),
|
||||
"PORT": env("POSTGRES_PORT", default="5432"),
|
||||
"NAME": default_db_name,
|
||||
"USER": default_db_user,
|
||||
"PASSWORD": default_db_password,
|
||||
"HOST": default_db_host,
|
||||
"PORT": default_db_port,
|
||||
},
|
||||
"admin": {
|
||||
"ENGINE": "psqlextra.backend",
|
||||
"NAME": env("POSTGRES_DB", default="prowler_db"),
|
||||
"NAME": default_db_name,
|
||||
"USER": env("POSTGRES_ADMIN_USER", default="prowler"),
|
||||
"PASSWORD": env("POSTGRES_ADMIN_PASSWORD", default="S3cret"),
|
||||
"HOST": env("POSTGRES_HOST", default="postgres-db"),
|
||||
"PORT": env("POSTGRES_PORT", default="5432"),
|
||||
"HOST": default_db_host,
|
||||
"PORT": default_db_port,
|
||||
},
|
||||
"replica": {
|
||||
"ENGINE": "psqlextra.backend",
|
||||
"NAME": env("POSTGRES_REPLICA_DB", default=default_db_name),
|
||||
"USER": env("POSTGRES_REPLICA_USER", default=default_db_user),
|
||||
"PASSWORD": env("POSTGRES_REPLICA_PASSWORD", default=default_db_password),
|
||||
"HOST": env("POSTGRES_REPLICA_HOST", default=default_db_host),
|
||||
"PORT": env("POSTGRES_REPLICA_PORT", default=default_db_port),
|
||||
},
|
||||
}
|
||||
|
||||
DATABASES["default"] = DATABASES["prowler_user"]
|
||||
|
||||
REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = tuple( # noqa: F405
|
||||
|
||||
@@ -6,22 +6,37 @@ ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["localhost", "127.0.0.
|
||||
|
||||
# Database
|
||||
# TODO Use Django database routers https://docs.djangoproject.com/en/5.0/topics/db/multi-db/#automatic-database-routing
|
||||
default_db_name = env("POSTGRES_DB")
|
||||
default_db_user = env("POSTGRES_USER")
|
||||
default_db_password = env("POSTGRES_PASSWORD")
|
||||
default_db_host = env("POSTGRES_HOST")
|
||||
default_db_port = env("POSTGRES_PORT")
|
||||
|
||||
DATABASES = {
|
||||
"prowler_user": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": env("POSTGRES_DB"),
|
||||
"USER": env("POSTGRES_USER"),
|
||||
"PASSWORD": env("POSTGRES_PASSWORD"),
|
||||
"HOST": env("POSTGRES_HOST"),
|
||||
"PORT": env("POSTGRES_PORT"),
|
||||
"ENGINE": "psqlextra.backend",
|
||||
"NAME": default_db_name,
|
||||
"USER": default_db_user,
|
||||
"PASSWORD": default_db_password,
|
||||
"HOST": default_db_host,
|
||||
"PORT": default_db_port,
|
||||
},
|
||||
"admin": {
|
||||
"ENGINE": "psqlextra.backend",
|
||||
"NAME": env("POSTGRES_DB"),
|
||||
"NAME": default_db_name,
|
||||
"USER": env("POSTGRES_ADMIN_USER"),
|
||||
"PASSWORD": env("POSTGRES_ADMIN_PASSWORD"),
|
||||
"HOST": env("POSTGRES_HOST"),
|
||||
"PORT": env("POSTGRES_PORT"),
|
||||
"HOST": default_db_host,
|
||||
"PORT": default_db_port,
|
||||
},
|
||||
"replica": {
|
||||
"ENGINE": "psqlextra.backend",
|
||||
"NAME": env("POSTGRES_REPLICA_DB", default=default_db_name),
|
||||
"USER": env("POSTGRES_REPLICA_USER", default=default_db_user),
|
||||
"PASSWORD": env("POSTGRES_REPLICA_PASSWORD", default=default_db_password),
|
||||
"HOST": env("POSTGRES_REPLICA_HOST", default=default_db_host),
|
||||
"PORT": env("POSTGRES_REPLICA_PORT", default=default_db_port),
|
||||
},
|
||||
}
|
||||
|
||||
DATABASES["default"] = DATABASES["prowler_user"]
|
||||
|
||||
@@ -5,6 +5,7 @@ from celery.utils.log import get_task_logger
|
||||
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
|
||||
from tasks.utils import batched
|
||||
|
||||
from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Finding, Integration, Provider
|
||||
from api.utils import initialize_prowler_integration, initialize_prowler_provider
|
||||
@@ -289,7 +290,7 @@ def upload_security_hub_integration(
|
||||
has_findings = False
|
||||
batch_number = 0
|
||||
|
||||
with rls_transaction(tenant_id):
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
qs = (
|
||||
Finding.all_objects.filter(tenant_id=tenant_id, scan_id=scan_id)
|
||||
.order_by("uid")
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from celery.utils.log import get_task_logger
|
||||
from config.settings.celery import CELERY_DEADLOCK_ATTEMPTS
|
||||
@@ -14,8 +18,11 @@ from api.compliance import (
|
||||
PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE,
|
||||
generate_scan_compliance,
|
||||
)
|
||||
from api.db_router import READ_REPLICA_ALIAS, MainRouter
|
||||
from api.db_utils import (
|
||||
create_objects_in_batches,
|
||||
POSTGRES_TENANT_VAR,
|
||||
SET_CONFIG_QUERY,
|
||||
psycopg_connection,
|
||||
rls_transaction,
|
||||
update_objects_in_batches,
|
||||
)
|
||||
@@ -40,6 +47,28 @@ from prowler.lib.scan.scan import Scan as ProwlerScan
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
# Column order must match `ComplianceRequirementOverview` schema in
|
||||
# `api/models.py`. Keep this list minimal but sufficient to populate all
|
||||
# non-nullable fields plus the counters we care about.
|
||||
COMPLIANCE_REQUIREMENT_COPY_COLUMNS = (
|
||||
"id",
|
||||
"tenant_id",
|
||||
"inserted_at",
|
||||
"compliance_id",
|
||||
"framework",
|
||||
"version",
|
||||
"description",
|
||||
"region",
|
||||
"requirement_id",
|
||||
"requirement_status",
|
||||
"passed_checks",
|
||||
"failed_checks",
|
||||
"total_checks",
|
||||
"passed_findings",
|
||||
"total_findings",
|
||||
"scan_id",
|
||||
)
|
||||
|
||||
|
||||
def _create_finding_delta(
|
||||
last_status: FindingStatus | None | str, new_status: FindingStatus | None
|
||||
@@ -107,6 +136,124 @@ def _store_resources(
|
||||
return resource_instance, (resource_instance.uid, resource_instance.region)
|
||||
|
||||
|
||||
def _copy_compliance_requirement_rows(
|
||||
tenant_id: str, rows: list[dict[str, Any]]
|
||||
) -> None:
|
||||
"""Stream compliance requirement rows into Postgres using COPY.
|
||||
|
||||
We leverage the admin connection (when available) to bypass the COPY + RLS
|
||||
restriction, writing only the fields required by
|
||||
``ComplianceRequirementOverview``.
|
||||
|
||||
Args:
|
||||
tenant_id: Target tenant UUID.
|
||||
rows: List of row dictionaries prepared by
|
||||
:func:`create_compliance_requirements`.
|
||||
"""
|
||||
|
||||
csv_buffer = io.StringIO()
|
||||
writer = csv.writer(csv_buffer)
|
||||
|
||||
datetime_now = datetime.now(tz=timezone.utc)
|
||||
for row in rows:
|
||||
writer.writerow(
|
||||
[
|
||||
str(row.get("id")),
|
||||
str(row.get("tenant_id")),
|
||||
(row.get("inserted_at") or datetime_now).isoformat(),
|
||||
row.get("compliance_id") or "",
|
||||
row.get("framework") or "",
|
||||
row.get("version") or "",
|
||||
row.get("description") or "",
|
||||
row.get("region") or "",
|
||||
row.get("requirement_id") or "",
|
||||
row.get("requirement_status") or "",
|
||||
row.get("passed_checks", 0),
|
||||
row.get("failed_checks", 0),
|
||||
row.get("total_checks", 0),
|
||||
row.get("passed_findings", 0),
|
||||
row.get("total_findings", 0),
|
||||
str(row.get("scan_id")),
|
||||
]
|
||||
)
|
||||
|
||||
csv_buffer.seek(0)
|
||||
copy_sql = (
|
||||
"COPY compliance_requirements_overviews ("
|
||||
+ ", ".join(COMPLIANCE_REQUIREMENT_COPY_COLUMNS)
|
||||
+ ") FROM STDIN WITH (FORMAT CSV, DELIMITER ',', QUOTE '\"', ESCAPE '\"', NULL '\\N')"
|
||||
)
|
||||
|
||||
try:
|
||||
with psycopg_connection(MainRouter.admin_db) as connection:
|
||||
connection.autocommit = False
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(SET_CONFIG_QUERY, [POSTGRES_TENANT_VAR, tenant_id])
|
||||
cursor.copy_expert(copy_sql, csv_buffer)
|
||||
connection.commit()
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
csv_buffer.close()
|
||||
|
||||
|
||||
def _persist_compliance_requirement_rows(
|
||||
tenant_id: str, rows: list[dict[str, Any]]
|
||||
) -> None:
|
||||
"""Persist compliance requirement rows using COPY with ORM fallback.
|
||||
|
||||
Args:
|
||||
tenant_id: Target tenant UUID.
|
||||
rows: Precomputed row dictionaries that reflect the compliance
|
||||
overview state for a scan.
|
||||
"""
|
||||
if not rows:
|
||||
return
|
||||
|
||||
try:
|
||||
_copy_compliance_requirement_rows(tenant_id, rows)
|
||||
except Exception as error:
|
||||
logger.exception(
|
||||
"COPY bulk insert for compliance requirements failed; falling back to ORM bulk_create",
|
||||
exc_info=error,
|
||||
)
|
||||
fallback_objects = [
|
||||
ComplianceRequirementOverview(
|
||||
id=row["id"],
|
||||
tenant_id=row["tenant_id"],
|
||||
inserted_at=row["inserted_at"],
|
||||
compliance_id=row["compliance_id"],
|
||||
framework=row["framework"],
|
||||
version=row["version"],
|
||||
description=row["description"],
|
||||
region=row["region"],
|
||||
requirement_id=row["requirement_id"],
|
||||
requirement_status=row["requirement_status"],
|
||||
passed_checks=row["passed_checks"],
|
||||
failed_checks=row["failed_checks"],
|
||||
total_checks=row["total_checks"],
|
||||
passed_findings=row.get("passed_findings", 0),
|
||||
total_findings=row.get("total_findings", 0),
|
||||
scan_id=row["scan_id"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
with rls_transaction(tenant_id):
|
||||
ComplianceRequirementOverview.objects.bulk_create(
|
||||
fallback_objects, batch_size=500
|
||||
)
|
||||
|
||||
|
||||
def _normalized_compliance_key(framework: str | None, version: str | None) -> str:
|
||||
"""Return normalized identifier used to group compliance totals."""
|
||||
|
||||
normalized_framework = (framework or "").lower().replace("-", "").replace("_", "")
|
||||
normalized_version = (version or "").lower().replace("-", "").replace("_", "")
|
||||
return f"{normalized_framework}{normalized_version}"
|
||||
|
||||
|
||||
def perform_prowler_scan(
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
@@ -143,7 +290,7 @@ def perform_prowler_scan(
|
||||
scan_instance.save()
|
||||
|
||||
# Find the mutelist processor if it exists
|
||||
with rls_transaction(tenant_id):
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
try:
|
||||
mutelist_processor = Processor.objects.get(
|
||||
tenant_id=tenant_id, processor_type=Processor.ProcessorChoices.MUTELIST
|
||||
@@ -272,7 +419,7 @@ def perform_prowler_scan(
|
||||
unique_resources.add((resource_instance.uid, resource_instance.region))
|
||||
|
||||
# Process finding
|
||||
with rls_transaction(tenant_id):
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
finding_uid = finding.uid
|
||||
last_first_seen_at = None
|
||||
if finding_uid not in last_status_cache:
|
||||
@@ -305,6 +452,12 @@ def perform_prowler_scan(
|
||||
# If the finding is muted at this time the reason must be the configured Mutelist
|
||||
muted_reason = "Muted by mutelist" if finding.muted else None
|
||||
|
||||
# Increment failed_findings_count cache if the finding status is FAIL and not muted
|
||||
if status == FindingStatus.FAIL and not finding.muted:
|
||||
resource_uid = finding.resource_uid
|
||||
resource_failed_findings_cache[resource_uid] += 1
|
||||
|
||||
with rls_transaction(tenant_id):
|
||||
# Create the finding
|
||||
finding_instance = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
@@ -325,11 +478,6 @@ def perform_prowler_scan(
|
||||
)
|
||||
finding_instance.add_resources([resource_instance])
|
||||
|
||||
# Increment failed_findings_count cache if the finding status is FAIL and not muted
|
||||
if status == FindingStatus.FAIL and not finding.muted:
|
||||
resource_uid = finding.resource_uid
|
||||
resource_failed_findings_cache[resource_uid] += 1
|
||||
|
||||
# Update scan resource summaries
|
||||
scan_resource_cache.add(
|
||||
(
|
||||
@@ -439,7 +587,7 @@ def aggregate_findings(tenant_id: str, scan_id: str):
|
||||
- muted_new: Muted findings with a delta of 'new'.
|
||||
- muted_changed: Muted findings with a delta of 'changed'.
|
||||
"""
|
||||
with rls_transaction(tenant_id):
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
findings = Finding.objects.filter(tenant_id=tenant_id, scan_id=scan_id)
|
||||
|
||||
aggregation = findings.values(
|
||||
@@ -582,15 +730,32 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
ValidationError: If tenant_id is not a valid UUID.
|
||||
"""
|
||||
try:
|
||||
with rls_transaction(tenant_id):
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
scan_instance = Scan.objects.get(pk=scan_id)
|
||||
provider_instance = scan_instance.provider
|
||||
prowler_provider = return_prowler_provider(provider_instance)
|
||||
|
||||
compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE[
|
||||
provider_instance.provider
|
||||
]
|
||||
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
|
||||
threatscore_requirements_by_check: dict[str, set[str]] = {}
|
||||
threatscore_framework = compliance_template.get(
|
||||
modeled_threatscore_compliance_id
|
||||
)
|
||||
if threatscore_framework:
|
||||
for requirement_id, requirement in threatscore_framework[
|
||||
"requirements"
|
||||
].items():
|
||||
for check_id in requirement["checks"]:
|
||||
threatscore_requirements_by_check.setdefault(check_id, set()).add(
|
||||
requirement_id
|
||||
)
|
||||
|
||||
# Get check status data by region from findings
|
||||
findings = (
|
||||
Finding.all_objects.filter(scan_id=scan_id, muted=False)
|
||||
.only("id", "check_id", "status")
|
||||
.only("id", "check_id", "status", "compliance")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"resources",
|
||||
@@ -601,14 +766,36 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
.iterator(chunk_size=1000)
|
||||
)
|
||||
|
||||
findings_count_by_compliance = {}
|
||||
check_status_by_region = {}
|
||||
with rls_transaction(tenant_id):
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
for finding in findings:
|
||||
for resource in finding.small_resources:
|
||||
region = resource.region
|
||||
current_status = check_status_by_region.setdefault(region, {})
|
||||
if current_status.get(finding.check_id) != "FAIL":
|
||||
current_status[finding.check_id] = finding.status
|
||||
if modeled_threatscore_compliance_id in finding.compliance:
|
||||
for requirement_id in finding.compliance[
|
||||
modeled_threatscore_compliance_id
|
||||
]:
|
||||
compliance_key = findings_count_by_compliance.setdefault(
|
||||
region, {}
|
||||
).setdefault(
|
||||
modeled_threatscore_compliance_id.lower().replace(
|
||||
"-", ""
|
||||
),
|
||||
{},
|
||||
)
|
||||
if requirement_id not in compliance_key:
|
||||
compliance_key[requirement_id] = {
|
||||
"total": 0,
|
||||
"pass": 0,
|
||||
}
|
||||
|
||||
compliance_key[requirement_id]["total"] += 1
|
||||
if finding.status == "PASS":
|
||||
compliance_key[requirement_id]["pass"] += 1
|
||||
|
||||
try:
|
||||
# Try to get regions from provider
|
||||
@@ -617,11 +804,6 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
# If not available, use regions from findings
|
||||
regions = set(check_status_by_region.keys())
|
||||
|
||||
# Get compliance template for the provider
|
||||
compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE[
|
||||
provider_instance.provider
|
||||
]
|
||||
|
||||
# Create compliance data by region
|
||||
compliance_overview_by_region = {
|
||||
region: deepcopy(compliance_template) for region in regions
|
||||
@@ -640,36 +822,53 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
status,
|
||||
)
|
||||
|
||||
# Prepare compliance requirement objects
|
||||
compliance_requirement_objects = []
|
||||
# Prepare compliance requirement rows
|
||||
compliance_requirement_rows: list[dict[str, Any]] = []
|
||||
utc_datetime_now = datetime.now(tz=timezone.utc)
|
||||
for region, compliance_data in compliance_overview_by_region.items():
|
||||
for compliance_id, compliance in compliance_data.items():
|
||||
modeled_compliance_id = _normalized_compliance_key(
|
||||
compliance["framework"], compliance["version"]
|
||||
)
|
||||
# Create an overview record for each requirement within each compliance framework
|
||||
for requirement_id, requirement in compliance["requirements"].items():
|
||||
compliance_requirement_objects.append(
|
||||
ComplianceRequirementOverview(
|
||||
tenant_id=tenant_id,
|
||||
scan=scan_instance,
|
||||
region=region,
|
||||
compliance_id=compliance_id,
|
||||
framework=compliance["framework"],
|
||||
version=compliance["version"],
|
||||
requirement_id=requirement_id,
|
||||
description=requirement["description"],
|
||||
passed_checks=requirement["checks_status"]["pass"],
|
||||
failed_checks=requirement["checks_status"]["fail"],
|
||||
total_checks=requirement["checks_status"]["total"],
|
||||
requirement_status=requirement["status"],
|
||||
)
|
||||
checks_status = requirement["checks_status"]
|
||||
compliance_requirement_rows.append(
|
||||
{
|
||||
"id": uuid.uuid4(),
|
||||
"tenant_id": tenant_id,
|
||||
"inserted_at": utc_datetime_now,
|
||||
"compliance_id": compliance_id,
|
||||
"framework": compliance["framework"],
|
||||
"version": compliance["version"] or "",
|
||||
"description": requirement.get("description") or "",
|
||||
"region": region,
|
||||
"requirement_id": requirement_id,
|
||||
"requirement_status": requirement["status"],
|
||||
"passed_checks": checks_status["pass"],
|
||||
"failed_checks": checks_status["fail"],
|
||||
"total_checks": checks_status["total"],
|
||||
"scan_id": scan_instance.id,
|
||||
"passed_findings": findings_count_by_compliance.get(
|
||||
region, {}
|
||||
)
|
||||
.get(modeled_compliance_id, {})
|
||||
.get(requirement_id, {})
|
||||
.get("pass", 0),
|
||||
"total_findings": findings_count_by_compliance.get(
|
||||
region, {}
|
||||
)
|
||||
.get(modeled_compliance_id, {})
|
||||
.get(requirement_id, {})
|
||||
.get("total", 0),
|
||||
}
|
||||
)
|
||||
|
||||
# Bulk create requirement records
|
||||
create_objects_in_batches(
|
||||
tenant_id, ComplianceRequirementOverview, compliance_requirement_objects
|
||||
)
|
||||
# Bulk create requirement records using PostgreSQL COPY
|
||||
_persist_compliance_requirement_rows(tenant_id, compliance_requirement_rows)
|
||||
|
||||
return {
|
||||
"requirements_created": len(compliance_requirement_objects),
|
||||
"requirements_created": len(compliance_requirement_rows),
|
||||
"regions_processed": list(regions),
|
||||
"compliance_frameworks": (
|
||||
list(compliance_overview_by_region.get(list(regions)[0], {}).keys())
|
||||
|
||||
@@ -34,6 +34,7 @@ from tasks.jobs.scan import (
|
||||
from tasks.utils import batched, get_next_execution_datetime
|
||||
|
||||
from api.compliance import get_compliance_frameworks
|
||||
from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_utils import rls_transaction
|
||||
from api.decorators import set_tenant
|
||||
from api.models import Finding, Integration, Provider, Scan, ScanSummary, StateChoices
|
||||
@@ -343,70 +344,73 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
|
||||
.order_by("uid")
|
||||
.iterator()
|
||||
)
|
||||
for batch, is_last in batched(qs, DJANGO_FINDINGS_BATCH_SIZE):
|
||||
fos = [FindingOutput.transform_api_finding(f, prowler_provider) for f in batch]
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
for batch, is_last in batched(qs, DJANGO_FINDINGS_BATCH_SIZE):
|
||||
fos = [
|
||||
FindingOutput.transform_api_finding(f, prowler_provider) for f in batch
|
||||
]
|
||||
|
||||
# Outputs
|
||||
for mode, cfg in OUTPUT_FORMATS_MAPPING.items():
|
||||
# Skip ASFF generation if not needed
|
||||
if mode == "json-asff" and not generate_asff:
|
||||
continue
|
||||
# Outputs
|
||||
for mode, cfg in OUTPUT_FORMATS_MAPPING.items():
|
||||
# Skip ASFF generation if not needed
|
||||
if mode == "json-asff" and not generate_asff:
|
||||
continue
|
||||
|
||||
cls = cfg["class"]
|
||||
suffix = cfg["suffix"]
|
||||
extra = cfg.get("kwargs", {}).copy()
|
||||
if mode == "html":
|
||||
extra.update(provider=prowler_provider, stats=scan_summary)
|
||||
cls = cfg["class"]
|
||||
suffix = cfg["suffix"]
|
||||
extra = cfg.get("kwargs", {}).copy()
|
||||
if mode == "html":
|
||||
extra.update(provider=prowler_provider, stats=scan_summary)
|
||||
|
||||
writer, initialization = get_writer(
|
||||
output_writers,
|
||||
cls,
|
||||
lambda cls=cls, fos=fos, suffix=suffix: cls(
|
||||
findings=fos,
|
||||
file_path=out_dir,
|
||||
file_extension=suffix,
|
||||
from_cli=False,
|
||||
),
|
||||
is_last,
|
||||
)
|
||||
if not initialization:
|
||||
writer.transform(fos)
|
||||
writer.batch_write_data_to_file(**extra)
|
||||
writer._data.clear()
|
||||
writer, initialization = get_writer(
|
||||
output_writers,
|
||||
cls,
|
||||
lambda cls=cls, fos=fos, suffix=suffix: cls(
|
||||
findings=fos,
|
||||
file_path=out_dir,
|
||||
file_extension=suffix,
|
||||
from_cli=False,
|
||||
),
|
||||
is_last,
|
||||
)
|
||||
if not initialization:
|
||||
writer.transform(fos)
|
||||
writer.batch_write_data_to_file(**extra)
|
||||
writer._data.clear()
|
||||
|
||||
# Compliance CSVs
|
||||
for name in frameworks_avail:
|
||||
compliance_obj = frameworks_bulk[name]
|
||||
# Compliance CSVs
|
||||
for name in frameworks_avail:
|
||||
compliance_obj = frameworks_bulk[name]
|
||||
|
||||
klass = GenericCompliance
|
||||
for condition, cls in COMPLIANCE_CLASS_MAP.get(provider_type, []):
|
||||
if condition(name):
|
||||
klass = cls
|
||||
break
|
||||
klass = GenericCompliance
|
||||
for condition, cls in COMPLIANCE_CLASS_MAP.get(provider_type, []):
|
||||
if condition(name):
|
||||
klass = cls
|
||||
break
|
||||
|
||||
filename = f"{comp_dir}_{name}.csv"
|
||||
filename = f"{comp_dir}_{name}.csv"
|
||||
|
||||
writer, initialization = get_writer(
|
||||
compliance_writers,
|
||||
name,
|
||||
lambda klass=klass, fos=fos: klass(
|
||||
findings=fos,
|
||||
compliance=compliance_obj,
|
||||
file_path=filename,
|
||||
from_cli=False,
|
||||
),
|
||||
is_last,
|
||||
)
|
||||
if not initialization:
|
||||
writer.transform(fos, compliance_obj, name)
|
||||
writer.batch_write_data_to_file()
|
||||
writer._data.clear()
|
||||
writer, initialization = get_writer(
|
||||
compliance_writers,
|
||||
name,
|
||||
lambda klass=klass, fos=fos: klass(
|
||||
findings=fos,
|
||||
compliance=compliance_obj,
|
||||
file_path=filename,
|
||||
from_cli=False,
|
||||
),
|
||||
is_last,
|
||||
)
|
||||
if not initialization:
|
||||
writer.transform(fos, compliance_obj, name)
|
||||
writer.batch_write_data_to_file()
|
||||
writer._data.clear()
|
||||
|
||||
compressed = _compress_output_files(out_dir)
|
||||
upload_uri = _upload_to_s3(tenant_id, compressed, scan_id)
|
||||
|
||||
# S3 integrations (need output_directory)
|
||||
with rls_transaction(tenant_id):
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
s3_integrations = Integration.objects.filter(
|
||||
integrationproviderrelationship__provider_id=provider_id,
|
||||
integration_type=Integration.IntegrationChoices.AMAZON_S3,
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import csv
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from io import StringIO
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from tasks.jobs.scan import (
|
||||
_copy_compliance_requirement_rows,
|
||||
_create_finding_delta,
|
||||
_persist_compliance_requirement_rows,
|
||||
_store_resources,
|
||||
create_compliance_requirements,
|
||||
perform_prowler_scan,
|
||||
)
|
||||
from tasks.utils import CustomEncoder
|
||||
|
||||
from api.db_router import MainRouter
|
||||
from api.exceptions import ProviderConnectionError
|
||||
from api.models import Finding, Provider, Resource, Scan, StateChoices, StatusChoices
|
||||
from prowler.lib.check.models import Severity
|
||||
@@ -1045,3 +1050,773 @@ class TestCreateComplianceRequirements:
|
||||
|
||||
assert "requirements_created" in result
|
||||
assert result["requirements_created"] >= 0
|
||||
|
||||
|
||||
class TestComplianceRequirementCopy:
|
||||
@patch("tasks.jobs.scan.psycopg_connection")
|
||||
def test_copy_compliance_requirement_rows_streams_csv(
|
||||
self, mock_psycopg_connection, settings
|
||||
):
|
||||
settings.DATABASES.setdefault("admin", settings.DATABASES["default"])
|
||||
|
||||
connection = MagicMock()
|
||||
cursor = MagicMock()
|
||||
cursor_context = MagicMock()
|
||||
cursor_context.__enter__.return_value = cursor
|
||||
cursor_context.__exit__.return_value = False
|
||||
connection.cursor.return_value = cursor_context
|
||||
connection.__enter__.return_value = connection
|
||||
connection.__exit__.return_value = False
|
||||
|
||||
context_manager = MagicMock()
|
||||
context_manager.__enter__.return_value = connection
|
||||
context_manager.__exit__.return_value = False
|
||||
mock_psycopg_connection.return_value = context_manager
|
||||
|
||||
captured = {}
|
||||
|
||||
def copy_side_effect(sql, file_obj):
|
||||
captured["sql"] = sql
|
||||
captured["data"] = file_obj.read()
|
||||
|
||||
cursor.copy_expert.side_effect = copy_side_effect
|
||||
|
||||
row = {
|
||||
"id": uuid.uuid4(),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"compliance_id": "cisa_aws",
|
||||
"framework": "CISA",
|
||||
"version": None,
|
||||
"description": "desc",
|
||||
"region": "us-east-1",
|
||||
"requirement_id": "req-1",
|
||||
"requirement_status": "PASS",
|
||||
"passed_checks": 1,
|
||||
"failed_checks": 0,
|
||||
"total_checks": 1,
|
||||
"scan_id": uuid.uuid4(),
|
||||
}
|
||||
|
||||
with patch.object(MainRouter, "admin_db", "admin"):
|
||||
_copy_compliance_requirement_rows(str(row["tenant_id"]), [row])
|
||||
|
||||
mock_psycopg_connection.assert_called_once_with("admin")
|
||||
connection.cursor.assert_called_once()
|
||||
cursor.execute.assert_called_once()
|
||||
cursor.copy_expert.assert_called_once()
|
||||
|
||||
csv_rows = list(csv.reader(StringIO(captured["data"])))
|
||||
assert csv_rows[0][0] == str(row["id"])
|
||||
assert csv_rows[0][5] == ""
|
||||
assert csv_rows[0][-1] == str(row["scan_id"])
|
||||
|
||||
@patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create")
|
||||
@patch("tasks.jobs.scan.rls_transaction")
|
||||
@patch(
|
||||
"tasks.jobs.scan._copy_compliance_requirement_rows",
|
||||
side_effect=Exception("copy failed"),
|
||||
)
|
||||
def test_persist_compliance_requirement_rows_fallback(
|
||||
self, mock_copy, mock_rls_transaction, mock_bulk_create
|
||||
):
|
||||
inserted_at = datetime.now(timezone.utc)
|
||||
row = {
|
||||
"id": uuid.uuid4(),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"inserted_at": inserted_at,
|
||||
"compliance_id": "cisa_aws",
|
||||
"framework": "CISA",
|
||||
"version": "1.0",
|
||||
"description": "desc",
|
||||
"region": "us-east-1",
|
||||
"requirement_id": "req-1",
|
||||
"requirement_status": "PASS",
|
||||
"passed_checks": 1,
|
||||
"failed_checks": 0,
|
||||
"total_checks": 1,
|
||||
"scan_id": uuid.uuid4(),
|
||||
}
|
||||
|
||||
tenant_id = row["tenant_id"]
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.__enter__.return_value = None
|
||||
ctx.__exit__.return_value = False
|
||||
mock_rls_transaction.return_value = ctx
|
||||
|
||||
_persist_compliance_requirement_rows(tenant_id, [row])
|
||||
|
||||
mock_copy.assert_called_once_with(tenant_id, [row])
|
||||
mock_rls_transaction.assert_called_once_with(tenant_id)
|
||||
mock_bulk_create.assert_called_once()
|
||||
|
||||
args, kwargs = mock_bulk_create.call_args
|
||||
objects = args[0]
|
||||
assert len(objects) == 1
|
||||
fallback = objects[0]
|
||||
assert fallback.version == row["version"]
|
||||
assert fallback.compliance_id == row["compliance_id"]
|
||||
|
||||
@patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create")
|
||||
@patch("tasks.jobs.scan.rls_transaction")
|
||||
@patch("tasks.jobs.scan._copy_compliance_requirement_rows")
|
||||
def test_persist_compliance_requirement_rows_no_rows(
|
||||
self, mock_copy, mock_rls_transaction, mock_bulk_create
|
||||
):
|
||||
_persist_compliance_requirement_rows(str(uuid.uuid4()), [])
|
||||
|
||||
mock_copy.assert_not_called()
|
||||
mock_rls_transaction.assert_not_called()
|
||||
mock_bulk_create.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.scan.psycopg_connection")
|
||||
def test_copy_compliance_requirement_rows_multiple_rows(
|
||||
self, mock_psycopg_connection, settings
|
||||
):
|
||||
"""Test COPY with multiple rows to ensure batch processing works correctly."""
|
||||
settings.DATABASES.setdefault("admin", settings.DATABASES["default"])
|
||||
|
||||
connection = MagicMock()
|
||||
cursor = MagicMock()
|
||||
cursor_context = MagicMock()
|
||||
cursor_context.__enter__.return_value = cursor
|
||||
cursor_context.__exit__.return_value = False
|
||||
connection.cursor.return_value = cursor_context
|
||||
connection.__enter__.return_value = connection
|
||||
connection.__exit__.return_value = False
|
||||
|
||||
context_manager = MagicMock()
|
||||
context_manager.__enter__.return_value = connection
|
||||
context_manager.__exit__.return_value = False
|
||||
mock_psycopg_connection.return_value = context_manager
|
||||
|
||||
captured = {}
|
||||
|
||||
def copy_side_effect(sql, file_obj):
|
||||
captured["sql"] = sql
|
||||
captured["data"] = file_obj.read()
|
||||
|
||||
cursor.copy_expert.side_effect = copy_side_effect
|
||||
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = uuid.uuid4()
|
||||
inserted_at = datetime.now(timezone.utc)
|
||||
|
||||
rows = [
|
||||
{
|
||||
"id": uuid.uuid4(),
|
||||
"tenant_id": tenant_id,
|
||||
"inserted_at": inserted_at,
|
||||
"compliance_id": "cisa_aws",
|
||||
"framework": "CISA",
|
||||
"version": "1.0",
|
||||
"description": "First requirement",
|
||||
"region": "us-east-1",
|
||||
"requirement_id": "req-1",
|
||||
"requirement_status": "PASS",
|
||||
"passed_checks": 5,
|
||||
"failed_checks": 0,
|
||||
"total_checks": 5,
|
||||
"scan_id": scan_id,
|
||||
},
|
||||
{
|
||||
"id": uuid.uuid4(),
|
||||
"tenant_id": tenant_id,
|
||||
"inserted_at": inserted_at,
|
||||
"compliance_id": "cisa_aws",
|
||||
"framework": "CISA",
|
||||
"version": "1.0",
|
||||
"description": "Second requirement",
|
||||
"region": "us-west-2",
|
||||
"requirement_id": "req-2",
|
||||
"requirement_status": "FAIL",
|
||||
"passed_checks": 3,
|
||||
"failed_checks": 2,
|
||||
"total_checks": 5,
|
||||
"scan_id": scan_id,
|
||||
},
|
||||
{
|
||||
"id": uuid.uuid4(),
|
||||
"tenant_id": tenant_id,
|
||||
"inserted_at": inserted_at,
|
||||
"compliance_id": "aws_foundational_security_aws",
|
||||
"framework": "AWS-Foundational-Security-Best-Practices",
|
||||
"version": "2.0",
|
||||
"description": "Third requirement",
|
||||
"region": "eu-west-1",
|
||||
"requirement_id": "req-3",
|
||||
"requirement_status": "MANUAL",
|
||||
"passed_checks": 0,
|
||||
"failed_checks": 0,
|
||||
"total_checks": 3,
|
||||
"scan_id": scan_id,
|
||||
},
|
||||
]
|
||||
|
||||
with patch.object(MainRouter, "admin_db", "admin"):
|
||||
_copy_compliance_requirement_rows(tenant_id, rows)
|
||||
|
||||
mock_psycopg_connection.assert_called_once_with("admin")
|
||||
connection.cursor.assert_called_once()
|
||||
cursor.execute.assert_called_once()
|
||||
cursor.copy_expert.assert_called_once()
|
||||
|
||||
csv_rows = list(csv.reader(StringIO(captured["data"])))
|
||||
assert len(csv_rows) == 3
|
||||
|
||||
# Validate first row
|
||||
assert csv_rows[0][0] == str(rows[0]["id"])
|
||||
assert csv_rows[0][1] == tenant_id
|
||||
assert csv_rows[0][3] == "cisa_aws"
|
||||
assert csv_rows[0][4] == "CISA"
|
||||
assert csv_rows[0][6] == "First requirement"
|
||||
assert csv_rows[0][7] == "us-east-1"
|
||||
assert csv_rows[0][10] == "5"
|
||||
assert csv_rows[0][11] == "0"
|
||||
assert csv_rows[0][12] == "5"
|
||||
|
||||
# Validate second row
|
||||
assert csv_rows[1][0] == str(rows[1]["id"])
|
||||
assert csv_rows[1][7] == "us-west-2"
|
||||
assert csv_rows[1][9] == "FAIL"
|
||||
assert csv_rows[1][10] == "3"
|
||||
assert csv_rows[1][11] == "2"
|
||||
|
||||
# Validate third row
|
||||
assert csv_rows[2][0] == str(rows[2]["id"])
|
||||
assert csv_rows[2][3] == "aws_foundational_security_aws"
|
||||
assert csv_rows[2][5] == "2.0"
|
||||
assert csv_rows[2][9] == "MANUAL"
|
||||
|
||||
@patch("tasks.jobs.scan.psycopg_connection")
|
||||
def test_copy_compliance_requirement_rows_null_values(
|
||||
self, mock_psycopg_connection, settings
|
||||
):
|
||||
"""Test COPY handles NULL/None values correctly in nullable fields."""
|
||||
settings.DATABASES.setdefault("admin", settings.DATABASES["default"])
|
||||
|
||||
connection = MagicMock()
|
||||
cursor = MagicMock()
|
||||
cursor_context = MagicMock()
|
||||
cursor_context.__enter__.return_value = cursor
|
||||
cursor_context.__exit__.return_value = False
|
||||
connection.cursor.return_value = cursor_context
|
||||
connection.__enter__.return_value = connection
|
||||
connection.__exit__.return_value = False
|
||||
|
||||
context_manager = MagicMock()
|
||||
context_manager.__enter__.return_value = connection
|
||||
context_manager.__exit__.return_value = False
|
||||
mock_psycopg_connection.return_value = context_manager
|
||||
|
||||
captured = {}
|
||||
|
||||
def copy_side_effect(sql, file_obj):
|
||||
captured["sql"] = sql
|
||||
captured["data"] = file_obj.read()
|
||||
|
||||
cursor.copy_expert.side_effect = copy_side_effect
|
||||
|
||||
# Row with all nullable fields set to None/empty
|
||||
row = {
|
||||
"id": uuid.uuid4(),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"compliance_id": "test_framework",
|
||||
"framework": "Test",
|
||||
"version": None, # nullable
|
||||
"description": None, # nullable
|
||||
"region": "",
|
||||
"requirement_id": "req-1",
|
||||
"requirement_status": "PASS",
|
||||
"passed_checks": 0,
|
||||
"failed_checks": 0,
|
||||
"total_checks": 0,
|
||||
"scan_id": uuid.uuid4(),
|
||||
}
|
||||
|
||||
with patch.object(MainRouter, "admin_db", "admin"):
|
||||
_copy_compliance_requirement_rows(str(row["tenant_id"]), [row])
|
||||
|
||||
csv_rows = list(csv.reader(StringIO(captured["data"])))
|
||||
assert len(csv_rows) == 1
|
||||
|
||||
# Validate that None values are converted to empty strings in CSV
|
||||
assert csv_rows[0][5] == "" # version
|
||||
assert csv_rows[0][6] == "" # description
|
||||
|
||||
@patch("tasks.jobs.scan.psycopg_connection")
|
||||
def test_copy_compliance_requirement_rows_special_characters(
|
||||
self, mock_psycopg_connection, settings
|
||||
):
|
||||
"""Test COPY correctly escapes special characters in CSV."""
|
||||
settings.DATABASES.setdefault("admin", settings.DATABASES["default"])
|
||||
|
||||
connection = MagicMock()
|
||||
cursor = MagicMock()
|
||||
cursor_context = MagicMock()
|
||||
cursor_context.__enter__.return_value = cursor
|
||||
cursor_context.__exit__.return_value = False
|
||||
connection.cursor.return_value = cursor_context
|
||||
connection.__enter__.return_value = connection
|
||||
connection.__exit__.return_value = False
|
||||
|
||||
context_manager = MagicMock()
|
||||
context_manager.__enter__.return_value = connection
|
||||
context_manager.__exit__.return_value = False
|
||||
mock_psycopg_connection.return_value = context_manager
|
||||
|
||||
captured = {}
|
||||
|
||||
def copy_side_effect(sql, file_obj):
|
||||
captured["sql"] = sql
|
||||
captured["data"] = file_obj.read()
|
||||
|
||||
cursor.copy_expert.side_effect = copy_side_effect
|
||||
|
||||
# Row with special characters that need escaping
|
||||
row = {
|
||||
"id": uuid.uuid4(),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"compliance_id": 'framework"with"quotes',
|
||||
"framework": "Framework,with,commas",
|
||||
"version": "1.0",
|
||||
"description": 'Description with "quotes", commas, and\nnewlines',
|
||||
"region": "us-east-1",
|
||||
"requirement_id": "req-1",
|
||||
"requirement_status": "PASS",
|
||||
"passed_checks": 1,
|
||||
"failed_checks": 0,
|
||||
"total_checks": 1,
|
||||
"scan_id": uuid.uuid4(),
|
||||
}
|
||||
|
||||
with patch.object(MainRouter, "admin_db", "admin"):
|
||||
_copy_compliance_requirement_rows(str(row["tenant_id"]), [row])
|
||||
|
||||
# Verify CSV was generated (csv module handles escaping automatically)
|
||||
csv_rows = list(csv.reader(StringIO(captured["data"])))
|
||||
assert len(csv_rows) == 1
|
||||
|
||||
# Verify special characters are preserved after CSV parsing
|
||||
assert csv_rows[0][3] == 'framework"with"quotes'
|
||||
assert csv_rows[0][4] == "Framework,with,commas"
|
||||
assert "quotes" in csv_rows[0][6]
|
||||
assert "commas" in csv_rows[0][6]
|
||||
|
||||
@patch("tasks.jobs.scan.psycopg_connection")
|
||||
def test_copy_compliance_requirement_rows_missing_inserted_at(
|
||||
self, mock_psycopg_connection, settings
|
||||
):
|
||||
"""Test COPY uses current datetime when inserted_at is missing."""
|
||||
settings.DATABASES.setdefault("admin", settings.DATABASES["default"])
|
||||
|
||||
connection = MagicMock()
|
||||
cursor = MagicMock()
|
||||
cursor_context = MagicMock()
|
||||
cursor_context.__enter__.return_value = cursor
|
||||
cursor_context.__exit__.return_value = False
|
||||
connection.cursor.return_value = cursor_context
|
||||
connection.__enter__.return_value = connection
|
||||
connection.__exit__.return_value = False
|
||||
|
||||
context_manager = MagicMock()
|
||||
context_manager.__enter__.return_value = connection
|
||||
context_manager.__exit__.return_value = False
|
||||
mock_psycopg_connection.return_value = context_manager
|
||||
|
||||
captured = {}
|
||||
|
||||
def copy_side_effect(sql, file_obj):
|
||||
captured["sql"] = sql
|
||||
captured["data"] = file_obj.read()
|
||||
|
||||
cursor.copy_expert.side_effect = copy_side_effect
|
||||
|
||||
# Row without inserted_at field
|
||||
row = {
|
||||
"id": uuid.uuid4(),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"compliance_id": "test_framework",
|
||||
"framework": "Test",
|
||||
"version": "1.0",
|
||||
"description": "desc",
|
||||
"region": "us-east-1",
|
||||
"requirement_id": "req-1",
|
||||
"requirement_status": "PASS",
|
||||
"passed_checks": 1,
|
||||
"failed_checks": 0,
|
||||
"total_checks": 1,
|
||||
"scan_id": uuid.uuid4(),
|
||||
# Note: inserted_at is intentionally missing
|
||||
}
|
||||
|
||||
before_call = datetime.now(timezone.utc)
|
||||
with patch.object(MainRouter, "admin_db", "admin"):
|
||||
_copy_compliance_requirement_rows(str(row["tenant_id"]), [row])
|
||||
after_call = datetime.now(timezone.utc)
|
||||
|
||||
csv_rows = list(csv.reader(StringIO(captured["data"])))
|
||||
assert len(csv_rows) == 1
|
||||
|
||||
# Verify inserted_at was auto-generated and is a valid ISO datetime
|
||||
inserted_at_str = csv_rows[0][2]
|
||||
inserted_at = datetime.fromisoformat(inserted_at_str)
|
||||
assert before_call <= inserted_at <= after_call
|
||||
|
||||
@patch("tasks.jobs.scan.psycopg_connection")
|
||||
def test_copy_compliance_requirement_rows_transaction_rollback_on_copy_error(
|
||||
self, mock_psycopg_connection, settings
|
||||
):
|
||||
"""Test transaction is rolled back when copy_expert fails."""
|
||||
settings.DATABASES.setdefault("admin", settings.DATABASES["default"])
|
||||
|
||||
connection = MagicMock()
|
||||
cursor = MagicMock()
|
||||
cursor_context = MagicMock()
|
||||
cursor_context.__enter__.return_value = cursor
|
||||
cursor_context.__exit__.return_value = False
|
||||
connection.cursor.return_value = cursor_context
|
||||
connection.__enter__.return_value = connection
|
||||
connection.__exit__.return_value = False
|
||||
|
||||
context_manager = MagicMock()
|
||||
context_manager.__enter__.return_value = connection
|
||||
context_manager.__exit__.return_value = False
|
||||
mock_psycopg_connection.return_value = context_manager
|
||||
|
||||
# Simulate copy_expert failure
|
||||
cursor.copy_expert.side_effect = Exception("COPY command failed")
|
||||
|
||||
row = {
|
||||
"id": uuid.uuid4(),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"compliance_id": "test",
|
||||
"framework": "Test",
|
||||
"version": "1.0",
|
||||
"description": "desc",
|
||||
"region": "us-east-1",
|
||||
"requirement_id": "req-1",
|
||||
"requirement_status": "PASS",
|
||||
"passed_checks": 1,
|
||||
"failed_checks": 0,
|
||||
"total_checks": 1,
|
||||
"scan_id": uuid.uuid4(),
|
||||
}
|
||||
|
||||
with patch.object(MainRouter, "admin_db", "admin"):
|
||||
with pytest.raises(Exception, match="COPY command failed"):
|
||||
_copy_compliance_requirement_rows(str(row["tenant_id"]), [row])
|
||||
|
||||
# Verify rollback was called
|
||||
connection.rollback.assert_called_once()
|
||||
connection.commit.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.scan.psycopg_connection")
|
||||
def test_copy_compliance_requirement_rows_transaction_rollback_on_set_config_error(
|
||||
self, mock_psycopg_connection, settings
|
||||
):
|
||||
"""Test transaction is rolled back when SET_CONFIG fails."""
|
||||
settings.DATABASES.setdefault("admin", settings.DATABASES["default"])
|
||||
|
||||
connection = MagicMock()
|
||||
cursor = MagicMock()
|
||||
cursor_context = MagicMock()
|
||||
cursor_context.__enter__.return_value = cursor
|
||||
cursor_context.__exit__.return_value = False
|
||||
connection.cursor.return_value = cursor_context
|
||||
connection.__enter__.return_value = connection
|
||||
connection.__exit__.return_value = False
|
||||
|
||||
context_manager = MagicMock()
|
||||
context_manager.__enter__.return_value = connection
|
||||
context_manager.__exit__.return_value = False
|
||||
mock_psycopg_connection.return_value = context_manager
|
||||
|
||||
# Simulate cursor.execute failure
|
||||
cursor.execute.side_effect = Exception("SET prowler.tenant_id failed")
|
||||
|
||||
row = {
|
||||
"id": uuid.uuid4(),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"compliance_id": "test",
|
||||
"framework": "Test",
|
||||
"version": "1.0",
|
||||
"description": "desc",
|
||||
"region": "us-east-1",
|
||||
"requirement_id": "req-1",
|
||||
"requirement_status": "PASS",
|
||||
"passed_checks": 1,
|
||||
"failed_checks": 0,
|
||||
"total_checks": 1,
|
||||
"scan_id": uuid.uuid4(),
|
||||
}
|
||||
|
||||
with patch.object(MainRouter, "admin_db", "admin"):
|
||||
with pytest.raises(Exception, match="SET prowler.tenant_id failed"):
|
||||
_copy_compliance_requirement_rows(str(row["tenant_id"]), [row])
|
||||
|
||||
# Verify rollback was called
|
||||
connection.rollback.assert_called_once()
|
||||
connection.commit.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.scan.psycopg_connection")
|
||||
def test_copy_compliance_requirement_rows_commit_on_success(
|
||||
self, mock_psycopg_connection, settings
|
||||
):
|
||||
"""Test transaction is committed on successful COPY."""
|
||||
settings.DATABASES.setdefault("admin", settings.DATABASES["default"])
|
||||
|
||||
connection = MagicMock()
|
||||
cursor = MagicMock()
|
||||
cursor_context = MagicMock()
|
||||
cursor_context.__enter__.return_value = cursor
|
||||
cursor_context.__exit__.return_value = False
|
||||
connection.cursor.return_value = cursor_context
|
||||
connection.__enter__.return_value = connection
|
||||
connection.__exit__.return_value = False
|
||||
|
||||
context_manager = MagicMock()
|
||||
context_manager.__enter__.return_value = connection
|
||||
context_manager.__exit__.return_value = False
|
||||
mock_psycopg_connection.return_value = context_manager
|
||||
|
||||
cursor.copy_expert.return_value = None # Success
|
||||
|
||||
row = {
|
||||
"id": uuid.uuid4(),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"compliance_id": "test",
|
||||
"framework": "Test",
|
||||
"version": "1.0",
|
||||
"description": "desc",
|
||||
"region": "us-east-1",
|
||||
"requirement_id": "req-1",
|
||||
"requirement_status": "PASS",
|
||||
"passed_checks": 1,
|
||||
"failed_checks": 0,
|
||||
"total_checks": 1,
|
||||
"scan_id": uuid.uuid4(),
|
||||
}
|
||||
|
||||
with patch.object(MainRouter, "admin_db", "admin"):
|
||||
_copy_compliance_requirement_rows(str(row["tenant_id"]), [row])
|
||||
|
||||
# Verify commit was called and rollback was not
|
||||
connection.commit.assert_called_once()
|
||||
connection.rollback.assert_not_called()
|
||||
# Verify autocommit was disabled
|
||||
assert connection.autocommit is False
|
||||
|
||||
@patch("tasks.jobs.scan._copy_compliance_requirement_rows")
|
||||
def test_persist_compliance_requirement_rows_success(self, mock_copy):
|
||||
"""Test successful COPY path without fallback to ORM."""
|
||||
mock_copy.return_value = None # Success, no exception
|
||||
|
||||
tenant_id = str(uuid.uuid4())
|
||||
rows = [
|
||||
{
|
||||
"id": uuid.uuid4(),
|
||||
"tenant_id": tenant_id,
|
||||
"inserted_at": datetime.now(timezone.utc),
|
||||
"compliance_id": "test",
|
||||
"framework": "Test",
|
||||
"version": "1.0",
|
||||
"description": "desc",
|
||||
"region": "us-east-1",
|
||||
"requirement_id": "req-1",
|
||||
"requirement_status": "PASS",
|
||||
"passed_checks": 1,
|
||||
"failed_checks": 0,
|
||||
"total_checks": 1,
|
||||
"scan_id": uuid.uuid4(),
|
||||
}
|
||||
]
|
||||
|
||||
_persist_compliance_requirement_rows(tenant_id, rows)
|
||||
|
||||
# Verify COPY was called
|
||||
mock_copy.assert_called_once_with(tenant_id, rows)
|
||||
|
||||
@patch("tasks.jobs.scan.logger")
|
||||
@patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create")
|
||||
@patch("tasks.jobs.scan.rls_transaction")
|
||||
@patch(
|
||||
"tasks.jobs.scan._copy_compliance_requirement_rows",
|
||||
side_effect=Exception("COPY failed"),
|
||||
)
|
||||
def test_persist_compliance_requirement_rows_fallback_logging(
|
||||
self, mock_copy, mock_rls_transaction, mock_bulk_create, mock_logger
|
||||
):
|
||||
"""Test logger.exception is called when COPY fails and fallback occurs."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
row = {
|
||||
"id": uuid.uuid4(),
|
||||
"tenant_id": tenant_id,
|
||||
"inserted_at": datetime.now(timezone.utc),
|
||||
"compliance_id": "test",
|
||||
"framework": "Test",
|
||||
"version": "1.0",
|
||||
"description": "desc",
|
||||
"region": "us-east-1",
|
||||
"requirement_id": "req-1",
|
||||
"requirement_status": "PASS",
|
||||
"passed_checks": 1,
|
||||
"failed_checks": 0,
|
||||
"total_checks": 1,
|
||||
"scan_id": uuid.uuid4(),
|
||||
}
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.__enter__.return_value = None
|
||||
ctx.__exit__.return_value = False
|
||||
mock_rls_transaction.return_value = ctx
|
||||
|
||||
_persist_compliance_requirement_rows(tenant_id, [row])
|
||||
|
||||
# Verify logger.exception was called
|
||||
mock_logger.exception.assert_called_once()
|
||||
args, kwargs = mock_logger.exception.call_args
|
||||
assert "COPY bulk insert" in args[0]
|
||||
assert "falling back to ORM" in args[0]
|
||||
assert kwargs.get("exc_info") is not None
|
||||
|
||||
@patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create")
|
||||
@patch("tasks.jobs.scan.rls_transaction")
|
||||
@patch(
|
||||
"tasks.jobs.scan._copy_compliance_requirement_rows",
|
||||
side_effect=Exception("copy failed"),
|
||||
)
|
||||
def test_persist_compliance_requirement_rows_fallback_multiple_rows(
|
||||
self, mock_copy, mock_rls_transaction, mock_bulk_create
|
||||
):
|
||||
"""Test ORM fallback with multiple rows."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = uuid.uuid4()
|
||||
inserted_at = datetime.now(timezone.utc)
|
||||
|
||||
rows = [
|
||||
{
|
||||
"id": uuid.uuid4(),
|
||||
"tenant_id": tenant_id,
|
||||
"inserted_at": inserted_at,
|
||||
"compliance_id": "cisa_aws",
|
||||
"framework": "CISA",
|
||||
"version": "1.0",
|
||||
"description": "First requirement",
|
||||
"region": "us-east-1",
|
||||
"requirement_id": "req-1",
|
||||
"requirement_status": "PASS",
|
||||
"passed_checks": 5,
|
||||
"failed_checks": 0,
|
||||
"total_checks": 5,
|
||||
"scan_id": scan_id,
|
||||
},
|
||||
{
|
||||
"id": uuid.uuid4(),
|
||||
"tenant_id": tenant_id,
|
||||
"inserted_at": inserted_at,
|
||||
"compliance_id": "cisa_aws",
|
||||
"framework": "CISA",
|
||||
"version": "1.0",
|
||||
"description": "Second requirement",
|
||||
"region": "us-west-2",
|
||||
"requirement_id": "req-2",
|
||||
"requirement_status": "FAIL",
|
||||
"passed_checks": 2,
|
||||
"failed_checks": 3,
|
||||
"total_checks": 5,
|
||||
"scan_id": scan_id,
|
||||
},
|
||||
]
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.__enter__.return_value = None
|
||||
ctx.__exit__.return_value = False
|
||||
mock_rls_transaction.return_value = ctx
|
||||
|
||||
_persist_compliance_requirement_rows(tenant_id, rows)
|
||||
|
||||
mock_copy.assert_called_once_with(tenant_id, rows)
|
||||
mock_rls_transaction.assert_called_once_with(tenant_id)
|
||||
mock_bulk_create.assert_called_once()
|
||||
|
||||
args, kwargs = mock_bulk_create.call_args
|
||||
objects = args[0]
|
||||
assert len(objects) == 2
|
||||
assert kwargs["batch_size"] == 500
|
||||
|
||||
# Validate first object
|
||||
assert objects[0].id == rows[0]["id"]
|
||||
assert objects[0].tenant_id == rows[0]["tenant_id"]
|
||||
assert objects[0].compliance_id == rows[0]["compliance_id"]
|
||||
assert objects[0].framework == rows[0]["framework"]
|
||||
assert objects[0].region == rows[0]["region"]
|
||||
assert objects[0].passed_checks == 5
|
||||
assert objects[0].failed_checks == 0
|
||||
|
||||
# Validate second object
|
||||
assert objects[1].id == rows[1]["id"]
|
||||
assert objects[1].requirement_id == rows[1]["requirement_id"]
|
||||
assert objects[1].requirement_status == rows[1]["requirement_status"]
|
||||
assert objects[1].passed_checks == 2
|
||||
assert objects[1].failed_checks == 3
|
||||
|
||||
@patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create")
|
||||
@patch("tasks.jobs.scan.rls_transaction")
|
||||
@patch(
|
||||
"tasks.jobs.scan._copy_compliance_requirement_rows",
|
||||
side_effect=Exception("copy failed"),
|
||||
)
|
||||
def test_persist_compliance_requirement_rows_fallback_all_fields(
|
||||
self, mock_copy, mock_rls_transaction, mock_bulk_create
|
||||
):
|
||||
"""Test ORM fallback correctly maps all fields from row dict to model."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
row_id = uuid.uuid4()
|
||||
scan_id = uuid.uuid4()
|
||||
inserted_at = datetime.now(timezone.utc)
|
||||
|
||||
row = {
|
||||
"id": row_id,
|
||||
"tenant_id": tenant_id,
|
||||
"inserted_at": inserted_at,
|
||||
"compliance_id": "aws_foundational_security_aws",
|
||||
"framework": "AWS-Foundational-Security-Best-Practices",
|
||||
"version": "2.0",
|
||||
"description": "Ensure MFA is enabled",
|
||||
"region": "eu-west-1",
|
||||
"requirement_id": "iam.1",
|
||||
"requirement_status": "FAIL",
|
||||
"passed_checks": 10,
|
||||
"failed_checks": 5,
|
||||
"total_checks": 15,
|
||||
"scan_id": scan_id,
|
||||
}
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.__enter__.return_value = None
|
||||
ctx.__exit__.return_value = False
|
||||
mock_rls_transaction.return_value = ctx
|
||||
|
||||
_persist_compliance_requirement_rows(tenant_id, [row])
|
||||
|
||||
args, kwargs = mock_bulk_create.call_args
|
||||
objects = args[0]
|
||||
assert len(objects) == 1
|
||||
|
||||
obj = objects[0]
|
||||
# Validate ALL fields are correctly mapped
|
||||
assert obj.id == row_id
|
||||
assert obj.tenant_id == tenant_id
|
||||
assert obj.inserted_at == inserted_at
|
||||
assert obj.compliance_id == "aws_foundational_security_aws"
|
||||
assert obj.framework == "AWS-Foundational-Security-Best-Practices"
|
||||
assert obj.version == "2.0"
|
||||
assert obj.description == "Ensure MFA is enabled"
|
||||
assert obj.region == "eu-west-1"
|
||||
assert obj.requirement_id == "iam.1"
|
||||
assert obj.requirement_status == "FAIL"
|
||||
assert obj.passed_checks == 10
|
||||
assert obj.failed_checks == 5
|
||||
assert obj.total_checks == 15
|
||||
assert obj.scan_id == scan_id
|
||||
|
||||
@@ -182,9 +182,6 @@ Microsoft 365 requires specifying the auth method:
|
||||
# To use service principal authentication for MSGraph and PowerShell modules
|
||||
prowler m365 --sp-env-auth
|
||||
|
||||
# To use both service principal (for MSGraph) and user credentials (for PowerShell modules)
|
||||
prowler m365 --env-auth
|
||||
|
||||
# To use az cli authentication
|
||||
prowler m365 --az-cli-auth
|
||||
|
||||
|
||||
@@ -5,17 +5,13 @@ Prowler for Microsoft 365 supports multiple authentication types. Authentication
|
||||
**Prowler App:**
|
||||
|
||||
- [**Service Principal Application**](#service-principal-authentication-recommended) (**Recommended**)
|
||||
- [**Service Principal with User Credentials**](#service-principal-and-user-credentials-authentication) (Being deprecated)
|
||||
- [**Service Principal with User Credentials**](#service-principal-and-user-credentials-authentication) (Deprecated)
|
||||
|
||||
**Prowler CLI:**
|
||||
|
||||
- [**Service Principal Application**](#service-principal-authentication-recommended) (**Recommended**)
|
||||
- [**Service Principal with User Credentials**](#service-principal-and-user-credentials-authentication) (Being deprecated)
|
||||
- [**Interactive browser authentication**](#interactive-browser-authentication)
|
||||
|
||||
???+ warning
|
||||
The Service Principal with User Credentials method will be deprecated in October 2025 when Microsoft enforces MFA in all tenants, which will not allow user authentication without interactive methods.
|
||||
|
||||
## Required Permissions
|
||||
|
||||
To run the full Prowler provider, including PowerShell checks, two types of permission scopes must be set in **Microsoft Entra ID**.
|
||||
@@ -30,7 +26,6 @@ When using service principal authentication, add these **Application Permissions
|
||||
- `Directory.Read.All`: Required for all services.
|
||||
- `Policy.Read.All`: Required for all services.
|
||||
- `SharePointTenantSettings.Read.All`: Required for SharePoint service.
|
||||
- `User.Read` (IMPORTANT: this must be set as **delegated**): Required for the sign-in.
|
||||
|
||||
**External API Permissions:**
|
||||
|
||||
@@ -43,20 +38,6 @@ When using service principal authentication, add these **Application Permissions
|
||||
???+ note
|
||||
This is the **recommended authentication method** because it allows running the full M365 provider including PowerShell checks, providing complete coverage of all available security checks.
|
||||
|
||||
### Service Principal + User Credentials Authentication Permissions
|
||||
|
||||
When using service principal with user credentials authentication, you need **both** sets of permissions:
|
||||
|
||||
**1. Service Principal Application Permissions**:
|
||||
|
||||
- All the Microsoft Graph API permissions listed above are required.
|
||||
- External API permissions listed above are **not needed**.
|
||||
|
||||
**2. User-Level Permissions**: These are set at the `M365_USER` level, so the user used to run Prowler must have one of the following roles:
|
||||
|
||||
- `Global Reader` (recommended): Allows reading all required information.
|
||||
- `Exchange Administrator` and `Teams Administrator`: User needs both roles for the same access as Global Reader.
|
||||
|
||||
### 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.
|
||||
@@ -144,30 +125,6 @@ When using browser authentication, permissions are delegated to the user, so the
|
||||
|
||||

|
||||
|
||||
#### Assign User Roles (For User Authentication)
|
||||
|
||||
When using Service Principal with User Credentials authentication, assign the following roles to the user:
|
||||
|
||||
1. Go to Users > All Users > Click on the email for the user
|
||||
|
||||

|
||||
|
||||
2. Click "Assigned Roles"
|
||||
|
||||

|
||||
|
||||
3. Click "Add assignments", then search and select:
|
||||
|
||||
- `Global Reader` (recommended)
|
||||
- OR `Exchange Administrator` and `Teams Administrator` (both required)
|
||||
|
||||

|
||||
|
||||
4. Click next, assign the role as "Active", and click "Assign"
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Service Principal Authentication (Recommended)
|
||||
|
||||
@@ -192,48 +149,6 @@ If the external API permissions described in the mentioned section above are not
|
||||
???+ note
|
||||
In order to scan all the checks from M365 required permissions to the service principal application must be added. Refer to the [PowerShell Module Permissions](#grant-powershell-module-permissions-for-service-principal-authentication) section for more information.
|
||||
|
||||
## Service Principal and User Credentials Authentication
|
||||
|
||||
*Available for both Prowler App and Prowler CLI*
|
||||
|
||||
**Authentication flag for CLI:** `--env-auth`
|
||||
|
||||
???+ warning
|
||||
This method is not recommended and will be deprecated in October 2025. Use the **Service Principal Application** authentication method instead.
|
||||
|
||||
This method builds upon Service Principal authentication by adding User Credentials. Configure the following environment variables: `M365_USER` and `M365_PASSWORD`.
|
||||
|
||||
```console
|
||||
export AZURE_CLIENT_ID="XXXXXXXXX"
|
||||
export AZURE_CLIENT_SECRET="XXXXXXXXX"
|
||||
export AZURE_TENANT_ID="XXXXXXXXX"
|
||||
export M365_USER="your_email@example.com"
|
||||
export M365_PASSWORD="examplepassword"
|
||||
```
|
||||
|
||||
These two new environment variables are **required** in this authentication method to execute the PowerShell modules needed to retrieve information from M365 services. Prowler uses Service Principal authentication to access Microsoft Graph and user credentials to authenticate to Microsoft PowerShell modules.
|
||||
|
||||
- `M365_USER` should be your Microsoft account email using the **assigned domain in the tenant**. This means it must look like `example@YourCompany.onmicrosoft.com` or `example@YourCompany.com`, but it must be the exact domain assigned to that user in the tenant.
|
||||
|
||||
???+ warning
|
||||
Newly created users must sign in with the account first, as Microsoft prompts for password change. Without completing this step, user authentication fails because Microsoft marks the initial password as expired.
|
||||
|
||||
???+ warning
|
||||
The user must not be MFA capable. Microsoft does not allow MFA capable users to authenticate programmatically. See [Microsoft documentation](https://learn.microsoft.com/en-us/entra/identity-platform/scenario-desktop-acquire-token-username-password?tabs=dotnet) for more information.
|
||||
|
||||
???+ warning
|
||||
Using a tenant domain other than the one assigned — even if it belongs to the same tenant — will cause Prowler to fail, as Microsoft authentication will not succeed.
|
||||
|
||||
Ensure the correct domain is used for the authenticating user.
|
||||
|
||||

|
||||
|
||||
- `M365_PASSWORD` must be the user password.
|
||||
|
||||
???+ note
|
||||
Previously an encrypted password was required, but now the user password is accepted directly. Prowler handles the password encryption.
|
||||
|
||||
|
||||
|
||||
## Interactive Browser Authentication
|
||||
|
||||
@@ -471,7 +386,7 @@ The required modules are automatically installed when running Prowler with the `
|
||||
Example command:
|
||||
|
||||
```console
|
||||
python3 prowler-cli.py m365 --verbose --log-level ERROR --env-auth --init-modules
|
||||
python3 prowler-cli.py m365 --verbose --log-level ERROR --sp-env-auth --init-modules
|
||||
```
|
||||
If the modules are already installed, running this command will not cause issues—it will simply verify that the necessary modules are available.
|
||||
|
||||
|
||||
@@ -55,11 +55,6 @@ Configure authentication for Microsoft 365 by following the [Microsoft 365 Authe
|
||||
- Tenant ID
|
||||
- `AZURE_CLIENT_SECRET` from the Service Principal setup
|
||||
|
||||
If using user authentication, also add:
|
||||
|
||||
- `M365_USER` (email using the assigned domain in tenant)
|
||||
- `M365_PASSWORD` (user password)
|
||||
|
||||

|
||||
|
||||
3. Click "Next"
|
||||
@@ -85,7 +80,6 @@ PowerShell 7.4+ is required for comprehensive Microsoft 365 security coverage. I
|
||||
Select an authentication method from the [Microsoft 365 Authentication](authentication.md) guide:
|
||||
|
||||
- **Service Principal Application** (recommended): `--sp-env-auth`
|
||||
- **Service Principal with User Credentials**: `--env-auth`
|
||||
- **Interactive Browser Authentication**: `--browser-auth`
|
||||
|
||||
### Basic Usage
|
||||
|
||||
@@ -194,10 +194,10 @@ If you are adding an **EKS**, **GKE**, **AKS** or external cluster, follow these
|
||||
For M365, you must enter your Domain ID and choose the authentication method you want to use:
|
||||
|
||||
- Service Principal Authentication (Recommended)
|
||||
- User Authentication (only works if the user does not have MFA enabled)
|
||||
- User Authentication (Deprecated)
|
||||
|
||||
???+ note
|
||||
User authentication with M365_USER and M365_PASSWORD is optional and will only work if the account does not enforce MFA.
|
||||
???+ warning
|
||||
User authentication with M365_USER and M365_PASSWORD is deprecated and will be removed.
|
||||
|
||||
For full setup instructions and requirements, check the [Microsoft 365 provider requirements](./microsoft365/getting-started-m365.md).
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- Add explicit "name" field for each compliance framework and include "FRAMEWORK" and "NAME" in CSV output [(#7920)](https://github.com/prowler-cloud/prowler/pull/7920)
|
||||
- Equality validation for CheckID, filename and classname [(#8690)](https://github.com/prowler-cloud/prowler/pull/8690)
|
||||
- Improve logging for Security Hub integration [(#8608)](https://github.com/prowler-cloud/prowler/pull/8608)
|
||||
- Support for Atlassian Document Format (ADF) in Jira integration [(#8878)](https://github.com/prowler-cloud/prowler/pull/8878)
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -28,12 +29,17 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- Update AWS AppStream service metadata to new format [(#8789)](https://github.com/prowler-cloud/prowler/pull/8789)
|
||||
- Update AWS API Gateway service metadata to new format [(#8788)](https://github.com/prowler-cloud/prowler/pull/8788)
|
||||
- Update AWS Athena service metadata to new format [(#8790)](https://github.com/prowler-cloud/prowler/pull/8790)
|
||||
- Update AWS Auto Scaling service metadata to new format [(#8824)](https://github.com/prowler-cloud/prowler/pull/8824)
|
||||
- Update AWS Backup service metadata to new format [(#8826)](https://github.com/prowler-cloud/prowler/pull/8826)
|
||||
- Update AWS CloudFormation service metadata to new format [(#8828)](https://github.com/prowler-cloud/prowler/pull/8828)
|
||||
- Update AWS Lambda service metadata to new format [(#8825)](https://github.com/prowler-cloud/prowler/pull/8825)
|
||||
- Update AWS CloudFront service metadata to new format [(#8829)](https://github.com/prowler-cloud/prowler/pull/8829)
|
||||
- Deprecate user authentication for M365 provider [(#8865)](https://github.com/prowler-cloud/prowler/pull/8865)
|
||||
|
||||
### Fixed
|
||||
- Fix SNS topics showing empty AWS_ResourceID in Quick Inventory output [(#8762)](https://github.com/prowler-cloud/prowler/issues/8762)
|
||||
- Fix HTML Markdown output for long strings [(#8803)](https://github.com/prowler-cloud/prowler/pull/8803)
|
||||
- Prowler ThreatScore scoring calculation CLI [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from colorama import Fore, Style
|
||||
from tabulate import tabulate
|
||||
|
||||
from prowler.config.config import orange_color
|
||||
from prowler.lib.check.compliance_models import Compliance
|
||||
|
||||
|
||||
def get_prowler_threatscore_table(
|
||||
@@ -23,9 +24,12 @@ def get_prowler_threatscore_table(
|
||||
fail_count = []
|
||||
muted_count = []
|
||||
pillars = {}
|
||||
generic_score = 0
|
||||
max_generic_score = 0
|
||||
counted_findings_generic = []
|
||||
score_per_pillar = {}
|
||||
max_score_per_pillar = {}
|
||||
counted_findings = []
|
||||
counted_findings_per_pillar = {}
|
||||
for index, finding in enumerate(findings):
|
||||
check = bulk_checks_metadata[finding.check_metadata.CheckID]
|
||||
check_compliances = check.Compliance
|
||||
@@ -39,12 +43,17 @@ def get_prowler_threatscore_table(
|
||||
[
|
||||
pillar in score_per_pillar.keys(),
|
||||
pillar in max_score_per_pillar.keys(),
|
||||
pillar in counted_findings_per_pillar.keys(),
|
||||
]
|
||||
):
|
||||
score_per_pillar[pillar] = 0
|
||||
max_score_per_pillar[pillar] = 0
|
||||
counted_findings_per_pillar[pillar] = []
|
||||
|
||||
if index not in counted_findings:
|
||||
if (
|
||||
index not in counted_findings_per_pillar[pillar]
|
||||
and not finding.muted
|
||||
):
|
||||
if finding.status == "PASS":
|
||||
score_per_pillar[pillar] += (
|
||||
attribute.LevelOfRisk * attribute.Weight
|
||||
@@ -52,7 +61,7 @@ def get_prowler_threatscore_table(
|
||||
max_score_per_pillar[pillar] += (
|
||||
attribute.LevelOfRisk * attribute.Weight
|
||||
)
|
||||
counted_findings.append(index)
|
||||
counted_findings_per_pillar[pillar].append(index)
|
||||
|
||||
if pillar not in pillars:
|
||||
pillars[pillar] = {"FAIL": 0, "PASS": 0, "Muted": 0}
|
||||
@@ -69,6 +78,27 @@ def get_prowler_threatscore_table(
|
||||
pass_count.append(index)
|
||||
pillars[pillar]["PASS"] += 1
|
||||
|
||||
# Generic score
|
||||
if index not in counted_findings_generic and not finding.muted:
|
||||
if finding.status == "PASS":
|
||||
generic_score += (
|
||||
attribute.LevelOfRisk * attribute.Weight
|
||||
)
|
||||
max_generic_score += (
|
||||
attribute.LevelOfRisk * attribute.Weight
|
||||
)
|
||||
counted_findings_generic.append(index)
|
||||
|
||||
no_findings_pillars = []
|
||||
bulk_compliance = Compliance.get_bulk(provider=compliance.Provider.lower()).get(
|
||||
compliance_framework
|
||||
)
|
||||
for requirement in bulk_compliance.Requirements:
|
||||
for attribute in requirement.Attributes:
|
||||
pillar = attribute.Section
|
||||
if pillar not in pillars.keys() and pillar not in no_findings_pillars:
|
||||
no_findings_pillars.append(pillar)
|
||||
|
||||
pillars = dict(sorted(pillars.items()))
|
||||
for pillar in pillars:
|
||||
pillar_table["Provider"].append(compliance.Provider)
|
||||
@@ -88,6 +118,16 @@ def get_prowler_threatscore_table(
|
||||
f"{orange_color}{pillars[pillar]['Muted']}{Style.RESET_ALL}"
|
||||
)
|
||||
|
||||
for pillar in no_findings_pillars:
|
||||
pillar_table["Provider"].append(compliance.Provider)
|
||||
pillar_table["Pillar"].append(pillar)
|
||||
pillar_table["Score"].append(f"{Style.BRIGHT}{Fore.GREEN}100%{Style.RESET_ALL}")
|
||||
pillar_table["Status"].append(f"{Fore.GREEN}PASS{Style.RESET_ALL}")
|
||||
pillar_table["Muted"].append(f"{orange_color}0{Style.RESET_ALL}")
|
||||
|
||||
# Sort table by pillars
|
||||
pillar_table["Pillar"] = sorted(pillar_table["Pillar"])
|
||||
|
||||
if (
|
||||
len(fail_count) + len(pass_count) + len(muted_count) > 1
|
||||
): # If there are no resources, don't print the compliance table
|
||||
@@ -108,7 +148,9 @@ def get_prowler_threatscore_table(
|
||||
print(
|
||||
f"\nFramework {Fore.YELLOW}{compliance_framework.upper()}{Style.RESET_ALL} Results:"
|
||||
)
|
||||
|
||||
print(
|
||||
f"\nGeneric Threat Score: {generic_score / max_generic_score * 100:.2f}%"
|
||||
)
|
||||
print(
|
||||
tabulate(
|
||||
pillar_table,
|
||||
|
||||
@@ -2,10 +2,12 @@ import base64
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import requests
|
||||
import requests.compat
|
||||
from markdown_it import MarkdownIt
|
||||
from markdown_it.token import Token
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.outputs.finding import Finding
|
||||
@@ -47,6 +49,204 @@ class JiraConnection(Connection):
|
||||
projects: dict = None
|
||||
|
||||
|
||||
class MarkdownToADFConverter:
|
||||
"""Helper to convert Markdown strings into Atlassian Document Format blocks."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._parser = MarkdownIt("commonmark", {"html": False})
|
||||
|
||||
def convert(self, text: Optional[str]) -> List[Dict]:
|
||||
if text is None:
|
||||
text = ""
|
||||
|
||||
tokens = self._parser.parse(text)
|
||||
if not tokens:
|
||||
return [self._paragraph_with_text(text)]
|
||||
|
||||
content_stack: List[List[Dict]] = [[]]
|
||||
node_stack: List[Dict] = []
|
||||
|
||||
for token in tokens:
|
||||
token_type = token.type
|
||||
|
||||
if token_type == "paragraph_open":
|
||||
node = {"type": "paragraph", "content": []}
|
||||
node_stack.append(node)
|
||||
content_stack.append(node["content"])
|
||||
elif token_type == "inline":
|
||||
inline_nodes = self._convert_inline(token.children or [])
|
||||
content_stack[-1].extend(inline_nodes)
|
||||
elif token_type == "paragraph_close":
|
||||
node = node_stack.pop()
|
||||
content_stack.pop()
|
||||
content_stack[-1].append(node)
|
||||
elif token_type == "bullet_list_open":
|
||||
node = {"type": "bulletList", "content": []}
|
||||
node_stack.append(node)
|
||||
content_stack.append(node["content"])
|
||||
elif token_type == "bullet_list_close":
|
||||
node = node_stack.pop()
|
||||
content_stack.pop()
|
||||
content_stack[-1].append(node)
|
||||
elif token_type == "ordered_list_open":
|
||||
node: Dict = {"type": "orderedList", "content": []}
|
||||
start_attr = token.attrGet("start")
|
||||
if start_attr and start_attr.isdigit():
|
||||
start = int(start_attr)
|
||||
if start != 1:
|
||||
node["attrs"] = {"order": start}
|
||||
node_stack.append(node)
|
||||
content_stack.append(node["content"])
|
||||
elif token_type == "ordered_list_close":
|
||||
node = node_stack.pop()
|
||||
content_stack.pop()
|
||||
content_stack[-1].append(node)
|
||||
elif token_type == "list_item_open":
|
||||
node = {"type": "listItem", "content": []}
|
||||
node_stack.append(node)
|
||||
content_stack.append(node["content"])
|
||||
elif token_type == "list_item_close":
|
||||
node = node_stack.pop()
|
||||
content_stack.pop()
|
||||
content_stack[-1].append(node)
|
||||
elif token_type == "heading_open":
|
||||
level = self._safe_heading_level(token.tag)
|
||||
node = {"type": "heading", "attrs": {"level": level}, "content": []}
|
||||
node_stack.append(node)
|
||||
content_stack.append(node["content"])
|
||||
elif token_type == "heading_close":
|
||||
node = node_stack.pop()
|
||||
content_stack.pop()
|
||||
content_stack[-1].append(node)
|
||||
elif token_type == "blockquote_open":
|
||||
node = {"type": "blockquote", "content": []}
|
||||
node_stack.append(node)
|
||||
content_stack.append(node["content"])
|
||||
elif token_type == "blockquote_close":
|
||||
node = node_stack.pop()
|
||||
content_stack.pop()
|
||||
content_stack[-1].append(node)
|
||||
elif token_type in {"fence", "code_block"}:
|
||||
language = None
|
||||
if token_type == "fence":
|
||||
info = (token.info or "").strip()
|
||||
if info:
|
||||
language = info.split()[0]
|
||||
code_text = token.content.rstrip("\n")
|
||||
code_node: Dict = {
|
||||
"type": "codeBlock",
|
||||
"content": [self._create_text_node(code_text, None)],
|
||||
}
|
||||
if language:
|
||||
code_node["attrs"] = {"language": language}
|
||||
content_stack[-1].append(code_node)
|
||||
elif token_type in {"hr", "thematic_break"}:
|
||||
content_stack[-1].append({"type": "rule"})
|
||||
elif token_type == "html_block":
|
||||
html_text = token.content.strip()
|
||||
if html_text:
|
||||
content_stack[-1].append(self._paragraph_with_text(html_text))
|
||||
|
||||
result = content_stack[0]
|
||||
if not result:
|
||||
return [self._paragraph_with_text(text)]
|
||||
|
||||
return result
|
||||
|
||||
def _convert_inline(self, tokens: List[Token]) -> List[Dict]:
|
||||
result: List[Dict] = []
|
||||
marks_stack: List[Dict] = []
|
||||
|
||||
for token in tokens:
|
||||
token_type = token.type
|
||||
|
||||
if token_type == "text":
|
||||
result.extend(self._text_to_nodes(token.content, marks_stack))
|
||||
elif token_type == "code_inline":
|
||||
marks = self._clone_marks(marks_stack)
|
||||
marks.append({"type": "code"})
|
||||
result.append(self._create_text_node(token.content, marks))
|
||||
elif token_type in {"softbreak", "hardbreak"}:
|
||||
result.append({"type": "hardBreak"})
|
||||
elif token_type == "strong_open":
|
||||
marks_stack.append({"type": "strong"})
|
||||
elif token_type == "strong_close":
|
||||
self._pop_mark(marks_stack, "strong")
|
||||
elif token_type == "em_open":
|
||||
marks_stack.append({"type": "em"})
|
||||
elif token_type == "em_close":
|
||||
self._pop_mark(marks_stack, "em")
|
||||
elif token_type == "link_open":
|
||||
href = token.attrGet("href") or ""
|
||||
mark: Dict = {"type": "link", "attrs": {"href": href}}
|
||||
title = token.attrGet("title")
|
||||
if title:
|
||||
mark["attrs"]["title"] = title
|
||||
marks_stack.append(mark)
|
||||
elif token_type == "link_close":
|
||||
self._pop_mark(marks_stack, "link")
|
||||
elif token_type == "html_inline":
|
||||
result.extend(self._text_to_nodes(token.content, marks_stack))
|
||||
elif token_type == "image":
|
||||
alt_text = token.attrGet("alt") or token.content or ""
|
||||
result.extend(self._text_to_nodes(alt_text, marks_stack))
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _clone_marks(marks_stack: List[Dict]) -> List[Dict]:
|
||||
cloned: List[Dict] = []
|
||||
for mark in marks_stack:
|
||||
mark_copy = {"type": mark["type"]}
|
||||
if "attrs" in mark:
|
||||
mark_copy["attrs"] = dict(mark["attrs"])
|
||||
cloned.append(mark_copy)
|
||||
return cloned
|
||||
|
||||
def _text_to_nodes(self, text: str, marks_stack: List[Dict]) -> List[Dict]:
|
||||
if not text:
|
||||
return []
|
||||
|
||||
nodes: List[Dict] = []
|
||||
marks = self._clone_marks(marks_stack)
|
||||
parts = text.split("\n")
|
||||
|
||||
for index, part in enumerate(parts):
|
||||
if part:
|
||||
nodes.append(self._create_text_node(part, marks))
|
||||
if index < len(parts) - 1:
|
||||
nodes.append({"type": "hardBreak"})
|
||||
|
||||
return nodes
|
||||
|
||||
@staticmethod
|
||||
def _create_text_node(text: str, marks: Optional[List[Dict]]) -> Dict:
|
||||
node: Dict = {"type": "text", "text": text}
|
||||
if marks:
|
||||
node["marks"] = marks
|
||||
return node
|
||||
|
||||
def _paragraph_with_text(self, text: str) -> Dict:
|
||||
return {"type": "paragraph", "content": [self._create_text_node(text, None)]}
|
||||
|
||||
@staticmethod
|
||||
def _pop_mark(marks_stack: List[Dict], mark_type: str) -> None:
|
||||
for index in range(len(marks_stack) - 1, -1, -1):
|
||||
if marks_stack[index]["type"] == mark_type:
|
||||
marks_stack.pop(index)
|
||||
break
|
||||
|
||||
@staticmethod
|
||||
def _safe_heading_level(tag: Optional[str]) -> int:
|
||||
if tag and tag.startswith("h"):
|
||||
try:
|
||||
level = int(tag[1])
|
||||
return max(1, min(level, 6))
|
||||
except (ValueError, IndexError):
|
||||
return 1
|
||||
return 1
|
||||
|
||||
|
||||
class Jira:
|
||||
"""
|
||||
Jira class to interact with the Jira API
|
||||
@@ -112,6 +312,7 @@ class Jira:
|
||||
jira.send_findings(findings=findings, project_key="KEY")
|
||||
"""
|
||||
|
||||
_markdown_converter = MarkdownToADFConverter()
|
||||
_redirect_uri: str = None
|
||||
_client_id: str = None
|
||||
_client_secret: str = None
|
||||
@@ -173,6 +374,45 @@ class Jira:
|
||||
message=init_error, file=os.path.basename(__file__)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_code_block_content(code_value: str) -> Optional[Dict]:
|
||||
if not code_value:
|
||||
return None
|
||||
|
||||
lines = code_value.splitlines()
|
||||
if not lines:
|
||||
return None
|
||||
|
||||
language = None
|
||||
first_line = lines[0].strip()
|
||||
if first_line.startswith("```"):
|
||||
language = first_line[3:].strip() or None
|
||||
lines = lines[1:]
|
||||
|
||||
while lines and not lines[0].strip():
|
||||
lines = lines[1:]
|
||||
|
||||
if lines and lines[-1].strip().startswith("```"):
|
||||
lines = lines[:-1]
|
||||
|
||||
while lines and not lines[-1].strip():
|
||||
lines = lines[:-1]
|
||||
|
||||
if not lines:
|
||||
return None
|
||||
|
||||
sanitized_text = "\n".join(lines)
|
||||
|
||||
code_block: Dict = {
|
||||
"type": "codeBlock",
|
||||
"content": [{"type": "text", "text": sanitized_text}],
|
||||
}
|
||||
|
||||
if language:
|
||||
code_block["attrs"] = {"language": language}
|
||||
|
||||
return code_block
|
||||
|
||||
@property
|
||||
def redirect_uri(self):
|
||||
return self._redirect_uri
|
||||
@@ -837,8 +1077,8 @@ class Jira:
|
||||
return "#0000FF"
|
||||
return "#000000" # Default black color for unknown severities
|
||||
|
||||
@staticmethod
|
||||
def get_adf_description(
|
||||
self,
|
||||
check_id: str = "",
|
||||
check_title: str = "",
|
||||
severity: str = "",
|
||||
@@ -1231,17 +1471,7 @@ class Jira:
|
||||
{
|
||||
"type": "tableCell",
|
||||
"attrs": {"colwidth": [3]},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": risk,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"content": self._markdown_converter.convert(risk),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1340,6 +1570,34 @@ class Jira:
|
||||
)
|
||||
|
||||
# Add recommendation row
|
||||
recommendation_content = self._markdown_converter.convert(recommendation_text)
|
||||
if recommendation_url:
|
||||
link_node = {
|
||||
"type": "text",
|
||||
"text": recommendation_url,
|
||||
"marks": [{"type": "link", "attrs": {"href": recommendation_url}}],
|
||||
}
|
||||
|
||||
if (
|
||||
recommendation_content
|
||||
and recommendation_content[-1].get("type") == "paragraph"
|
||||
):
|
||||
paragraph = recommendation_content[-1]
|
||||
paragraph_content = paragraph.setdefault("content", [])
|
||||
if paragraph_content:
|
||||
last_inline = paragraph_content[-1]
|
||||
if last_inline.get("type") == "text" and not last_inline.get(
|
||||
"text", ""
|
||||
).endswith(" "):
|
||||
paragraph_content.append({"type": "text", "text": " "})
|
||||
elif last_inline.get("type") != "text":
|
||||
paragraph_content.append({"type": "text", "text": " "})
|
||||
paragraph_content.append(link_node)
|
||||
else:
|
||||
recommendation_content.append(
|
||||
{"type": "paragraph", "content": [link_node]}
|
||||
)
|
||||
|
||||
table_rows.append(
|
||||
{
|
||||
"type": "tableRow",
|
||||
@@ -1363,27 +1621,7 @@ class Jira:
|
||||
{
|
||||
"type": "tableCell",
|
||||
"attrs": {"colwidth": [3]},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": recommendation_text + " ",
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": recommendation_url,
|
||||
"marks": [
|
||||
{
|
||||
"type": "link",
|
||||
"attrs": {"href": recommendation_url},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"content": recommendation_content,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1399,6 +1637,14 @@ class Jira:
|
||||
|
||||
for code_type, code_value in remediation_codes:
|
||||
if code_value and code_value.strip():
|
||||
if code_type == "Other":
|
||||
formatted_content = self._markdown_converter.convert(code_value)
|
||||
else:
|
||||
code_block = self._build_code_block_content(code_value)
|
||||
if not code_block:
|
||||
continue
|
||||
formatted_content = [code_block]
|
||||
|
||||
table_rows.append(
|
||||
{
|
||||
"type": "tableRow",
|
||||
@@ -1422,18 +1668,7 @@ class Jira:
|
||||
{
|
||||
"type": "tableCell",
|
||||
"attrs": {"colwidth": [3]},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": code_value,
|
||||
"marks": [{"type": "code"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"content": formatted_content,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1645,9 +1880,11 @@ class Jira:
|
||||
payload = {
|
||||
"fields": {
|
||||
"project": {"key": project_key},
|
||||
"summary": summary,
|
||||
"summary": f"[Prowler] {finding.metadata.Severity.value.upper()} - {finding.metadata.CheckID} - {finding.resource_uid}",
|
||||
"description": adf_description,
|
||||
"issuetype": {"name": issue_type},
|
||||
"customfield_10148": {"value": "SDK"},
|
||||
"customfield_10088": {"value": "Core"},
|
||||
}
|
||||
}
|
||||
if issue_labels:
|
||||
|
||||
+15
-10
@@ -1,28 +1,33 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "autoscaling_find_secrets_ec2_launch_configuration",
|
||||
"CheckTitle": "[DEPRECATED] Find secrets in EC2 Auto Scaling Launch Configuration",
|
||||
"CheckTitle": "[DEPRECATED] EC2 Auto Scaling launch configuration user data contains no secrets",
|
||||
"CheckType": [
|
||||
"IAM"
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Sensitive Data Identifications/Passwords",
|
||||
"Effects/Data Exposure"
|
||||
],
|
||||
"ServiceName": "autoscaling",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:autoscaling:region:account-id:autoScalingGroupName/resource-name",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "critical",
|
||||
"ResourceType": "AwsAutoScalingLaunchConfiguration",
|
||||
"Description": "[DEPRECATED] Find secrets in EC2 Auto Scaling Launch Configuration",
|
||||
"Risk": "The use of a hard-coded password increases the possibility of password guessing. If hard-coded passwords are used, it is possible that malicious users gain access through the account in question.",
|
||||
"Description": "[DEPRECATED] EC2 Auto Scaling launch configurations are analyzed for **secrets** embedded in `User Data`, such as passwords, tokens, or API keys in bootstrapping scripts.",
|
||||
"Risk": "Secrets in `User Data` erode **confidentiality** and **integrity**:\n- Instance users or processes can read or log them\n- Exposed keys enable unauthorized API calls, data exfiltration, and lateral movement\n- Credential reuse increases blast radius across accounts and services",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
"NativeIaC": "```yaml\n# CloudFormation Launch Configuration without secrets in UserData\nResources:\n <example_resource_name>:\n Type: AWS::AutoScaling::LaunchConfiguration\n Properties:\n ImageId: <AMI_ID>\n InstanceType: <INSTANCE_TYPE>\n UserData: '' # Critical: empty user data ensures no secrets are present\n```",
|
||||
"Other": "1. In the AWS Console, go to EC2 > Launch configurations and click Create launch configuration\n2. Reuse the same AMI and instance type; leave User data empty\n3. Go to EC2 > Auto Scaling groups, select the group using the failing launch configuration, click Edit\n4. Under Launch options, select the new launch configuration and Save\n5. After the ASG is updated, delete the old launch configuration",
|
||||
"Terraform": "```hcl\n# Launch configuration with no secrets in user data\nresource \"aws_launch_configuration\" \"<example_resource_name>\" {\n image_id = \"<AMI_ID>\"\n instance_type = \"<INSTANCE_TYPE>\"\n user_data = \"\" # Critical: empty user data ensures no secrets are present\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Do not include sensitive information in user data within the launch configuration, try to use Secrets Manager instead.",
|
||||
"Url": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html"
|
||||
"Text": "Never place secrets in `User Data`.\n- Use a managed secret store with an instance role to fetch at runtime\n- Enforce **least privilege**, rotate secrets, and avoid writing secrets to logs\n- Prefer short-lived, scoped credentials and layer controls for **defense in depth**",
|
||||
"Url": "https://hub.prowler.com/check/autoscaling_find_secrets_ec2_launch_configuration"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
|
||||
+20
-13
@@ -1,32 +1,39 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "autoscaling_group_capacity_rebalance_enabled",
|
||||
"CheckTitle": "Check if Amazon EC2 Auto Scaling groups have capacity rebalance enabled.",
|
||||
"CheckTitle": "Amazon EC2 Auto Scaling group has Capacity Rebalancing enabled",
|
||||
"CheckType": [
|
||||
"Resilience"
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Effects/Denial of Service"
|
||||
],
|
||||
"ServiceName": "autoscaling",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:aws:autoscaling:region:account-id:autoScalingGroup/autoScalingGroupName",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsAutoScalingAutoScalingGroup",
|
||||
"Description": "This control checks whether an Amazon EC2 Auto Scaling group has capacity rebalance enabled.",
|
||||
"Risk": "When you don't use Capacity Rebalancing, Amazon EC2 Auto Scaling doesn't replace Spot Instances until after the Amazon EC2 Spot service interrupts the instances and their health check fails. Before interrupting an instance, Amazon EC2 always gives both an EC2 instance rebalance recommendation and a Spot two-minute instance interruption notice.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/autoscaling/ec2/userguide/ec2-auto-scaling-capacity-rebalancing.html",
|
||||
"Description": "**EC2 Auto Scaling groups** use **Capacity Rebalancing** to act on EC2 `rebalance` recommendations by launching replacement Spot instances and terminating at-risk ones after they are healthy.\n\n*Assesses whether this proactive replacement behavior is enabled.*",
|
||||
"Risk": "Without **Capacity Rebalancing**, Spot interruptions can drop targets and reduce capacity, causing timeouts, 5xx spikes, and backlog growth. The two-minute notice is often insufficient, reducing service **availability** and increasing the chance of cascading failures and slow recovery.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/awssupport/latest/user/fault-tolerance-checks.html#amazon-ec2-auto-scaling-group-capacity-rebalance-enabled",
|
||||
"https://docs.aws.amazon.com/autoscaling/ec2/userguide/ec2-auto-scaling-capacity-rebalancing.html",
|
||||
"https://trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/enable-capacity-rebalancing.html",
|
||||
"https://docs.aws.amazon.com/autoscaling/ec2/userguide/enable-capacity-rebalancing-console-cli.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws autoscaling create-auto-scaling-group --capacity-rebalance",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://docs.aws.amazon.com/autoscaling/ec2/userguide/enable-capacity-rebalancing-console-cli.html",
|
||||
"Terraform": ""
|
||||
"CLI": "aws autoscaling update-auto-scaling-group --auto-scaling-group-name <example_resource_name> --capacity-rebalance",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: Enable Capacity Rebalancing on an Auto Scaling group\nResources:\n <example_resource_name>:\n Type: AWS::AutoScaling::AutoScalingGroup\n Properties:\n MinSize: \"1\"\n MaxSize: \"1\"\n AvailabilityZones: [\"<example_az>\"]\n LaunchTemplate:\n LaunchTemplateName: <example_resource_name>\n Version: \"$Default\"\n CapacityRebalance: true # CRITICAL: Enables proactive replacement of at-risk Spot instances\n```",
|
||||
"Other": "1. In the AWS Console, go to EC2 > Auto Scaling Groups\n2. Select <example_resource_name> and open the Details tab\n3. Click Allocation strategies > Edit, check Capacity rebalancing\n4. Click Update/Save",
|
||||
"Terraform": "```hcl\n# Terraform: Enable Capacity Rebalancing on an Auto Scaling group\nresource \"aws_autoscaling_group\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n min_size = 1\n max_size = 1\n desired_capacity = 1\n availability_zones = [\"<example_az>\"]\n\n launch_template {\n id = \"<example_resource_id>\"\n version = \"$Latest\"\n }\n\n capacity_rebalance = true # CRITICAL: Turns on Capacity Rebalancing\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "When you enable Capacity Rebalancing for your Auto Scaling group, Amazon EC2 Auto Scaling attempts to proactively replace the Spot Instances in your group that have received a rebalance recommendation. This provides an opportunity to rebalance your workload to new Spot Instances that aren't at an elevated risk of interruption.",
|
||||
"Url": "https://docs.aws.amazon.com/awssupport/latest/user/fault-tolerance-checks.html#amazon-ec2-auto-scaling-group-capacity-rebalance-enabled"
|
||||
"Text": "Enable **Capacity Rebalancing** for ASGs that use Spot.\n\nApply resilience practices:\n- Prefer `price-capacity-optimized` allocation\n- Keep headroom below `MaxSize`\n- Use lifecycle hooks to drain/deregister\n- Design stateless, interruption-tolerant workloads (least privilege and defense-in-depth for dependencies)",
|
||||
"Url": "https://hub.prowler.com/check/autoscaling_group_capacity_rebalance_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"redundancy"
|
||||
"resilience"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
|
||||
+20
-12
@@ -1,31 +1,39 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "autoscaling_group_elb_health_check_enabled",
|
||||
"CheckTitle": "Check if Auto Scaling groups associated with a load balancer use ELB health checks.",
|
||||
"CheckTitle": "Auto Scaling group associated with a load balancer has ELB health checks enabled",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices"
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
|
||||
],
|
||||
"ServiceName": "autoscaling",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:aws:autoscaling:region:account-id:autoScalingGroup/autoScalingGroupName",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "low",
|
||||
"ResourceType": "AwsAutoScalingAutoScalingGroup",
|
||||
"Description": "This control checks whether an Amazon EC2 Auto Scaling group that is associated with a load balancer uses Elastic Load Balancing (ELB) health checks. The control fails if the Auto Scaling group doesn't use ELB health checks.",
|
||||
"Risk": "If ELB health checks are not enabled, the Auto Scaling group might not be able to accurately determine the health of instances, which could impact the availability and reliability of the applications running on these instances.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/autoscaling/ec2/userguide/as-add-elb-healthcheck.html#as-add-elb-healthcheck-console",
|
||||
"Description": "EC2 Auto Scaling groups attached to a load balancer are evaluated for **ELB-based health checks** that use the load balancer's target health instead of instance-only checks.",
|
||||
"Risk": "Without **ELB health checks**, the group may keep instances that fail load balancer probes, causing:\n- Reduced **availability** from routing to bad targets\n- Higher error rates impacting transaction **integrity**\n- Inefficient scaling and increased **costs**",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/securityhub/latest/userguide/autoscaling-controls.html#autoscaling-1",
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/AutoScaling/auto-scaling-group-health-check.html",
|
||||
"https://docs.aws.amazon.com/autoscaling/ec2/userguide/as-add-elb-healthcheck.html#as-add-elb-healthcheck-console"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws autoscaling update-auto-scaling-group --auto-scaling-group-name <auto-scaling-group-name> --health-check-type ELB",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/autoscaling-controls.html#autoscaling-1",
|
||||
"Terraform": ""
|
||||
"NativeIaC": "```yaml\n# CloudFormation: Enable ELB health checks for the Auto Scaling group\nResources:\n <example_resource_name>:\n Type: AWS::AutoScaling::AutoScalingGroup\n Properties:\n HealthCheckType: ELB # Remediation: use ELB health checks so the ASG evaluates instance health via the load balancer\n```",
|
||||
"Other": "1. In AWS Console, go to EC2 > Auto Scaling Groups\n2. Select the Auto Scaling group\n3. On the Details tab, click Edit under Health checks\n4. Under Additional health check types, select Elastic Load Balancing (ELB)\n5. Click Update/Save",
|
||||
"Terraform": "```hcl\n# Enable ELB health checks on the Auto Scaling group\nresource \"aws_autoscaling_group\" \"<example_resource_name>\" {\n health_check_type = \"ELB\" # Remediation: ensures ASG uses load balancer health status\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Configure your Auto Scaling groups to use ELB health checks to improve the monitoring and availability of your applications.",
|
||||
"Url": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/AutoScaling/auto-scaling-group-health-check.html"
|
||||
"Text": "Enable **ELB health checks** for Auto Scaling groups behind load balancers to reflect real client reachability. Apply **high availability** and **defense in depth** by:\n- Using application-appropriate LB probes\n- Tuning grace and threshold settings to avoid flapping\n- Monitoring health metrics and alerts",
|
||||
"Url": "https://hub.prowler.com/check/autoscaling_group_elb_health_check_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"resilience"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
+20
-13
@@ -1,28 +1,35 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "autoscaling_group_launch_configuration_no_public_ip",
|
||||
"CheckTitle": "Check if Amazon EC2 instances launched using Auto Scaling group launch configurations have Public IP addresses.",
|
||||
"CheckTitle": "Auto Scaling group associated launch configuration does not assign a public IP address",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices"
|
||||
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark"
|
||||
],
|
||||
"ServiceName": "autoscaling",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:aws:autoscaling:region:account-id:launchConfiguration/launchConfigurationName",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "AwsAutoScalingLaunchConfiguration",
|
||||
"Description": "This control checks whether an Auto Scaling group's associated launch configuration assigns a public IP address to the group's instances. The control fails if the associated launch configuration assigns a public IP address.",
|
||||
"Risk": "Assigning a public IP address to EC2 instances can expose them directly to the internet, increasing the risk of unauthorized access and potential security breaches.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/autoscaling/ec2/userguide/create-auto-scaling-groups-launch-configuration.html",
|
||||
"ResourceType": "AwsAutoScalingAutoScalingGroup",
|
||||
"Description": "**Amazon EC2 Auto Scaling groups** are evaluated to determine whether their associated **launch configuration** assigns **public IP addresses** to instances (e.g., `AssociatePublicIpAddress=true`).",
|
||||
"Risk": "**Publicly addressable instances** are reachable from the Internet, enabling reconnaissance, brute-force, and exploitation of exposed services.\n\nCompromise can lead to remote access, **data exfiltration**, and **lateral movement**, impacting **confidentiality**, **integrity**, and **availability**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/securityhub/latest/userguide/autoscaling-controls.html#autoscaling-5",
|
||||
"https://docs.aws.amazon.com/autoscaling/ec2/userguide/create-auto-scaling-groups-launch-configuration.html",
|
||||
"https://docs.aws.amazon.com/autoscaling/ec2/userguide/change-launch-config.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws autoscaling create-launch-configuration --launch-configuration-name <new-launch-config> --associate-public-ip-address false",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/autoscaling-controls.html#autoscaling-5",
|
||||
"Terraform": ""
|
||||
"CLI": "",
|
||||
"NativeIaC": "```yaml\n# CloudFormation Launch Configuration without public IPs\nResources:\n <example_resource_name>:\n Type: AWS::AutoScaling::LaunchConfiguration\n Properties:\n ImageId: <example_ami_id>\n InstanceType: <example_instance_type>\n AssociatePublicIpAddress: false # Critical: disables assigning public IPs to instances\n```",
|
||||
"Other": "1. In the AWS console, go to EC2 > Auto Scaling > Launch configurations and click Create launch configuration\n2. Use the same AMI and instance type as the current group; under Advanced details set IP address type to Do not assign a public IP address\n3. Create the launch configuration\n4. Go to EC2 > Auto Scaling Groups, select your group, click Edit next to Launch configuration, choose the new configuration, and click Update",
|
||||
"Terraform": "```hcl\n# Launch Configuration without public IPs\nresource \"aws_launch_configuration\" \"<example_resource_name>\" {\n image_id = \"<example_ami_id>\"\n instance_type = \"<example_instance_type>\"\n associate_public_ip_address = false # Critical: disables assigning public IPs\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Create a new launch configuration without a public IP address and update your Auto Scaling groups to use the new configuration.",
|
||||
"Url": "https://docs.aws.amazon.com/autoscaling/ec2/userguide/change-launch-config.html"
|
||||
"Text": "Place instances in private subnets and disable public addressing (`AssociatePublicIpAddress=false`). Publish services via **load balancers** or **private endpoints**, enforce **least privilege** security groups, and use **SSM**, VPN, or a hardened bastion for admin access. Prefer **launch templates** to standardize network controls.",
|
||||
"Url": "https://hub.prowler.com/check/autoscaling_group_launch_configuration_no_public_ip"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
|
||||
+26
-14
@@ -1,31 +1,43 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "autoscaling_group_launch_configuration_requires_imdsv2",
|
||||
"CheckTitle": "Check if Auto Scaling group launch configurations require Instance Metadata Service Version 2 (IMDSv2).",
|
||||
"CheckTitle": "Auto Scaling group enforces IMDSv2 or disables the instance metadata service",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices"
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark",
|
||||
"TTPs/Credential Access",
|
||||
"Effects/Data Exposure"
|
||||
],
|
||||
"ServiceName": "autoscaling",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:aws:autoscaling:region:account-id:launchConfiguration/launchConfigurationName",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "AwsAutoScalingLaunchConfiguration",
|
||||
"Description": "This control checks whether IMDSv2 is enabled on all instances launched by Amazon EC2 Auto Scaling groups. The control fails if the Instance Metadata Service (IMDS) version isn't included in the launch configuration or is configured as token optional, which allows either IMDSv1 or IMDSv2.",
|
||||
"Risk": "If IMDSv2 is not enforced, instances may be vulnerable to certain types of attacks that target the metadata service, potentially exposing sensitive instance information.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-new-instances.html",
|
||||
"ResourceType": "AwsAutoScalingAutoScalingGroup",
|
||||
"Description": "Amazon EC2 Auto Scaling launch configurations are evaluated for **Instance Metadata Service** settings. Instances should have the metadata endpoint `enabled` with `http_tokens=required` (enforcing **IMDSv2**), or have the metadata service `disabled`.\n\nAllowing `http_tokens=optional` or omitting the version leaves legacy access enabled.",
|
||||
"Risk": "Without enforced **IMDSv2**, **SSRF** and local escape paths can access **IAM role credentials**, enabling unauthorized API calls.\n\nAttackers could:\n- Exfiltrate data with stolen tokens\n- Move laterally and modify resources, degrading confidentiality and integrity",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/require-imds-v2.html",
|
||||
"https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-new-instances.html",
|
||||
"https://docs.aws.amazon.com/securityhub/latest/userguide/autoscaling-controls.html#autoscaling-3",
|
||||
"https://aws.plainenglish.io/dont-let-metadata-leak-why-imdsv2-is-a-must-and-how-to-migrate-a88e1e285394"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws autoscaling create-launch-configuration --launch-configuration-name <new-launch-config> --metadata-options 'HttpTokens=required'",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/autoscaling-controls.html#autoscaling-3",
|
||||
"Terraform": ""
|
||||
"CLI": "aws autoscaling create-launch-configuration --launch-configuration-name <new-launch-config> --image-id <AMI_ID> --instance-type <INSTANCE_TYPE> --metadata-options 'HttpTokens=required,HttpEndpoint=enabled'",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: ASG launch configuration enforces IMDSv2\nResources:\n LaunchConfig:\n Type: AWS::AutoScaling::LaunchConfiguration\n Properties:\n ImageId: <example_ami_id>\n InstanceType: <example_instance_type>\n MetadataOptions:\n HttpTokens: required # critical: require IMDSv2 tokens (disables IMDSv1)\n HttpEndpoint: enabled # critical: keep IMDS enabled while enforcing v2\n\n AutoScalingGroup:\n Type: AWS::AutoScaling::AutoScalingGroup\n Properties:\n LaunchConfigurationName: !Ref LaunchConfig\n MinSize: 1\n MaxSize: 1\n VPCZoneIdentifier:\n - <example_subnet_id>\n```",
|
||||
"Other": "1. In the AWS Console, go to EC2 > Auto Scaling > Launch configurations\n2. Click Create launch configuration and choose the same AMI and instance type used by the group\n3. Expand Advanced details and set Metadata options to: Metadata accessible = Enabled, Metadata version = V2 only (token required)\n4. Create the launch configuration\n5. Go to EC2 > Auto Scaling > Auto Scaling groups, select the group, click Edit\n6. Under Launch configuration, select the new launch configuration and Save\n7. (Alternative) To disable IMDS entirely: when creating the launch configuration, set Metadata accessible = Disabled",
|
||||
"Terraform": "```hcl\n# ASG launch configuration enforces IMDSv2\nresource \"aws_launch_configuration\" \"example\" {\n image_id = \"<example_ami_id>\"\n instance_type = \"<example_instance_type>\"\n\n metadata_options {\n http_tokens = \"required\" # critical: require IMDSv2 tokens (blocks IMDSv1)\n http_endpoint = \"enabled\" # critical: IMDS enabled while enforcing v2\n }\n}\n\nresource \"aws_autoscaling_group\" \"example\" {\n launch_configuration = aws_launch_configuration.example.name\n min_size = 1\n max_size = 1\n vpc_zone_identifier = [\"<example_subnet_id>\"]\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Create a new launch configuration that requires IMDSv2 and update your Auto Scaling groups to use the new configuration.",
|
||||
"Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-new-instances.html"
|
||||
"Text": "Require **IMDSv2** for Auto Scaling-launched instances by setting `http_tokens=required` when metadata is `enabled`. *If metadata is not needed*, disable it.\n\nApply **least privilege** to instance roles, set IMDSv2 as an account default, and use **defense in depth** (egress filtering, SSRF protections) to limit exposure.",
|
||||
"Url": "https://hub.prowler.com/check/autoscaling_group_launch_configuration_requires_imdsv2"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"secrets"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
+22
-13
@@ -1,30 +1,39 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "autoscaling_group_multiple_az",
|
||||
"CheckTitle": "EC2 Auto Scaling Group should use multiple Availability Zones",
|
||||
"CheckType": [],
|
||||
"CheckTitle": "Auto Scaling group uses multiple Availability Zones",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Effects/Denial of Service"
|
||||
],
|
||||
"ServiceName": "autoscaling",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:autoscaling:region:account-id:autoScalingGroupName/resource-name",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsAutoScalingAutoScalingGroup",
|
||||
"Description": "EC2 Auto Scaling Group should use multiple Availability Zones",
|
||||
"Risk": "In case of a failure in a single Availability Zone, the Auto Scaling Group will not be able to launch new instances to replace the failed ones.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/autoscaling/ec2/userguide/as-add-availability-zone.html",
|
||||
"Description": "**EC2 Auto Scaling groups** use **multiple Availability Zones** within a Region, with instances distributed across more than one zone rather than confined to a single zone.",
|
||||
"Risk": "Relying on a single zone concentrates failure risk and harms **availability**. An AZ outage or capacity shortfall can block replacements and scaling, causing downtime, dropped traffic, and a wider blast radius. Recovery can lag because workloads can't shift to healthy zones.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/autoscaling/ec2/userguide/as-add-az-console.html",
|
||||
"https://docs.aws.amazon.com/autoscaling/ec2/userguide/ec2-auto-scaling-availability-zone-balanced.html",
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/AutoScaling/multiple-availability-zones.html",
|
||||
"https://docs.aws.amazon.com/autoscaling/ec2/userguide/disaster-recovery-resiliency.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws autoscaling update-auto-scaling-group",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/AutoScaling/multiple-availability-zones.html",
|
||||
"Terraform": ""
|
||||
"CLI": "aws autoscaling update-auto-scaling-group --auto-scaling-group-name <example_resource_name> --vpc-zone-identifier \"<subnet_id_az1>,<subnet_id_az2>\"",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: ensure ASG spans multiple AZs\nResources:\n <example_resource_name>:\n Type: AWS::AutoScaling::AutoScalingGroup\n Properties:\n MinSize: '1'\n MaxSize: '1'\n LaunchTemplate:\n LaunchTemplateId: <example_resource_id>\n Version: '$Latest'\n VPCZoneIdentifier:\n - <subnet_id_az1>\n - <subnet_id_az2> # CRITICAL: Add a second subnet in a different AZ to ensure multiple AZs\n```",
|
||||
"Other": "1. In the AWS Console, go to EC2 > Auto Scaling Groups\n2. Select the group and open the Details tab\n3. Click Network > Edit\n4. In Subnets, add one more subnet from a different Availability Zone\n5. Click Update to save",
|
||||
"Terraform": "```hcl\n# Terraform: ensure ASG spans multiple AZs\nresource \"aws_autoscaling_group\" \"<example_resource_name>\" {\n min_size = 1\n max_size = 1\n\n launch_template {\n id = \"<example_resource_id>\"\n version = \"$Latest\"\n }\n\n vpc_zone_identifier = [\n \"<subnet_id_az1>\",\n \"<subnet_id_az2>\" # CRITICAL: two subnets in different AZs to pass the check\n ]\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Configure multiple Availability Zones for EC2 Auto Scaling Group",
|
||||
"Url": "https://docs.aws.amazon.com/autoscaling/ec2/userguide/as-add-availability-zone.html"
|
||||
"Text": "Distribute each group across at least two **Availability Zones** to design for failure. Use a load balancer to spread traffic and health-based replacement to sustain capacity. Apply **resilience** and **fault isolation** principles so service continues during zonal degradation.",
|
||||
"Url": "https://hub.prowler.com/check/autoscaling_group_multiple_az"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"redundancy"
|
||||
"resilience"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
|
||||
+21
-13
@@ -1,31 +1,39 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "autoscaling_group_multiple_instance_types",
|
||||
"CheckTitle": "EC2 Auto Scaling Group should use multiple instance types in multiple Availability Zones.",
|
||||
"CheckTitle": "Auto Scaling group spans multiple Availability Zones and has multiple instance types per Availability Zone",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices"
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Effects/Denial of Service"
|
||||
],
|
||||
"ServiceName": "autoscaling",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:autoscaling:region:account-id:autoScalingGroupName/resource-name",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsAutoScalingAutoScalingGroup",
|
||||
"Description": "This control checks whether an Amazon EC2 Auto Scaling group uses multiple instance types in all the Availability Zones, meaning that there should be multiple Availability Zones with multiple instances on each one. The control fails if the Auto Scaling group has only one instance type defined.",
|
||||
"Risk": "Using only one instance type in an Auto Scaling group reduces the flexibility to launch new instances when there is insufficient capacity for that specific type, potentially affecting the availability of the application.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/autoscaling/ec2/userguide/ec2-auto-scaling-mixed-instances-groups.html",
|
||||
"Description": "**EC2 Auto Scaling groups** are evaluated for using **multiple instance types** in each **Availability Zone** and spanning more than one AZ.\n\nGroups are identified when every AZ defines at least two instance types; groups with any AZ using a single or no type, or confined to one AZ, are noted.",
|
||||
"Risk": "Limited to one instance type per AZ or a single AZ, scaling can stall during **capacity shortages**, hindering **failover** and degrading **availability** (timeouts, backlog growth). Costs may spike if only expensive capacity is available. Reduced diversity increases the likelihood of prolonged outages during zonal or market disruptions.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/AutoScaling/asg-multiple-instance-type-az.html",
|
||||
"https://docs.aws.amazon.com/autoscaling/ec2/userguide/ec2-auto-scaling-mixed-instances-groups.html",
|
||||
"https://docs.aws.amazon.com/securityhub/latest/userguide/autoscaling-controls.html#autoscaling-6"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws autoscaling create-auto-scaling-group --mixed-instances-policy ...",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/autoscaling-controls.html#autoscaling-6",
|
||||
"Terraform": ""
|
||||
"CLI": "aws autoscaling update-auto-scaling-group --auto-scaling-group-name <example_resource_name> --mixed-instances-policy '{\"LaunchTemplate\":{\"LaunchTemplateSpecification\":{\"LaunchTemplateName\":\"<example_resource_name>\",\"Version\":\"$Latest\"},\"Overrides\":[{\"InstanceType\":\"<INSTANCE_TYPE_1>\"},{\"InstanceType\":\"<INSTANCE_TYPE_2>\"}]}}' --vpc-zone-identifier \"<subnet_id_1>,<subnet_id_2>\"",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: Ensure ASG uses multiple instance types across multiple AZs\nResources:\n <example_resource_name>:\n Type: AWS::AutoScaling::AutoScalingGroup\n Properties:\n MinSize: \"1\"\n MaxSize: \"1\"\n VPCZoneIdentifier:\n - <subnet_id_1> # CRITICAL: Use subnets in different AZs to span multiple AZs\n - <subnet_id_2> # CRITICAL: Ensures at least two Availability Zones\n MixedInstancesPolicy:\n LaunchTemplate:\n LaunchTemplateSpecification:\n LaunchTemplateName: <example_resource_name>\n Version: $Latest\n Overrides:\n - InstanceType: <INSTANCE_TYPE_1> # CRITICAL: Multiple instance types per AZ\n - InstanceType: <INSTANCE_TYPE_2> # CRITICAL: Multiple instance types per AZ\n```",
|
||||
"Other": "1. In the AWS Console, go to EC2 > Auto Scaling Groups and select <example_resource_name>\n2. Click Edit\n3. Under Network, add at least two subnets in different Availability Zones\n4. Under Launch options, choose Mixed instance types\n5. Select your Launch template and set Version to $Latest\n6. Add at least two Instance types in Overrides\n7. Click Update to save",
|
||||
"Terraform": "```hcl\n# Terraform: Ensure ASG uses multiple instance types across multiple AZs\nresource \"aws_autoscaling_group\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n min_size = 1\n max_size = 1\n vpc_zone_identifier = [\"<subnet_id_1>\", \"<subnet_id_2>\"] # CRITICAL: Subnets in different AZs\n\n mixed_instances_policy {\n launch_template {\n launch_template_specification {\n launch_template_name = \"<example_resource_name>\"\n version = \"$Latest\"\n }\n override { instance_type = \"<INSTANCE_TYPE_1>\" } # CRITICAL: Multiple instance types per AZ\n override { instance_type = \"<INSTANCE_TYPE_2>\" } # CRITICAL: Multiple instance types per AZ\n }\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Configure your EC2 Auto Scaling group to use multiple instance types across multiple Availability Zones.",
|
||||
"Url": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/AutoScaling/asg-multiple-instance-type-az.html"
|
||||
"Text": "Adopt a **mixed instances** strategy for resilience:\n- Use diverse instance families and sizes per AZ\n- Distribute capacity across multiple AZs\n- Favor allocation approaches that tolerate spot/on-demand scarcity\nApply **redundancy** and **fault tolerance** principles and validate scaling policies to avoid single points of capacity failure.",
|
||||
"Url": "https://hub.prowler.com/check/autoscaling_group_multiple_instance_types"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"resilience"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
+19
-12
@@ -1,31 +1,38 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "autoscaling_group_using_ec2_launch_template",
|
||||
"CheckTitle": "Check if Amazon EC2 Auto Scaling groups use EC2 launch templates.",
|
||||
"CheckTitle": "Amazon EC2 Auto Scaling group uses an EC2 launch template",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices"
|
||||
],
|
||||
"ServiceName": "autoscaling",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:aws:autoscaling:region:account-id:autoScalingGroup/autoScalingGroupName",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsAutoScalingAutoScalingGroup",
|
||||
"Description": "This control checks whether an Amazon EC2 Auto Scaling group is created using an EC2 launch template. The control fails if the Auto Scaling group is not created with a launch template or if a launch template is not specified in a mixed instances policy.",
|
||||
"Risk": "Using launch configurations instead of launch templates may limit your access to the latest EC2 features and improvements, reducing the flexibility and efficiency of your Auto Scaling groups.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/autoscaling/ec2/userguide/create-asg-launch-template.html",
|
||||
"Description": "**EC2 Auto Scaling groups** use an **EC2 launch template** directly or via a `mixed instances policy` to define instance configuration and versioned settings.",
|
||||
"Risk": "Without a launch template, there is no **versioned, auditable baseline** for instance settings, increasing configuration drift. Inconsistent metadata and network options can enable unauthorized access or unstable deployments, degrading confidentiality and availability.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/AutoScaling/asg-launch-template.html",
|
||||
"https://docs.aws.amazon.com/securityhub/latest/userguide/autoscaling-controls.html#autoscaling-9",
|
||||
"https://docs.aws.amazon.com/autoscaling/ec2/userguide/create-asg-launch-template.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws autoscaling create-auto-scaling-group --launch-template LaunchTemplateId=<template-id>",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/autoscaling-controls.html#autoscaling-9",
|
||||
"Terraform": ""
|
||||
"CLI": "aws autoscaling update-auto-scaling-group --auto-scaling-group-name <example_resource_name> --launch-template LaunchTemplateId=<template-id>",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: attach a Launch Template to the ASG\nResources:\n ASG:\n Type: AWS::AutoScaling::AutoScalingGroup\n Properties:\n MinSize: '0'\n MaxSize: '1'\n VPCZoneIdentifier:\n - <example_subnet_id>\n LaunchTemplate: # critical: ensures the ASG uses an EC2 launch template (fixes the check)\n LaunchTemplateId: <example_launch_template_id> # references the EC2 Launch Template\n Version: $Default\n```",
|
||||
"Other": "1. In the AWS console, go to EC2 > Auto Scaling Groups\n2. Select <example_resource_name> and click Edit\n3. Under \"Launch template or configuration\", choose Launch template and select your template and version (Default or Latest)\n4. Click Update to save",
|
||||
"Terraform": "```hcl\n# Terraform: attach a Launch Template to the ASG\nresource \"aws_autoscaling_group\" \"example\" {\n min_size = 0\n max_size = 1\n vpc_zone_identifier = [\"<example_subnet_id>\"]\n\n launch_template {\n id = \"<example_launch_template_id>\" # critical: ensures the ASG uses an EC2 launch template (fixes the check)\n version = \"$Default\"\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Use EC2 launch templates when creating Auto Scaling groups to ensure access to the latest features and improvements.",
|
||||
"Url": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/AutoScaling/asg-launch-template.html"
|
||||
"Text": "Adopt **launch templates** for all Auto Scaling groups and include them in any `mixed instances policy`. Use versioning with approvals, enforce hardened defaults (least privilege roles, secure metadata like `IMDSv2`, encrypted storage), and apply change control to ensure consistency and defense in depth.",
|
||||
"Url": "https://hub.prowler.com/check/autoscaling_group_using_ec2_launch_template"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"resilience"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
+22
-15
@@ -1,33 +1,40 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "backup_plans_exist",
|
||||
"CheckTitle": "Ensure that there is at least one AWS Backup plan",
|
||||
"CheckTitle": "At least one AWS Backup plan exists",
|
||||
"CheckType": [
|
||||
"Recover",
|
||||
"Resilience",
|
||||
"Backup"
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
|
||||
],
|
||||
"ServiceName": "backup",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:service:region:account-id:backup-plan:backup-plan-id",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "low",
|
||||
"ResourceType": "AwsBackupBackupPlan",
|
||||
"Description": "This check ensures that there is at least one backup plan in place.",
|
||||
"Risk": "Without a backup plan, an organization may be at risk of losing important data due to accidental deletion, system failures, or natural disasters. This can result in significant financial and reputational damage for the organization.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/aws-backup/latest/devguide/about-backup-plans.html",
|
||||
"Description": "**AWS Backup** is assessed for the existence of at least one **backup plan** that schedules and retains recovery points for selected resources.\n\nThe evaluation determines whether any plan is configured; when none is found-even if backup vaults exist-the absence of a plan is noted.",
|
||||
"Risk": "Without a backup plan, resources lack scheduled recovery points, undermining RPO/RTO.\n- Irrecoverable data after deletion or corruption (integrity)\n- Prolonged outages due to unavailable restores (availability)\n- Inconsistent backups that hinder investigations and controlled recovery",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://awscli.amazonaws.com/v2/documentation/api/2.0.33/reference/backup/create-backup-plan.html",
|
||||
"https://docs.aws.amazon.com/aws-backup/latest/devguide/about-backup-plans.html",
|
||||
"https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/backup_plan",
|
||||
"https://medium.com/@christopheradamson253/backup-strategies-using-aws-backup-1b17b94a7957"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws backup create-backup-plan --backup-plan <backup_plan_name> --backup-plan-rule <backup_rule_name>",
|
||||
"NativeIaC": "",
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
"CLI": "aws backup create-backup-plan --backup-plan \"{\\\"BackupPlanName\\\":\\\"<example_resource_name>\\\",\\\"Rules\\\":[{\\\"RuleName\\\":\\\"<example_resource_name>\\\",\\\"TargetBackupVaultName\\\":\\\"Default\\\"}]}\"",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: create a minimal AWS Backup Plan to pass the check\nResources:\n <example_resource_name>:\n Type: AWS::Backup::BackupPlan\n Properties:\n BackupPlan:\n BackupPlanName: <example_resource_name> # Critical: ensures at least one Backup Plan exists\n Rules:\n - RuleName: <example_resource_name> # Critical: minimal required rule\n TargetBackupVault: Default # Critical: required vault for the rule\n```",
|
||||
"Other": "1. In the AWS Console, go to AWS Backup\n2. Click Backup plans > Create backup plan\n3. Choose Build a new plan\n4. Enter Plan name: <example_resource_name>\n5. Under Backup rule, set Rule name: <example_resource_name> and Target backup vault: Default\n6. Click Create plan",
|
||||
"Terraform": "```hcl\n# Terraform: minimal AWS Backup Plan to satisfy the check\nresource \"aws_backup_plan\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\" # Critical: creates the Backup Plan so the check passes\n\n rule {\n rule_name = \"<example_resource_name>\" # Critical: minimal rule\n target_vault_name = \"Default\" # Critical: required vault\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Use AWS Backup to create backup plans for your critical data and services.",
|
||||
"Url": "https://docs.aws.amazon.com/aws-backup/latest/devguide/about-backup-plans.html"
|
||||
"Text": "Establish and enforce **backup plans** for critical workloads:\n- Define schedules, retention, and lifecycle to meet RPO/RTO\n- Use tagging to include all required resources by policy\n- Enable cross-Region/account copies and immutability where feasible\n- Apply least privilege to backup roles\n- Regularly test restores and review reports",
|
||||
"Url": "https://hub.prowler.com/check/backup_plans_exist"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"resilience"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
+21
-12
@@ -1,28 +1,37 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "backup_recovery_point_encrypted",
|
||||
"CheckTitle": "Check if AWS Backup recovery points are encrypted at rest.",
|
||||
"CheckTitle": "AWS Backup recovery point is encrypted at rest",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices"
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
|
||||
"Effects/Data Exposure"
|
||||
],
|
||||
"ServiceName": "backup",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:aws:backup:region:account-id:recovery-point/recovery-point-id",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsBackupRecoveryPoint",
|
||||
"Description": "This control checks if an AWS Backup recovery point is encrypted at rest. The control fails if the recovery point isn't encrypted at rest.",
|
||||
"Risk": "Without encryption at rest, AWS Backup recovery points are vulnerable to unauthorized access, which could compromise the confidentiality and integrity of the backed-up data.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/aws-backup/latest/devguide/encryption.html",
|
||||
"Description": "**AWS Backup recovery points** are evaluated for **encryption at rest** using the backup vault's KMS configuration. Items lacking vault-level encryption are highlighted, regardless of the source resource's encryption.",
|
||||
"Risk": "Unencrypted recovery points can be read or copied if vault access is obtained, enabling offline analysis and data theft (**confidentiality**). Snapshots or restores may be altered (**integrity**), and unsafe restores can disrupt recovery operations (**availability**).",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/securityhub/latest/userguide/backup-controls.html#backup-1",
|
||||
"https://readmedium.com/how-would-you-desgin-a-solution-for-autmated-backup-and-recovery-of-data-and-services-in-aws-311662f5a43e",
|
||||
"https://docs.aws.amazon.com/aws-backup/latest/devguide/encryption.html",
|
||||
"https://medium.com/cloud-devops-security-ai-career-talk/how-would-you-desgin-a-solution-for-autmated-backup-and-recovery-of-data-and-services-in-aws-311662f5a43e",
|
||||
"https://github.com/turbot/steampipe-mod-aws-compliance/issues/598"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws backup update-backup-vault --backup-vault-name <backup_vault_name> --encryption-key-arn <kms_key_arn>",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/backup-controls.html#backup-1",
|
||||
"Terraform": ""
|
||||
"CLI": "",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: Encrypted AWS Backup Vault\nResources:\n <example_resource_name>:\n Type: AWS::Backup::BackupVault\n Properties:\n BackupVaultName: <example_resource_name>\n EncryptionKeyArn: <kms_key_arn> # Critical: vault uses this KMS key so recovery points stored here are encrypted at rest\n```",
|
||||
"Other": "1. In AWS Backup, go to Backup vaults > Create backup vault\n2. Enter a name and select a KMS key (aws/backup or a customer-managed key)\n3. Save the vault\n4. Go to Backup plans > select your plan > Edit and set the Target backup vault to the encrypted vault > Save\n5. To remediate existing unencrypted recovery points: Recovery points > select the item > Copy > choose the encrypted vault > Start copy, then delete the original unencrypted recovery point",
|
||||
"Terraform": "```hcl\n# Encrypted AWS Backup Vault\nresource \"aws_backup_vault\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n kms_key_arn = \"<kms_key_arn>\" # Critical: ensures recovery points in this vault are encrypted at rest\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Ensure that AWS Backup recovery points are encrypted at rest by using an AWS KMS key when creating backups.",
|
||||
"Url": "https://docs.aws.amazon.com/aws-backup/latest/devguide/encryption.html"
|
||||
"Text": "Encrypt all recovery points with **KMS**, preferring **customer-managed keys** for rotation and control. Apply **least privilege** to keys and vaults, require encrypted copies across accounts/Regions, and continuously monitor for unencrypted artifacts. Use `aws/backup` or `CMEK` consistently.",
|
||||
"Url": "https://hub.prowler.com/check/backup_recovery_point_encrypted"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
|
||||
+19
-15
@@ -1,33 +1,37 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "backup_reportplans_exist",
|
||||
"CheckTitle": "Ensure that there is at least one AWS Backup report plan",
|
||||
"CheckTitle": "At least one AWS Backup report plan exists",
|
||||
"CheckType": [
|
||||
"Recover",
|
||||
"Resilience",
|
||||
"Backup"
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
|
||||
],
|
||||
"ServiceName": "backup",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:service:region:account-id:backup-report-plan:backup-report-plan-id",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "low",
|
||||
"ResourceType": "AwsBackupBackupPlan",
|
||||
"Description": "This check ensures that there is at least one backup report plan in place.",
|
||||
"Risk": "Without a backup report plan, an organization may lack visibility into the success or failure of backup operations.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/aws-backup/latest/devguide/create-report-plan-console.html",
|
||||
"Description": "**AWS Backup** environments with existing backup plans are assessed for the presence of at least one **report plan** that generates `jobs` or `compliance` reports.",
|
||||
"Risk": "Without a report plan, backup failures and missed restores may go unnoticed, harming **availability** and recovery objectives. Gaps in retention, scheduling, or encryption controls can persist unreported, weakening **integrity** and auditability across accounts and Regions, increasing the chance of SLA breaches.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/aws-backup/latest/devguide/create-report-plan-console.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws backup create-report-plan --report-plan-name <report-plan-name> --report-delivery-channel <value> --report-setting <value>",
|
||||
"NativeIaC": "",
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
"CLI": "aws backup create-report-plan --report-plan-name <REPORT_PLAN_NAME> --report-delivery-channel s3BucketName=<S3_BUCKET_NAME>,formats=CSV --report-setting reportTemplate=BACKUP_JOB_REPORT",
|
||||
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::Backup::ReportPlan\n Properties:\n ReportPlanName: <example_resource_name> # Critical: creates the report plan required to pass the check\n ReportDeliveryChannel:\n S3BucketName: <example_resource_name> # Critical: destination bucket for reports\n Formats:\n - CSV # Critical: valid report file format\n ReportSetting:\n ReportTemplate: BACKUP_JOB_REPORT # Critical: minimal template to enable job reports\n```",
|
||||
"Other": "1. Open the AWS Backup console and go to Reports\n2. Click Create report plan\n3. Select the Backup jobs (job report) template\n4. Enter a Report plan name and choose an S3 bucket\n5. Select CSV as the file format\n6. Click Create report plan",
|
||||
"Terraform": "```hcl\nresource \"aws_backup_report_plan\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\" # Critical: creates at least one report plan\n\n report_delivery_channel {\n s3_bucket_name = \"<example_resource_name>\" # Critical: destination bucket for reports\n formats = [\"CSV\"] # Critical: valid report file format\n }\n\n report_setting {\n report_template = \"BACKUP_JOB_REPORT\" # Critical: minimal job report template\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Use AWS Backup to create backup report plans that provide visibility into the success or failure of backup operations.",
|
||||
"Url": "https://docs.aws.amazon.com/aws-backup/latest/devguide/create-report-plan-console.html"
|
||||
"Text": "Establish and maintain **report plans** to continuously monitor backup activity and policy adherence.\n- Apply least privilege to report storage\n- Include relevant accounts and Regions for coverage\n- Review reports routinely and alert on anomalies\n- Enforce separation of duties between backup admins and auditors",
|
||||
"Url": "https://hub.prowler.com/check/backup_reportplans_exist"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"logging"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
+24
-13
@@ -1,31 +1,42 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "backup_vaults_encrypted",
|
||||
"CheckTitle": "Ensure that AWS Backup vaults are encrypted with AWS KMS",
|
||||
"CheckTitle": "AWS Backup vault is encrypted at rest",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices"
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/NIST 800-53 Controls (USA)",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/NIST CSF Controls (USA)",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/PCI-DSS",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/ISO 27001 Controls",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/HIPAA Controls (USA)"
|
||||
],
|
||||
"ServiceName": "backup",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:service:region:account-id:backup-vault:backup-vault-id",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsBackupBackupVault",
|
||||
"Description": "This check ensures that AWS Backup vaults are encrypted with AWS KMS.",
|
||||
"Risk": "Without encryption using AWS KMS, an organization's backup data may be at risk of unauthorized access, which can lead to data breaches and other security incidents.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/aws-backup/latest/devguide/encryption.html",
|
||||
"Description": "**AWS Backup vaults** are evaluated for **encryption at rest** with **AWS KMS**. The finding highlights vaults without a configured KMS key protecting stored recovery points.",
|
||||
"Risk": "Unencrypted vaults allow recovery points to be read if storage or credentials are compromised, undermining **confidentiality** and enabling data exfiltration. Missing KMS controls also weaken **integrity** guarantees and impede forensic **auditability** during investigations.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/aws-backup/latest/devguide/encryption.html",
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/Athena/encrypted-with-cmk.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws backup update-backup-vault --backup-vault-name <backup_vault_name> --encryption-key-arn <kms_key_arn>",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/Athena/encrypted-with-cmk.html",
|
||||
"Terraform": ""
|
||||
"CLI": "",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: Encrypted AWS Backup Vault\nResources:\n <example_resource_name>:\n Type: AWS::Backup::BackupVault\n Properties:\n BackupVaultName: <example_resource_name>\n EncryptionKeyArn: <kms_key_arn> # CRITICAL: sets KMS key to encrypt the vault at rest\n```",
|
||||
"Other": "1. In the AWS Console, go to AWS Backup > Backup vaults\n2. Click Create backup vault\n3. Set Name to <example_resource_name>\n4. Under Encryption key, select a customer managed KMS key (<kms_key_arn>)\n5. Click Create backup vault\n6. Update any Backup plans to use the new vault (Plans > select plan > Edit > change Target vault name)\n7. Delete the old unencrypted vault after it is empty (select vault > Delete backup vault)",
|
||||
"Terraform": "```hcl\n# Encrypted AWS Backup Vault\nresource \"aws_backup_vault\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n kms_key_arn = \"<kms_key_arn>\" # CRITICAL: enables encryption at rest for the vault\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Use AWS KMS to encrypt your AWS Backup vaults and backup data.",
|
||||
"Url": "https://docs.aws.amazon.com/aws-backup/latest/devguide/encryption.html"
|
||||
"Text": "Encrypt every backup vault with **customer-managed KMS keys** (`CMK`). Enforce **least privilege** in key policies, enable rotation, and separate key admins from backup operators. Add **defense-in-depth** with vault lock and logging. *For copies*, ensure destination vaults use appropriate KMS keys.",
|
||||
"Url": "https://hub.prowler.com/check/backup_vaults_encrypted"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"encryption"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
+19
-15
@@ -1,33 +1,37 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "backup_vaults_exist",
|
||||
"CheckTitle": "Ensure AWS Backup vaults exist",
|
||||
"CheckTitle": "At least one AWS Backup vault exists",
|
||||
"CheckType": [
|
||||
"Recover",
|
||||
"Resilience",
|
||||
"Backup"
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
|
||||
],
|
||||
"ServiceName": "backup",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:service:region:account-id:backup-vault:backup-vault-id",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "low",
|
||||
"ResourceType": "AwsBackupBackupVault",
|
||||
"Description": "This check ensures that AWS Backup vaults exist to provide a secure and durable storage location for backup data.",
|
||||
"Risk": "Without an AWS Backup vault, an organization's critical data may be at risk of being lost in the event of an accidental deletion, system failures, or natural disasters.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/aws-backup/latest/devguide/vaults.html",
|
||||
"Description": "**AWS Backup** in the account/region includes at least one **backup vault** that stores and organizes recovery points for use by backup plans and copies.",
|
||||
"Risk": "Without a vault, recovery points cannot be created or retained in AWS Backup, degrading **availability** and **integrity**. Data may be irrecoverable after deletion, ransomware, or misconfiguration, and RPO/RTO targets may be missed during incidents.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/aws-backup/latest/devguide/vaults.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws backup create-backup-vault --backup-vault-name <backup_vault_name>",
|
||||
"NativeIaC": "",
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
"CLI": "aws backup create-backup-vault --backup-vault-name <example_resource_name>",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: create a Backup Vault\nResources:\n BackupVault:\n Type: AWS::Backup::BackupVault\n Properties:\n VaultName: <example_resource_name> # Critical: creates a backup vault to satisfy the check\n```",
|
||||
"Other": "1. Sign in to the AWS Management Console and open the AWS Backup console\n2. In the left navigation pane, select Backup vaults\n3. Click Create backup vault\n4. Enter a name (e.g., <example_resource_name>)\n5. Click Create backup vault",
|
||||
"Terraform": "```hcl\n# Create a Backup Vault\nresource \"aws_backup_vault\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\" # Critical: ensures at least one backup vault exists\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Use AWS Backup to create backup vaults for your critical data and services.",
|
||||
"Url": "https://docs.aws.amazon.com/aws-backup/latest/devguide/vaults.html"
|
||||
"Text": "Create and maintain a **backup vault** in each required region. Enforce **least privilege** access, encrypt with **KMS CMKs**, and enable **Vault Lock** to prevent tampering. Use lifecycle rules and cross-region/cross-account copies, and regularly test restores for **defense in depth**.",
|
||||
"Url": "https://hub.prowler.com/check/backup_vaults_exist"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"resilience"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
+21
-12
@@ -1,26 +1,35 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "cloudfront_distributions_custom_ssl_certificate",
|
||||
"CheckTitle": "CloudFront distributions should use custom SSL/TLS certificates.",
|
||||
"CheckType": [],
|
||||
"CheckTitle": "CloudFront distribution uses a custom SSL/TLS certificate",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
|
||||
],
|
||||
"ServiceName": "cloudfront",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsCloudFrontDistribution",
|
||||
"Description": "Ensure that your Amazon CloudFront distributions are configured to use a custom SSL/TLS certificate instead of the default one.",
|
||||
"Risk": "Using the default SSL/TLS certificate provided by CloudFront can limit your ability to use custom domain names and may not align with your organization's security policies or branding requirements.",
|
||||
"RelatedUrl": "https://aws.amazon.com/what-is/ssl-certificate/",
|
||||
"Description": "CloudFront distributions are configured with a **custom SSL/TLS certificate** rather than the default `*.cloudfront.net` certificate for viewer connections.",
|
||||
"Risk": "Using the default certificate prevents HTTPS on your own hostnames, breaking hostname validation. Clients may face errors or avoid TLS, impacting **authentication** and **availability**. Control over TLS posture and domain-bound security headers is reduced, weakening **confidentiality** and user trust.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-distro-custom-tls.html",
|
||||
"https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-7",
|
||||
"https://support.icompaas.com/support/solutions/articles/62000233491-ensure-cloudfront-distributions-use-custom-ssl-tls-certificates",
|
||||
"https://reintech.io/blog/configure-https-ssl-certificates-cloudfront-distributions"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "https://docs.prowler.com/checks/aws/networking-policies/ensure-aws-cloudfront-distribution-uses-custom-ssl-certificate/",
|
||||
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-7",
|
||||
"Terraform": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-distro-custom-tls.html"
|
||||
"CLI": "aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --output json > current-config.json && echo 'Manually edit current-config.json to add Aliases and ViewerCertificate fields, then run:' && echo 'aws cloudfront update-distribution --id <DISTRIBUTION_ID> --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --query \"ETag\" --output text)'",
|
||||
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n Origins:\n - Id: origin1\n DomainName: <example_origin_domain>\n S3OriginConfig: {}\n DefaultCacheBehavior:\n TargetOriginId: origin1\n ViewerProtocolPolicy: redirect-to-https\n ForwardedValues:\n QueryString: false\n Aliases:\n - <example_domain> # CRITICAL: add an alternate domain name (CNAME) covered by the certificate\n ViewerCertificate:\n AcmCertificateArn: <example_certificate_arn> # CRITICAL: attach custom ACM cert (must be in us-east-1)\n SslSupportMethod: sni-only # CRITICAL: required when using ACM cert\n```",
|
||||
"Other": "1. Open the CloudFront console and select your distribution\n2. Go to the Settings/General tab and click Edit\n3. In Alternate domain name (CNAME), add <example_domain>\n4. In SSL certificate, choose Custom SSL certificate and select your ACM certificate (issued in us-east-1 and covering <example_domain>)\n5. Click Save/Yes, Edit and wait for the distribution to deploy",
|
||||
"Terraform": "```hcl\nresource \"aws_cloudfront_distribution\" \"<example_resource_name>\" {\n enabled = true\n\n origin {\n domain_name = \"<example_origin_domain>\"\n origin_id = \"origin1\"\n s3_origin_config {}\n }\n\n default_cache_behavior {\n target_origin_id = \"origin1\"\n viewer_protocol_policy = \"redirect-to-https\"\n forwarded_values { query_string = false }\n }\n\n aliases = [\"<example_domain>\"] # CRITICAL: add CNAME covered by the cert\n\n viewer_certificate {\n acm_certificate_arn = \"<example_certificate_arn>\" # CRITICAL: custom ACM cert (in us-east-1)\n ssl_support_method = \"sni-only\" # CRITICAL: required with ACM cert\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Configure your CloudFront distributions to use a custom SSL/TLS certificate to enable secure access via your own domain names and meet specific security and branding needs. This allows for more control over encryption and authentication settings.",
|
||||
"Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/CNAMEs.html#CreatingCNAME"
|
||||
"Text": "- Use a **custom SSL/TLS certificate** covering your domains and configure aliases.\n- Enforce modern TLS policy, **SNI**, and **HSTS**; disable legacy protocols.\n- Apply **least privilege** to certificate lifecycle and rotate/monitor keys.",
|
||||
"Url": "https://hub.prowler.com/check/cloudfront_distributions_custom_ssl_certificate"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
|
||||
+19
-12
@@ -1,26 +1,33 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "cloudfront_distributions_default_root_object",
|
||||
"CheckTitle": "Check if CloudFront distributions have a default root object.",
|
||||
"CheckType": [],
|
||||
"CheckTitle": "CloudFront distribution has a default root object configured",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices"
|
||||
],
|
||||
"ServiceName": "cloudfront",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "AwsCloudFrontDistribution",
|
||||
"Description": "Check if CloudFront distributions have a default root object.",
|
||||
"Risk": "Without a default root object, requests to the root URL may result in an error or expose unintended content, leading to potential security risks and a poor user experience.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/DefaultRootObject.html#DefaultRootObjectHow",
|
||||
"Description": "CloudFront distributions are evaluated for a configured **default root object** that maps `/` requests to a specific file such as `index.html`, rather than forwarding root requests directly to the origin.",
|
||||
"Risk": "Without a **default root object**, root requests can reveal **origin listings** or unintended files, exposing data (**confidentiality**) and aiding reconnaissance. They may also return errors, lowering uptime (**availability**), or route unpredictably, risking wrong content delivery (**integrity**).",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-1",
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-default-object.html",
|
||||
"https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/DefaultRootObject.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws cloudfront update-distribution --id <distribution-id> --default-root-object <new-root-object>",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-1",
|
||||
"Terraform": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-default-object.html"
|
||||
"CLI": "aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --output json > current-config.json && echo 'Manually edit current-config.json to add DefaultRootObject: \"index.html\", then run:' && echo 'aws cloudfront update-distribution --id <DISTRIBUTION_ID> --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --query \"ETag\" --output text)'",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: Set a default root object on a CloudFront distribution\nResources:\n CloudFrontDistribution:\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n DefaultRootObject: index.html # CRITICAL: ensures a default root object is configured\n Origins:\n - Id: <example_origin_id>\n DomainName: <example_origin_domain>\n S3OriginConfig: {}\n DefaultCacheBehavior:\n TargetOriginId: <example_origin_id>\n ViewerProtocolPolicy: allow-all\n ForwardedValues:\n QueryString: false\n```",
|
||||
"Other": "1. Open the AWS Console and go to CloudFront\n2. Select the target distribution and choose Settings > General > Edit\n3. In Default root object, enter index.html (do not start with a /)\n4. Save changes and wait for deployment to complete",
|
||||
"Terraform": "```hcl\n# Terraform: Set a default root object on a CloudFront distribution\nresource \"aws_cloudfront_distribution\" \"<example_resource_name>\" {\n enabled = true\n default_root_object = \"index.html\" # CRITICAL: ensures a default root object is configured\n\n origin {\n domain_name = \"<example_origin_domain>\"\n origin_id = \"<example_origin_id>\"\n\n s3_origin_config {}\n }\n\n default_cache_behavior {\n target_origin_id = \"<example_origin_id>\"\n viewer_protocol_policy = \"allow-all\"\n forwarded_values {\n query_string = false\n }\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Configure a default root object for your CloudFront distribution to ensure that a specific file (such as index.html) is returned when users access the root URL. This improves user experience and ensures that sensitive content isn't accidentally exposed.",
|
||||
"Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/DefaultRootObject.html#DefaultRootObjectHowToDefine"
|
||||
"Text": "Set a **default root object** that returns a safe landing page (e.g., `index.html`). Apply **defense in depth**: restrict direct origin access, define explicit error pages, and standardize redirects. Test root and subdirectory requests for predictable responses. Align origin permissions with **least privilege**.",
|
||||
"Url": "https://hub.prowler.com/check/cloudfront_distributions_default_root_object"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
|
||||
+19
-12
@@ -1,26 +1,33 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "cloudfront_distributions_field_level_encryption_enabled",
|
||||
"CheckTitle": "Check if CloudFront distributions have Field Level Encryption enabled.",
|
||||
"CheckType": [],
|
||||
"CheckTitle": "CloudFront distribution has Field Level Encryption enabled",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Effects/Data Exposure"
|
||||
],
|
||||
"ServiceName": "cloudfront",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "low",
|
||||
"ResourceType": "AwsCloudFrontDistribution",
|
||||
"Description": "Check if CloudFront distributions have Field Level Encryption enabled.",
|
||||
"Risk": "Allows you protect specific data throughout system processing so that only certain applications can see it.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/field-level-encryption.html",
|
||||
"Description": "CloudFront distributions have the default cache behavior associated with **Field-Level Encryption** via `field_level_encryption_id`, targeting specified request fields for edge encryption.",
|
||||
"Risk": "Absent **field-level encryption**, sensitive inputs (PII, payment data, credentials) may surface in origin paths, logs, or middleware in plaintext. This undermines **confidentiality**, enables data exfiltration and insider misuse, and can lead to session or account compromise if tokens are captured.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/field-level-encryption.html",
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/field-level-encryption-enabled.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/field-level-encryption-enabled.html",
|
||||
"Terraform": ""
|
||||
"CLI": "aws cloudfront create-field-level-encryption-config --field-level-encryption-config file://fle-config.json && aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --output json > current-config.json && echo 'Manually edit current-config.json to add FieldLevelEncryptionId to DefaultCacheBehavior, then run:' && echo 'aws cloudfront update-distribution --id <DISTRIBUTION_ID> --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --query \"ETag\" --output text)'",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: Enable Field Level Encryption on Default Cache Behavior\nResources:\n FLEConfig:\n Type: AWS::CloudFront::FieldLevelEncryptionConfig\n Properties:\n FieldLevelEncryptionConfig:\n CallerReference: !Ref AWS::StackName\n ContentTypeProfileConfig:\n ForwardWhenContentTypeIsUnknown: true\n ContentTypeProfiles:\n Quantity: 0\n\n <example_resource_name>:\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n Origins:\n - DomainName: \"<example_resource_name>.s3.amazonaws.com\"\n Id: \"<example_resource_id>\"\n S3OriginConfig: {}\n DefaultCacheBehavior:\n TargetOriginId: \"<example_resource_id>\"\n ViewerProtocolPolicy: redirect-to-https\n CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6\n FieldLevelEncryptionId: !Ref FLEConfig # Critical: enables FLE on the default cache behavior\n```",
|
||||
"Other": "1. In the AWS Console, go to CloudFront\n2. If you don't have a Field-level encryption configuration:\n - In the left menu, click Public keys > Add public key (paste your RSA public key)\n - Click Field-level encryption > Create profile (choose the public key and add fields to encrypt)\n - Click Field-level encryption > Create configuration (set the profile as Default profile)\n3. Attach it to your distribution:\n - Go to Distributions > select <example_resource_id>\n - Choose Behaviors > select Default (*) > Edit\n - Set Field-level encryption configuration to your created configuration\n - Click Save changes and wait for deployment",
|
||||
"Terraform": "```hcl\n# Enable Field Level Encryption on Default Cache Behavior\nresource \"aws_cloudfront_field_level_encryption_config\" \"fle\" {\n content_type_profile_config {\n forward_when_content_type_is_unknown = true\n }\n}\n\nresource \"aws_cloudfront_distribution\" \"<example_resource_name>\" {\n enabled = true\n\n origin {\n domain_name = \"<example_resource_name>.s3.amazonaws.com\"\n origin_id = \"<example_resource_id>\"\n }\n\n default_cache_behavior {\n target_origin_id = \"<example_resource_id>\"\n viewer_protocol_policy = \"redirect-to-https\"\n cache_policy_id = \"658327ea-f89d-4fab-a63d-7e88639e58f6\"\n field_level_encryption_id = aws_cloudfront_field_level_encryption_config.fle.id # Critical: enables FLE\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Check if applicable to any sensitive data. This encryption ensures that only applications that need the data—and have the credentials to decrypt it - are able to do so.",
|
||||
"Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/field-level-encryption.html"
|
||||
"Text": "Enable **Field-Level Encryption** for sensitive request fields and bind it to relevant cache behaviors. Apply **least privilege** to decryption keys, rotate and monitor keys, and separate duties. As **defense in depth**, minimize data collection, avoid logging secrets, require HTTPS end-to-end, and validate inputs.",
|
||||
"Url": "https://hub.prowler.com/check/cloudfront_distributions_field_level_encryption_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
|
||||
+22
-13
@@ -1,29 +1,38 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "cloudfront_distributions_geo_restrictions_enabled",
|
||||
"CheckTitle": "Check if Geo restrictions are enabled in CloudFront distributions.",
|
||||
"CheckType": [],
|
||||
"CheckTitle": "CloudFront distribution has Geo restrictions enabled",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability"
|
||||
],
|
||||
"ServiceName": "cloudfront",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "low",
|
||||
"ResourceType": "AwsCloudFrontDistribution",
|
||||
"Description": "Check if Geo restrictions are enabled in CloudFront distributions.",
|
||||
"Risk": "Consider countries where service should not be accessed, by legal or compliance requirements. Additionally if not restricted the attack vector is increased.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/georestrictions.html",
|
||||
"Description": "**CloudFront distributions** have **geographic restrictions** configured to limit access by country using an allowlist or blocklist (`RestrictionType` not `none`).",
|
||||
"Risk": "Absent geo restrictions, content is globally reachable, enabling:\n- Access from sanctioned or unlicensed regions (confidentiality/compliance)\n- Broader bot abuse, scraping, and DDoS staging (availability)\n- More credential-stuffing and fraud attempts against apps",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://repost.aws/knowledge-center/cloudfront-geo-restriction",
|
||||
"https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/georestrictions.html",
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/geo-restriction.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/geo-restriction.html",
|
||||
"Terraform": ""
|
||||
"CLI": "aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --output json > current-config.json && echo 'Manually edit current-config.json to add Restrictions.GeoRestriction with RestrictionType: \"whitelist\" and Locations: [\"US\"], then run:' && echo 'aws cloudfront update-distribution --id <DISTRIBUTION_ID> --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --query \"ETag\" --output text)'",
|
||||
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n Origins:\n - DomainName: \"<example_origin_domain>\"\n Id: \"<example_origin_id>\"\n DefaultCacheBehavior:\n TargetOriginId: \"<example_origin_id>\"\n ViewerProtocolPolicy: allow-all\n CachePolicyId: \"<example_cache_policy_id>\"\n Restrictions:\n GeoRestriction:\n RestrictionType: whitelist # CRITICAL: enables geo restrictions\n Locations: # CRITICAL: at least one allowed country\n - US\n```",
|
||||
"Other": "1. In the AWS Console, go to CloudFront > Distributions\n2. Select the target distribution\n3. Open the Security tab > Geographic restrictions > Edit\n4. Choose Allow list (or Block list)\n5. Add at least one country to the list\n6. Save changes",
|
||||
"Terraform": "```hcl\nresource \"aws_cloudfront_distribution\" \"<example_resource_name>\" {\n enabled = true\n\n origins {\n domain_name = \"<example_origin_domain>\"\n origin_id = \"<example_origin_id>\"\n }\n\n default_cache_behavior {\n target_origin_id = \"<example_origin_id>\"\n viewer_protocol_policy = \"allow-all\"\n cache_policy_id = \"<example_cache_policy_id>\"\n }\n\n restrictions {\n geo_restriction {\n restriction_type = \"whitelist\" # CRITICAL: enables geo restrictions\n locations = [\"US\"] # CRITICAL: at least one allowed country\n }\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "If possible, define and enable Geo restrictions for this service.",
|
||||
"Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/georestrictions.html"
|
||||
"Text": "Apply **least privilege** to distribution scope: enable geo restrictions with a country **allowlist** where feasible, or maintain a precise blocklist aligned to legal, licensing, and threat models.\n\nLayer **defense in depth**: use WAF/bot controls, signed URLs or cookies, and monitoring to detect abuse and configuration drift.",
|
||||
"Url": "https://hub.prowler.com/check/cloudfront_distributions_geo_restrictions_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Infrastructure Security"
|
||||
|
||||
+21
-12
@@ -1,26 +1,35 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "cloudfront_distributions_https_enabled",
|
||||
"CheckTitle": "Check if CloudFront distributions are set to HTTPS.",
|
||||
"CheckType": [],
|
||||
"CheckTitle": "CloudFront distribution has viewer protocol policy set to HTTPS only or redirect to HTTPS",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
|
||||
"Effects/Data Exposure"
|
||||
],
|
||||
"ServiceName": "cloudfront",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsCloudFrontDistribution",
|
||||
"Description": "Check if CloudFront distributions are set to HTTPS.",
|
||||
"Risk": "If not enabled sensitive information in transit is not protected. Surveillance and other threats are risks may exists.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/IAM/latest/UserGuide/what-is-access-analyzer.html",
|
||||
"Description": "CloudFront distributions require viewer connections over **HTTPS** when the default cache behavior `viewer_protocol_policy` is `https-only` or `redirect-to-https`. Configurations that use `allow-all` permit HTTP.",
|
||||
"Risk": "Allowing HTTP exposes traffic to **man-in-the-middle** interception and **session hijacking**, enabling theft of cookies, tokens, or PII. Attackers can **tamper** with responses, inject malware, or perform **downgrade/strip** attacks, undermining confidentiality and integrity.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/security-policy.html",
|
||||
"https://docs.aws.amazon.com/IAM/latest/UserGuide/what-is-access-analyzer.html",
|
||||
"https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-https.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "https://docs.prowler.com/checks/aws/networking-policies/networking_32#cloudformation",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/security-policy.html",
|
||||
"Terraform": "https://docs.prowler.com/checks/aws/networking-policies/networking_32#terraform"
|
||||
"CLI": "aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --output json > current-config.json && echo 'Manually edit current-config.json to change DefaultCacheBehavior.ViewerProtocolPolicy to \"redirect-to-https\" or \"https-only\", then run:' && echo 'aws cloudfront update-distribution --id <DISTRIBUTION_ID> --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --query \"ETag\" --output text)'",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: set ViewerProtocolPolicy to require HTTPS\nResources:\n <example_resource_name>:\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n DefaultCacheBehavior:\n ViewerProtocolPolicy: https-only # Critical: requires HTTPS for viewers\n```",
|
||||
"Other": "1. In the AWS Console, go to CloudFront > Distributions\n2. Select the target distribution and open the Behaviors tab\n3. Select the Default (*) behavior and click Edit\n4. Set Viewer protocol policy to Redirect HTTP to HTTPS (or HTTPS Only)\n5. Save changes and deploy",
|
||||
"Terraform": "```hcl\n# Terraform: set viewer_protocol_policy to force HTTPS\nresource \"aws_cloudfront_distribution\" \"<example_resource_name>\" {\n default_cache_behavior {\n target_origin_id = \"<example_origin_id>\"\n viewer_protocol_policy = \"redirect-to-https\" # Critical: forces HTTP to HTTPS\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Use HTTPS everywhere possible. It will enforce privacy and protect against account hijacking and other threats.",
|
||||
"Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-https.html"
|
||||
"Text": "Enforce **HTTPS-only** access for viewers by setting `viewer_protocol_policy` to `https-only` or `redirect-to-https`; avoid `allow-all`. Extend encryption end-to-end to origins, enable **HSTS**, prefer modern TLS and ciphers, and mark cookies `Secure`. This supports **defense in depth** and prevents downgrade.",
|
||||
"Url": "https://hub.prowler.com/check/cloudfront_distributions_https_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
|
||||
+20
-12
@@ -1,26 +1,34 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "cloudfront_distributions_https_sni_enabled",
|
||||
"CheckTitle": "Check if CloudFront distributions are using SNI to serve HTTPS requests.",
|
||||
"CheckType": [],
|
||||
"CheckTitle": "CloudFront distribution serves HTTPS requests using SNI",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability"
|
||||
],
|
||||
"ServiceName": "cloudfront",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "low",
|
||||
"ResourceType": "AwsCloudFrontDistribution",
|
||||
"Description": "Check if CloudFront distributions are using SNI to serve HTTPS requests.",
|
||||
"Risk": "If SNI is not used, CloudFront will allocate a dedicated IP address for each SSL certificate, leading to higher costs and inefficient IP address utilization. This could also complicate scaling and managing multiple distributions, especially if your domain requires multiple SSL certificates.",
|
||||
"RelatedUrl": "https://www.cloudflare.com/es-es/learning/ssl/what-is-sni/",
|
||||
"Description": "**CloudFront distributions** that use **custom SSL/TLS certificates** are configured to serve **HTTPS** using **Server Name Indication** (`ssl_support_method: sni-only`). It evaluates SNI use rather than dedicated IP during the TLS handshake.",
|
||||
"Risk": "Without **SNI**, distributions use dedicated IP SSL, driving higher costs and inefficient IP usage. Dedicated IPs can strain quotas and hinder scaling, reducing **availability**. Managing IP-bound certificates adds **operational risk** during rotations and expansions.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-sni.html",
|
||||
"https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-8",
|
||||
"https://support.icompaas.com/support/solutions/articles/62000223557-ensure-cloudfront-sni-enabled",
|
||||
"https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cnames-https-dedicated-ip-or-sni.html#cnames-https-sni"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-8",
|
||||
"Terraform": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-sni.html"
|
||||
"CLI": "aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --output json > current-config.json && echo 'Manually edit current-config.json to change ViewerCertificate.SslSupportMethod to sni-only', then run:' && echo 'aws cloudfront update-distribution --id <DISTRIBUTION_ID> --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --query \"ETag\" --output text)'",
|
||||
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n Origins:\n - Id: <example_origin_id>\n DomainName: <example_origin_domain>\n S3OriginConfig:\n OriginAccessIdentity: ''\n DefaultCacheBehavior:\n TargetOriginId: <example_origin_id>\n ViewerProtocolPolicy: allow-all\n ForwardedValues:\n QueryString: false\n Cookies:\n Forward: none\n MinTTL: 0\n ViewerCertificate:\n AcmCertificateArn: <example_certificate_arn>\n SslSupportMethod: sni-only # Critical: enable SNI for HTTPS\n MinimumProtocolVersion: TLSv1 # Required when using SNI with a custom cert\n```",
|
||||
"Other": "1. In the AWS Console, go to CloudFront and open your distribution\n2. Select the Settings/General tab and click Edit\n3. Under SSL certificate, ensure your custom certificate is selected\n4. Set Client support to SNI only\n5. Click Save changes",
|
||||
"Terraform": "```hcl\nresource \"aws_cloudfront_distribution\" \"<example_resource_name>\" {\n enabled = true\n\n origin {\n domain_name = \"<example_origin_domain>\"\n origin_id = \"<example_origin_id>\"\n }\n\n default_cache_behavior {\n target_origin_id = \"<example_origin_id>\"\n viewer_protocol_policy = \"allow-all\"\n forwarded_values {\n query_string = false\n cookies { forward = \"none\" }\n }\n min_ttl = 0\n }\n\n viewer_certificate {\n acm_certificate_arn = \"<example_certificate_arn>\"\n ssl_support_method = \"sni-only\" # Critical: enable SNI for HTTPS\n minimum_protocol_version = \"TLSv1\" # Required with SNI\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Ensure that your CloudFront distributions are configured to use Server Name Indication (SNI) when serving HTTPS requests with custom SSL/TLS certificates. This is the recommended approach for reducing costs and optimizing IP address usage.",
|
||||
"Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cnames-https-dedicated-ip-or-sni.html#cnames-https-sni"
|
||||
"Text": "Use **SNI** (`sni-only`) for **HTTPS** with custom certificates; avoid dedicated IP unless a critical, non-SNI client requires it. Document and periodically review exceptions, plan client upgrades, and adopt the latest **TLS security policy** to standardize secure, modern configurations.",
|
||||
"Url": "https://hub.prowler.com/check/cloudfront_distributions_https_sni_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
|
||||
+22
-13
@@ -1,30 +1,39 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "cloudfront_distributions_logging_enabled",
|
||||
"CheckTitle": "Check if CloudFront distributions have logging enabled.",
|
||||
"CheckType": [],
|
||||
"CheckTitle": "CloudFront distribution has logging enabled",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
|
||||
],
|
||||
"ServiceName": "cloudfront",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsCloudFrontDistribution",
|
||||
"Description": "Check if CloudFront distributions have logging enabled.",
|
||||
"Risk": "If not enabled monitoring of service use is not possible.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/AccessLogs.html",
|
||||
"Description": "**CloudFront distributions** record viewer requests using either **standard access logs** or an attached **real-time log configuration**.\n\nThe finding evaluates whether logging is configured so request metadata is captured for each distribution.",
|
||||
"Risk": "Missing **CloudFront logs** blinds monitoring of edge requests, impeding detection of bot abuse, credential stuffing, origin probing, and cache-bypass attempts.\n\nThis delays incident response and weakens evidence for forensics, impacting **confidentiality**, **integrity**, and **availability**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/AccessLogs.html",
|
||||
"https://repost.aws/knowledge-center/cloudfront-logging-requests",
|
||||
"https://aws.amazon.com/awstv/watch/e895e7811ac/",
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/enable-real-time-logging.html",
|
||||
"https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/real-time-logs.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws cloudfront update-distribution --id <DISTRIBUTION_ID> --distribution-config logging.json --if-match <ETAG>",
|
||||
"NativeIaC": "https://docs.prowler.com/checks/aws/logging-policies/logging_20#cloudformation",
|
||||
"Other": "https://docs.prowler.com/checks/aws/logging-policies/logging_20",
|
||||
"Terraform": "https://docs.prowler.com/checks/aws/logging-policies/logging_20#terraform"
|
||||
"CLI": "aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --output json > current-config.json && echo 'Manually edit current-config.json to add Logging.Bucket: <example_bucket>.s3.amazonaws.com', then run:' && echo 'aws cloudfront update-distribution --id <DISTRIBUTION_ID> --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --query \"ETag\" --output text)'",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: enable CloudFront standard access logging\nResources:\n <example_resource_name>:\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n Origins:\n - Id: origin1\n DomainName: <example_origin_domain>\n S3OriginConfig: {}\n DefaultCacheBehavior:\n TargetOriginId: origin1\n ViewerProtocolPolicy: allow-all\n Logging:\n Bucket: <example_bucket>.s3.amazonaws.com # CRITICAL: enables standard access logs to S3 for this distribution\n # The presence of Logging with Bucket turns on access logging\n```",
|
||||
"Other": "1. In the AWS Console, go to CloudFront and select your distribution\n2. Open the General tab and click Edit\n3. In Standard logging, set to On\n4. Select the S3 bucket to receive logs\n5. Ensure the S3 bucket has Object Ownership set to ACLs enabled (Bucket owner preferred/ObjectWriter)\n6. Save changes",
|
||||
"Terraform": "```hcl\n# Add this block to your existing CloudFront distribution to enable access logging\nresource \"aws_cloudfront_distribution\" \"<example_resource_name>\" {\n # ... existing required config ...\n logging_config {\n bucket = \"<example_bucket>.s3.amazonaws.com\" # CRITICAL: enables standard access logs to S3\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Real-time monitoring can be achieved by directing CloudTrail Logs to CloudWatch Logs and establishing corresponding metric filters and alarms. Enable logging for services with defined log rotation. These logs are useful for Incident Response and forensics investigation among other use cases.",
|
||||
"Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/AccessLogs.html"
|
||||
"Text": "Enable **standard access logs** or **real-time logs** for all distributions.\n\nApply **least privilege** to log storage, enforce retention and immutability, and centralize ingestion with alerts.\n\nUse **defense-in-depth**: correlate with WAF metrics, sample real-time when needed, and audit new distributions for logging.",
|
||||
"Url": "https://hub.prowler.com/check/cloudfront_distributions_logging_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"forensics-ready",
|
||||
"logging"
|
||||
],
|
||||
"DependsOn": [],
|
||||
|
||||
+21
-16
@@ -1,34 +1,39 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "cloudfront_distributions_multiple_origin_failover_configured",
|
||||
"CheckTitle": "Check if CloudFront distributions have origin failover enabled.",
|
||||
"CheckTitle": "CloudFront distribution has origin failover configured with at least two origins",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks",
|
||||
"Industry and Regulatory Standards",
|
||||
"NIST 800-53 Controls"
|
||||
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/NIST 800-53 Controls",
|
||||
"Effects/Denial of Service"
|
||||
],
|
||||
"ServiceName": "cloudfront",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "low",
|
||||
"ResourceType": "AWSCloudFrontDistribution",
|
||||
"Description": "Check if CloudFront distributions have origin failover enabled.",
|
||||
"Risk": "Without origin failover, if the primary origin becomes unavailable, your CloudFront distribution may experience downtime, leading to potential service interruptions and a poor user experience.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/cloudfront/latest/APIReference/API_OriginGroup.html",
|
||||
"ResourceType": "AwsCloudFrontDistribution",
|
||||
"Description": "**CloudFront distributions** are evaluated for an **origin group** configured with at least `2` origins to support automatic origin failover.",
|
||||
"Risk": "Without **origin failover**, the origin becomes a **single point of failure**. Origin outages, regional incidents, or targeted **DoS** can cause **downtime**, elevated error rates, and latency, degrading **availability** and impacting user experience and SLAs.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/high_availability_origin_failover.html#concept_origin_groups.creating",
|
||||
"https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-4",
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/origin-failover-enabled.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-4",
|
||||
"Terraform": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/origin-failover-enabled.html"
|
||||
"CLI": "aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --output json > current-config.json && echo 'Manually edit current-config.json to add OriginGroups with two origins and FailoverCriteria, then run:' && echo 'aws cloudfront update-distribution --id <DISTRIBUTION_ID> --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --query \"ETag\" --output text)'",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: Add an origin group with two origins and use it in the default cache behavior\nResources:\n <example_resource_name>:\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n Origins:\n Quantity: 2\n Items:\n - Id: primary\n DomainName: <primary_origin_domain>\n S3OriginConfig: {}\n - Id: secondary\n DomainName: <secondary_origin_domain>\n S3OriginConfig: {}\n OriginGroups:\n Quantity: 1\n Items:\n - Id: <example_origin_group_id> # Critical: define origin group with 2 origins\n FailoverCriteria:\n StatusCodes:\n Quantity: 1\n Items: [500] # Critical: fail over on 500 to enable origin failover\n Members:\n Quantity: 2\n Items:\n - OriginId: primary\n - OriginId: secondary\n DefaultCacheBehavior:\n TargetOriginId: <example_origin_group_id> # Critical: use the origin group for requests\n ViewerProtocolPolicy: allow-all\n ForwardedValues:\n QueryString: false\n Cookies:\n Forward: none\n```",
|
||||
"Other": "1. In the AWS Console, go to CloudFront and open your distribution\n2. Select the Origins tab and ensure two origins exist; add a second origin if needed\n3. In the Origin groups pane, click Create origin group\n4. Select the two origins; set one as primary and the other as secondary\n5. Choose at least one failover status code (e.g., 500) and create the group\n6. Go to Behaviors, edit the relevant cache behavior (or Default behavior)\n7. Set Origin to the new origin group and save changes\n8. Deploy/confirm the distribution update",
|
||||
"Terraform": "```hcl\n# Configure an origin group with two origins and use it in the default cache behavior\nresource \"aws_cloudfront_distribution\" \"<example_resource_name>\" {\n enabled = true\n\n origin {\n domain_name = \"<primary_origin_domain>\"\n origin_id = \"primary\"\n s3_origin_config {}\n }\n\n origin {\n domain_name = \"<secondary_origin_domain>\"\n origin_id = \"secondary\"\n s3_origin_config {}\n }\n\n origin_group {\n origin_id = \"<example_origin_group_id>\" # Critical: define origin group with 2 origins\n failover_criteria {\n status_codes = [500] # Critical: fail over on 500 to enable origin failover\n }\n member { origin_id = \"primary\" }\n member { origin_id = \"secondary\" }\n }\n\n default_cache_behavior {\n target_origin_id = \"<example_origin_group_id>\" # Critical: use the origin group for requests\n viewer_protocol_policy = \"allow-all\"\n forwarded_values {\n query_string = false\n cookies { forward = \"none\" }\n }\n }\n\n restrictions {\n geo_restriction { restriction_type = \"none\" }\n }\n\n viewer_certificate { cloudfront_default_certificate = true }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Configure origin failover in your CloudFront distribution by setting up an origin group with at least two origins to enhance availability and ensure traffic is redirected if the primary origin fails.",
|
||||
"Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/high_availability_origin_failover.html#concept_origin_groups.creating"
|
||||
"Text": "Enable **origin failover** by defining an origin group with primary and secondary origins. Distribute origins across independent zones or providers, set clear failover criteria (e.g., HTTP codes/timeouts), monitor health, and routinely test failover. Align with **resilience** and **defense-in-depth** to protect availability.",
|
||||
"Url": "https://hub.prowler.com/check/cloudfront_distributions_multiple_origin_failover_configured"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"redundancy"
|
||||
"resilience"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
|
||||
+27
-14
@@ -1,29 +1,42 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "cloudfront_distributions_origin_traffic_encrypted",
|
||||
"CheckTitle": "Check if CloudFront distributions encrypt traffic to custom origins.",
|
||||
"CheckType": [],
|
||||
"CheckTitle": "CloudFront distribution encrypts traffic to custom origins",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices/Network Security",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
|
||||
"Effects/Data Exposure"
|
||||
],
|
||||
"ServiceName": "cloudfront",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AWSCloudFrontDistribution",
|
||||
"Description": "Check if CloudFront distributions encrypt traffic to custom origins.",
|
||||
"Risk": "Allowing unencrypted HTTP traffic between CloudFront and custom origins can expose data to potential eavesdropping and manipulation, compromising data security and integrity.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/whitepapers/latest/secure-content-delivery-amazon-cloudfront/custom-origin-with-cloudfront.html",
|
||||
"ResourceType": "AwsCloudFrontDistribution",
|
||||
"Description": "**CloudFront distributions** are evaluated for **TLS to origins**. The check ensures custom origins use `origin_protocol_policy`=`https-only`, or `match-viewer` only when the viewer protocol policy disallows HTTP. For S3 origins, it inspects the viewer protocol policy and flags `allow-all` as permitting non-encrypted paths.",
|
||||
"Risk": "Unencrypted origin links enable on-path interception and manipulation. Secrets, cookies, and PII can be exposed, and responses altered, undermining **confidentiality** and **integrity**. This increases chances of session hijacking, cache poisoning, and malicious content injection.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-https.html",
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-traffic-to-origin-unencrypted.html",
|
||||
"https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-9",
|
||||
"https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-https-cloudfront-to-custom-origin.html",
|
||||
"https://docs.aws.amazon.com/whitepapers/latest/secure-content-delivery-amazon-cloudfront/custom-origin-with-cloudfront.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-9",
|
||||
"Terraform": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-traffic-to-origin-unencrypted.html"
|
||||
"CLI": "aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --output json > current-config.json && echo 'Manually edit current-config.json to change Origins[].CustomOriginConfig.OriginProtocolPolicy to https-only', then run:' && echo 'aws cloudfront update-distribution --id <DISTRIBUTION_ID> --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --query \"ETag\" --output text)'",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: set CloudFront origin to use HTTPS only\nResources:\n <example_resource_name>:\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n Origins:\n - Id: <example_origin_id>\n DomainName: <example_origin_domain>\n CustomOriginConfig:\n OriginProtocolPolicy: https-only # FIX: Force CloudFront-to-origin over HTTPS only\n DefaultCacheBehavior:\n TargetOriginId: <example_origin_id>\n ViewerProtocolPolicy: allow-all\n ForwardedValues:\n QueryString: false\n```",
|
||||
"Other": "1. In the AWS Console, open CloudFront and select your distribution\n2. Go to the Origins tab, select the custom origin, and click Edit\n3. Set Protocol to HTTPS only (Origin protocol policy = HTTPS Only)\n4. Click Save changes and wait for the distribution to deploy",
|
||||
"Terraform": "```hcl\n# Terraform: set CloudFront origin to use HTTPS only\nresource \"aws_cloudfront_distribution\" \"<example_resource_name>\" {\n enabled = true\n\n origin {\n domain_name = \"<example_origin_domain>\"\n origin_id = \"<example_origin_id>\"\n\n custom_origin_config {\n http_port = 80\n https_port = 443\n origin_protocol_policy = \"https-only\" # FIX: Force CloudFront-to-origin over HTTPS only\n origin_ssl_protocols = [\"TLSv1.2\"]\n }\n }\n\n default_cache_behavior {\n target_origin_id = \"<example_origin_id>\"\n viewer_protocol_policy = \"allow-all\"\n forwarded_values {\n query_string = false\n cookies { forward = \"none\" }\n }\n }\n\n restrictions { geo_restriction { restriction_type = \"none\" } }\n viewer_certificate { cloudfront_default_certificate = true }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Configure your CloudFront distributions to require HTTPS (TLS) for traffic to custom origins, ensuring all data transmitted between CloudFront and the origin is encrypted and protected from unauthorized access.",
|
||||
"Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-https-cloudfront-to-custom-origin.html"
|
||||
"Text": "Enforce end-to-end HTTPS. Set `origin_protocol_policy` to `https-only` and viewer policy to `https-only` or `redirect-to-https`. Use trusted certificates and modern TLS (`TLSv1.2+`), disabling weak protocols. Apply **least privilege** and **defense in depth** by restricting direct origin access and preferring private connectivity.",
|
||||
"Url": "https://hub.prowler.com/check/cloudfront_distributions_origin_traffic_encrypted"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"encryption"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
+24
-14
@@ -1,31 +1,41 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "cloudfront_distributions_s3_origin_access_control",
|
||||
"CheckTitle": "Check if CloudFront distributions with S3 origin use OAC.",
|
||||
"CheckTitle": "CloudFront distribution uses Origin Access Control (OAC) for all S3 origins",
|
||||
"CheckType": [
|
||||
"Data Exposure"
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
|
||||
"Effects/Data Exposure"
|
||||
],
|
||||
"ServiceName": "cloudfront",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AWSCloudFrontDistribution",
|
||||
"Description": "Check if CloudFront distributions use origin access control.",
|
||||
"Risk": "Without OAC, your S3 bucket could be accessed directly, bypassing CloudFront, which could expose your content to unauthorized access. Additionally, relying on Origin Access Identity (OAI) may limit functionality and security features, making your distribution less secure and more difficult to manage.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html#migrate-from-oai-to-oac",
|
||||
"ResourceType": "AwsCloudFrontDistribution",
|
||||
"Description": "**CloudFront distributions** with **Amazon S3 origins** are expected to use **Origin Access Control** (`OAC`) on each S3 origin.\n\nThe evaluation inspects distributions that include `s3_origin_config` and identifies S3 origins that lack an associated OAC.",
|
||||
"Risk": "Without **OAC**, S3 objects can be reached outside CloudFront, bypassing edge controls and weakening **confidentiality** and **integrity**.\n- Direct access enables data exfiltration\n- Loss of WAF, rate-limiting, and detailed logging; cost abuse\n- Limited support for signed writes and **SSE-KMS**, increasing tampering risk",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/s3-origin.html",
|
||||
"https://repost.aws/knowledge-center/cloudfront-access-to-amazon-s3",
|
||||
"https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-13",
|
||||
"https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "https://docs.prowler.com/checks/aws/iam-policies/ensure-aws-cloudfromt-distribution-with-s3-have-origin-access-set-to-enabled/",
|
||||
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-13",
|
||||
"Terraform": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/s3-origin.html"
|
||||
"CLI": "aws cloudfront create-origin-access-control --origin-access-control-config '{Name\":\"<example_resource_name>\",\"SigningProtocol\":\"sigv4\",\"SigningBehavior\":\"always\",\"OriginAccessControlOriginType\":\"s3\"}' && aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --output json > current-config.json && echo 'Manually edit current-config.json to add OriginAccessControlId to S3 origins, then run:' && echo 'aws cloudfront update-distribution --id <DISTRIBUTION_ID> --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --query \"ETag\" --output text)'",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: attach OAC to S3 origin in a CloudFront distribution\nResources:\n ExampleOAC:\n Type: AWS::CloudFront::OriginAccessControl\n Properties:\n OriginAccessControlConfig:\n Name: <example_resource_name>\n OriginAccessControlOriginType: s3\n SigningBehavior: always\n SigningProtocol: sigv4\n\n ExampleDistribution:\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n Origins:\n - Id: s3-<example_resource_id>\n DomainName: <example_bucket>.s3.amazonaws.com\n OriginAccessControlId: !Ref ExampleOAC # CRITICAL: attaches OAC to the S3 origin to satisfy the check\n S3OriginConfig:\n OriginAccessIdentity: \"\" # CRITICAL: disable OAI when using OAC\n DefaultCacheBehavior:\n TargetOriginId: s3-<example_resource_id>\n ViewerProtocolPolicy: redirect-to-https\n CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6\n```",
|
||||
"Other": "1. In the AWS Console, open CloudFront and go to Security > Origin access > Origin access control (OAC). Click Create control setting, choose Origin type S3, keep Sign requests, and create the OAC.\n2. Open your CloudFront distribution, go to the Origins tab.\n3. For each S3 origin: click Edit, select Origin access control settings (recommended), choose the OAC created in step 1, and Save changes.\n4. Repeat step 3 for all S3 origins in the distribution.",
|
||||
"Terraform": "```hcl\n# Terraform: attach OAC to S3 origin in a CloudFront distribution\nresource \"aws_cloudfront_origin_access_control\" \"oac\" {\n name = \"<example_resource_name>\"\n origin_access_control_origin_type = \"s3\"\n signing_behavior = \"always\"\n signing_protocol = \"sigv4\"\n}\n\nresource \"aws_cloudfront_distribution\" \"dist\" {\n enabled = true\n\n origin {\n domain_name = \"<example_bucket>.s3.amazonaws.com\"\n origin_id = \"s3-<example_resource_id>\"\n\n origin_access_control_id = aws_cloudfront_origin_access_control.oac.id # CRITICAL: attaches OAC to the S3 origin to satisfy the check\n\n s3_origin_config {\n origin_access_identity = \"\" # CRITICAL: disable OAI when using OAC\n }\n }\n\n default_cache_behavior {\n target_origin_id = \"s3-<example_resource_id>\"\n viewer_protocol_policy = \"redirect-to-https\"\n cache_policy_id = \"658327ea-f89d-4fab-a63d-7e88639e58f6\"\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Configure Origin Access Control (OAC) for CloudFront distributions that use an Amazon S3 origin. This will ensure that the content in your S3 bucket is accessible only through the specified CloudFront distribution, enhancing security by preventing direct access to the bucket.",
|
||||
"Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html"
|
||||
"Text": "Enable **Origin Access Control** for all S3 origins and keep buckets non-public.\n\nApply **least privilege**: scope bucket and key permissions to CloudFront and the intended distribution. Ensure origin requests are signed, migrate from legacy OAI, and use **defense in depth** with WAF and monitoring to protect and observe access.",
|
||||
"Url": "https://hub.prowler.com/check/cloudfront_distributions_s3_origin_access_control"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"trust-boundaries"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
+18
-11
@@ -1,32 +1,39 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "cloudfront_distributions_s3_origin_non_existent_bucket",
|
||||
"CheckTitle": "CloudFront distributions should not point to non-existent S3 origins without static website hosting.",
|
||||
"CheckTitle": "CloudFront distribution S3 origins reference existing buckets",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/NIST 800-53 Controls"
|
||||
],
|
||||
"ServiceName": "cloudfront",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "AwsCloudFrontDistribution",
|
||||
"Description": "This control checks whether Amazon CloudFront distributions are pointing to non-existent Amazon S3 origins without static website hosting. The control fails if the origin is configured to point to a non-existent bucket.",
|
||||
"Risk": "Pointing a CloudFront distribution to a non-existent S3 bucket can allow malicious actors to create the bucket and potentially serve unauthorized content through your distribution, leading to security and integrity issues.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/whitepapers/latest/secure-content-delivery-amazon-cloudfront/s3-origin-with-cloudfront.html",
|
||||
"Description": "**CloudFront distributions** with `S3OriginConfig` should reference existing **S3 bucket origins** (excluding static website hosting).\n\nIdentifies origins where the configured bucket name does not exist.",
|
||||
"Risk": "**Dangling S3 origins** allow potential **bucket takeover**: an attacker could create the missing bucket and have CloudFront retrieve attacker-controlled objects *if access isn't restricted*.\n\nThis threatens **integrity** (content spoofing, cache poisoning) and **availability** (errors/timeouts), undermining user trust.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/whitepapers/latest/secure-content-delivery-amazon-cloudfront/s3-origin-with-cloudfront.html",
|
||||
"https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-12",
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-existing-s3-bucket.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-12",
|
||||
"Terraform": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-existing-s3-bucket.html"
|
||||
"NativeIaC": "```yaml\n# CloudFormation: ensure the S3 bucket referenced by the CloudFront S3 origin exists\nResources:\n <example_resource_name>:\n Type: AWS::S3::Bucket\n Properties:\n BucketName: <example_resource_name> # Critical: must exactly match the bucket name used in the CloudFront origin's domain (before \".s3\") to make it exist\n```",
|
||||
"Other": "1. In the AWS console, open CloudFront and select the distribution\n2. Go to Origins, select the S3 origin, and note the Domain Name (the bucket name is the text before \".s3\")\n3. Open the S3 console, click Create bucket, enter the exact bucket name from step 2, and create the bucket\n4. Re-run the check",
|
||||
"Terraform": "```hcl\n# Ensure the S3 bucket referenced by the CloudFront S3 origin exists\nresource \"aws_s3_bucket\" \"<example_resource_name>\" {\n bucket = \"<example_resource_name>\" # Critical: must exactly match the bucket name used in the CloudFront origin's domain (before \".s3\")\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Verify that all CloudFront distributions are configured to point to valid, existing S3 buckets. Update the origin settings as needed to ensure that your distributions are linked to appropriate and secure origins.",
|
||||
"Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/HowToUpdateDistribution.html"
|
||||
"Text": "Ensure origins reference valid, owned buckets; delete or update stale references. Enforce **origin access control** (or OAI) and tight bucket policies so only the distribution can access objects. Apply **least privilege**, manage bucket names, and monitor origin health to prevent misrouting.",
|
||||
"Url": "https://hub.prowler.com/check/cloudfront_distributions_s3_origin_non_existent_bucket"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trustboundaries"
|
||||
"resilience"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
|
||||
+20
-12
@@ -1,26 +1,34 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "cloudfront_distributions_using_deprecated_ssl_protocols",
|
||||
"CheckTitle": "Check if CloudFront distributions are using deprecated SSL protocols.",
|
||||
"CheckType": [],
|
||||
"CheckTitle": "CloudFront distribution does not use SSLv3, TLSv1, or TLSv1.1 for origin connections",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
|
||||
],
|
||||
"ServiceName": "cloudfront",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "low",
|
||||
"ResourceType": "AwsCloudFrontDistribution",
|
||||
"Description": "Check if CloudFront distributions are using deprecated SSL protocols.",
|
||||
"Risk": "Using insecure ciphers could affect privacy of in transit information.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/secure-connections-supported-viewer-protocols-ciphers.html",
|
||||
"Description": "CloudFront distributions have origins whose `OriginSslProtocols` allow **deprecated SSL/TLS versions** (`SSLv3`, `TLSv1`, `TLSv1.1`) for CloudFront-to-origin HTTPS connections.",
|
||||
"Risk": "Weak protocols between CloudFront and the origin allow downgrades and known exploits (e.g., POODLE/BEAST), enabling eavesdropping or content tampering. This compromises the **confidentiality** and **integrity** of data in transit, exposing cookies, credentials, and responses served to viewers.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/secure-connections-supported-viewer-protocols-ciphers.html",
|
||||
"https://trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-insecure-origin-ssl-protocols.html",
|
||||
"https://support.icompaas.com/support/solutions/articles/62000223404-ensure-cloudfront-distributions-are-not-using-deprecated-ssl-protocols"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://docs.prowler.com/checks/aws/networking-policies/networking_33",
|
||||
"Terraform": ""
|
||||
"CLI": "aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --output json > current-config.json && echo 'Manually edit current-config.json to change Origins[].CustomOriginConfig.OriginSslProtocols to [TLSv1.2], then run:' && echo 'aws cloudfront update-distribution --id <DISTRIBUTION_ID> --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> --query \"ETag\" --output text)'",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: set origin to allow only TLSv1.2 when connecting to the origin\nResources:\n <example_resource_name>:\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n Origins:\n - Id: <example_origin_id>\n DomainName: <origin.example.com>\n CustomOriginConfig:\n OriginProtocolPolicy: https-only\n OriginSslProtocols:\n - TLSv1.2 # CRITICAL: restrict origin SSL protocols to TLSv1.2 to remove SSLv3/TLSv1/TLSv1.1\n DefaultCacheBehavior:\n TargetOriginId: <example_origin_id>\n ViewerProtocolPolicy: redirect-to-https\n```",
|
||||
"Other": "1. Open the AWS Console and go to CloudFront\n2. Select the distribution and open the Origins tab\n3. Select the custom origin and click Edit\n4. Under Origin SSL protocols, select only TLSv1.2\n5. Save changes\n6. Repeat for any other custom origins in the distribution",
|
||||
"Terraform": "```hcl\n# Terraform: set origin to allow only TLSv1.2 when connecting to the origin\nresource \"aws_cloudfront_distribution\" \"<example_resource_name>\" {\n enabled = true\n\n origin {\n domain_name = \"<origin.example.com>\"\n origin_id = \"<example_origin_id>\"\n\n custom_origin_config {\n http_port = 80\n https_port = 443\n origin_protocol_policy = \"https-only\"\n origin_ssl_protocols = [\"TLSv1.2\"] # CRITICAL: restrict origin SSL protocols to TLSv1.2\n }\n }\n\n default_cache_behavior {\n target_origin_id = \"<example_origin_id>\"\n viewer_protocol_policy = \"redirect-to-https\"\n allowed_methods = [\"GET\", \"HEAD\"]\n cached_methods = [\"GET\", \"HEAD\"]\n forwarded_values { query_string = false }\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Use a Security policy with ciphers that are as strong as possible. Drop legacy and insecure ciphers.",
|
||||
"Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/secure-connections-supported-viewer-protocols-ciphers.html"
|
||||
"Text": "Enforce **TLS 1.2+** for CloudFront-to-origin traffic. Remove `SSLv3`, `TLSv1`, `TLSv1.1` from allowed protocols and prefer modern cipher suites. Verify origin compatibility, update certificates and libraries, and periodically review policies as part of **defense in depth** and **least privilege**.",
|
||||
"Url": "https://hub.prowler.com/check/cloudfront_distributions_using_deprecated_ssl_protocols"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
|
||||
+21
-12
@@ -1,31 +1,40 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "cloudfront_distributions_using_waf",
|
||||
"CheckTitle": "Check if CloudFront distributions are using WAF.",
|
||||
"CheckTitle": "CloudFront distribution uses an AWS WAF web ACL",
|
||||
"CheckType": [
|
||||
"IAM"
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/PCI-DSS"
|
||||
],
|
||||
"ServiceName": "cloudfront",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsCloudFrontDistribution",
|
||||
"Description": "Check if CloudFront distributions are using WAF.",
|
||||
"Risk": "Potential attacks and / or abuse of service, more even for even for internet reachable services.",
|
||||
"RelatedUrl": "https://docs.aws.amazon.com/waf/latest/developerguide/cloudfront-features.html",
|
||||
"Description": "**CloudFront distributions** are assessed for an associated **AWS WAF** web ACL that inspects and filters HTTP/S requests at the edge.\n\nThe finding highlights distributions without this web ACL association.",
|
||||
"Risk": "Absent **WAF** on Internet-facing distributions exposes apps to layer-7 threats: SQLi/XSS and bot abuse can cause data exfiltration (**confidentiality**), unauthorized actions (**integrity**), and request floods that overload origins (**availability**). It may also raise egress and compute costs.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://repost.aws/questions/QUTY5hPVxgS6Caa3eZHX7-nQ/waf-on-alb-or-cloudfront",
|
||||
"https://docs.aws.amazon.com/waf/latest/developerguide/cloudfront-features.html",
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-integrated-with-waf.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "https://docs.prowler.com/checks/aws/general-policies/bc_aws_general_27#cloudformation",
|
||||
"Other": "https://docs.prowler.com/checks/aws/general-policies/bc_aws_general_27",
|
||||
"Terraform": "https://docs.prowler.com/checks/aws/general-policies/bc_aws_general_27#terraform"
|
||||
"NativeIaC": "```yaml\n# CloudFormation: associate an AWS WAFv2 Web ACL with a CloudFront distribution\nResources:\n <example_distribution>:\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n Origins:\n - Id: origin1\n DomainName: <example_origin_domain>\n S3OriginConfig: {}\n DefaultCacheBehavior:\n TargetOriginId: origin1\n ViewerProtocolPolicy: redirect-to-https\n ForwardedValues:\n QueryString: false\n WebACLId: <example_web_acl_arn> # CRITICAL: Associates the WAFv2 Web ACL (ARN) to this distribution\n # This makes the distribution PASS by enabling WAF protection\n```",
|
||||
"Other": "1. In the AWS Console, go to CloudFront > Distributions and select your distribution\n2. Click Edit (General/Settings)\n3. Set AWS WAF Web ACL to your Web ACL (scope: Global/CloudFront)\n4. Click Save/Yes, Edit and wait for Deployment to complete\n5. If no Web ACL exists: go to WAF & Shield > Web ACLs (scope: CloudFront), Create web ACL, then repeat steps 1-4 to associate it",
|
||||
"Terraform": "```hcl\n# Add this to the existing CloudFront distribution resource\nresource \"aws_cloudfront_distribution\" \"<example_resource_name>\" {\n web_acl_id = \"<example_web_acl_arn>\" # CRITICAL: Associates the WAFv2 Web ACL (ARN) to the distribution to PASS the check\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Use AWS WAF to protect your service from common web exploits. These could affect availability and performance, compromise security, or consume excessive resources.",
|
||||
"Url": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-integrated-with-waf.html"
|
||||
"Text": "Associate each distribution with an **AWS WAF web ACL** and apply defense-in-depth:\n- Use managed rule groups and rate limits\n- Add IP/geo and bot controls as needed\n- Enable logging, test new rules in `count` mode, and tune\n- Monitor metrics and update rules\n\nAlign controls with **least privilege** for requests.",
|
||||
"Url": "https://hub.prowler.com/check/cloudfront_distributions_using_waf"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
@@ -220,7 +220,6 @@ class Provider(ABC):
|
||||
config_path=arguments.config_file,
|
||||
mutelist_path=arguments.mutelist_file,
|
||||
sp_env_auth=arguments.sp_env_auth,
|
||||
env_auth=arguments.env_auth,
|
||||
az_cli_auth=arguments.az_cli_auth,
|
||||
browser_auth=arguments.browser_auth,
|
||||
certificate_auth=arguments.certificate_auth,
|
||||
|
||||
@@ -94,26 +94,6 @@ class M365BaseException(ProwlerException):
|
||||
"message": "Tenant Id is required for Microsoft 365 static credentials. Make sure you are using the correct credentials.",
|
||||
"remediation": "Check the Microsoft 365 Tenant ID and ensure it is properly set up.",
|
||||
},
|
||||
(6022, "M365MissingEnvironmentCredentialsError"): {
|
||||
"message": "User and Password environment variables are needed to use Credentials authentication method.",
|
||||
"remediation": "Ensure your environment variables are properly set up.",
|
||||
},
|
||||
(6023, "M365UserCredentialsError"): {
|
||||
"message": "The provided User credentials are not valid.",
|
||||
"remediation": "Check the User credentials and ensure they are valid.",
|
||||
},
|
||||
(6024, "M365NotValidUserError"): {
|
||||
"message": "The provided User is not valid.",
|
||||
"remediation": "Check the User and ensure it is a valid user.",
|
||||
},
|
||||
(6025, "M365NotValidPasswordError"): {
|
||||
"message": "The provided Password is not valid.",
|
||||
"remediation": "Check the Password and ensure it is a valid password.",
|
||||
},
|
||||
(6026, "M365UserNotBelongingToTenantError"): {
|
||||
"message": "The provided User does not belong to the specified tenant.",
|
||||
"remediation": "Check the User email domain and ensure it belongs to the specified tenant.",
|
||||
},
|
||||
(6027, "M365GraphConnectionError"): {
|
||||
"message": "Failed to establish connection to Microsoft Graph API.",
|
||||
"remediation": "Check your Microsoft Application credentials and ensure the app has proper permissions.",
|
||||
@@ -315,41 +295,6 @@ class M365NotTenantIdButClientIdAndClientSecretError(M365CredentialsError):
|
||||
)
|
||||
|
||||
|
||||
class M365MissingEnvironmentCredentialsError(M365CredentialsError):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
6022, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class M365UserCredentialsError(M365CredentialsError):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
6023, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class M365NotValidUserError(M365CredentialsError):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
6024, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class M365NotValidPasswordError(M365CredentialsError):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
6025, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class M365UserNotBelongingToTenantError(M365CredentialsError):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
6026, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class M365GraphConnectionError(M365CredentialsError):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import argparse
|
||||
|
||||
|
||||
def init_parser(self):
|
||||
"""Init the M365 Provider CLI parser"""
|
||||
m365_parser = self.subparsers.add_parser(
|
||||
@@ -13,15 +16,16 @@ def init_parser(self):
|
||||
action="store_true",
|
||||
help="Use Azure CLI authentication to log in against Microsoft 365",
|
||||
)
|
||||
m365_auth_modes_group.add_argument(
|
||||
"--env-auth",
|
||||
action="store_true",
|
||||
help="Use User and Password environment variables authentication to log in against Microsoft 365",
|
||||
)
|
||||
m365_auth_modes_group.add_argument(
|
||||
"--sp-env-auth",
|
||||
action="store_true",
|
||||
help="Use Azure Service Principal environment variables authentication to log in against Microsoft 365",
|
||||
help="Use Service Principal environment variables authentication to log in against Microsoft 365",
|
||||
)
|
||||
m365_auth_modes_group.add_argument(
|
||||
"--env-auth",
|
||||
dest="sp_env_auth",
|
||||
action="store_true",
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
m365_auth_modes_group.add_argument(
|
||||
"--browser-auth",
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import os
|
||||
import platform
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.powershell.powershell import PowerShellSession
|
||||
from prowler.providers.m365.exceptions.exceptions import (
|
||||
M365CertificateCreationError,
|
||||
M365GraphConnectionError,
|
||||
M365UserCredentialsError,
|
||||
M365UserNotBelongingToTenantError,
|
||||
)
|
||||
from prowler.providers.m365.lib.jwt.jwt_decoder import decode_jwt, decode_msal_token
|
||||
from prowler.providers.m365.models import M365Credentials, M365IdentityInfo
|
||||
@@ -76,10 +73,9 @@ class M365PowerShell(PowerShellSession):
|
||||
"""
|
||||
Initialize PowerShell credential object for Microsoft 365 authentication.
|
||||
|
||||
Supports three authentication methods:
|
||||
1. User authentication (username/password) - Will be deprecated in October 2025
|
||||
2. Application authentication (client_id/client_secret)
|
||||
3. Certificate authentication (certificate_content in base64/application_id)
|
||||
Supports two authentication methods:
|
||||
1. Application authentication (client_id/client_secret)
|
||||
2. Certificate authentication (certificate_content in base64/client_id)
|
||||
|
||||
Args:
|
||||
credentials (M365Credentials): The credentials object containing
|
||||
@@ -115,22 +111,6 @@ class M365PowerShell(PowerShellSession):
|
||||
self.execute(f'$tenantID = "{sanitized_tenant_id}"')
|
||||
self.execute(f'$tenantDomain = "{credentials.tenant_domains[0]}"')
|
||||
|
||||
# User Auth (Will be deprecated in October 2025)
|
||||
elif credentials.user and credentials.passwd:
|
||||
credentials.encrypted_passwd = self.encrypt_password(credentials.passwd)
|
||||
|
||||
# Sanitize user and password
|
||||
sanitized_user = self.sanitize(credentials.user)
|
||||
sanitized_encrypted_passwd = self.sanitize(credentials.encrypted_passwd)
|
||||
|
||||
# Securely convert encrypted password to SecureString
|
||||
self.execute(f'$user = "{sanitized_user}"')
|
||||
self.execute(
|
||||
f'$secureString = "{sanitized_encrypted_passwd}" | ConvertTo-SecureString'
|
||||
)
|
||||
self.execute(
|
||||
"$credential = New-Object System.Management.Automation.PSCredential ($user, $secureString)"
|
||||
)
|
||||
else:
|
||||
# Application Auth
|
||||
self.execute(f'$clientID = "{credentials.client_id}"')
|
||||
@@ -143,51 +123,13 @@ class M365PowerShell(PowerShellSession):
|
||||
'$graphToken = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$tenantID/oauth2/v2.0/token" -Method POST -Body $graphtokenBody | Select-Object -ExpandProperty Access_Token'
|
||||
)
|
||||
|
||||
def encrypt_password(self, password: str) -> str:
|
||||
"""
|
||||
Encrypts a password using Windows CryptProtectData on Windows systems
|
||||
or UTF-16LE encoding on other systems.
|
||||
|
||||
Args:
|
||||
password (str): The password to encrypt
|
||||
|
||||
Returns:
|
||||
str: The encrypted password in hexadecimal format
|
||||
|
||||
Raises:
|
||||
ValueError: If password is None or empty
|
||||
"""
|
||||
try:
|
||||
if platform.system() == "Windows":
|
||||
import win32crypt
|
||||
|
||||
encrypted_blob = win32crypt.CryptProtectData(
|
||||
password.encode("utf-16le"), None, None, None, None, 0
|
||||
)
|
||||
|
||||
encrypted_bytes = encrypted_blob
|
||||
if isinstance(encrypted_blob, tuple):
|
||||
encrypted_bytes = encrypted_blob[1]
|
||||
elif hasattr(encrypted_blob, "data"):
|
||||
encrypted_bytes = encrypted_blob.data
|
||||
|
||||
return encrypted_bytes.hex()
|
||||
|
||||
else:
|
||||
return password.encode("utf-16le").hex()
|
||||
except Exception as error:
|
||||
raise Exception(
|
||||
f"[{os.path.basename(__file__)}] Error encrypting password: {str(error)}"
|
||||
)
|
||||
|
||||
def test_credentials(self, credentials: M365Credentials) -> bool:
|
||||
"""
|
||||
Test Microsoft 365 credentials by attempting to authenticate against Entra ID.
|
||||
|
||||
Supports testing three authentication methods:
|
||||
1. User authentication (username/password)
|
||||
2. Application authentication (client_id/client_secret)
|
||||
3. Certificate authentication (certificate_content in base64/application_id)
|
||||
Supports testing two authentication methods:
|
||||
1. Application authentication (client_id/client_secret)
|
||||
2. Certificate authentication (certificate_content in base64/client_id)
|
||||
|
||||
Args:
|
||||
credentials (M365Credentials): The credentials object containing
|
||||
@@ -204,50 +146,6 @@ class M365PowerShell(PowerShellSession):
|
||||
except Exception as e:
|
||||
logger.error(f"Exchange Online Certificate connection failed: {e}")
|
||||
|
||||
# Test User Auth
|
||||
elif credentials.user and credentials.passwd:
|
||||
self.execute(
|
||||
f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString' # encrypted password already sanitized
|
||||
)
|
||||
self.execute(
|
||||
f'$credential = New-Object System.Management.Automation.PSCredential("{self.sanitize(credentials.user)}", $securePassword)'
|
||||
)
|
||||
|
||||
user_domain = credentials.user.split("@")[1]
|
||||
if not any(
|
||||
user_domain.endswith(domain)
|
||||
for domain in self.tenant_identity.tenant_domains
|
||||
):
|
||||
raise M365UserNotBelongingToTenantError(
|
||||
file=os.path.basename(__file__),
|
||||
message=f"The user domain {user_domain} does not match any of the tenant domains: {', '.join(self.tenant_identity.tenant_domains)}",
|
||||
)
|
||||
|
||||
# Validate credentials
|
||||
# Test Exchange Online connection
|
||||
result = self.execute("Connect-ExchangeOnline -Credential $credential")
|
||||
if "https://aka.ms/exov3-module" not in result:
|
||||
if "AADSTS" in result: # Entra Security Token Service Error
|
||||
raise M365UserCredentialsError(
|
||||
file=os.path.basename(__file__),
|
||||
message=result,
|
||||
)
|
||||
# Test Microsoft Teams connection
|
||||
result = self.execute("Connect-MicrosoftTeams -Credential $credential")
|
||||
if self.tenant_identity.user not in result:
|
||||
if "AADSTS" in result: # Entra Security Token Service Error
|
||||
raise M365UserCredentialsError(
|
||||
file=os.path.basename(__file__),
|
||||
message=result,
|
||||
)
|
||||
else: # Unknown error, could be a permission issue or modules not installed
|
||||
raise M365UserCredentialsError(
|
||||
file=os.path.basename(__file__),
|
||||
message=f"Error connecting to PowerShell modules: {result if result else 'Unknown error'}",
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
else:
|
||||
# Test Microsoft Graph connection
|
||||
try:
|
||||
@@ -317,21 +215,6 @@ class M365PowerShell(PowerShellSession):
|
||||
return False
|
||||
return True
|
||||
|
||||
def test_teams_user_connection(self) -> bool:
|
||||
"""Test Microsoft Teams API connection using user authentication and raise exception if it fails."""
|
||||
result = self.execute("Connect-MicrosoftTeams -Credential $credential")
|
||||
if self.tenant_identity.user not in result:
|
||||
logger.error(f"Microsoft Teams User Auth connection failed: {result}.")
|
||||
return False
|
||||
|
||||
connection = self.execute("Get-CsTeamsClientConfiguration")
|
||||
if not connection:
|
||||
logger.error(
|
||||
"Microsoft Teams User Auth connection failed: Please check your permissions and try again."
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def test_exchange_connection(self) -> bool:
|
||||
"""Test Exchange Online API connection and raise exception if it fails."""
|
||||
try:
|
||||
@@ -368,30 +251,14 @@ class M365PowerShell(PowerShellSession):
|
||||
return False
|
||||
return True
|
||||
|
||||
def test_exchange_user_connection(self) -> bool:
|
||||
"""Test Exchange Online API connection using user authentication and raise exception if it fails."""
|
||||
result = self.execute("Connect-ExchangeOnline -Credential $credential")
|
||||
if "https://aka.ms/exov3-module" not in result:
|
||||
logger.error(f"Exchange Online User Auth connection failed: {result}.")
|
||||
return False
|
||||
|
||||
connection = self.execute("Get-OrganizationConfig")
|
||||
if not connection:
|
||||
logger.error(
|
||||
"Exchange Online User Auth connection failed: Please check your permissions and try again."
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def connect_microsoft_teams(self) -> dict:
|
||||
"""
|
||||
Connect to Microsoft Teams Module PowerShell Module.
|
||||
|
||||
Establishes a connection to Microsoft Teams using the initialized credentials.
|
||||
Supports three authentication methods:
|
||||
1. User authentication (username/password)
|
||||
2. Application authentication (client_id/client_secret)
|
||||
3. Certificate authentication (certificate_content in base64/application_id)
|
||||
Supports two authentication methods:
|
||||
1. Application authentication (client_id/client_secret)
|
||||
2. Certificate authentication (certificate_content in base64/client_id)
|
||||
|
||||
Returns:
|
||||
dict: Connection status information in JSON format.
|
||||
@@ -402,12 +269,8 @@ class M365PowerShell(PowerShellSession):
|
||||
# Certificate Auth
|
||||
if self.execute("Write-Output $certificate") != "":
|
||||
return self.test_teams_certificate_connection()
|
||||
# User Auth
|
||||
if self.execute("Write-Output $credential") != "":
|
||||
return self.test_teams_user_connection()
|
||||
# Application Auth
|
||||
else:
|
||||
return self.test_teams_connection()
|
||||
return self.test_teams_connection()
|
||||
|
||||
def get_teams_settings(self) -> dict:
|
||||
"""
|
||||
@@ -494,10 +357,9 @@ class M365PowerShell(PowerShellSession):
|
||||
Connect to Exchange Online PowerShell Module.
|
||||
|
||||
Establishes a connection to Exchange Online using the initialized credentials.
|
||||
Supports three authentication methods:
|
||||
1. User authentication (username/password)
|
||||
2. Application authentication (client_id/client_secret)
|
||||
3. Certificate authentication (certificate_content in base64/application_id)
|
||||
Supports two authentication methods:
|
||||
1. Application authentication (client_id/client_secret)
|
||||
2. Certificate authentication (certificate_content in base64/client_id)
|
||||
|
||||
Returns:
|
||||
dict: Connection status information in JSON format.
|
||||
@@ -508,12 +370,8 @@ class M365PowerShell(PowerShellSession):
|
||||
# Certificate Auth
|
||||
if self.execute("Write-Output $certificate") != "":
|
||||
return self.test_exchange_certificate_connection()
|
||||
# User Auth
|
||||
if self.execute("Write-Output $credential") != "":
|
||||
return self.test_exchange_user_connection()
|
||||
# Application Auth
|
||||
else:
|
||||
return self.test_exchange_connection()
|
||||
return self.test_exchange_connection()
|
||||
|
||||
def get_audit_log_config(self) -> dict:
|
||||
"""
|
||||
|
||||
@@ -40,7 +40,6 @@ from prowler.providers.m365.exceptions.exceptions import (
|
||||
M365HTTPResponseError,
|
||||
M365InteractiveBrowserCredentialError,
|
||||
M365InvalidProviderIdError,
|
||||
M365MissingEnvironmentCredentialsError,
|
||||
M365NoAuthenticationMethodError,
|
||||
M365NotTenantIdButClientIdAndClientSecretError,
|
||||
M365NotValidCertificateContentError,
|
||||
@@ -52,7 +51,6 @@ from prowler.providers.m365.exceptions.exceptions import (
|
||||
M365SetUpSessionError,
|
||||
M365TenantIdAndClientIdNotBelongingToClientSecretError,
|
||||
M365TenantIdAndClientSecretNotBelongingToClientIdError,
|
||||
M365UserCredentialsError,
|
||||
)
|
||||
from prowler.providers.m365.lib.mutelist.mutelist import M365Mutelist
|
||||
from prowler.providers.m365.lib.powershell.m365_powershell import (
|
||||
@@ -96,7 +94,7 @@ class M365Provider(Provider):
|
||||
mutelist(self) -> M365Mutelist: Returns the mutelist object associated with the M365 provider.
|
||||
setup_region_config(cls, region): Sets up the region configuration for the M365 provider.
|
||||
print_credentials(self): Prints the M365 credentials information.
|
||||
setup_session(cls, az_cli_auth, app_env_auth, browser_auth, managed_identity_auth, tenant_id, region_config): Set up the M365 session with the specified authentication method.
|
||||
setup_session(cls, az_cli_auth, sp_env_auth, browser_auth, managed_identity_auth, tenant_id, region_config): Set up the M365 session with the specified authentication method.
|
||||
"""
|
||||
|
||||
_type: str = "m365"
|
||||
@@ -109,10 +107,12 @@ class M365Provider(Provider):
|
||||
# TODO: this is not optional, enforce for all providers
|
||||
audit_metadata: Audit_Metadata
|
||||
|
||||
# TODO: The user and password parameters are deprecated and will be removed in a future version.
|
||||
# They are currently only kept for backwards compatibility with the API.
|
||||
# Use client credentials or certificate authentication instead.
|
||||
def __init__(
|
||||
self,
|
||||
sp_env_auth: bool = False,
|
||||
env_auth: bool = False,
|
||||
az_cli_auth: bool = False,
|
||||
browser_auth: bool = False,
|
||||
certificate_auth: bool = False,
|
||||
@@ -163,14 +163,11 @@ class M365Provider(Provider):
|
||||
self.validate_arguments(
|
||||
az_cli_auth,
|
||||
sp_env_auth,
|
||||
env_auth,
|
||||
browser_auth,
|
||||
certificate_auth,
|
||||
tenant_id,
|
||||
client_id,
|
||||
client_secret,
|
||||
user,
|
||||
password,
|
||||
certificate_content,
|
||||
certificate_path,
|
||||
)
|
||||
@@ -185,8 +182,6 @@ class M365Provider(Provider):
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
user=user,
|
||||
password=password,
|
||||
certificate_content=certificate_content,
|
||||
certificate_path=certificate_path,
|
||||
)
|
||||
@@ -195,7 +190,6 @@ class M365Provider(Provider):
|
||||
self._session = self.setup_session(
|
||||
az_cli_auth,
|
||||
sp_env_auth,
|
||||
env_auth,
|
||||
browser_auth,
|
||||
certificate_auth,
|
||||
certificate_path,
|
||||
@@ -207,7 +201,6 @@ class M365Provider(Provider):
|
||||
# Set up the identity
|
||||
self._identity = self.setup_identity(
|
||||
sp_env_auth,
|
||||
env_auth,
|
||||
browser_auth,
|
||||
az_cli_auth,
|
||||
certificate_auth,
|
||||
@@ -216,7 +209,6 @@ class M365Provider(Provider):
|
||||
|
||||
# Set up PowerShell session credentials
|
||||
self._credentials = self.setup_powershell(
|
||||
env_auth=env_auth,
|
||||
sp_env_auth=sp_env_auth,
|
||||
certificate_auth=certificate_auth,
|
||||
certificate_path=certificate_path,
|
||||
@@ -294,14 +286,11 @@ class M365Provider(Provider):
|
||||
def validate_arguments(
|
||||
az_cli_auth: bool,
|
||||
sp_env_auth: bool,
|
||||
env_auth: bool,
|
||||
browser_auth: bool,
|
||||
certificate_auth: bool,
|
||||
tenant_id: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
user: str,
|
||||
password: str,
|
||||
certificate_content: str,
|
||||
certificate_path: str,
|
||||
):
|
||||
@@ -311,14 +300,11 @@ class M365Provider(Provider):
|
||||
Args:
|
||||
az_cli_auth (bool): Flag indicating whether Azure CLI authentication is enabled.
|
||||
sp_env_auth (bool): Flag indicating whether application authentication with environment variables is enabled.
|
||||
env_auth: (bool): Flag indicating whether to use application and PowerShell authentication with environment variables.
|
||||
browser_auth (bool): Flag indicating whether browser authentication is enabled.
|
||||
certificate_auth (bool): Flag indicating whether certificate authentication is enabled.
|
||||
tenant_id (str): The M365 Tenant ID.
|
||||
client_id (str): The M365 Client ID.
|
||||
client_secret (str): The M365 Client Secret.
|
||||
user (str): The M365 User Account.
|
||||
password (str): The M365 User Password.
|
||||
certificate_content (str): The M365 Certificate Content.
|
||||
certificate_path (str): The path to the certificate file.
|
||||
|
||||
@@ -327,7 +313,7 @@ class M365Provider(Provider):
|
||||
"""
|
||||
|
||||
if not client_id and not client_secret:
|
||||
if not browser_auth and tenant_id and not env_auth:
|
||||
if not browser_auth and tenant_id:
|
||||
raise M365BrowserAuthNoFlagError(
|
||||
file=os.path.basename(__file__),
|
||||
message="M365 tenant ID error: browser authentication flag (--browser-auth) not found",
|
||||
@@ -336,12 +322,11 @@ class M365Provider(Provider):
|
||||
not az_cli_auth
|
||||
and not sp_env_auth
|
||||
and not browser_auth
|
||||
and not env_auth
|
||||
and not certificate_auth
|
||||
):
|
||||
raise M365NoAuthenticationMethodError(
|
||||
file=os.path.basename(__file__),
|
||||
message="M365 provider requires at least one authentication method set: [--env-auth | --az-cli-auth | --sp-env-auth | --browser-auth | --certificate-auth]",
|
||||
message="M365 provider requires at least one authentication method set: [--az-cli-auth | --sp-env-auth | --browser-auth | --certificate-auth]",
|
||||
)
|
||||
elif browser_auth and not tenant_id:
|
||||
raise M365BrowserAuthNoTenantIDError(
|
||||
@@ -354,12 +339,7 @@ class M365Provider(Provider):
|
||||
file=os.path.basename(__file__),
|
||||
message="Tenant Id is required for M365 static credentials. Make sure you are using the correct credentials.",
|
||||
)
|
||||
if (
|
||||
not certificate_content
|
||||
and not certificate_path
|
||||
and not (user and password)
|
||||
and not client_secret
|
||||
):
|
||||
if not certificate_content and not certificate_path and not client_secret:
|
||||
raise M365ConfigCredentialsError(
|
||||
file=os.path.basename(__file__),
|
||||
message="You must provide a valid set of credentials. Please check your credentials and try again.",
|
||||
@@ -404,7 +384,6 @@ class M365Provider(Provider):
|
||||
|
||||
@staticmethod
|
||||
def setup_powershell(
|
||||
env_auth: bool = False,
|
||||
sp_env_auth: bool = False,
|
||||
certificate_auth: bool = False,
|
||||
certificate_path: str = None,
|
||||
@@ -415,51 +394,22 @@ class M365Provider(Provider):
|
||||
"""Gets the M365 credentials.
|
||||
|
||||
Args:
|
||||
env_auth: (bool): Flag indicating whether to use application and PowerShell authentication with environment variables.
|
||||
sp_env_auth (bool): Flag indicating whether to use application authentication with environment variables.
|
||||
|
||||
Returns:
|
||||
M365Credentials: Object containing the user credentials.
|
||||
If env_auth is True, retrieves from environment variables.
|
||||
If False, returns empty credentials.
|
||||
M365Credentials: Object containing the credentials for PowerShell operations.
|
||||
"""
|
||||
logger.info("M365 provider: Setting up PowerShell session...")
|
||||
credentials = None
|
||||
|
||||
if m365_credentials:
|
||||
credentials = M365Credentials(
|
||||
user=m365_credentials.get("user", None),
|
||||
passwd=m365_credentials.get("password", None),
|
||||
client_id=m365_credentials.get("client_id", ""),
|
||||
client_secret=m365_credentials.get("client_secret", ""),
|
||||
tenant_id=m365_credentials.get("tenant_id", ""),
|
||||
certificate_content=m365_credentials.get("certificate_content", ""),
|
||||
tenant_domains=identity.tenant_domains,
|
||||
)
|
||||
elif env_auth:
|
||||
m365_user = getenv("M365_USER")
|
||||
m365_password = getenv("M365_PASSWORD")
|
||||
client_id = getenv("AZURE_CLIENT_ID")
|
||||
client_secret = getenv("AZURE_CLIENT_SECRET")
|
||||
tenant_id = getenv("AZURE_TENANT_ID")
|
||||
|
||||
if not m365_user or not m365_password:
|
||||
logger.critical(
|
||||
"M365 provider: Missing M365_USER or M365_PASSWORD environment variables needed for credentials authentication"
|
||||
)
|
||||
raise M365MissingEnvironmentCredentialsError(
|
||||
file=os.path.basename(__file__),
|
||||
message="Missing M365_USER or M365_PASSWORD environment variables required for credentials authentication.",
|
||||
)
|
||||
|
||||
credentials = M365Credentials(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
tenant_id=tenant_id,
|
||||
tenant_domains=identity.tenant_domains,
|
||||
user=m365_user,
|
||||
passwd=m365_password,
|
||||
)
|
||||
|
||||
elif sp_env_auth:
|
||||
client_id = getenv("AZURE_CLIENT_ID")
|
||||
client_secret = getenv("AZURE_CLIENT_SECRET")
|
||||
@@ -488,9 +438,6 @@ class M365Provider(Provider):
|
||||
)
|
||||
|
||||
if credentials:
|
||||
if identity and credentials.user:
|
||||
identity.user = credentials.user
|
||||
identity.identity_type = "Service Principal and User Credentials"
|
||||
if identity and credentials.certificate_content:
|
||||
identity.identity_type = "Service Principal with Certificate"
|
||||
test_session = M365PowerShell(credentials, identity)
|
||||
@@ -499,9 +446,9 @@ class M365Provider(Provider):
|
||||
initialize_m365_powershell_modules()
|
||||
if test_session.test_credentials(credentials):
|
||||
return credentials
|
||||
raise M365UserCredentialsError(
|
||||
raise M365ConfigCredentialsError(
|
||||
file=os.path.basename(__file__),
|
||||
message="The provided User credentials are not valid.",
|
||||
message="The provided credentials are not valid.",
|
||||
)
|
||||
finally:
|
||||
test_session.close()
|
||||
@@ -523,11 +470,7 @@ class M365Provider(Provider):
|
||||
f"M365 Tenant Domain: {Fore.YELLOW}{self._identity.tenant_domain}{Style.RESET_ALL} M365 Tenant ID: {Fore.YELLOW}{self._identity.tenant_id}{Style.RESET_ALL}",
|
||||
f"M365 Identity Type: {Fore.YELLOW}{self._identity.identity_type}{Style.RESET_ALL} M365 Identity ID: {Fore.YELLOW}{self._identity.identity_id}{Style.RESET_ALL}",
|
||||
]
|
||||
if self.credentials and self.credentials.user:
|
||||
report_lines.append(
|
||||
f"M365 User: {Fore.YELLOW}{self.credentials.user}{Style.RESET_ALL}"
|
||||
)
|
||||
elif self.credentials and self.credentials.certificate_content:
|
||||
if self.credentials and self.credentials.certificate_content:
|
||||
report_lines.append(
|
||||
f"M365 Certificate Thumbprint: {Fore.YELLOW}{self._identity.certificate_thumbprint}{Style.RESET_ALL}"
|
||||
)
|
||||
@@ -542,7 +485,6 @@ class M365Provider(Provider):
|
||||
def setup_session(
|
||||
az_cli_auth: bool,
|
||||
sp_env_auth: bool,
|
||||
env_auth: bool,
|
||||
browser_auth: bool,
|
||||
certificate_auth: bool,
|
||||
certificate_path: str,
|
||||
@@ -563,8 +505,6 @@ class M365Provider(Provider):
|
||||
- tenant_id: The M365 Active Directory tenant ID.
|
||||
- client_id: The M365 client ID.
|
||||
- client_secret: The M365 client secret
|
||||
- user: The M365 user email
|
||||
- password: The M365 user password
|
||||
- certificate_content: The M365 certificate content
|
||||
- certificate_path: The path to the certificate file.
|
||||
- provider_id: The M365 provider ID (in this case the Tenant ID).
|
||||
@@ -579,7 +519,7 @@ class M365Provider(Provider):
|
||||
"""
|
||||
logger.info("M365 provider: Setting up session...")
|
||||
if not browser_auth:
|
||||
if sp_env_auth or env_auth:
|
||||
if sp_env_auth:
|
||||
try:
|
||||
M365Provider.check_service_principal_creds_env_vars()
|
||||
except M365EnvironmentVariableError as environment_credentials_error:
|
||||
@@ -623,8 +563,6 @@ class M365Provider(Provider):
|
||||
tenant_id=m365_credentials["tenant_id"],
|
||||
client_id=m365_credentials["client_id"],
|
||||
client_secret=m365_credentials["client_secret"],
|
||||
user=m365_credentials["user"],
|
||||
password=m365_credentials["password"],
|
||||
)
|
||||
return credentials
|
||||
except ClientAuthenticationError as error:
|
||||
@@ -675,7 +613,7 @@ class M365Provider(Provider):
|
||||
try:
|
||||
credentials = DefaultAzureCredential(
|
||||
exclude_environment_credential=not (
|
||||
sp_env_auth or env_auth or certificate_auth
|
||||
sp_env_auth or certificate_auth
|
||||
),
|
||||
exclude_cli_credential=not az_cli_auth,
|
||||
# M365 Auth using Managed Identity is not supported
|
||||
@@ -738,7 +676,6 @@ class M365Provider(Provider):
|
||||
def test_connection(
|
||||
az_cli_auth: bool = False,
|
||||
sp_env_auth: bool = False,
|
||||
env_auth: bool = False,
|
||||
browser_auth: bool = False,
|
||||
certificate_auth: bool = False,
|
||||
tenant_id: str = None,
|
||||
@@ -746,8 +683,6 @@ class M365Provider(Provider):
|
||||
raise_on_exception: bool = True,
|
||||
client_id: str = None,
|
||||
client_secret: str = None,
|
||||
user: str = None,
|
||||
password: str = None,
|
||||
certificate_content: str = None,
|
||||
certificate_path: str = None,
|
||||
provider_id: str = None,
|
||||
@@ -760,7 +695,6 @@ class M365Provider(Provider):
|
||||
|
||||
az_cli_auth (bool): Flag indicating whether to use Azure CLI authentication.
|
||||
sp_env_auth (bool): Flag indicating whether to use application authentication with environment variables.
|
||||
env_auth: (bool): Flag indicating whether to use application and PowerShell authentication with environment variables.
|
||||
browser_auth (bool): Flag indicating whether to use interactive browser authentication.
|
||||
certificate_auth (bool): Flag indicating whether to use certificate authentication.
|
||||
tenant_id (str): The M365 Active Directory tenant ID.
|
||||
@@ -768,8 +702,6 @@ class M365Provider(Provider):
|
||||
raise_on_exception (bool): Flag indicating whether to raise an exception if the connection fails.
|
||||
client_id (str): The M365 client ID.
|
||||
client_secret (str): The M365 client secret.
|
||||
user (str): The M365 user email.
|
||||
password (str): The M365 password.
|
||||
provider_id (str): The M365 provider ID (in this case the Tenant ID).
|
||||
|
||||
|
||||
@@ -797,14 +729,11 @@ class M365Provider(Provider):
|
||||
M365Provider.validate_arguments(
|
||||
az_cli_auth,
|
||||
sp_env_auth,
|
||||
env_auth,
|
||||
browser_auth,
|
||||
certificate_auth,
|
||||
tenant_id,
|
||||
client_id,
|
||||
client_secret,
|
||||
user,
|
||||
password,
|
||||
certificate_content,
|
||||
certificate_path,
|
||||
)
|
||||
@@ -813,20 +742,11 @@ class M365Provider(Provider):
|
||||
# Get the dict from the static credentials
|
||||
m365_credentials = None
|
||||
if tenant_id and client_id and client_secret:
|
||||
if not user and not password:
|
||||
m365_credentials = M365Provider.validate_static_credentials(
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
else:
|
||||
m365_credentials = M365Provider.validate_static_credentials(
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
user=user,
|
||||
password=password,
|
||||
)
|
||||
m365_credentials = M365Provider.validate_static_credentials(
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
elif tenant_id and client_id and certificate_content:
|
||||
m365_credentials = M365Provider.validate_static_credentials(
|
||||
tenant_id=tenant_id,
|
||||
@@ -838,7 +758,6 @@ class M365Provider(Provider):
|
||||
session = M365Provider.setup_session(
|
||||
az_cli_auth,
|
||||
sp_env_auth,
|
||||
env_auth,
|
||||
browser_auth,
|
||||
certificate_auth,
|
||||
certificate_path,
|
||||
@@ -854,7 +773,6 @@ class M365Provider(Provider):
|
||||
# Set up Identity
|
||||
identity = M365Provider.setup_identity(
|
||||
sp_env_auth,
|
||||
env_auth,
|
||||
browser_auth,
|
||||
az_cli_auth,
|
||||
certificate_auth,
|
||||
@@ -877,7 +795,6 @@ class M365Provider(Provider):
|
||||
|
||||
# Set up PowerShell credentials
|
||||
M365Provider.setup_powershell(
|
||||
env_auth,
|
||||
sp_env_auth,
|
||||
certificate_auth,
|
||||
certificate_path,
|
||||
@@ -1046,7 +963,6 @@ class M365Provider(Provider):
|
||||
@staticmethod
|
||||
def setup_identity(
|
||||
sp_env_auth,
|
||||
env_auth,
|
||||
browser_auth,
|
||||
az_cli_auth,
|
||||
certificate_auth,
|
||||
@@ -1058,7 +974,6 @@ class M365Provider(Provider):
|
||||
Args:
|
||||
az_cli_auth (bool): Flag indicating if Azure CLI authentication is used.
|
||||
sp_env_auth (bool): Flag indicating if application authentication with environment variables is used.
|
||||
env_auth: (bool): Flag indicating whether to use application and PowerShell authentication with environment variables.
|
||||
browser_auth (bool): Flag indicating if interactive browser authentication is used.
|
||||
client_id (str): The M365 client ID.
|
||||
|
||||
@@ -1116,13 +1031,6 @@ class M365Provider(Provider):
|
||||
or session.credentials[0]._credential.client_id
|
||||
or "Unknown user id (Missing AAD permissions)"
|
||||
)
|
||||
elif env_auth:
|
||||
identity.identity_type = "Service Principal and User Credentials"
|
||||
identity.identity_id = (
|
||||
getenv("AZURE_CLIENT_ID")
|
||||
or session.credentials[0]._credential.client_id
|
||||
or "Unknown user id (Missing AAD permissions)"
|
||||
)
|
||||
elif certificate_auth:
|
||||
identity.identity_type = "Service Principal with Certificate"
|
||||
identity.identity_id = (
|
||||
@@ -1174,8 +1082,6 @@ class M365Provider(Provider):
|
||||
tenant_id: str = None,
|
||||
client_id: str = None,
|
||||
client_secret: str = None,
|
||||
user: str = None,
|
||||
password: str = None,
|
||||
certificate_content: str = None,
|
||||
certificate_path: str = None,
|
||||
) -> dict:
|
||||
@@ -1186,8 +1092,6 @@ class M365Provider(Provider):
|
||||
tenant_id (str): The M365 Active Directory tenant ID.
|
||||
client_id (str): The M365 client ID.
|
||||
client_secret (str): The M365 client secret.
|
||||
user (str): The M365 user email.
|
||||
password (str): The M365 user password.
|
||||
certificate_content (str): The M365 Certificate Content.
|
||||
certificate_path (str): The path to the certificate file.
|
||||
|
||||
@@ -1257,8 +1161,6 @@ class M365Provider(Provider):
|
||||
"tenant_id": tenant_id,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"user": user,
|
||||
"password": password,
|
||||
"certificate_content": certificate_content,
|
||||
"certificate_path": certificate_path,
|
||||
}
|
||||
|
||||
@@ -25,9 +25,6 @@ class M365RegionConfig(BaseModel):
|
||||
|
||||
|
||||
class M365Credentials(BaseModel):
|
||||
user: Optional[str] = None
|
||||
passwd: Optional[str] = None
|
||||
encrypted_passwd: Optional[str] = None
|
||||
client_id: str = ""
|
||||
client_secret: Optional[str] = None
|
||||
tenant_id: str = ""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import base64
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
@@ -60,6 +61,49 @@ class TestJiraIntegration:
|
||||
domain=self.domain,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _collect_text_from_cell(cell: dict) -> str:
|
||||
pieces: List[str] = []
|
||||
|
||||
def walk(node: dict) -> None:
|
||||
node_type = node.get("type")
|
||||
if node_type == "text":
|
||||
pieces.append(node.get("text", ""))
|
||||
elif node_type == "hardBreak":
|
||||
pieces.append(" ")
|
||||
else:
|
||||
for child in node.get("content", []):
|
||||
walk(child)
|
||||
if node_type in {"paragraph", "listItem"}:
|
||||
pieces.append(" ")
|
||||
|
||||
for child in cell.get("content", []):
|
||||
walk(child)
|
||||
|
||||
flattened = "".join(pieces)
|
||||
return " ".join(flattened.split())
|
||||
|
||||
@staticmethod
|
||||
def _find_link_mark(nodes: List[dict]) -> Optional[dict]:
|
||||
for node in nodes:
|
||||
if node.get("type") == "text":
|
||||
for mark in node.get("marks", []):
|
||||
if mark.get("type") == "link":
|
||||
return mark
|
||||
found = TestJiraIntegration._find_link_mark(node.get("content", []))
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _find_table_row(rows: List[dict], header: str) -> dict:
|
||||
for row in rows:
|
||||
header_cell = row.get("content", [])[0]
|
||||
header_text = TestJiraIntegration._collect_text_from_cell(header_cell)
|
||||
if header_text == header:
|
||||
return row
|
||||
raise AssertionError(f"Row with header '{header}' not found")
|
||||
|
||||
@patch.object(Jira, "get_auth", return_value=None)
|
||||
def test_auth_code_url(self, mock_get_auth):
|
||||
"""Test to verify the authorization URL generation with correct query parameters"""
|
||||
@@ -711,10 +755,6 @@ class TestJiraIntegration:
|
||||
|
||||
call_args = mock_post.call_args
|
||||
|
||||
mock_post.assert_called_once()
|
||||
|
||||
call_args = mock_post.call_args
|
||||
|
||||
expected_url = (
|
||||
"https://api.atlassian.com/ex/jira/valid_cloud_id/rest/api/3/issue"
|
||||
)
|
||||
@@ -747,24 +787,24 @@ class TestJiraIntegration:
|
||||
assert table["type"] == "table"
|
||||
|
||||
table_rows = table["content"]
|
||||
row_texts = []
|
||||
row_entries = {}
|
||||
recommendation_cell = None
|
||||
|
||||
for row in table_rows:
|
||||
if row["type"] == "tableRow":
|
||||
cells = row["content"]
|
||||
if len(cells) == 2:
|
||||
key_cell = cells[0]["content"][0]["content"][0]["text"]
|
||||
if row["type"] != "tableRow":
|
||||
continue
|
||||
|
||||
value_content = cells[1]["content"][0]["content"]
|
||||
if len(value_content) > 1:
|
||||
value_texts = []
|
||||
for content_item in value_content:
|
||||
if content_item["type"] == "text":
|
||||
value_texts.append(content_item["text"])
|
||||
value_cell = "".join(value_texts)
|
||||
else:
|
||||
value_cell = value_content[0]["text"]
|
||||
cells = row["content"]
|
||||
if len(cells) != 2:
|
||||
continue
|
||||
|
||||
row_texts.append((key_cell, value_cell))
|
||||
key_text = self._collect_text_from_cell(cells[0])
|
||||
value_text = self._collect_text_from_cell(cells[1])
|
||||
|
||||
if key_text:
|
||||
row_entries[key_text] = value_text
|
||||
if key_text == "Recommendation":
|
||||
recommendation_cell = cells[1]
|
||||
|
||||
expected_keys = [
|
||||
"Check Id",
|
||||
@@ -788,12 +828,15 @@ class TestJiraIntegration:
|
||||
"Tenant Info",
|
||||
]
|
||||
|
||||
actual_keys = [key for key, _ in row_texts]
|
||||
|
||||
for expected_key in expected_keys:
|
||||
assert expected_key in actual_keys, f"Missing row key: {expected_key}"
|
||||
assert expected_key in row_entries, f"Missing row key: {expected_key}"
|
||||
|
||||
row_dict = dict(row_texts)
|
||||
row_dict = row_entries
|
||||
|
||||
assert recommendation_cell is not None
|
||||
link_mark = self._find_link_mark(recommendation_cell.get("content", []))
|
||||
assert link_mark is not None
|
||||
assert link_mark.get("attrs", {}).get("href") == "remediation_url"
|
||||
assert row_dict["Check Id"] == "CHECK-1"
|
||||
assert row_dict["Check Title"] == "Check Title"
|
||||
assert row_dict["Status"] == "FAIL"
|
||||
@@ -828,6 +871,117 @@ class TestJiraIntegration:
|
||||
assert "https://prowler-cloud-link/findings/12345" in row_dict["Finding URL"]
|
||||
assert "Tenant Info" in row_dict["Tenant Info"]
|
||||
|
||||
def test_get_adf_description_renders_markdown(self):
|
||||
status_extended_md = "Finding uses **bold** text and `code` snippets."
|
||||
risk_md = "High risk:\n- Item one\n- Item two"
|
||||
recommendation_md = "Apply fixes:\n- Step one\n- Step two"
|
||||
recommendation_url = "https://example.com/fix"
|
||||
|
||||
adf_description = self.jira_integration.get_adf_description(
|
||||
check_id="CHECK-1",
|
||||
check_title="Sample check",
|
||||
severity="HIGH",
|
||||
severity_color="#FF0000",
|
||||
status="FAIL",
|
||||
status_color="#00FF00",
|
||||
status_extended=status_extended_md,
|
||||
provider="aws",
|
||||
region="us-east-1",
|
||||
resource_uid="resource-1",
|
||||
resource_name="resource-name",
|
||||
risk=risk_md,
|
||||
recommendation_text=recommendation_md,
|
||||
recommendation_url=recommendation_url,
|
||||
)
|
||||
|
||||
assert adf_description["type"] == "doc"
|
||||
table = adf_description["content"][1]
|
||||
assert table["type"] == "table"
|
||||
|
||||
rows = {}
|
||||
for row in table["content"]:
|
||||
if row.get("type") != "tableRow":
|
||||
continue
|
||||
key_cell, value_cell = row["content"]
|
||||
key_text = self._collect_text_from_cell(key_cell)
|
||||
rows[key_text] = value_cell
|
||||
|
||||
assert "Status Extended" in rows
|
||||
assert "Risk" in rows
|
||||
assert "Recommendation" in rows
|
||||
|
||||
def walk_nodes(nodes: List[dict]):
|
||||
stack = list(nodes)
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
yield current
|
||||
stack.extend(current.get("content", []))
|
||||
|
||||
status_text = self._collect_text_from_cell(rows["Status Extended"])
|
||||
assert status_text == status_extended_md
|
||||
|
||||
risk_nodes = list(walk_nodes(rows["Risk"].get("content", [])))
|
||||
assert any(node.get("type") == "bulletList" for node in risk_nodes)
|
||||
|
||||
recommendation_cell = rows["Recommendation"]
|
||||
recommendation_nodes = list(walk_nodes(recommendation_cell.get("content", [])))
|
||||
assert any(node.get("type") == "bulletList" for node in recommendation_nodes)
|
||||
link_mark = self._find_link_mark(recommendation_cell.get("content", []))
|
||||
assert link_mark is not None
|
||||
assert link_mark.get("attrs", {}).get("href") == recommendation_url
|
||||
|
||||
def test_get_adf_description_code_blocks_strip_fences(self):
|
||||
code_block_value = """```hcl\nresource \"aws_s3_bucket\" \"example\" {\n bucket = \"my-bucket\"\n}\n```"""
|
||||
|
||||
adf_description = self.jira_integration.get_adf_description(
|
||||
check_id="CHECK-1",
|
||||
check_title="Sample check",
|
||||
severity="HIGH",
|
||||
severity_color="#FF0000",
|
||||
status="FAIL",
|
||||
status_color="#00FF00",
|
||||
recommendation_text="",
|
||||
remediation_code_native_iac=code_block_value,
|
||||
)
|
||||
|
||||
table = adf_description["content"][1]
|
||||
code_row = self._find_table_row(table["content"], "Remediation Native IaC")
|
||||
code_cell = code_row["content"][1]
|
||||
code_block = code_cell["content"][0]
|
||||
|
||||
assert code_block["type"] == "codeBlock"
|
||||
assert code_block.get("attrs", {}).get("language") == "hcl"
|
||||
expected_text = (
|
||||
'resource "aws_s3_bucket" "example" {\n bucket = "my-bucket"\n}'
|
||||
)
|
||||
assert code_block["content"][0]["text"] == expected_text
|
||||
|
||||
def test_get_adf_description_other_remediation_uses_markdown(self):
|
||||
other_value = "Use **bold** text"
|
||||
|
||||
adf_description = self.jira_integration.get_adf_description(
|
||||
check_id="CHECK-1",
|
||||
check_title="Sample check",
|
||||
severity="HIGH",
|
||||
severity_color="#FF0000",
|
||||
status="FAIL",
|
||||
status_color="#00FF00",
|
||||
recommendation_text="",
|
||||
remediation_code_other=other_value,
|
||||
)
|
||||
|
||||
table = adf_description["content"][1]
|
||||
other_row = self._find_table_row(table["content"], "Remediation Other")
|
||||
other_cell = other_row["content"][1]
|
||||
|
||||
paragraph = other_cell["content"][0]
|
||||
assert paragraph["type"] == "paragraph"
|
||||
assert any(
|
||||
mark.get("type") == "strong"
|
||||
for node in paragraph.get("content", [])
|
||||
for mark in node.get("marks", [])
|
||||
)
|
||||
|
||||
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
|
||||
@patch.object(
|
||||
Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"]
|
||||
|
||||
@@ -147,29 +147,6 @@ class TestM365Arguments:
|
||||
assert kwargs["action"] == "store_true"
|
||||
assert "Azure CLI authentication" in kwargs["help"]
|
||||
|
||||
def test_env_auth_argument_configuration(self):
|
||||
"""Test that env-auth argument is configured correctly"""
|
||||
mock_m365_args = MagicMock()
|
||||
mock_m365_args.subparsers = self.mock_subparsers
|
||||
mock_m365_args.common_providers_parser = MagicMock()
|
||||
|
||||
arguments.init_parser(mock_m365_args)
|
||||
|
||||
# Find the env-auth argument call
|
||||
calls = self.mock_auth_modes_group.add_argument.call_args_list
|
||||
env_auth_call = None
|
||||
for call in calls:
|
||||
if call[0][0] == "--env-auth":
|
||||
env_auth_call = call
|
||||
break
|
||||
|
||||
assert env_auth_call is not None
|
||||
|
||||
# Check argument configuration
|
||||
kwargs = env_auth_call[1]
|
||||
assert kwargs["action"] == "store_true"
|
||||
assert "User and Password environment variables" in kwargs["help"]
|
||||
|
||||
def test_sp_env_auth_argument_configuration(self):
|
||||
"""Test that sp-env-auth argument is configured correctly"""
|
||||
mock_m365_args = MagicMock()
|
||||
@@ -191,7 +168,7 @@ class TestM365Arguments:
|
||||
# Check argument configuration
|
||||
kwargs = sp_env_call[1]
|
||||
assert kwargs["action"] == "store_true"
|
||||
assert "Azure Service Principal environment variables" in kwargs["help"]
|
||||
assert "Service Principal environment variables" in kwargs["help"]
|
||||
|
||||
def test_browser_auth_argument_configuration(self):
|
||||
"""Test that browser-auth argument is configured correctly"""
|
||||
@@ -336,28 +313,7 @@ class TestM365ArgumentsIntegration:
|
||||
args = parser.parse_args(["m365", "--az-cli-auth"])
|
||||
|
||||
assert args.az_cli_auth is True
|
||||
assert args.env_auth is False
|
||||
assert args.sp_env_auth is False
|
||||
assert args.browser_auth is False
|
||||
assert args.certificate_auth is False
|
||||
|
||||
def test_real_argument_parsing_env_auth(self):
|
||||
"""Test parsing arguments with environment authentication"""
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers()
|
||||
common_parser = argparse.ArgumentParser(add_help=False)
|
||||
|
||||
mock_m365_args = MagicMock()
|
||||
mock_m365_args.subparsers = subparsers
|
||||
mock_m365_args.common_providers_parser = common_parser
|
||||
|
||||
arguments.init_parser(mock_m365_args)
|
||||
|
||||
# Parse arguments with environment auth
|
||||
args = parser.parse_args(["m365", "--env-auth"])
|
||||
|
||||
assert args.az_cli_auth is False
|
||||
assert args.env_auth is True
|
||||
assert not hasattr(args, "env_auth")
|
||||
assert args.sp_env_auth is False
|
||||
assert args.browser_auth is False
|
||||
assert args.certificate_auth is False
|
||||
@@ -378,7 +334,7 @@ class TestM365ArgumentsIntegration:
|
||||
args = parser.parse_args(["m365", "--sp-env-auth"])
|
||||
|
||||
assert args.az_cli_auth is False
|
||||
assert args.env_auth is False
|
||||
assert not hasattr(args, "env_auth")
|
||||
assert args.sp_env_auth is True
|
||||
assert args.browser_auth is False
|
||||
assert args.certificate_auth is False
|
||||
@@ -406,7 +362,7 @@ class TestM365ArgumentsIntegration:
|
||||
)
|
||||
|
||||
assert args.az_cli_auth is False
|
||||
assert args.env_auth is False
|
||||
assert not hasattr(args, "env_auth")
|
||||
assert args.sp_env_auth is False
|
||||
assert args.browser_auth is True
|
||||
assert args.certificate_auth is False
|
||||
@@ -428,7 +384,7 @@ class TestM365ArgumentsIntegration:
|
||||
args = parser.parse_args(["m365", "--certificate-auth"])
|
||||
|
||||
assert args.az_cli_auth is False
|
||||
assert args.env_auth is False
|
||||
assert not hasattr(args, "env_auth")
|
||||
assert args.sp_env_auth is False
|
||||
assert args.browser_auth is False
|
||||
assert args.certificate_auth is True
|
||||
@@ -495,7 +451,7 @@ class TestM365ArgumentsIntegration:
|
||||
args = parser.parse_args(["m365"])
|
||||
|
||||
assert args.az_cli_auth is False
|
||||
assert args.env_auth is False
|
||||
assert not hasattr(args, "env_auth")
|
||||
assert args.sp_env_auth is False
|
||||
assert args.browser_auth is False
|
||||
assert args.certificate_auth is False
|
||||
@@ -547,7 +503,7 @@ class TestM365ArgumentsIntegration:
|
||||
|
||||
# This should raise SystemExit due to mutually exclusive group
|
||||
try:
|
||||
parser.parse_args(["m365", "--az-cli-auth", "--env-auth"])
|
||||
parser.parse_args(["m365", "--az-cli-auth", "--sp-env-auth"])
|
||||
assert False, "Expected SystemExit due to mutually exclusive arguments"
|
||||
except SystemExit:
|
||||
# This is expected
|
||||
@@ -636,6 +592,6 @@ class TestM365ArgumentsIntegration:
|
||||
assert args.certificate_path == "/home/user/cert.pem"
|
||||
assert args.tenant_id == "12345678-1234-1234-1234-123456789012"
|
||||
assert args.az_cli_auth is False
|
||||
assert args.env_auth is False
|
||||
assert not hasattr(args, "env_auth")
|
||||
assert args.sp_env_auth is False
|
||||
assert args.browser_auth is False
|
||||
|
||||
@@ -7,8 +7,6 @@ from prowler.lib.powershell.powershell import PowerShellSession
|
||||
from prowler.providers.m365.exceptions.exceptions import (
|
||||
M365CertificateCreationError,
|
||||
M365GraphConnectionError,
|
||||
M365UserCredentialsError,
|
||||
M365UserNotBelongingToTenantError,
|
||||
)
|
||||
from prowler.providers.m365.lib.powershell.m365_powershell import M365PowerShell
|
||||
from prowler.providers.m365.models import M365Credentials, M365IdentityInfo
|
||||
@@ -19,7 +17,11 @@ class Testm365PowerShell:
|
||||
def test_init(self, mock_popen):
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
credentials = M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="User",
|
||||
@@ -40,7 +42,11 @@ class Testm365PowerShell:
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_sanitize(self, _):
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
credentials = M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="User",
|
||||
@@ -77,180 +83,50 @@ class Testm365PowerShell:
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(
|
||||
user="test@example.com",
|
||||
passwd="test_password",
|
||||
encrypted_passwd="test_password",
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="User",
|
||||
identity_type="Service Principal",
|
||||
tenant_id="test_tenant",
|
||||
tenant_domain="example.com",
|
||||
tenant_domains=["example.com"],
|
||||
location="test_location",
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
with patch.object(M365PowerShell, "init_credential") as mock_init:
|
||||
session = M365PowerShell(credentials, identity)
|
||||
mock_init.assert_called_once_with(credentials)
|
||||
|
||||
# Mock encrypt_password to return a known value
|
||||
session.encrypt_password = MagicMock(return_value="encrypted_password")
|
||||
session.execute = MagicMock()
|
||||
|
||||
session.init_credential(credentials)
|
||||
# Call original init_credential to verify application authentication setup
|
||||
M365PowerShell.init_credential(session, credentials)
|
||||
|
||||
# Verify encrypt_password was called
|
||||
session.encrypt_password.assert_any_call(credentials.passwd)
|
||||
|
||||
# Verify execute was called with the correct commands
|
||||
session.execute.assert_any_call(f'$user = "{credentials.user}"')
|
||||
session.execute.assert_any_call('$clientID = "test_client_id"')
|
||||
session.execute.assert_any_call('$clientSecret = "test_client_secret"')
|
||||
session.execute.assert_any_call('$tenantID = "test_tenant_id"')
|
||||
session.execute.assert_any_call(
|
||||
f'$secureString = "{credentials.encrypted_passwd}" | ConvertTo-SecureString'
|
||||
'$graphtokenBody = @{ Grant_Type = "client_credentials"; Scope = "https://graph.microsoft.com/.default"; Client_Id = $clientID; Client_Secret = $clientSecret }'
|
||||
)
|
||||
session.execute.assert_any_call(
|
||||
"$credential = New-Object System.Management.Automation.PSCredential ($user, $secureString)"
|
||||
'$graphToken = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$tenantID/oauth2/v2.0/token" -Method POST -Body $graphtokenBody | Select-Object -ExpandProperty Access_Token'
|
||||
)
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_credentials_exchange_success(self, mock_popen):
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
|
||||
credentials = M365Credentials(
|
||||
user="test@contoso.onmicrosoft.com",
|
||||
passwd="test_password",
|
||||
encrypted_passwd="test_encrypted_password",
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="User",
|
||||
tenant_id="test_tenant",
|
||||
tenant_domain="contoso.onmicrosoft.com",
|
||||
tenant_domains=["contoso.onmicrosoft.com"],
|
||||
location="test_location",
|
||||
user="test@contoso.onmicrosoft.com",
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock encrypt_password to return a known value
|
||||
session.encrypt_password = MagicMock(return_value="encrypted_password")
|
||||
|
||||
# Mock execute to simulate successful Exchange connection
|
||||
def mock_execute_side_effect(command):
|
||||
if "Connect-ExchangeOnline" in command:
|
||||
return "Connected successfully https://aka.ms/exov3-module"
|
||||
return ""
|
||||
|
||||
session.execute = MagicMock(side_effect=mock_execute_side_effect)
|
||||
|
||||
# Execute the test
|
||||
result = session.test_credentials(credentials)
|
||||
assert result is True
|
||||
|
||||
# Verify execute was called with the correct commands
|
||||
session.execute.assert_any_call(
|
||||
f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString'
|
||||
)
|
||||
session.execute.assert_any_call(
|
||||
f'$credential = New-Object System.Management.Automation.PSCredential("{session.sanitize(credentials.user)}", $securePassword)'
|
||||
)
|
||||
# Exchange connection should be tested
|
||||
session.execute.assert_any_call(
|
||||
"Connect-ExchangeOnline -Credential $credential"
|
||||
)
|
||||
|
||||
# Verify Teams connection was NOT called (since Exchange succeeded)
|
||||
teams_calls = [
|
||||
call
|
||||
for call in session.execute.call_args_list
|
||||
if "Connect-MicrosoftTeams" in str(call)
|
||||
]
|
||||
assert (
|
||||
len(teams_calls) == 0
|
||||
), "Teams connection should not be called when Exchange succeeds"
|
||||
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_credentials_exchange_fail_teams_success(self, mock_popen):
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
|
||||
credentials = M365Credentials(
|
||||
user="test@contoso.onmicrosoft.com",
|
||||
passwd="test_password",
|
||||
encrypted_passwd="test_encrypted_password",
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="User",
|
||||
tenant_id="test_tenant",
|
||||
tenant_domain="contoso.onmicrosoft.com",
|
||||
tenant_domains=["contoso.onmicrosoft.com"],
|
||||
location="test_location",
|
||||
user="test@contoso.onmicrosoft.com",
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock encrypt_password to return a known value
|
||||
session.encrypt_password = MagicMock(return_value="encrypted_password")
|
||||
|
||||
# Mock execute to simulate Exchange fail and Teams success
|
||||
def mock_execute_side_effect(command):
|
||||
if "Connect-ExchangeOnline" in command:
|
||||
return (
|
||||
"Connection failed" # No "https://aka.ms/exov3-module" in response
|
||||
)
|
||||
elif "Connect-MicrosoftTeams" in command:
|
||||
return "Connected successfully test@contoso.onmicrosoft.com"
|
||||
return ""
|
||||
|
||||
session.execute = MagicMock(side_effect=mock_execute_side_effect)
|
||||
|
||||
# Execute the test
|
||||
result = session.test_credentials(credentials)
|
||||
assert result is True
|
||||
|
||||
# Verify execute was called with the correct commands
|
||||
session.execute.assert_any_call(
|
||||
f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString'
|
||||
)
|
||||
session.execute.assert_any_call(
|
||||
f'$credential = New-Object System.Management.Automation.PSCredential("{session.sanitize(credentials.user)}", $securePassword)'
|
||||
)
|
||||
# Both Exchange and Teams connections should be tested
|
||||
session.execute.assert_any_call(
|
||||
"Connect-ExchangeOnline -Credential $credential"
|
||||
)
|
||||
session.execute.assert_any_call(
|
||||
"Connect-MicrosoftTeams -Credential $credential"
|
||||
)
|
||||
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_credentials_application_auth(self, mock_popen):
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(
|
||||
user="",
|
||||
passwd="",
|
||||
encrypted_passwd="",
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
identity_type="Service Principal",
|
||||
tenant_id="test_tenant",
|
||||
tenant_domain="contoso.onmicrosoft.com",
|
||||
tenant_domains=["contoso.onmicrosoft.com"],
|
||||
@@ -264,162 +140,13 @@ class Testm365PowerShell:
|
||||
session.execute.assert_any_call("Write-Output $graphToken")
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
@patch("msal.ConfidentialClientApplication")
|
||||
def test_test_credentials_user_not_belonging_to_tenant(self, mock_msal, mock_popen):
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
mock_msal_instance = MagicMock()
|
||||
mock_msal.return_value = mock_msal_instance
|
||||
mock_msal_instance.acquire_token_by_username_password.return_value = {
|
||||
"access_token": "test_token"
|
||||
}
|
||||
|
||||
credentials = M365Credentials(
|
||||
user="user@otherdomain.com",
|
||||
passwd="test_password",
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="User",
|
||||
tenant_id="test_tenant",
|
||||
tenant_domain="contoso.onmicrosoft.com",
|
||||
tenant_domains=["contoso.onmicrosoft.com"],
|
||||
location="test_location",
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock the execute method to return the decrypted password
|
||||
def mock_execute(command, *args, **kwargs):
|
||||
if "Write-Output" in command:
|
||||
return "decrypted_password"
|
||||
return None
|
||||
|
||||
session.execute = MagicMock(side_effect=mock_execute)
|
||||
session.process.stdin.write = MagicMock()
|
||||
session.read_output = MagicMock(return_value="decrypted_password")
|
||||
|
||||
with pytest.raises(M365UserNotBelongingToTenantError) as exception:
|
||||
session.test_credentials(credentials)
|
||||
|
||||
assert exception.type == M365UserNotBelongingToTenantError
|
||||
assert (
|
||||
"The user domain otherdomain.com does not match any of the tenant domains: contoso.onmicrosoft.com"
|
||||
in str(exception.value)
|
||||
)
|
||||
|
||||
# Verify MSAL was not called since domain validation failed first
|
||||
mock_msal.assert_not_called()
|
||||
mock_msal_instance.acquire_token_by_username_password.assert_not_called()
|
||||
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_credentials_auth_failure_aadsts_error(self, mock_popen):
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
|
||||
credentials = M365Credentials(
|
||||
user="test@contoso.onmicrosoft.com",
|
||||
passwd="test_password",
|
||||
encrypted_passwd="test_encrypted_password",
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="User",
|
||||
tenant_id="test_tenant",
|
||||
tenant_domain="contoso.onmicrosoft.com",
|
||||
tenant_domains=["contoso.onmicrosoft.com"],
|
||||
location="test_location",
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock encrypt_password and execute to simulate AADSTS error
|
||||
session.encrypt_password = MagicMock(return_value="encrypted_password")
|
||||
session.execute = MagicMock(
|
||||
return_value="AADSTS50126: Error validating credentials due to invalid username or password"
|
||||
)
|
||||
|
||||
with pytest.raises(M365UserCredentialsError) as exc_info:
|
||||
session.test_credentials(credentials)
|
||||
|
||||
assert (
|
||||
"AADSTS50126: Error validating credentials due to invalid username or password"
|
||||
in str(exc_info.value)
|
||||
)
|
||||
|
||||
# Verify execute was called with the correct commands
|
||||
session.execute.assert_any_call(
|
||||
f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString'
|
||||
)
|
||||
session.execute.assert_any_call(
|
||||
f'$credential = New-Object System.Management.Automation.PSCredential("{session.sanitize(credentials.user)}", $securePassword)'
|
||||
)
|
||||
session.execute.assert_any_call(
|
||||
"Connect-ExchangeOnline -Credential $credential"
|
||||
)
|
||||
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_credentials_auth_failure_no_access_token(self, mock_popen):
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
|
||||
credentials = M365Credentials(
|
||||
user="test@contoso.onmicrosoft.com",
|
||||
passwd="test_password",
|
||||
encrypted_passwd="test_encrypted_password",
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="User",
|
||||
tenant_id="test_tenant",
|
||||
tenant_domain="contoso.onmicrosoft.com",
|
||||
tenant_domains=["contoso.onmicrosoft.com"],
|
||||
location="test_location",
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock encrypt_password and execute to simulate AADSTS invalid grant error
|
||||
session.encrypt_password = MagicMock(return_value="encrypted_password")
|
||||
session.execute = MagicMock(
|
||||
return_value="AADSTS70002: The request body must contain the following parameter: 'client_secret' or 'client_assertion'."
|
||||
)
|
||||
|
||||
with pytest.raises(M365UserCredentialsError) as exc_info:
|
||||
session.test_credentials(credentials)
|
||||
|
||||
assert (
|
||||
"AADSTS70002: The request body must contain the following parameter: 'client_secret' or 'client_assertion'."
|
||||
in str(exc_info.value)
|
||||
)
|
||||
|
||||
# Verify execute was called with the correct commands
|
||||
session.execute.assert_any_call(
|
||||
f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString'
|
||||
)
|
||||
session.execute.assert_any_call(
|
||||
f'$credential = New-Object System.Management.Automation.PSCredential("{session.sanitize(credentials.user)}", $securePassword)'
|
||||
)
|
||||
session.execute.assert_any_call(
|
||||
"Connect-ExchangeOnline -Credential $credential"
|
||||
)
|
||||
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_remove_ansi(self, mock_popen):
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
credentials = M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="User",
|
||||
@@ -446,7 +173,11 @@ class Testm365PowerShell:
|
||||
def test_execute(self, mock_popen):
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
credentials = M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="User",
|
||||
@@ -469,7 +200,11 @@ class Testm365PowerShell:
|
||||
"""Test the read_output method with various scenarios"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
credentials = M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="User",
|
||||
@@ -516,7 +251,11 @@ class Testm365PowerShell:
|
||||
def test_json_parse_output(self, mock_popen):
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
credentials = M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="User",
|
||||
@@ -549,7 +288,11 @@ class Testm365PowerShell:
|
||||
def test_close(self, mock_popen):
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
credentials = M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="User",
|
||||
@@ -718,7 +461,11 @@ class Testm365PowerShell:
|
||||
"""Test test_graph_connection when token is valid"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
credentials = M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
@@ -743,7 +490,11 @@ class Testm365PowerShell:
|
||||
"""Test test_graph_connection when token is empty"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
credentials = M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
@@ -769,7 +520,11 @@ class Testm365PowerShell:
|
||||
"""Test test_graph_connection when an exception occurs"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
credentials = M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
@@ -797,7 +552,11 @@ class Testm365PowerShell:
|
||||
"""Test test_teams_connection when token is valid"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
credentials = M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
@@ -835,7 +594,11 @@ class Testm365PowerShell:
|
||||
"""Test test_teams_connection when token lacks required permissions"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
credentials = M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
@@ -870,7 +633,11 @@ class Testm365PowerShell:
|
||||
"""Test test_teams_connection when an exception occurs"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
credentials = M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
@@ -899,7 +666,11 @@ class Testm365PowerShell:
|
||||
"""Test test_exchange_connection when token is valid"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
credentials = M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
@@ -937,7 +708,11 @@ class Testm365PowerShell:
|
||||
"""Test test_exchange_connection when token lacks required permissions"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
credentials = M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
@@ -972,7 +747,11 @@ class Testm365PowerShell:
|
||||
"""Test test_exchange_connection when an exception occurs"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
credentials = M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
@@ -995,58 +774,6 @@ class Testm365PowerShell:
|
||||
)
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_encrypt_password(self, mock_popen):
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="User",
|
||||
tenant_id="test_tenant",
|
||||
tenant_domain="example.com",
|
||||
tenant_domains=["example.com"],
|
||||
location="test_location",
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Test non-Windows system (should use utf-16le hex encoding)
|
||||
from unittest import mock
|
||||
|
||||
with mock.patch("platform.system", return_value="Linux"):
|
||||
result = session.encrypt_password("password123")
|
||||
expected = "password123".encode("utf-16le").hex()
|
||||
assert result == expected
|
||||
|
||||
# Test Windows system with tuple return
|
||||
with mock.patch("platform.system", return_value="Windows"):
|
||||
import sys
|
||||
|
||||
win32crypt_mock = mock.MagicMock()
|
||||
win32crypt_mock.CryptProtectData.return_value = (None, b"encrypted_bytes")
|
||||
sys.modules["win32crypt"] = win32crypt_mock
|
||||
|
||||
result = session.encrypt_password("password123")
|
||||
assert result == b"encrypted_bytes".hex()
|
||||
|
||||
# Clean up mock
|
||||
del sys.modules["win32crypt"]
|
||||
|
||||
# Test error handling
|
||||
with mock.patch("platform.system", return_value="Windows"):
|
||||
import sys
|
||||
|
||||
win32crypt_mock = mock.MagicMock()
|
||||
win32crypt_mock.CryptProtectData.side_effect = Exception("Test error")
|
||||
sys.modules["win32crypt"] = win32crypt_mock
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
session.encrypt_password("password123")
|
||||
assert "Error encrypting password: Test error" in str(exc_info.value)
|
||||
|
||||
# Clean up mock
|
||||
del sys.modules["win32crypt"]
|
||||
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_clean_certificate_content(self, mock_popen):
|
||||
"""Test clean_certificate_content method with various certificate content formats"""
|
||||
|
||||
@@ -23,7 +23,9 @@ LOCATION = "global"
|
||||
def set_mocked_m365_provider(
|
||||
session_credentials: DefaultAzureCredential = DefaultAzureCredential(),
|
||||
credentials: M365Credentials = M365Credentials(
|
||||
user="user@email.com", passwd="111111aa111111aaa1111"
|
||||
client_id=CLIENT_ID,
|
||||
client_secret=CLIENT_SECRET,
|
||||
tenant_id=TENANT_ID,
|
||||
),
|
||||
identity: M365IdentityInfo = M365IdentityInfo(
|
||||
identity_id=IDENTITY_ID,
|
||||
|
||||
@@ -29,20 +29,15 @@ from prowler.providers.m365.exceptions.exceptions import (
|
||||
M365GetTokenIdentityError,
|
||||
M365HTTPResponseError,
|
||||
M365InvalidProviderIdError,
|
||||
M365MissingEnvironmentCredentialsError,
|
||||
M365NoAuthenticationMethodError,
|
||||
M365NotTenantIdButClientIdAndClientSecretError,
|
||||
M365NotValidCertificateContentError,
|
||||
M365NotValidCertificatePathError,
|
||||
M365NotValidClientIdError,
|
||||
M365NotValidClientSecretError,
|
||||
M365NotValidPasswordError,
|
||||
M365NotValidTenantIdError,
|
||||
M365NotValidUserError,
|
||||
M365TenantIdAndClientIdNotBelongingToClientSecretError,
|
||||
M365TenantIdAndClientSecretNotBelongingToClientIdError,
|
||||
M365UserCredentialsError,
|
||||
M365UserNotBelongingToTenantError,
|
||||
)
|
||||
from prowler.providers.m365.m365_provider import M365Provider
|
||||
from prowler.providers.m365.models import (
|
||||
@@ -97,8 +92,6 @@ class TestM365Provider:
|
||||
client_id=CLIENT_ID,
|
||||
tenant_id=TENANT_ID,
|
||||
client_secret=CLIENT_SECRET,
|
||||
user="",
|
||||
passwd="",
|
||||
),
|
||||
),
|
||||
):
|
||||
@@ -106,71 +99,6 @@ class TestM365Provider:
|
||||
sp_env_auth=True,
|
||||
az_cli_auth=False,
|
||||
browser_auth=False,
|
||||
env_auth=False,
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
region=azure_region,
|
||||
config_path=default_config_file_path,
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
|
||||
assert m365_provider.region_config == M365RegionConfig(
|
||||
name="M365Global",
|
||||
authority=None,
|
||||
base_url="https://graph.microsoft.com",
|
||||
credential_scopes=["https://graph.microsoft.com/.default"],
|
||||
)
|
||||
assert m365_provider.identity == M365IdentityInfo(
|
||||
identity_id=IDENTITY_ID,
|
||||
identity_type=IDENTITY_TYPE,
|
||||
tenant_id=TENANT_ID,
|
||||
tenant_domain=DOMAIN,
|
||||
location=LOCATION,
|
||||
)
|
||||
|
||||
def test_m365_provider_env_auth(self):
|
||||
tenant_id = None
|
||||
client_id = None
|
||||
client_secret = None
|
||||
|
||||
fixer_config = load_and_validate_config_file(
|
||||
"m365", default_fixer_config_file_path
|
||||
)
|
||||
azure_region = "M365Global"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.m365.m365_provider.M365Provider.setup_session",
|
||||
return_value=ClientSecretCredential(
|
||||
client_id=CLIENT_ID,
|
||||
tenant_id=TENANT_ID,
|
||||
client_secret=CLIENT_SECRET,
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.m365.m365_provider.M365Provider.setup_identity",
|
||||
return_value=M365IdentityInfo(
|
||||
identity_id=IDENTITY_ID,
|
||||
identity_type=IDENTITY_TYPE,
|
||||
tenant_id=TENANT_ID,
|
||||
tenant_domain=DOMAIN,
|
||||
location=LOCATION,
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.m365.m365_provider.M365Provider.setup_powershell",
|
||||
return_value=M365Credentials(
|
||||
user="test@test.com",
|
||||
passwd="password",
|
||||
),
|
||||
),
|
||||
):
|
||||
m365_provider = M365Provider(
|
||||
sp_env_auth=False,
|
||||
az_cli_auth=False,
|
||||
browser_auth=False,
|
||||
env_auth=True,
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
@@ -228,7 +156,6 @@ class TestM365Provider:
|
||||
sp_env_auth=False,
|
||||
az_cli_auth=True,
|
||||
browser_auth=False,
|
||||
env_auth=False,
|
||||
region=azure_region,
|
||||
config_path=default_config_file_path,
|
||||
fixer_config=fixer_config,
|
||||
@@ -278,7 +205,6 @@ class TestM365Provider:
|
||||
sp_env_auth=False,
|
||||
az_cli_auth=False,
|
||||
browser_auth=True,
|
||||
env_auth=False,
|
||||
tenant_id=TENANT_ID,
|
||||
region=azure_region,
|
||||
config_path=default_config_file_path,
|
||||
@@ -398,68 +324,6 @@ class TestM365Provider:
|
||||
assert test_connection.is_connected
|
||||
assert test_connection.error is None
|
||||
|
||||
def test_test_connection_tenant_id_client_id_client_secret_no_user_password(
|
||||
self,
|
||||
):
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.m365.m365_provider.M365Provider.validate_static_credentials"
|
||||
) as mock_validate_static_credentials,
|
||||
patch(
|
||||
"prowler.providers.m365.m365_provider.M365Provider.validate_arguments"
|
||||
),
|
||||
patch("prowler.providers.m365.m365_provider.M365Provider.setup_powershell"),
|
||||
):
|
||||
mock_validate_static_credentials.side_effect = M365NotValidUserError(
|
||||
file=os.path.basename(__file__),
|
||||
message="The provided M365 User is not valid.",
|
||||
)
|
||||
|
||||
with pytest.raises(M365NotValidUserError) as exception:
|
||||
M365Provider.test_connection(
|
||||
tenant_id=str(uuid4()),
|
||||
region="M365Global",
|
||||
raise_on_exception=True,
|
||||
client_id=str(uuid4()),
|
||||
client_secret=str(uuid4()),
|
||||
user=None,
|
||||
password="test_password",
|
||||
)
|
||||
|
||||
assert exception.type == M365NotValidUserError
|
||||
assert "The provided M365 User is not valid." in str(exception.value)
|
||||
|
||||
def test_test_connection_tenant_id_client_id_client_secret_user_no_password(
|
||||
self,
|
||||
):
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.m365.m365_provider.M365Provider.validate_static_credentials"
|
||||
) as mock_validate_static_credentials,
|
||||
patch(
|
||||
"prowler.providers.m365.m365_provider.M365Provider.validate_arguments"
|
||||
),
|
||||
patch("prowler.providers.m365.m365_provider.M365Provider.setup_powershell"),
|
||||
):
|
||||
mock_validate_static_credentials.side_effect = M365NotValidPasswordError(
|
||||
file=os.path.basename(__file__),
|
||||
message="The provided M365 Password is not valid.",
|
||||
)
|
||||
|
||||
with pytest.raises(M365NotValidPasswordError) as exception:
|
||||
M365Provider.test_connection(
|
||||
tenant_id=str(uuid4()),
|
||||
region="M365Global",
|
||||
raise_on_exception=True,
|
||||
client_id=str(uuid4()),
|
||||
client_secret=str(uuid4()),
|
||||
user="test@example.com",
|
||||
password=None,
|
||||
)
|
||||
|
||||
assert exception.type == M365NotValidPasswordError
|
||||
assert "The provided M365 Password is not valid." in str(exception.value)
|
||||
|
||||
def test_test_connection_with_httpresponseerror(self):
|
||||
with (
|
||||
patch(
|
||||
@@ -513,27 +377,24 @@ class TestM365Provider:
|
||||
|
||||
assert exception.type == M365NoAuthenticationMethodError
|
||||
assert (
|
||||
"M365 provider requires at least one authentication method set: [--env-auth | --az-cli-auth | --sp-env-auth | --browser-auth | --certificate-auth]"
|
||||
"M365 provider requires at least one authentication method set: [--az-cli-auth | --sp-env-auth | --browser-auth | --certificate-auth]"
|
||||
in exception.value.args[0]
|
||||
)
|
||||
|
||||
def test_setup_powershell_valid_credentials(self):
|
||||
credentials_dict = {
|
||||
"user": "test@example.com",
|
||||
"password": "test_password",
|
||||
"client_id": "test_client_id",
|
||||
"tenant_id": "test_tenant_id",
|
||||
"client_secret": "test_client_secret",
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.test_credentials",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
with patch("prowler.providers.m365.m365_provider.M365PowerShell") as mock_ps:
|
||||
mock_session = MagicMock()
|
||||
mock_session.test_credentials.return_value = True
|
||||
mock_session.close = MagicMock()
|
||||
mock_ps.return_value = mock_session
|
||||
|
||||
result = M365Provider.setup_powershell(
|
||||
env_auth=False,
|
||||
m365_credentials=credentials_dict,
|
||||
identity=M365IdentityInfo(
|
||||
identity_id=IDENTITY_ID,
|
||||
@@ -544,42 +405,9 @@ class TestM365Provider:
|
||||
location=LOCATION,
|
||||
),
|
||||
)
|
||||
assert result.user == credentials_dict["user"]
|
||||
assert result.passwd == credentials_dict["password"]
|
||||
|
||||
def test_test_connection_user_not_belonging_to_tenant(
|
||||
self,
|
||||
):
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.m365.m365_provider.M365Provider.validate_static_credentials"
|
||||
) as mock_validate_static_credentials,
|
||||
patch(
|
||||
"prowler.providers.m365.m365_provider.M365Provider.validate_arguments"
|
||||
),
|
||||
patch("prowler.providers.m365.m365_provider.M365Provider.setup_powershell"),
|
||||
):
|
||||
mock_validate_static_credentials.side_effect = M365UserNotBelongingToTenantError(
|
||||
file=os.path.basename(__file__),
|
||||
message="The provided M365 User does not belong to the specified tenant.",
|
||||
)
|
||||
|
||||
with pytest.raises(M365UserNotBelongingToTenantError) as exception:
|
||||
M365Provider.test_connection(
|
||||
tenant_id="contoso.onmicrosoft.com",
|
||||
region="M365Global",
|
||||
raise_on_exception=True,
|
||||
client_id=str(uuid4()),
|
||||
client_secret=str(uuid4()),
|
||||
user="user@otherdomain.com",
|
||||
password="test_password",
|
||||
)
|
||||
|
||||
assert exception.type == M365UserNotBelongingToTenantError
|
||||
assert (
|
||||
"The provided M365 User does not belong to the specified tenant."
|
||||
in str(exception.value)
|
||||
)
|
||||
assert result.client_id == credentials_dict["client_id"]
|
||||
assert result.client_secret == credentials_dict["client_secret"]
|
||||
|
||||
def test_validate_static_credentials_invalid_tenant_id(self):
|
||||
with pytest.raises(M365NotValidTenantIdError) as exception:
|
||||
@@ -587,8 +415,6 @@ class TestM365Provider:
|
||||
tenant_id="invalid-tenant-id",
|
||||
client_id="12345678-1234-5678-1234-567812345678",
|
||||
client_secret="test_secret",
|
||||
user="test@example.com",
|
||||
password="test_password",
|
||||
)
|
||||
assert "The provided Tenant ID is not valid." in str(exception.value)
|
||||
|
||||
@@ -598,8 +424,6 @@ class TestM365Provider:
|
||||
tenant_id="12345678-1234-5678-1234-567812345678",
|
||||
client_id="",
|
||||
client_secret="test_secret",
|
||||
user="test@example.com",
|
||||
password="test_password",
|
||||
)
|
||||
assert "The provided Client ID is not valid." in str(exception.value)
|
||||
|
||||
@@ -609,36 +433,12 @@ class TestM365Provider:
|
||||
tenant_id="12345678-1234-5678-1234-567812345678",
|
||||
client_id="12345678-1234-5678-1234-567812345678",
|
||||
client_secret="",
|
||||
user="test@example.com",
|
||||
password="test_password",
|
||||
)
|
||||
assert (
|
||||
"You must provide a client secret, certificate content or certificate path. Please check your credentials and try again."
|
||||
in str(exception.value)
|
||||
)
|
||||
|
||||
def test_validate_arguments_missing_env_credentials(self):
|
||||
with pytest.raises(M365ConfigCredentialsError) as exception:
|
||||
M365Provider.validate_arguments(
|
||||
az_cli_auth=False,
|
||||
sp_env_auth=False,
|
||||
env_auth=True,
|
||||
browser_auth=False,
|
||||
certificate_auth=False,
|
||||
tenant_id="test_tenant_id",
|
||||
client_id="test_client_id",
|
||||
client_secret=None,
|
||||
user=None,
|
||||
password=None,
|
||||
certificate_content=None,
|
||||
certificate_path=None,
|
||||
)
|
||||
|
||||
assert (
|
||||
"You must provide a valid set of credentials. Please check your credentials and try again."
|
||||
in str(exception.value)
|
||||
)
|
||||
|
||||
def test_test_connection_invalid_provider_id(self):
|
||||
with (
|
||||
patch(
|
||||
@@ -680,8 +480,6 @@ class TestM365Provider:
|
||||
raise_on_exception=True,
|
||||
client_id=str(uuid4()),
|
||||
client_secret=str(uuid4()),
|
||||
user=f"user@{user_domain}",
|
||||
password="test_password",
|
||||
provider_id=provider_id,
|
||||
)
|
||||
|
||||
@@ -694,24 +492,23 @@ class TestM365Provider:
|
||||
def test_provider_init_modules_false(self):
|
||||
"""Test that initialize_m365_powershell_modules is not called when init_modules is False"""
|
||||
credentials_dict = {
|
||||
"user": "test@example.com",
|
||||
"password": "test_password",
|
||||
"client_id": "test_client_id",
|
||||
"tenant_id": "test_tenant_id",
|
||||
"client_secret": "test_client_secret",
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.test_credentials",
|
||||
return_value=True,
|
||||
),
|
||||
patch("prowler.providers.m365.m365_provider.M365PowerShell") as mock_ps,
|
||||
patch(
|
||||
"prowler.providers.m365.m365_provider.initialize_m365_powershell_modules"
|
||||
) as mock_init_modules,
|
||||
):
|
||||
mock_session = MagicMock()
|
||||
mock_session.test_credentials.return_value = True
|
||||
mock_session.close = MagicMock()
|
||||
mock_ps.return_value = mock_session
|
||||
|
||||
M365Provider.setup_powershell(
|
||||
env_auth=False,
|
||||
m365_credentials=credentials_dict,
|
||||
identity=M365IdentityInfo(
|
||||
identity_id=IDENTITY_ID,
|
||||
@@ -728,24 +525,23 @@ class TestM365Provider:
|
||||
def test_provider_init_modules_true(self):
|
||||
"""Test that initialize_m365_powershell_modules is called when init_modules is True"""
|
||||
credentials_dict = {
|
||||
"user": "test@example.com",
|
||||
"password": "test_password",
|
||||
"client_id": "test_client_id",
|
||||
"tenant_id": "test_tenant_id",
|
||||
"client_secret": "test_client_secret",
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.test_credentials",
|
||||
return_value=True,
|
||||
),
|
||||
patch("prowler.providers.m365.m365_provider.M365PowerShell") as mock_ps,
|
||||
patch(
|
||||
"prowler.providers.m365.m365_provider.initialize_m365_powershell_modules"
|
||||
) as mock_init_modules,
|
||||
):
|
||||
mock_session = MagicMock()
|
||||
mock_session.test_credentials.return_value = True
|
||||
mock_session.close = MagicMock()
|
||||
mock_ps.return_value = mock_session
|
||||
|
||||
M365Provider.setup_powershell(
|
||||
env_auth=False,
|
||||
m365_credentials=credentials_dict,
|
||||
identity=M365IdentityInfo(
|
||||
identity_id=IDENTITY_ID,
|
||||
@@ -762,26 +558,25 @@ class TestM365Provider:
|
||||
def test_setup_powershell_init_modules_failure(self):
|
||||
"""Test that setup_powershell handles initialization failures correctly"""
|
||||
credentials_dict = {
|
||||
"user": "test@example.com",
|
||||
"password": "test_password",
|
||||
"client_id": "test_client_id",
|
||||
"tenant_id": "test_tenant_id",
|
||||
"client_secret": "test_client_secret",
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.test_credentials",
|
||||
return_value=True,
|
||||
),
|
||||
patch("prowler.providers.m365.m365_provider.M365PowerShell") as mock_ps,
|
||||
patch(
|
||||
"prowler.providers.m365.m365_provider.initialize_m365_powershell_modules",
|
||||
side_effect=Exception("Module initialization failed"),
|
||||
),
|
||||
):
|
||||
mock_session = MagicMock()
|
||||
mock_session.test_credentials.return_value = True
|
||||
mock_session.close = MagicMock()
|
||||
mock_ps.return_value = mock_session
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
M365Provider.setup_powershell(
|
||||
env_auth=False,
|
||||
m365_credentials=credentials_dict,
|
||||
identity=M365IdentityInfo(
|
||||
identity_id=IDENTITY_ID,
|
||||
@@ -837,8 +632,6 @@ class TestM365Provider:
|
||||
raise_on_exception=True,
|
||||
client_id=str(uuid4()),
|
||||
client_secret=str(uuid4()),
|
||||
user="user@contoso.onmicrosoft.com",
|
||||
password="test_password",
|
||||
provider_id=provider_id,
|
||||
)
|
||||
|
||||
@@ -893,7 +686,6 @@ class TestM365Provider:
|
||||
sp_env_auth=False,
|
||||
az_cli_auth=False,
|
||||
browser_auth=False,
|
||||
env_auth=False,
|
||||
certificate_auth=True,
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
@@ -1067,64 +859,6 @@ class TestM365Provider:
|
||||
check_certificate_content=True
|
||||
)
|
||||
|
||||
def test_setup_powershell_env_auth_missing_credentials(self):
|
||||
"""Test setup_powershell with env_auth but missing environment variables"""
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
pytest.raises(M365MissingEnvironmentCredentialsError) as exception,
|
||||
):
|
||||
M365Provider.setup_powershell(
|
||||
env_auth=True,
|
||||
identity=M365IdentityInfo(
|
||||
identity_id=IDENTITY_ID,
|
||||
identity_type="User",
|
||||
tenant_id=TENANT_ID,
|
||||
tenant_domain=DOMAIN,
|
||||
tenant_domains=["test.onmicrosoft.com"],
|
||||
location=LOCATION,
|
||||
),
|
||||
)
|
||||
|
||||
assert exception.type == M365MissingEnvironmentCredentialsError
|
||||
assert (
|
||||
"Missing M365_USER or M365_PASSWORD environment variables required for credentials authentication."
|
||||
in str(exception.value)
|
||||
)
|
||||
|
||||
def test_setup_powershell_env_auth_success(self):
|
||||
"""Test setup_powershell with env_auth and valid environment variables"""
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"M365_USER": "test@example.com",
|
||||
"M365_PASSWORD": "password",
|
||||
"AZURE_CLIENT_ID": CLIENT_ID,
|
||||
"AZURE_CLIENT_SECRET": CLIENT_SECRET,
|
||||
"AZURE_TENANT_ID": TENANT_ID,
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.test_credentials",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
result = M365Provider.setup_powershell(
|
||||
env_auth=True,
|
||||
identity=M365IdentityInfo(
|
||||
identity_id=IDENTITY_ID,
|
||||
identity_type="User",
|
||||
tenant_id=TENANT_ID,
|
||||
tenant_domain=DOMAIN,
|
||||
tenant_domains=["test.onmicrosoft.com"],
|
||||
location=LOCATION,
|
||||
),
|
||||
)
|
||||
|
||||
assert result.user == "test@example.com"
|
||||
assert result.passwd == "password"
|
||||
assert result.client_id == CLIENT_ID
|
||||
|
||||
def test_setup_powershell_sp_env_auth_success(self):
|
||||
"""Test setup_powershell with sp_env_auth and valid environment variables"""
|
||||
with (
|
||||
@@ -1136,11 +870,13 @@ class TestM365Provider:
|
||||
"AZURE_TENANT_ID": TENANT_ID,
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.test_credentials",
|
||||
return_value=True,
|
||||
),
|
||||
patch("prowler.providers.m365.m365_provider.M365PowerShell") as mock_ps,
|
||||
):
|
||||
mock_session = MagicMock()
|
||||
mock_session.test_credentials.return_value = True
|
||||
mock_session.close = MagicMock()
|
||||
mock_ps.return_value = mock_session
|
||||
|
||||
result = M365Provider.setup_powershell(
|
||||
sp_env_auth=True,
|
||||
identity=M365IdentityInfo(
|
||||
@@ -1170,11 +906,13 @@ class TestM365Provider:
|
||||
"M365_CERTIFICATE_CONTENT": certificate_content,
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.test_credentials",
|
||||
return_value=True,
|
||||
),
|
||||
patch("prowler.providers.m365.m365_provider.M365PowerShell") as mock_ps,
|
||||
):
|
||||
mock_session = MagicMock()
|
||||
mock_session.test_credentials.return_value = True
|
||||
mock_session.close = MagicMock()
|
||||
mock_ps.return_value = mock_session
|
||||
|
||||
identity = M365IdentityInfo(
|
||||
identity_id=IDENTITY_ID,
|
||||
identity_type="Service Principal with Certificate",
|
||||
@@ -1197,22 +935,21 @@ class TestM365Provider:
|
||||
def test_setup_powershell_invalid_credentials(self):
|
||||
"""Test setup_powershell with invalid credentials"""
|
||||
credentials_dict = {
|
||||
"user": "test@example.com",
|
||||
"password": "test_password",
|
||||
"client_id": "test_client_id",
|
||||
"tenant_id": "test_tenant_id",
|
||||
"client_secret": "test_client_secret",
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.test_credentials",
|
||||
return_value=False,
|
||||
),
|
||||
pytest.raises(M365UserCredentialsError) as exception,
|
||||
patch("prowler.providers.m365.m365_provider.M365PowerShell") as mock_ps,
|
||||
pytest.raises(M365ConfigCredentialsError) as exception,
|
||||
):
|
||||
mock_session = MagicMock()
|
||||
mock_session.test_credentials.return_value = False
|
||||
mock_session.close = MagicMock()
|
||||
mock_ps.return_value = mock_session
|
||||
|
||||
M365Provider.setup_powershell(
|
||||
env_auth=False,
|
||||
m365_credentials=credentials_dict,
|
||||
identity=M365IdentityInfo(
|
||||
identity_id=IDENTITY_ID,
|
||||
@@ -1223,9 +960,8 @@ class TestM365Provider:
|
||||
location=LOCATION,
|
||||
),
|
||||
)
|
||||
|
||||
assert exception.type == M365UserCredentialsError
|
||||
assert "The provided User credentials are not valid." in str(exception.value)
|
||||
assert exception.type == M365ConfigCredentialsError
|
||||
assert "The provided credentials are not valid." in str(exception.value)
|
||||
|
||||
def test_validate_arguments_browser_auth_without_tenant_id(self):
|
||||
"""Test validate_arguments with browser_auth but missing tenant_id"""
|
||||
@@ -1233,14 +969,11 @@ class TestM365Provider:
|
||||
M365Provider.validate_arguments(
|
||||
az_cli_auth=False,
|
||||
sp_env_auth=False,
|
||||
env_auth=False,
|
||||
browser_auth=True,
|
||||
certificate_auth=False,
|
||||
tenant_id=None,
|
||||
client_id=None,
|
||||
client_secret=None,
|
||||
user=None,
|
||||
password=None,
|
||||
certificate_content=None,
|
||||
certificate_path=None,
|
||||
)
|
||||
@@ -1257,14 +990,11 @@ class TestM365Provider:
|
||||
M365Provider.validate_arguments(
|
||||
az_cli_auth=False,
|
||||
sp_env_auth=False,
|
||||
env_auth=False,
|
||||
browser_auth=False,
|
||||
certificate_auth=False,
|
||||
tenant_id=TENANT_ID,
|
||||
client_id=None,
|
||||
client_secret=None,
|
||||
user=None,
|
||||
password=None,
|
||||
certificate_content=None,
|
||||
certificate_path=None,
|
||||
)
|
||||
@@ -1280,14 +1010,11 @@ class TestM365Provider:
|
||||
M365Provider.validate_arguments(
|
||||
az_cli_auth=False,
|
||||
sp_env_auth=False,
|
||||
env_auth=False,
|
||||
browser_auth=False,
|
||||
certificate_auth=False,
|
||||
tenant_id=None,
|
||||
client_id=CLIENT_ID,
|
||||
client_secret=CLIENT_SECRET,
|
||||
user=None,
|
||||
password=None,
|
||||
certificate_content=None,
|
||||
certificate_path=None,
|
||||
)
|
||||
@@ -1326,8 +1053,6 @@ class TestM365Provider:
|
||||
"tenant_id": TENANT_ID,
|
||||
"client_id": CLIENT_ID,
|
||||
"client_secret": None,
|
||||
"user": None,
|
||||
"password": None,
|
||||
"certificate_content": certificate_content,
|
||||
},
|
||||
),
|
||||
@@ -1392,8 +1117,6 @@ class TestM365Provider:
|
||||
tenant_id=str(uuid4()),
|
||||
client_id=str(uuid4()),
|
||||
client_secret="test_secret",
|
||||
user="test@example.com",
|
||||
password="test_password",
|
||||
)
|
||||
|
||||
assert exception.type == M365ClientIdAndClientSecretNotBelongingToTenantIdError
|
||||
@@ -1419,8 +1142,6 @@ class TestM365Provider:
|
||||
tenant_id=str(uuid4()),
|
||||
client_id=str(uuid4()),
|
||||
client_secret="test_secret",
|
||||
user="test@example.com",
|
||||
password="test_password",
|
||||
)
|
||||
|
||||
assert exception.type == M365TenantIdAndClientSecretNotBelongingToClientIdError
|
||||
@@ -1446,8 +1167,6 @@ class TestM365Provider:
|
||||
tenant_id=str(uuid4()),
|
||||
client_id=str(uuid4()),
|
||||
client_secret="test_secret",
|
||||
user="test@example.com",
|
||||
password="test_password",
|
||||
)
|
||||
|
||||
assert exception.type == M365TenantIdAndClientIdNotBelongingToClientSecretError
|
||||
@@ -1529,7 +1248,6 @@ class TestM365Provider:
|
||||
result = M365Provider.setup_session(
|
||||
az_cli_auth=False,
|
||||
sp_env_auth=False,
|
||||
env_auth=False,
|
||||
browser_auth=False,
|
||||
certificate_auth=True,
|
||||
certificate_path=None,
|
||||
@@ -1580,7 +1298,6 @@ class TestM365Provider:
|
||||
M365Provider.setup_session(
|
||||
az_cli_auth=False,
|
||||
sp_env_auth=False,
|
||||
env_auth=False,
|
||||
browser_auth=False,
|
||||
certificate_auth=True,
|
||||
certificate_path=None,
|
||||
@@ -1600,8 +1317,6 @@ class TestM365Provider:
|
||||
"tenant_id": TENANT_ID,
|
||||
"client_id": CLIENT_ID,
|
||||
"client_secret": CLIENT_SECRET,
|
||||
"user": None,
|
||||
"password": None,
|
||||
"certificate_content": certificate_content,
|
||||
}
|
||||
|
||||
@@ -1623,7 +1338,6 @@ class TestM365Provider:
|
||||
result = M365Provider.setup_session(
|
||||
az_cli_auth=False,
|
||||
sp_env_auth=False,
|
||||
env_auth=False,
|
||||
browser_auth=False,
|
||||
certificate_auth=False,
|
||||
certificate_path=None,
|
||||
@@ -1671,14 +1385,11 @@ class TestM365Provider:
|
||||
M365Provider.validate_arguments(
|
||||
az_cli_auth=False,
|
||||
sp_env_auth=False,
|
||||
env_auth=False,
|
||||
browser_auth=False,
|
||||
certificate_auth=True,
|
||||
tenant_id=TENANT_ID,
|
||||
client_id=CLIENT_ID,
|
||||
client_secret=None,
|
||||
user=None,
|
||||
password=None,
|
||||
certificate_content=certificate_content,
|
||||
certificate_path=None,
|
||||
)
|
||||
@@ -1689,14 +1400,11 @@ class TestM365Provider:
|
||||
M365Provider.validate_arguments(
|
||||
az_cli_auth=False,
|
||||
sp_env_auth=False,
|
||||
env_auth=False,
|
||||
browser_auth=False,
|
||||
certificate_auth=False,
|
||||
tenant_id=TENANT_ID,
|
||||
client_id=CLIENT_ID,
|
||||
client_secret=None,
|
||||
user=None,
|
||||
password=None,
|
||||
certificate_content=None,
|
||||
certificate_path=None,
|
||||
)
|
||||
@@ -1991,7 +1699,6 @@ class TestM365Provider:
|
||||
result = M365Provider.setup_session(
|
||||
az_cli_auth=False,
|
||||
sp_env_auth=False,
|
||||
env_auth=False,
|
||||
browser_auth=False,
|
||||
certificate_auth=True,
|
||||
certificate_path=certificate_path,
|
||||
@@ -2018,8 +1725,6 @@ class TestM365Provider:
|
||||
"tenant_id": TENANT_ID,
|
||||
"client_id": CLIENT_ID,
|
||||
"client_secret": None,
|
||||
"user": None,
|
||||
"password": None,
|
||||
"certificate_content": None,
|
||||
"certificate_path": certificate_path,
|
||||
}
|
||||
@@ -2036,7 +1741,6 @@ class TestM365Provider:
|
||||
result = M365Provider.setup_session(
|
||||
az_cli_auth=False,
|
||||
sp_env_auth=False,
|
||||
env_auth=False,
|
||||
browser_auth=False,
|
||||
certificate_auth=False,
|
||||
certificate_path=None,
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
SITE_URL=http://localhost:3000
|
||||
API_BASE_URL=http://localhost:8080/api/v1
|
||||
AUTH_TRUST_HOST=true
|
||||
|
||||
# openssl rand -base64 32
|
||||
AUTH_SECRET=your-secret-key
|
||||
+10
-7
@@ -7,11 +7,13 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
### 🚀 Added
|
||||
|
||||
- Support for Markdown and AdditionalURLs in findings detail page [(#8704)](https://github.com/prowler-cloud/prowler/pull/8704)
|
||||
- `Prowler Hub` menu item with tooltip [(#8692)] (https://github.com/prowler-cloud/prowler/pull/8692)
|
||||
- Copy link button to finding detail page [(#8685)] (https://github.com/prowler-cloud/prowler/pull/8685)
|
||||
- `Prowler Hub` menu item with tooltip [(#8692)](https://github.com/prowler-cloud/prowler/pull/8692)
|
||||
- Copy link button to finding detail page [(#8685)](https://github.com/prowler-cloud/prowler/pull/8685)
|
||||
- React Compiler support for automatic optimization [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748)
|
||||
- Turbopack support for faster development builds [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748)
|
||||
- Add compliance name in compliance detail view [(#8775)](https://github.com/prowler-cloud/prowler/pull/8775)
|
||||
- API key management in user profile [(#8308)](https://github.com/prowler-cloud/prowler/pull/8308)
|
||||
- Refresh access token error handling [(#8864)](https://github.com/prowler-cloud/prowler/pull/8864)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
@@ -26,13 +28,14 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
- Migrated from `useFormState` to `useActionState` for React 19 compatibility [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748)
|
||||
- References display in findings detail page now shows as a proper bulleted list [(#8793)](https://github.com/prowler-cloud/prowler/pull/8793)
|
||||
|
||||
---
|
||||
|
||||
## [1.12.4] (Prowler v5.12.4)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- SAML configuration errors are now properly caught and displayed [(#8880)](https://github.com/prowler-cloud/prowler/pull/8880)
|
||||
- ThreatScore for each pillar in Prowler ThreatScore specific view [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582)
|
||||
- Remove maxTokens model param for GPT-5 models [(#8843)](https://github.com/prowler-cloud/prowler/pull/8843)
|
||||
- MITRE ATTACK compliance view now shows all requirements in charts [(#8886)](https://github.com/prowler-cloud/prowler/pull/8886)
|
||||
|
||||
---
|
||||
|
||||
## [1.12.3] (Prowler v5.12.3)
|
||||
|
||||
@@ -92,7 +95,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
- Disable `See Compliance` button until scan completes [(#8487)](https://github.com/prowler-cloud/prowler/pull/8487)
|
||||
- Provider connection filter now shows "Connected/Disconnected" instead of "true/false" for better UX [(#8520)](https://github.com/prowler-cloud/prowler/pull/8520)
|
||||
- Provider Uid filter on scan page to list all UIDs regardless of connection status [(#8375)] (https://github.com/prowler-cloud/prowler/pull/8375)
|
||||
- Provider Uid filter on scan page to list all UIDs regardless of connection status [(#8375)](https://github.com/prowler-cloud/prowler/pull/8375)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
ApiKeyResponse,
|
||||
EnrichedApiKey,
|
||||
} from "@/components/users/profile/api-keys/types";
|
||||
import { getApiKeyUserEmail } from "@/components/users/profile/api-keys/utils";
|
||||
import { MetaDataProps } from "@/types";
|
||||
|
||||
/**
|
||||
* Adapts the raw API response to enriched API keys with metadata
|
||||
* - Resolves user email from included resources
|
||||
* - Co-locates data for better performance
|
||||
* - Preserves pagination metadata
|
||||
*
|
||||
* @param response - Raw API response with data and included resources
|
||||
* @returns Object with enriched API keys and metadata
|
||||
*/
|
||||
export function adaptApiKeysResponse(response: ApiKeyResponse | undefined): {
|
||||
data: EnrichedApiKey[];
|
||||
metadata?: MetaDataProps;
|
||||
} {
|
||||
if (!response?.data) {
|
||||
return { data: [] };
|
||||
}
|
||||
|
||||
const enrichedData = response.data.map((key) => ({
|
||||
...key,
|
||||
userEmail: getApiKeyUserEmail(key, response.included),
|
||||
}));
|
||||
|
||||
// Transform meta to MetaDataProps format if pagination exists
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use server";
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
import {
|
||||
ApiKeyResponse,
|
||||
CreateApiKeyPayload,
|
||||
CreateApiKeyResponse,
|
||||
SingleApiKeyResponse,
|
||||
UpdateApiKeyPayload,
|
||||
} from "@/components/users/profile/api-keys/types";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
import { adaptApiKeysResponse } from "./api-keys.adapter";
|
||||
|
||||
interface GetApiKeysParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches API keys for the current tenant with pagination support
|
||||
* Returns enriched API keys with user data already resolved and pagination metadata
|
||||
*/
|
||||
export const getApiKeys = async (params?: GetApiKeysParams) => {
|
||||
const { page = 1, pageSize = 10, sort } = params || {};
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}/api-keys`);
|
||||
url.searchParams.set("include", "entity.roles");
|
||||
url.searchParams.set("page[number]", page.toString());
|
||||
url.searchParams.set("page[size]", pageSize.toString());
|
||||
|
||||
if (sort) {
|
||||
url.searchParams.set("sort", sort);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
headers,
|
||||
next: { tags: ["api-keys"] },
|
||||
});
|
||||
|
||||
const apiResponse = (await handleApiResponse(response)) as ApiKeyResponse;
|
||||
|
||||
return adaptApiKeysResponse(apiResponse);
|
||||
} catch (error) {
|
||||
console.error("Error fetching API keys:", error);
|
||||
return { data: [], metadata: undefined };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new API key
|
||||
* IMPORTANT: The full API key is only returned in this response, it cannot be retrieved again
|
||||
*/
|
||||
export const createApiKey = async (
|
||||
payload: CreateApiKeyPayload,
|
||||
): Promise<
|
||||
| { data: CreateApiKeyResponse; error?: never }
|
||||
| { data?: never; error: string }
|
||||
> => {
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(`${apiBaseUrl}/api-keys`);
|
||||
|
||||
const body = {
|
||||
data: {
|
||||
type: "api-keys",
|
||||
attributes: {
|
||||
name: payload.name,
|
||||
...(payload.expires_at && { expires_at: payload.expires_at }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return handleApiError(response);
|
||||
}
|
||||
|
||||
const data = (await handleApiResponse(response)) as CreateApiKeyResponse;
|
||||
|
||||
// Revalidate the api-keys list
|
||||
revalidateTag("api-keys");
|
||||
|
||||
return { data };
|
||||
} catch (error) {
|
||||
console.error("Error creating API key:", error);
|
||||
return {
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to create API key",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates an API key (only the name can be updated)
|
||||
*/
|
||||
export const updateApiKey = async (
|
||||
id: string,
|
||||
payload: UpdateApiKeyPayload,
|
||||
): Promise<
|
||||
| { data: SingleApiKeyResponse; error?: never }
|
||||
| { data?: never; error: string }
|
||||
> => {
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(`${apiBaseUrl}/api-keys/${id}`);
|
||||
|
||||
const body = {
|
||||
data: {
|
||||
type: "api-keys",
|
||||
id,
|
||||
attributes: {
|
||||
name: payload.name,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return handleApiError(response);
|
||||
}
|
||||
|
||||
const data = (await handleApiResponse(response)) as SingleApiKeyResponse;
|
||||
|
||||
// Revalidate the api-keys list
|
||||
revalidateTag("api-keys");
|
||||
|
||||
return { data };
|
||||
} catch (error) {
|
||||
console.error("Error updating API key:", error);
|
||||
return {
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to update API key",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Revokes an API key (cannot be undone)
|
||||
*/
|
||||
export const revokeApiKey = async (
|
||||
id: string,
|
||||
): Promise<{ error?: string; success?: boolean }> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}/api-keys/${id}/revoke`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = handleApiError(response);
|
||||
return { error: errorData.error };
|
||||
}
|
||||
|
||||
// Revalidate the api-keys list
|
||||
revalidateTag("api-keys");
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error revoking API key:", error);
|
||||
return {
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to revoke API key",
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -40,7 +40,18 @@ export const createSamlConfig = async (_prevState: any, formData: FormData) => {
|
||||
}),
|
||||
});
|
||||
|
||||
await handleApiResponse(response, "/integrations", false);
|
||||
const result = await handleApiResponse(response, "/integrations", false);
|
||||
if (result.error) {
|
||||
return {
|
||||
errors: {
|
||||
general:
|
||||
result.error instanceof Error
|
||||
? result.error.message
|
||||
: "Error creating SAML configuration. Please try again.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { success: "SAML configuration created successfully!" };
|
||||
} catch (error) {
|
||||
console.error("Error creating SAML config:", error);
|
||||
|
||||
@@ -4,10 +4,11 @@ import { getSamlConfig } from "@/actions/integrations/saml";
|
||||
import { getUserInfo } from "@/actions/users/users";
|
||||
import { SamlIntegrationCard } from "@/components/integrations/saml/saml-integration-card";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { UserBasicInfoCard } from "@/components/users/profile";
|
||||
import { ApiKeysCard, UserBasicInfoCard } from "@/components/users/profile";
|
||||
import { MembershipsCard } from "@/components/users/profile/memberships-card";
|
||||
import { RolesCard } from "@/components/users/profile/roles-card";
|
||||
import { SkeletonUserInfo } from "@/components/users/profile/skeleton-user-info";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
import {
|
||||
MembershipDetailData,
|
||||
RoleDetail,
|
||||
@@ -15,17 +16,27 @@ import {
|
||||
UserProfileResponse,
|
||||
} from "@/types/users";
|
||||
|
||||
export default async function Profile() {
|
||||
export default async function Profile({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
|
||||
return (
|
||||
<ContentLayout title="User Profile" icon="lucide:users">
|
||||
<Suspense fallback={<SkeletonUserInfo />}>
|
||||
<SSRDataUser />
|
||||
<SSRDataUser searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const SSRDataUser = async () => {
|
||||
const SSRDataUser = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
}) => {
|
||||
const userProfile = (await getUserInfo()) as UserProfileResponse | undefined;
|
||||
if (!userProfile?.data) {
|
||||
return null;
|
||||
@@ -96,22 +107,21 @@ const SSRDataUser = async () => {
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<UserBasicInfoCard user={userData} tenantId={userTenantId || ""} />
|
||||
<div className="flex flex-col gap-6 xl:flex-row">
|
||||
<div className="w-full">
|
||||
<div className="flex w-full flex-col gap-6 xl:max-w-[50%]">
|
||||
<RolesCard roles={roleDetails} roleDetails={roleDetailsMap} />
|
||||
{hasManageIntegrations && (
|
||||
<SamlIntegrationCard samlConfig={samlConfig?.data?.[0]} />
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="flex w-full flex-col gap-6 xl:max-w-[50%]">
|
||||
<MembershipsCard
|
||||
memberships={membershipsIncluded}
|
||||
tenantsMap={tenantsMap}
|
||||
isOwner={isOwner && hasManageAccount}
|
||||
/>
|
||||
{hasManageAccount && <ApiKeysCard searchParams={searchParams} />}
|
||||
</div>
|
||||
</div>
|
||||
{hasManageIntegrations && (
|
||||
<div className="w-full pr-0 xl:w-1/2 xl:pr-3">
|
||||
<SamlIntegrationCard samlConfig={samlConfig?.data?.[0]} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Spacer } from "@heroui/spacer";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getRoles } from "@/actions/roles/roles";
|
||||
import { getUsers } from "@/actions/users/users";
|
||||
import { FilterControls } from "@/components/filters";
|
||||
import { filterUsers } from "@/components/filters/data-filters";
|
||||
@@ -52,6 +53,7 @@ const SSRDataTable = async ({
|
||||
const query = (filters["filter[search]"] as string) || "";
|
||||
|
||||
const usersData = await getUsers({ query, page, sort, filters, pageSize });
|
||||
const rolesData = await getRoles({});
|
||||
|
||||
// Create a dictionary for roles by user ID
|
||||
const roleDict = (usersData?.included || []).reduce(
|
||||
@@ -67,7 +69,7 @@ const SSRDataTable = async ({
|
||||
// Generate the array of roles with all the roles available
|
||||
const roles = Array.from(
|
||||
new Map(
|
||||
(usersData?.included || []).map((role: Role) => [
|
||||
(rolesData?.data || []).map((role: Role) => [
|
||||
role.id,
|
||||
{ id: role.id, name: role.attributes?.name || "Unnamed Role" },
|
||||
]),
|
||||
|
||||
+222
-98
@@ -1,55 +1,199 @@
|
||||
import { jwtDecode, JwtPayload } from "jwt-decode";
|
||||
import NextAuth, { type NextAuthConfig, User } from "next-auth";
|
||||
import { jwtDecode, type JwtPayload } from "jwt-decode";
|
||||
import NextAuth, {
|
||||
type DefaultSession,
|
||||
type NextAuthConfig,
|
||||
type Session,
|
||||
User,
|
||||
} from "next-auth";
|
||||
import type { JWT } from "next-auth/jwt";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getToken, getUserByMe } from "./actions/auth";
|
||||
import { apiBaseUrl } from "./lib";
|
||||
import type { RolePermissionAttributes } from "./types/users";
|
||||
|
||||
interface CustomJwtPayload extends JwtPayload {
|
||||
user_id: string;
|
||||
tenant_id: string;
|
||||
}
|
||||
|
||||
const refreshAccessToken = async (token: JwtPayload) => {
|
||||
type DefaultSessionUser = NonNullable<DefaultSession["user"]>;
|
||||
|
||||
type TokenUser = DefaultSessionUser & {
|
||||
companyName?: string;
|
||||
dateJoined?: string;
|
||||
permissions: RolePermissionAttributes;
|
||||
};
|
||||
|
||||
type AuthToken = JWT & {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
accessTokenExpires?: number;
|
||||
user_id?: string;
|
||||
tenant_id?: string;
|
||||
user?: TokenUser;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type ExtendedSession = Session & {
|
||||
user?: TokenUser;
|
||||
userId?: string;
|
||||
tenantId?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_PERMISSIONS: RolePermissionAttributes = {
|
||||
manage_users: false,
|
||||
manage_account: false,
|
||||
manage_providers: false,
|
||||
manage_scans: false,
|
||||
manage_integrations: false,
|
||||
manage_billing: false,
|
||||
unlimited_visibility: false,
|
||||
};
|
||||
|
||||
type TokenUserInput = Partial<TokenUser> & { company?: string };
|
||||
|
||||
const toTokenUser = (user?: TokenUserInput): TokenUser =>
|
||||
({
|
||||
name: user?.name ?? undefined,
|
||||
email: user?.email ?? undefined,
|
||||
companyName: user?.companyName ?? user?.company,
|
||||
dateJoined: user?.dateJoined,
|
||||
permissions: user?.permissions ?? { ...DEFAULT_PERMISSIONS },
|
||||
}) as TokenUser;
|
||||
|
||||
type UserMeResponse = Awaited<ReturnType<typeof getUserByMe>>;
|
||||
|
||||
const tokenUserFromApi = (user: UserMeResponse) =>
|
||||
toTokenUser({
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
companyName: user.company,
|
||||
dateJoined: user.dateJoined,
|
||||
permissions: user.permissions,
|
||||
});
|
||||
|
||||
const applyDecodedClaims = (
|
||||
target: AuthToken,
|
||||
accessToken?: string,
|
||||
logContext = "access token",
|
||||
) => {
|
||||
if (!accessToken) return;
|
||||
|
||||
try {
|
||||
const decodedToken = jwtDecode<CustomJwtPayload>(accessToken);
|
||||
target.accessTokenExpires = decodedToken.exp
|
||||
? decodedToken.exp * 1000
|
||||
: target.accessTokenExpires;
|
||||
target.user_id = decodedToken.user_id ?? target.user_id;
|
||||
target.tenant_id = decodedToken.tenant_id ?? target.tenant_id;
|
||||
} catch (decodeError) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Unable to decode ${logContext}`, decodeError);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshTokenPromises = new Map<string, Promise<AuthToken>>();
|
||||
|
||||
const refreshAccessToken = async (token: AuthToken): Promise<AuthToken> => {
|
||||
const refreshToken = token.refreshToken;
|
||||
|
||||
if (!refreshToken) {
|
||||
return {
|
||||
...token,
|
||||
error: "MissingRefreshToken",
|
||||
};
|
||||
}
|
||||
|
||||
const existingPromise = refreshTokenPromises.get(refreshToken);
|
||||
|
||||
if (existingPromise) {
|
||||
return existingPromise;
|
||||
}
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/tokens/refresh`);
|
||||
|
||||
const bodyData = {
|
||||
data: {
|
||||
type: "tokens-refresh",
|
||||
attributes: {
|
||||
refresh: (token as any).refreshToken,
|
||||
refresh: refreshToken,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
Accept: "application/vnd.api+json",
|
||||
},
|
||||
body: JSON.stringify(bodyData),
|
||||
});
|
||||
const refreshPromise = (async () => {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
Accept: "application/vnd.api+json",
|
||||
},
|
||||
body: JSON.stringify(bodyData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const payload = await response.json().catch(() => undefined);
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = payload?.errors?.[0]?.detail;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"Failed to refresh access token:",
|
||||
detail || `HTTP error ${response.status}`,
|
||||
);
|
||||
return {
|
||||
...token,
|
||||
error: "RefreshAccessTokenError",
|
||||
};
|
||||
}
|
||||
|
||||
const newAccessToken = payload?.data?.attributes?.access as
|
||||
| string
|
||||
| undefined;
|
||||
const nextRefreshToken =
|
||||
(payload?.data?.attributes?.refresh as string | undefined) ??
|
||||
refreshToken;
|
||||
|
||||
if (!newAccessToken) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Missing access token in refresh response");
|
||||
return {
|
||||
...token,
|
||||
error: "RefreshAccessTokenError",
|
||||
};
|
||||
}
|
||||
|
||||
const nextToken: AuthToken = {
|
||||
...token,
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: nextRefreshToken,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
applyDecodedClaims(nextToken, newAccessToken, "refreshed access token");
|
||||
|
||||
return nextToken;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Error refreshing access token:", error);
|
||||
return {
|
||||
...token,
|
||||
error: "RefreshAccessTokenError",
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
const newTokens = await response.json();
|
||||
refreshTokenPromises.set(refreshToken, refreshPromise);
|
||||
|
||||
return {
|
||||
...token,
|
||||
accessToken: newTokens.data.attributes.access,
|
||||
refreshToken: newTokens.data.attributes.refresh,
|
||||
};
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error refreshing access token:", error);
|
||||
return {
|
||||
error: "RefreshAccessTokenError",
|
||||
};
|
||||
try {
|
||||
return await refreshPromise;
|
||||
} finally {
|
||||
refreshTokenPromises.delete(refreshToken);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -90,13 +234,7 @@ export const authConfig = {
|
||||
|
||||
const userMeResponse = await getUserByMe(tokenResponse.accessToken);
|
||||
|
||||
const user = {
|
||||
name: userMeResponse.name,
|
||||
email: userMeResponse.email,
|
||||
company: userMeResponse?.company,
|
||||
dateJoined: userMeResponse.dateJoined,
|
||||
permissions: userMeResponse.permissions,
|
||||
};
|
||||
const user = tokenUserFromApi(userMeResponse);
|
||||
|
||||
return {
|
||||
...user,
|
||||
@@ -122,14 +260,7 @@ export const authConfig = {
|
||||
try {
|
||||
const userMeResponse = await getUserByMe(accessToken as string);
|
||||
|
||||
const user = {
|
||||
name: userMeResponse.name,
|
||||
email: userMeResponse.email,
|
||||
company: userMeResponse?.company,
|
||||
dateJoined: userMeResponse.dateJoined,
|
||||
|
||||
permissions: userMeResponse.permissions,
|
||||
};
|
||||
const user = tokenUserFromApi(userMeResponse);
|
||||
|
||||
return {
|
||||
...user,
|
||||
@@ -137,7 +268,6 @@ export const authConfig = {
|
||||
refreshToken: credentials.refreshToken,
|
||||
};
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error in authorize:", error);
|
||||
return null;
|
||||
}
|
||||
@@ -162,74 +292,68 @@ export const authConfig = {
|
||||
},
|
||||
|
||||
jwt: async ({ token, account, user }) => {
|
||||
if (token?.accessToken) {
|
||||
const decodedToken = jwtDecode(
|
||||
token.accessToken as string,
|
||||
) as CustomJwtPayload;
|
||||
// eslint-disable-next-line no-console
|
||||
// console.log("decodedToken", decodedToken);
|
||||
token.accessTokenExpires = (decodedToken.exp as number) * 1000;
|
||||
token.user_id = decodedToken.user_id;
|
||||
token.tenant_id = decodedToken.tenant_id;
|
||||
}
|
||||
const authToken = token as AuthToken;
|
||||
|
||||
const userInfo = {
|
||||
name: user?.name,
|
||||
companyName: user?.company,
|
||||
email: user?.email,
|
||||
dateJoined: user?.dateJoined,
|
||||
permissions: user?.permissions || {
|
||||
manage_users: false,
|
||||
manage_account: false,
|
||||
manage_providers: false,
|
||||
manage_scans: false,
|
||||
manage_integrations: false,
|
||||
manage_billing: false,
|
||||
unlimited_visibility: false,
|
||||
},
|
||||
};
|
||||
applyDecodedClaims(authToken, authToken.accessToken);
|
||||
|
||||
if (account && user) {
|
||||
return {
|
||||
...token,
|
||||
userId: token.user_id,
|
||||
tenantId: token.tenant_id,
|
||||
accessToken: (user as User & { accessToken: JwtPayload }).accessToken,
|
||||
refreshToken: (user as User & { refreshToken: JwtPayload })
|
||||
.refreshToken,
|
||||
user: userInfo,
|
||||
const signedInUser = user as User &
|
||||
TokenUserInput & {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
const nextAuthToken: AuthToken = {
|
||||
...authToken,
|
||||
accessToken: signedInUser.accessToken,
|
||||
refreshToken: signedInUser.refreshToken,
|
||||
user: toTokenUser(signedInUser),
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
applyDecodedClaims(
|
||||
nextAuthToken,
|
||||
signedInUser.accessToken,
|
||||
"access token on sign-in",
|
||||
);
|
||||
|
||||
return nextAuthToken;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
// console.log(
|
||||
// "Access token expires",
|
||||
// token.accessTokenExpires,
|
||||
// new Date(Number(token.accessTokenExpires)),
|
||||
// );
|
||||
|
||||
// If the access token is not expired, return the token
|
||||
if (
|
||||
typeof token.accessTokenExpires === "number" &&
|
||||
Date.now() < token.accessTokenExpires
|
||||
)
|
||||
return token;
|
||||
typeof authToken.accessTokenExpires === "number" &&
|
||||
Date.now() < authToken.accessTokenExpires
|
||||
) {
|
||||
return authToken;
|
||||
}
|
||||
|
||||
// If the access token is expired, try to refresh it
|
||||
return refreshAccessToken(token as JwtPayload);
|
||||
return refreshAccessToken(authToken);
|
||||
},
|
||||
|
||||
session: async ({ session, token }) => {
|
||||
if (token) {
|
||||
session.userId = token?.user_id as string;
|
||||
session.tenantId = token?.tenant_id as string;
|
||||
session.accessToken = token?.accessToken as string;
|
||||
session.refreshToken = token?.refreshToken as string;
|
||||
session.user = token.user as any;
|
||||
const authToken = token as AuthToken;
|
||||
const nextSession = { ...session } as ExtendedSession;
|
||||
|
||||
if (authToken?.error) {
|
||||
nextSession.error = authToken.error;
|
||||
nextSession.user = undefined;
|
||||
nextSession.userId = undefined;
|
||||
nextSession.tenantId = undefined;
|
||||
nextSession.accessToken = undefined;
|
||||
nextSession.refreshToken = undefined;
|
||||
return nextSession;
|
||||
}
|
||||
|
||||
// console.log("session", session);
|
||||
return session;
|
||||
nextSession.error = undefined;
|
||||
nextSession.userId = authToken.user_id ?? nextSession.userId;
|
||||
nextSession.tenantId = authToken.tenant_id ?? nextSession.tenantId;
|
||||
nextSession.accessToken =
|
||||
authToken.accessToken ?? nextSession.accessToken;
|
||||
nextSession.refreshToken =
|
||||
authToken.refreshToken ?? nextSession.refreshToken;
|
||||
nextSession.user = authToken.user ?? nextSession.user;
|
||||
|
||||
return nextSession;
|
||||
},
|
||||
},
|
||||
} satisfies NextAuthConfig;
|
||||
|
||||
@@ -35,6 +35,7 @@ export const SignInForm = ({
|
||||
|
||||
useEffect(() => {
|
||||
const samlError = searchParams.get("sso_saml_failed");
|
||||
const sessionError = searchParams.get("error");
|
||||
|
||||
if (samlError) {
|
||||
setTimeout(() => {
|
||||
@@ -46,6 +47,37 @@ export const SignInForm = ({
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
if (sessionError) {
|
||||
setTimeout(() => {
|
||||
const errorMessages: Record<
|
||||
string,
|
||||
{ title: string; description: string }
|
||||
> = {
|
||||
RefreshAccessTokenError: {
|
||||
title: "Session Expired",
|
||||
description:
|
||||
"Your session has expired. Please sign in again to continue.",
|
||||
},
|
||||
MissingRefreshToken: {
|
||||
title: "Session Error",
|
||||
description:
|
||||
"There was a problem with your session. Please sign in again.",
|
||||
},
|
||||
};
|
||||
|
||||
const errorConfig = errorMessages[sessionError] || {
|
||||
title: "Authentication Error",
|
||||
description: "Please sign in again to continue.",
|
||||
};
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: errorConfig.title,
|
||||
description: errorConfig.description,
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}, [searchParams, toast]);
|
||||
|
||||
const form = useForm<SignInFormData>({
|
||||
|
||||
@@ -17,7 +17,7 @@ interface CISDetailsProps {
|
||||
|
||||
export const CISCustomDetails = ({ requirement }: CISDetailsProps) => {
|
||||
const processReferences = (
|
||||
references: string | number | string[] | object[] | undefined,
|
||||
references: string | number | boolean | string[] | object[] | undefined,
|
||||
): string[] => {
|
||||
if (typeof references !== "string") return [];
|
||||
|
||||
|
||||
@@ -54,6 +54,25 @@ export const ThreatCustomDetails = ({
|
||||
conditional={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{typeof requirement.passedFindings === "number" &&
|
||||
typeof requirement.totalFindings === "number" && (
|
||||
<>
|
||||
<ComplianceBadge
|
||||
label="Findings"
|
||||
value={`${requirement.passedFindings}/${requirement.totalFindings}`}
|
||||
color="blue"
|
||||
/>
|
||||
{requirement.totalFindings > 0 && (
|
||||
<ComplianceBadge
|
||||
label="Pass Rate"
|
||||
value={`${Math.round((requirement.passedFindings / requirement.totalFindings) * 100)}%`}
|
||||
color="green"
|
||||
conditional={true}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ComplianceBadgeContainer>
|
||||
|
||||
{requirement.additionalInformation && (
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart as RechartsBar,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { ChartTooltip } from "./shared/ChartTooltip";
|
||||
import { CHART_COLORS, LAYOUT_OPTIONS } from "./shared/constants";
|
||||
import { getSeverityColorByName } from "./shared/utils";
|
||||
import { BarDataPoint, LayoutOption } from "./types";
|
||||
|
||||
interface BarChartProps {
|
||||
data: BarDataPoint[];
|
||||
layout?: LayoutOption;
|
||||
xLabel?: string;
|
||||
yLabel?: string;
|
||||
height?: number;
|
||||
showValues?: boolean;
|
||||
}
|
||||
|
||||
const CustomLabel = ({ x, y, width, height, value, data }: any) => {
|
||||
const percentage = data.percentage;
|
||||
return (
|
||||
<text
|
||||
x={x + width + 10}
|
||||
y={y + height / 2}
|
||||
fill={CHART_COLORS.textSecondary}
|
||||
fontSize={12}
|
||||
textAnchor="start"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{percentage !== undefined
|
||||
? `${percentage}% • ${value.toLocaleString()}`
|
||||
: value.toLocaleString()}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
export function BarChart({
|
||||
data,
|
||||
layout = LAYOUT_OPTIONS.horizontal,
|
||||
xLabel,
|
||||
yLabel,
|
||||
height = 400,
|
||||
showValues = true,
|
||||
}: BarChartProps) {
|
||||
const isHorizontal = layout === LAYOUT_OPTIONS.horizontal;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RechartsBar
|
||||
data={data}
|
||||
layout={layout}
|
||||
margin={{ top: 20, right: showValues ? 100 : 30, left: 20, bottom: 20 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke={CHART_COLORS.gridLine}
|
||||
horizontal={isHorizontal}
|
||||
vertical={!isHorizontal}
|
||||
/>
|
||||
{isHorizontal ? (
|
||||
<>
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
|
||||
label={
|
||||
xLabel
|
||||
? {
|
||||
value: xLabel,
|
||||
position: "insideBottom",
|
||||
offset: -10,
|
||||
fill: CHART_COLORS.textSecondary,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="name"
|
||||
type="category"
|
||||
width={100}
|
||||
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
|
||||
label={
|
||||
yLabel
|
||||
? {
|
||||
value: yLabel,
|
||||
angle: -90,
|
||||
position: "insideLeft",
|
||||
fill: CHART_COLORS.textSecondary,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
|
||||
label={
|
||||
xLabel
|
||||
? {
|
||||
value: xLabel,
|
||||
position: "insideBottom",
|
||||
offset: -10,
|
||||
fill: CHART_COLORS.textSecondary,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
|
||||
label={
|
||||
yLabel
|
||||
? {
|
||||
value: yLabel,
|
||||
angle: -90,
|
||||
position: "insideLeft",
|
||||
fill: CHART_COLORS.textSecondary,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Bar
|
||||
dataKey="value"
|
||||
radius={4}
|
||||
label={
|
||||
showValues && isHorizontal
|
||||
? (props: any) => (
|
||||
<CustomLabel {...props} data={data[props.index]} />
|
||||
)
|
||||
: false
|
||||
}
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={
|
||||
entry.color ||
|
||||
getSeverityColorByName(entry.name) ||
|
||||
CHART_COLORS.defaultColor
|
||||
}
|
||||
opacity={1}
|
||||
className="transition-opacity hover:opacity-80"
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</RechartsBar>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Cell, Label, Pie, PieChart, Tooltip } from "recharts";
|
||||
|
||||
import { ChartConfig, ChartContainer } from "@/components/ui/chart/Chart";
|
||||
|
||||
import { ChartLegend } from "./shared/ChartLegend";
|
||||
import { DonutDataPoint } from "./types";
|
||||
|
||||
interface DonutChartProps {
|
||||
data: DonutDataPoint[];
|
||||
height?: number;
|
||||
innerRadius?: number;
|
||||
outerRadius?: number;
|
||||
showLegend?: boolean;
|
||||
centerLabel?: {
|
||||
value: string | number;
|
||||
label: string;
|
||||
};
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-3 w-3 rounded-sm"
|
||||
style={{ backgroundColor: data.color }}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{data.percentage}% {data.name}
|
||||
</span>
|
||||
</div>
|
||||
{data.change !== undefined && (
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
<span className="font-bold">
|
||||
{data.change > 0 ? "+" : ""}
|
||||
{data.change}%
|
||||
</span>{" "}
|
||||
Since last scan
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const CustomLegend = ({ payload }: any) => {
|
||||
const items = payload.map((entry: any) => ({
|
||||
label: `${entry.value} (${entry.payload.percentage}%)`,
|
||||
color: entry.color,
|
||||
}));
|
||||
|
||||
return <ChartLegend items={items} />;
|
||||
};
|
||||
|
||||
export function DonutChart({
|
||||
data,
|
||||
innerRadius = 80,
|
||||
outerRadius = 120,
|
||||
showLegend = true,
|
||||
centerLabel,
|
||||
}: DonutChartProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
const chartConfig = data.reduce(
|
||||
(config, item) => ({
|
||||
...config,
|
||||
[item.name]: {
|
||||
label: item.name,
|
||||
color: item.color,
|
||||
},
|
||||
}),
|
||||
{} as ChartConfig,
|
||||
);
|
||||
|
||||
const chartData = data.map((item) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
fill: item.color,
|
||||
color: item.color,
|
||||
percentage: item.percentage,
|
||||
change: item.change,
|
||||
}));
|
||||
|
||||
const legendPayload = chartData.map((entry) => ({
|
||||
value: entry.name,
|
||||
color: entry.color,
|
||||
payload: {
|
||||
percentage: entry.percentage,
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="mx-auto aspect-square max-h-[350px]"
|
||||
>
|
||||
<PieChart>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
strokeWidth={0}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{chartData.map((entry, index) => {
|
||||
const opacity =
|
||||
hoveredIndex === null ? 1 : hoveredIndex === index ? 1 : 0.5;
|
||||
return (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.fill}
|
||||
opacity={opacity}
|
||||
style={{ transition: "opacity 0.2s" }}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{centerLabel && (
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||
const formattedValue =
|
||||
typeof centerLabel.value === "number"
|
||||
? centerLabel.value.toLocaleString()
|
||||
: centerLabel.value;
|
||||
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className="fill-white text-3xl font-bold"
|
||||
>
|
||||
{formattedValue}
|
||||
</tspan>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) + 24}
|
||||
className="fill-slate-400"
|
||||
>
|
||||
{centerLabel.label}
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
{showLegend && <CustomLegend payload={legendPayload} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { Bell } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { CHART_COLORS, SEVERITY_ORDER } from "./shared/constants";
|
||||
import { getSeverityColorByName } from "./shared/utils";
|
||||
import { BarDataPoint } from "./types";
|
||||
|
||||
interface HorizontalBarChartProps {
|
||||
data: BarDataPoint[];
|
||||
height?: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
const sortedData = [...data].sort((a, b) => {
|
||||
const orderA = SEVERITY_ORDER[a.name as keyof typeof SEVERITY_ORDER] ?? 999;
|
||||
const orderB = SEVERITY_ORDER[b.name as keyof typeof SEVERITY_ORDER] ?? 999;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{title && (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{sortedData.map((item, index) => {
|
||||
const isHovered = hoveredIndex === index;
|
||||
const isFaded = hoveredIndex !== null && !isHovered;
|
||||
const barColor =
|
||||
item.color ||
|
||||
getSeverityColorByName(item.name) ||
|
||||
CHART_COLORS.defaultColor;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="relative flex items-center gap-4"
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
>
|
||||
<div className="w-24 text-right">
|
||||
<span
|
||||
className="text-sm text-white"
|
||||
style={{
|
||||
opacity: isFaded ? 0.5 : 1,
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute inset-0 h-8 w-full rounded-lg bg-slate-700/50" />
|
||||
<div
|
||||
className="relative h-8 rounded-lg transition-all duration-300"
|
||||
style={{
|
||||
width: `${item.percentage || (item.value / Math.max(...data.map((d) => d.value))) * 100}%`,
|
||||
backgroundColor: barColor,
|
||||
opacity: isFaded ? 0.5 : 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
{isHovered && (
|
||||
<div className="absolute top-10 left-0 z-10 min-w-[200px] rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-3 w-3 rounded-sm"
|
||||
style={{ backgroundColor: barColor }}
|
||||
/>
|
||||
<span className="font-semibold text-white">
|
||||
{item.value.toLocaleString()} {item.name} Risk
|
||||
</span>
|
||||
</div>
|
||||
{item.newFindings !== undefined && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Bell size={14} className="text-slate-400" />
|
||||
<span className="text-sm text-slate-400">
|
||||
{item.newFindings} New Findings
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{item.change !== undefined && (
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
<span className="font-bold">
|
||||
{item.change > 0 ? "+" : ""}
|
||||
{item.change}%
|
||||
</span>{" "}
|
||||
Since Last Scan
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex w-40 items-center gap-2 text-sm text-white"
|
||||
style={{
|
||||
opacity: isFaded ? 0.5 : 1,
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
>
|
||||
<span className="font-semibold">{item.percentage}%</span>
|
||||
<span className="text-slate-400">•</span>
|
||||
<span>{item.value.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { Bell } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart as RechartsLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
TooltipProps,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { AlertPill } from "./shared/AlertPill";
|
||||
import { ChartLegend } from "./shared/ChartLegend";
|
||||
import { CHART_COLORS } from "./shared/constants";
|
||||
import { LineConfig, LineDataPoint } from "./types";
|
||||
|
||||
interface LineChartProps {
|
||||
data: LineDataPoint[];
|
||||
lines: LineConfig[];
|
||||
xLabel?: string;
|
||||
yLabel?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface TooltipPayloadItem {
|
||||
dataKey: string;
|
||||
value: number;
|
||||
stroke: string;
|
||||
name: string;
|
||||
payload: LineDataPoint;
|
||||
}
|
||||
|
||||
const CustomLineTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: TooltipProps<number, string>) => {
|
||||
if (!active || !payload || payload.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const typedPayload = payload as unknown as TooltipPayloadItem[];
|
||||
const totalValue = typedPayload.reduce((sum, item) => sum + item.value, 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
|
||||
<p className="mb-3 text-xs text-slate-400">{label}</p>
|
||||
|
||||
<div className="mb-3">
|
||||
<AlertPill value={totalValue} textSize="sm" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{typedPayload.map((item) => {
|
||||
const newFindings = item.payload[`${item.dataKey}_newFindings`];
|
||||
const change = item.payload[`${item.dataKey}_change`];
|
||||
|
||||
return (
|
||||
<div key={item.dataKey} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: item.stroke }}
|
||||
/>
|
||||
<span className="text-sm text-white">{item.value}</span>
|
||||
</div>
|
||||
{newFindings !== undefined && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell size={14} className="text-slate-400" />
|
||||
<span className="text-xs text-slate-400">
|
||||
{newFindings} New Findings
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{change !== undefined && typeof change === "number" && (
|
||||
<p className="text-xs text-slate-400">
|
||||
<span className="font-bold">
|
||||
{change > 0 ? "+" : ""}
|
||||
{change}%
|
||||
</span>{" "}
|
||||
Since Last Scan
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomLegend = ({ payload }: any) => {
|
||||
const severityOrder = [
|
||||
"Informational",
|
||||
"Low",
|
||||
"Medium",
|
||||
"High",
|
||||
"Critical",
|
||||
"Muted",
|
||||
];
|
||||
|
||||
const sortedPayload = [...payload].sort((a, b) => {
|
||||
const indexA = severityOrder.indexOf(a.value);
|
||||
const indexB = severityOrder.indexOf(b.value);
|
||||
return indexA - indexB;
|
||||
});
|
||||
|
||||
const items = sortedPayload.map((entry: any) => ({
|
||||
label: entry.value,
|
||||
color: entry.color,
|
||||
}));
|
||||
|
||||
return <ChartLegend items={items} />;
|
||||
};
|
||||
|
||||
export function LineChart({
|
||||
data,
|
||||
lines,
|
||||
xLabel,
|
||||
yLabel,
|
||||
height = 400,
|
||||
}: LineChartProps) {
|
||||
const [hoveredLine, setHoveredLine] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RechartsLine
|
||||
data={data}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.gridLine} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
label={
|
||||
xLabel
|
||||
? {
|
||||
value: xLabel,
|
||||
position: "insideBottom",
|
||||
offset: -10,
|
||||
fill: CHART_COLORS.textSecondary,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
label={
|
||||
yLabel
|
||||
? {
|
||||
value: yLabel,
|
||||
angle: -90,
|
||||
position: "insideLeft",
|
||||
fill: CHART_COLORS.textSecondary,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip content={<CustomLineTooltip />} />
|
||||
<Legend content={<CustomLegend />} />
|
||||
{lines.map((line) => {
|
||||
const isHovered = hoveredLine === line.dataKey;
|
||||
const isFaded = hoveredLine !== null && !isHovered;
|
||||
return (
|
||||
<Line
|
||||
key={line.dataKey}
|
||||
type="monotone"
|
||||
dataKey={line.dataKey}
|
||||
stroke={line.color}
|
||||
strokeWidth={2}
|
||||
strokeOpacity={isFaded ? 0.5 : 1}
|
||||
name={line.label}
|
||||
dot={{ fill: line.color, r: 4, opacity: isFaded ? 0.5 : 1 }}
|
||||
activeDot={{ r: 6 }}
|
||||
onMouseEnter={() => setHoveredLine(line.dataKey)}
|
||||
onMouseLeave={() => setHoveredLine(null)}
|
||||
style={{ transition: "stroke-opacity 0.2s" }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</RechartsLine>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
PolarAngleAxis,
|
||||
PolarGrid,
|
||||
Radar,
|
||||
RadarChart as RechartsRadar,
|
||||
} from "recharts";
|
||||
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
} from "@/components/ui/chart/Chart";
|
||||
|
||||
import { AlertPill } from "./shared/AlertPill";
|
||||
import { CHART_COLORS } from "./shared/constants";
|
||||
import { RadarDataPoint } from "./types";
|
||||
|
||||
interface RadarChartProps {
|
||||
data: RadarDataPoint[];
|
||||
height?: number;
|
||||
dataKey?: string;
|
||||
onSelectPoint?: (point: RadarDataPoint | null) => void;
|
||||
selectedPoint?: RadarDataPoint | null;
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
value: {
|
||||
label: "Findings",
|
||||
color: "var(--color-magenta)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0];
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
|
||||
<p className="text-sm font-semibold text-white">
|
||||
{data.payload.category}
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
<AlertPill value={data.value} />
|
||||
</div>
|
||||
{data.payload.change !== undefined && (
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
<span className="font-bold">
|
||||
{data.payload.change > 0 ? "+" : ""}
|
||||
{data.payload.change}%
|
||||
</span>{" "}
|
||||
Since Last Scan
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const CustomDot = (props: any) => {
|
||||
const { cx, cy, payload, selectedPoint, onSelectPoint } = props;
|
||||
const currentCategory = payload.category || payload.name;
|
||||
const isSelected = selectedPoint?.category === currentCategory;
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onSelectPoint) {
|
||||
if (isSelected) {
|
||||
onSelectPoint(null);
|
||||
} else {
|
||||
const point = {
|
||||
category: currentCategory,
|
||||
value: payload.value,
|
||||
change: payload.change,
|
||||
};
|
||||
onSelectPoint(point);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={isSelected ? 9 : 6}
|
||||
fill={isSelected ? "var(--color-success)" : "var(--color-purple-dark)"}
|
||||
fillOpacity={1}
|
||||
style={{
|
||||
cursor: onSelectPoint ? "pointer" : "default",
|
||||
pointerEvents: "all",
|
||||
}}
|
||||
onClick={onSelectPoint ? handleClick : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export function RadarChart({
|
||||
data,
|
||||
height = 400,
|
||||
dataKey = "value",
|
||||
onSelectPoint,
|
||||
selectedPoint,
|
||||
}: RadarChartProps) {
|
||||
return (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="mx-auto w-full"
|
||||
style={{ height }}
|
||||
>
|
||||
<RechartsRadar data={data}>
|
||||
<ChartTooltip cursor={false} content={<CustomTooltip />} />
|
||||
<PolarAngleAxis
|
||||
dataKey="category"
|
||||
tick={{ fill: CHART_COLORS.textPrimary }}
|
||||
/>
|
||||
<PolarGrid strokeOpacity={0.3} />
|
||||
<Radar
|
||||
dataKey={dataKey}
|
||||
fill="var(--color-magenta)"
|
||||
fillOpacity={0.2}
|
||||
activeDot={false}
|
||||
dot={
|
||||
onSelectPoint
|
||||
? (dotProps: any) => {
|
||||
const { key, ...rest } = dotProps;
|
||||
return (
|
||||
<CustomDot
|
||||
key={key}
|
||||
{...rest}
|
||||
selectedPoint={selectedPoint}
|
||||
onSelectPoint={onSelectPoint}
|
||||
/>
|
||||
);
|
||||
}
|
||||
: {
|
||||
r: 6,
|
||||
fill: "var(--color-purple-dark)",
|
||||
fillOpacity: 1,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</RechartsRadar>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
PolarAngleAxis,
|
||||
RadialBar,
|
||||
RadialBarChart,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
import { CHART_COLORS } from "./shared/constants";
|
||||
interface RadialChartProps {
|
||||
percentage: number;
|
||||
label?: string;
|
||||
color?: string;
|
||||
backgroundColor?: string;
|
||||
height?: number;
|
||||
innerRadius?: number;
|
||||
outerRadius?: number;
|
||||
startAngle?: number;
|
||||
endAngle?: number;
|
||||
}
|
||||
|
||||
export function RadialChart({
|
||||
percentage,
|
||||
label = "Score",
|
||||
color = "var(--color-success)",
|
||||
backgroundColor = CHART_COLORS.tooltipBackground,
|
||||
height = 250,
|
||||
innerRadius = 60,
|
||||
outerRadius = 100,
|
||||
startAngle = 90,
|
||||
endAngle = -270,
|
||||
}: RadialChartProps) {
|
||||
const data = [
|
||||
{
|
||||
name: label,
|
||||
value: percentage,
|
||||
fill: color,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RadialBarChart
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
barSize={20}
|
||||
data={data}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
>
|
||||
<PolarAngleAxis
|
||||
type="number"
|
||||
domain={[0, 100]}
|
||||
angleAxisId={0}
|
||||
tick={false}
|
||||
/>
|
||||
<RadialBar
|
||||
background={{ fill: backgroundColor }}
|
||||
dataKey="value"
|
||||
cornerRadius={10}
|
||||
fill={color}
|
||||
/>
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="fill-white text-4xl font-bold"
|
||||
>
|
||||
{percentage}%
|
||||
</text>
|
||||
</RadialBarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts";
|
||||
|
||||
import { CHART_COLORS, SEVERITY_COLORS } from "./shared/constants";
|
||||
|
||||
interface SankeyNode {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface SankeyLink {
|
||||
source: number;
|
||||
target: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface SankeyChartProps {
|
||||
data: {
|
||||
nodes: SankeyNode[];
|
||||
links: SankeyLink[];
|
||||
};
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const COLORS: Record<string, string> = {
|
||||
Success: "var(--color-success)",
|
||||
Fail: "var(--color-destructive)",
|
||||
AWS: "var(--color-orange)",
|
||||
Azure: "var(--color-cyan)",
|
||||
Google: "var(--color-red)",
|
||||
...SEVERITY_COLORS,
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
|
||||
<p className="text-sm font-semibold text-white">{data.name}</p>
|
||||
{data.value && (
|
||||
<p className="text-xs text-slate-400">Value: {data.value}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const CustomNode = ({ x, y, width, height, payload, containerWidth }: any) => {
|
||||
const isOut = x + width + 6 > containerWidth;
|
||||
const nodeName = payload.name;
|
||||
const color = COLORS[nodeName] || CHART_COLORS.defaultColor;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<Rectangle
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={height}
|
||||
fill={color}
|
||||
fillOpacity="1"
|
||||
/>
|
||||
<text
|
||||
textAnchor={isOut ? "end" : "start"}
|
||||
x={isOut ? x - 6 : x + width + 6}
|
||||
y={y + height / 2}
|
||||
fontSize="14"
|
||||
className="fill-white stroke-white"
|
||||
>
|
||||
{nodeName}
|
||||
</text>
|
||||
<text
|
||||
textAnchor={isOut ? "end" : "start"}
|
||||
x={isOut ? x - 6 : x + width + 6}
|
||||
y={y + height / 2 + 13}
|
||||
fontSize="12"
|
||||
className="fill-slate-400 stroke-slate-400"
|
||||
strokeOpacity="0.5"
|
||||
>
|
||||
{payload.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomLink = (props: any) => {
|
||||
const {
|
||||
sourceX,
|
||||
targetX,
|
||||
sourceY,
|
||||
targetY,
|
||||
sourceControlX,
|
||||
targetControlX,
|
||||
linkWidth,
|
||||
} = props;
|
||||
|
||||
const sourceName = props.payload.source?.name || "";
|
||||
const color = COLORS[sourceName] || CHART_COLORS.defaultColor;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<path
|
||||
d={`
|
||||
M${sourceX},${sourceY + linkWidth / 2}
|
||||
C${sourceControlX},${sourceY + linkWidth / 2}
|
||||
${targetControlX},${targetY + linkWidth / 2}
|
||||
${targetX},${targetY + linkWidth / 2}
|
||||
L${targetX},${targetY - linkWidth / 2}
|
||||
C${targetControlX},${targetY - linkWidth / 2}
|
||||
${sourceControlX},${sourceY - linkWidth / 2}
|
||||
${sourceX},${sourceY - linkWidth / 2}
|
||||
Z
|
||||
`}
|
||||
fill={color}
|
||||
fillOpacity="0.4"
|
||||
stroke="none"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<Sankey
|
||||
data={data}
|
||||
node={<CustomNode />}
|
||||
link={<CustomLink />}
|
||||
nodePadding={50}
|
||||
margin={{ top: 20, right: 160, bottom: 20, left: 160 }}
|
||||
>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
</Sankey>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Scatter,
|
||||
ScatterChart,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { AlertPill } from "./shared/AlertPill";
|
||||
import { ChartLegend } from "./shared/ChartLegend";
|
||||
import { CHART_COLORS } from "./shared/constants";
|
||||
import { getSeverityColorByRiskScore } from "./shared/utils";
|
||||
|
||||
interface ScatterDataPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
provider: string;
|
||||
name: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
interface ScatterPlotProps {
|
||||
data: ScatterDataPoint[];
|
||||
xLabel?: string;
|
||||
yLabel?: string;
|
||||
height?: number;
|
||||
onSelectPoint?: (point: ScatterDataPoint | null) => void;
|
||||
selectedPoint?: ScatterDataPoint | null;
|
||||
}
|
||||
|
||||
const PROVIDER_COLORS = {
|
||||
AWS: "var(--color-orange)",
|
||||
Azure: "var(--color-cyan)",
|
||||
Google: "var(--color-red)",
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
const severityColor = getSeverityColorByRiskScore(data.x);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
|
||||
<p className="text-sm font-semibold text-white">{data.name}</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
<span style={{ color: severityColor }}>{data.x}</span> Risk Score
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<AlertPill value={data.y} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const CustomScatterDot = ({
|
||||
cx,
|
||||
cy,
|
||||
payload,
|
||||
selectedPoint,
|
||||
onSelectPoint,
|
||||
}: any) => {
|
||||
const isSelected = selectedPoint?.name === payload.name;
|
||||
const size = isSelected ? 18 : 8;
|
||||
const fill = isSelected
|
||||
? "var(--color-success)"
|
||||
: PROVIDER_COLORS[payload.provider as keyof typeof PROVIDER_COLORS] ||
|
||||
CHART_COLORS.defaultColor;
|
||||
|
||||
return (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={size / 2}
|
||||
fill={fill}
|
||||
stroke={isSelected ? "var(--color-success)" : "transparent"}
|
||||
strokeWidth={2}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => onSelectPoint?.(payload)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomLegend = ({ payload }: any) => {
|
||||
const items = payload.map((entry: any) => ({
|
||||
label: entry.value,
|
||||
color: entry.color,
|
||||
}));
|
||||
|
||||
return <ChartLegend items={items} />;
|
||||
};
|
||||
|
||||
export function ScatterPlot({
|
||||
data,
|
||||
xLabel = "Risk Score",
|
||||
yLabel = "Failed Findings",
|
||||
height = 400,
|
||||
onSelectPoint,
|
||||
selectedPoint,
|
||||
}: ScatterPlotProps) {
|
||||
const handlePointClick = (point: ScatterDataPoint) => {
|
||||
if (onSelectPoint) {
|
||||
if (selectedPoint?.name === point.name) {
|
||||
onSelectPoint(null);
|
||||
} else {
|
||||
onSelectPoint(point);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dataByProvider = data.reduce(
|
||||
(acc, point) => {
|
||||
const provider = point.provider;
|
||||
if (!acc[provider]) {
|
||||
acc[provider] = [];
|
||||
}
|
||||
acc[provider].push(point);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, ScatterDataPoint[]>,
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.gridLine} />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="x"
|
||||
name={xLabel}
|
||||
label={{
|
||||
value: xLabel,
|
||||
position: "insideBottom",
|
||||
offset: -10,
|
||||
fill: CHART_COLORS.textSecondary,
|
||||
}}
|
||||
tick={{ fill: CHART_COLORS.textSecondary }}
|
||||
domain={[0, 10]}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="y"
|
||||
name={yLabel}
|
||||
label={{
|
||||
value: yLabel,
|
||||
angle: -90,
|
||||
position: "insideLeft",
|
||||
fill: CHART_COLORS.textSecondary,
|
||||
}}
|
||||
tick={{ fill: CHART_COLORS.textSecondary }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend content={<CustomLegend />} />
|
||||
{Object.entries(dataByProvider).map(([provider, points]) => (
|
||||
<Scatter
|
||||
key={provider}
|
||||
name={provider}
|
||||
data={points}
|
||||
fill={
|
||||
PROVIDER_COLORS[provider as keyof typeof PROVIDER_COLORS] ||
|
||||
CHART_COLORS.defaultColor
|
||||
}
|
||||
shape={(props: any) => (
|
||||
<CustomScatterDot
|
||||
{...props}
|
||||
selectedPoint={selectedPoint}
|
||||
onSelectPoint={handlePointClick}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { DEFAULT_SORT_OPTION, SORT_OPTIONS } from "../shared/constants";
|
||||
|
||||
type SortOption = (typeof SORT_OPTIONS)[keyof typeof SORT_OPTIONS];
|
||||
|
||||
interface SortableItem {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export function useSortableData<T extends SortableItem>(data: T[]) {
|
||||
const [sortBy, setSortBy] = useState<SortOption>(DEFAULT_SORT_OPTION);
|
||||
|
||||
const sortedData = [...data].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case SORT_OPTIONS.highLow:
|
||||
return b.value - a.value;
|
||||
case SORT_OPTIONS.lowHigh:
|
||||
return a.value - b.value;
|
||||
case SORT_OPTIONS.alphabetical:
|
||||
return a.name.localeCompare(b.name);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
sortBy,
|
||||
setSortBy,
|
||||
sortedData,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { BarChart } from "./BarChart";
|
||||
export { DonutChart } from "./DonutChart";
|
||||
export { HorizontalBarChart } from "./HorizontalBarChart";
|
||||
export { LineChart } from "./LineChart";
|
||||
export { RadarChart } from "./RadarChart";
|
||||
export { RadialChart } from "./RadialChart";
|
||||
export { SankeyChart } from "./SankeyChart";
|
||||
export { ScatterPlot } from "./ScatterPlot";
|
||||
export { ChartLegend, type ChartLegendItem } from "./shared/ChartLegend";
|
||||
@@ -0,0 +1,34 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AlertPillProps {
|
||||
value: number;
|
||||
label?: string;
|
||||
iconSize?: number;
|
||||
textSize?: "xs" | "sm" | "base";
|
||||
}
|
||||
|
||||
export function AlertPill({
|
||||
value,
|
||||
label = "Fail Findings",
|
||||
iconSize = 12,
|
||||
textSize = "xs",
|
||||
}: AlertPillProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-alert-pill-bg flex items-center gap-1 rounded-full px-2 py-1">
|
||||
<AlertTriangle size={iconSize} className="text-alert-pill-text" />
|
||||
<span
|
||||
className={cn(
|
||||
`text-${textSize}`,
|
||||
"text-alert-pill-text font-semibold",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
<span className={cn(`text-${textSize}`, "text-slate-400")}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface ChartLegendItem {
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ChartLegendProps {
|
||||
items: ChartLegendItem[];
|
||||
}
|
||||
|
||||
export function ChartLegend({ items }: ChartLegendProps) {
|
||||
return (
|
||||
<div className="bg-card-border mt-4 inline-flex gap-[46px] rounded-full border-2 px-[19px] py-[9px]">
|
||||
{items.map((item, index) => (
|
||||
<div key={`legend-${index}`} className="flex items-center gap-1">
|
||||
<div
|
||||
className="h-3 w-3 rounded"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span className="text-xs text-gray-300">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Bell, VolumeX } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { TooltipData } from "../types";
|
||||
|
||||
interface ChartTooltipProps {
|
||||
active?: boolean;
|
||||
payload?: any[];
|
||||
label?: string;
|
||||
showColorIndicator?: boolean;
|
||||
colorIndicatorShape?: "circle" | "square";
|
||||
}
|
||||
|
||||
export function ChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
showColorIndicator = true,
|
||||
colorIndicatorShape = "square",
|
||||
}: ChartTooltipProps) {
|
||||
if (!active || !payload || payload.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: TooltipData = payload[0].payload || payload[0];
|
||||
const color = payload[0].color || data.color;
|
||||
|
||||
return (
|
||||
<div className="min-w-[200px] rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
{showColorIndicator && color && (
|
||||
<div
|
||||
className={cn(
|
||||
"h-3 w-3",
|
||||
colorIndicatorShape === "circle" ? "rounded-full" : "rounded-sm",
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm font-semibold text-white">{label || data.name}</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-xs text-white">
|
||||
{typeof data.value === "number"
|
||||
? data.value.toLocaleString()
|
||||
: data.value}
|
||||
{data.percentage !== undefined && ` (${data.percentage}%)`}
|
||||
</p>
|
||||
|
||||
{data.newFindings !== undefined && data.newFindings > 0 && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Bell size={14} className="text-slate-400" />
|
||||
<span className="text-xs text-slate-400">
|
||||
{data.newFindings} New Findings
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.new !== undefined && data.new > 0 && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Bell size={14} className="text-slate-400" />
|
||||
<span className="text-xs text-slate-400">{data.new} New</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.muted !== undefined && data.muted > 0 && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<VolumeX size={14} className="text-slate-400" />
|
||||
<span className="text-xs text-slate-400">{data.muted} Muted</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.change !== undefined && (
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
<span className="font-bold">
|
||||
{data.change > 0 ? "+" : ""}
|
||||
{data.change}%
|
||||
</span>{" "}
|
||||
Since Last Scan
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip for charts with multiple data series (like LineChart)
|
||||
*/
|
||||
export function MultiSeriesChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: ChartTooltipProps) {
|
||||
if (!active || !payload || payload.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-[200px] rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
|
||||
<p className="mb-2 text-sm font-semibold text-white">{label}</p>
|
||||
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<span className="text-xs text-white">{entry.name}:</span>
|
||||
<span className="text-xs font-semibold text-white">
|
||||
{entry.value}
|
||||
</span>
|
||||
{entry.payload[`${entry.dataKey}_change`] && (
|
||||
<span className="text-xs text-slate-400">
|
||||
({entry.payload[`${entry.dataKey}_change`] > 0 ? "+" : ""}
|
||||
{entry.payload[`${entry.dataKey}_change`]}%)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export const SEVERITY_COLORS = {
|
||||
Informational: "var(--color-info)",
|
||||
Low: "var(--color-warning)",
|
||||
Medium: "var(--color-warning-emphasis)",
|
||||
High: "var(--color-danger)",
|
||||
Critical: "var(--color-danger-emphasis)",
|
||||
} as const;
|
||||
|
||||
export const CHART_COLORS = {
|
||||
tooltipBorder: "var(--color-slate-700)",
|
||||
tooltipBackground: "var(--color-slate-800)",
|
||||
textPrimary: "var(--color-white)",
|
||||
textSecondary: "var(--color-slate-400)",
|
||||
gridLine: "var(--color-slate-700)",
|
||||
backgroundTrack: "rgba(51, 65, 85, 0.5)", // slate-700 with 50% opacity
|
||||
alertPillBg: "var(--color-alert-pill-bg)",
|
||||
alertPillText: "var(--color-alert-pill-text)",
|
||||
defaultColor: "var(--color-slate-500)", // Default fallback color for charts
|
||||
} as const;
|
||||
|
||||
export const CHART_DIMENSIONS = {
|
||||
defaultHeight: 400,
|
||||
tooltipMinWidth: "200px",
|
||||
borderRadius: "8px",
|
||||
} as const;
|
||||
|
||||
export const SORT_OPTIONS = {
|
||||
highLow: "high-low",
|
||||
lowHigh: "low-high",
|
||||
alphabetical: "alphabetical",
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_SORT_OPTION = SORT_OPTIONS.highLow;
|
||||
|
||||
export const SEVERITY_ORDER = {
|
||||
Critical: 0,
|
||||
High: 1,
|
||||
Medium: 2,
|
||||
Low: 3,
|
||||
Informational: 4,
|
||||
} as const;
|
||||
|
||||
export const LAYOUT_OPTIONS = {
|
||||
horizontal: "horizontal",
|
||||
vertical: "vertical",
|
||||
} as const;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { SEVERITY_COLORS } from "./constants";
|
||||
|
||||
export function getSeverityColorByRiskScore(riskScore: number): string {
|
||||
if (riskScore >= 7) return SEVERITY_COLORS.Critical;
|
||||
if (riskScore >= 5) return SEVERITY_COLORS.High;
|
||||
if (riskScore >= 3) return SEVERITY_COLORS.Medium;
|
||||
if (riskScore >= 1) return SEVERITY_COLORS.Low;
|
||||
return SEVERITY_COLORS.Informational;
|
||||
}
|
||||
|
||||
export function getSeverityColorByName(name: string): string | undefined {
|
||||
return SEVERITY_COLORS[name as keyof typeof SEVERITY_COLORS];
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { LAYOUT_OPTIONS, SORT_OPTIONS } from "./shared/constants";
|
||||
|
||||
export type SortOption = (typeof SORT_OPTIONS)[keyof typeof SORT_OPTIONS];
|
||||
|
||||
export type LayoutOption = (typeof LAYOUT_OPTIONS)[keyof typeof LAYOUT_OPTIONS];
|
||||
|
||||
export interface BaseDataPoint {
|
||||
name: string;
|
||||
value: number;
|
||||
percentage?: number;
|
||||
color?: string;
|
||||
change?: number;
|
||||
newFindings?: number;
|
||||
}
|
||||
|
||||
export interface BarDataPoint extends BaseDataPoint {}
|
||||
|
||||
export interface DonutDataPoint {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
percentage?: number;
|
||||
new?: number;
|
||||
muted?: number;
|
||||
change?: number;
|
||||
}
|
||||
|
||||
export interface LineDataPoint {
|
||||
date: string;
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
export interface RadarDataPoint {
|
||||
category: string;
|
||||
value: number;
|
||||
change?: number;
|
||||
}
|
||||
|
||||
export interface LineConfig {
|
||||
dataKey: string;
|
||||
color: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface TooltipData {
|
||||
name: string;
|
||||
value: number | string;
|
||||
color?: string;
|
||||
percentage?: number;
|
||||
newFindings?: number;
|
||||
new?: number;
|
||||
muted?: number;
|
||||
change?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const alertVariants = cva(
|
||||
variant: {
|
||||
default: "bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50",
|
||||
destructive:
|
||||
"border-red-500/50 text-red-500 dark:border-red-500 [&>svg]:text-red-500 dark:border-red-900/50 dark:text-red-900 dark:dark:border-red-900 dark:[&>svg]:text-red-900",
|
||||
"bg-danger-50 border-red-500/50 text-red-200 dark:border-red-500 dark:border-red-900/50 dark:text-red-200 dark:dark:border-red-900",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -119,7 +119,12 @@ export const EditForm = ({
|
||||
</div>
|
||||
<div className="text-small flex items-center text-gray-600">
|
||||
<ShieldIcon className="mr-2 h-4 w-4" />
|
||||
<span className="text-gray-500">Role:</span>
|
||||
<span className="text-gray-500">
|
||||
Role:
|
||||
<span className="ml-2 font-semibold text-gray-900">
|
||||
{currentRole ? currentRole : "No role"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-2 font-semibold text-gray-900">
|
||||
{currentRole}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { Snippet } from "@heroui/snippet";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert/Alert";
|
||||
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
|
||||
import { CustomButton } from "@/components/ui/custom/custom-button";
|
||||
|
||||
interface ApiKeySuccessModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export const ApiKeySuccessModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
apiKey,
|
||||
}: ApiKeySuccessModalProps) => {
|
||||
return (
|
||||
<CustomAlertModal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title="API Key Created Successfully"
|
||||
size="2xl"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>⚠️ Important</AlertTitle>
|
||||
<AlertDescription>
|
||||
This is the only time you will see this API key. Please copy it now
|
||||
and store it securely. Once you close this dialog, the key cannot be
|
||||
retrieved again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">Your API Key</p>
|
||||
<Snippet
|
||||
hideSymbol
|
||||
classNames={{
|
||||
pre: "font-mono text-sm break-all whitespace-pre-wrap",
|
||||
}}
|
||||
tooltipProps={{
|
||||
content: "Copy API key",
|
||||
color: "default",
|
||||
}}
|
||||
>
|
||||
{apiKey}
|
||||
</Snippet>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CustomButton
|
||||
ariaLabel="Close and confirm API key saved"
|
||||
color="action"
|
||||
onPress={onClose}
|
||||
>
|
||||
Acknowledged
|
||||
</CustomButton>
|
||||
</CustomAlertModal>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user