Merge branch 'master' into PROWLER-187-create-new-user

This commit is contained in:
StylusFrost
2025-10-14 11:03:23 +02:00
126 changed files with 8165 additions and 2045 deletions
+6
View File
@@ -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
+2 -1
View File
@@ -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
+4
View File
@@ -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)
+21 -2
View File
@@ -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),
+83 -18
View File
@@ -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)
+10 -6
View File
@@ -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):
+30
View File
@@ -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
)
+33 -11
View File
@@ -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):
+13 -2
View File
@@ -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",
),
],
},
),
@@ -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),
),
]
+7
View File
@@ -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,
+2 -1
View File
@@ -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()
)
+155 -14
View File
@@ -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."
+343 -25
View File
@@ -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",
],
+80 -1
View File
@@ -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
+62 -37
View File
@@ -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")
+23 -8
View File
@@ -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
+24 -9
View File
@@ -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"]
+2 -1
View File
@@ -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")
+238 -39
View File
@@ -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())
+56 -52
View File
@@ -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,
+776 -1
View File
@@ -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
-3
View File
@@ -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
+2 -87
View File
@@ -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
![Grant Admin Consent](../microsoft365/img/grant-external-api-permissions.png)
#### 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
![User Overview](../microsoft365/img/user-info-page.png)
2. Click "Assigned Roles"
![User Roles](../microsoft365/img/user-role-page.png)
3. Click "Add assignments", then search and select:
- `Global Reader` (recommended)
- OR `Exchange Administrator` and `Teams Administrator` (both required)
![Global Reader Screenshots](../microsoft365/img/global-reader.png)
4. Click next, assign the role as "Active", and click "Assign"
![Grant Admin Consent for Role](../microsoft365/img/grant-admin-consent-for-role.png)
---
## 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.
![User Domains](img/user-domains.png)
- `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)
![Prowler Cloud M365 Credentials](./img/m365-credentials.png)
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
+3 -3
View File
@@ -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).
+6
View File
@@ -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,
+284 -47
View File
@@ -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:
@@ -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": [
@@ -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": [],
@@ -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": ""
@@ -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": [
@@ -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": ""
@@ -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": [],
@@ -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": ""
@@ -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": ""
@@ -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": ""
@@ -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": [
@@ -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": ""
@@ -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": ""
@@ -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": ""
@@ -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": [
@@ -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": [],
@@ -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": [
@@ -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"
@@ -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": [
@@ -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": [
@@ -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": [],
@@ -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": [],
@@ -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": ""
@@ -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": ""
@@ -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": [],
@@ -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": [
@@ -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": ""
-1
View File
@@ -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:
"""
+19 -117
View File
@@ -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,
}
-3
View File
@@ -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 = ""
+177 -23
View File
@@ -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"""
+3 -1
View File
@@ -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,
+48 -344
View File
@@ -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,
-6
View File
@@ -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
View File
@@ -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
+44
View File
@@ -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 };
}
+184
View File
@@ -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",
};
}
};
+12 -1
View File
@@ -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);
+21 -11
View File
@@ -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>
);
};
+3 -1
View File
@@ -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
View File
@@ -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;
+32
View File
@@ -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 && (
+162
View File
@@ -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>
);
}
+171
View File
@@ -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>
);
}
+121
View File
@@ -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>
);
}
+189
View File
@@ -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>
);
}
+146
View File
@@ -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>
);
}
+78
View File
@@ -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>
);
}
+137
View File
@@ -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>
);
}
+181
View File
@@ -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,
};
}
+9
View File
@@ -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";
+34
View File
@@ -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>
);
}
+46
View File
@@ -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;
+13
View File
@@ -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];
}
+55
View File
@@ -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;
}
+1 -1
View File
@@ -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: {
+6 -1
View File
@@ -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