merge branch 'master' into prwlr-7751-github-app-authentication-incorrectly-handles-key-parameter-and-environment-variables

This commit is contained in:
Daniel Barranquero
2025-10-09 14:40:46 +02:00
156 changed files with 8873 additions and 3182 deletions
+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)(\([^)]+\))?!?: .+'
+15 -8
View File
@@ -9,14 +9,15 @@ on:
branches:
- "master"
- "v5.*"
pull_request_target:
types:
- opened
- synchronize
- reopened
branches:
- "master"
- "v5.*"
# Leaving this commented until we find a way to run it for forks but in Prowler's context
# pull_request_target:
# types:
# - opened
# - synchronize
# - reopened
# branches:
# - "master"
# - "v5.*"
jobs:
conflict-checker:
@@ -166,3 +167,9 @@ jobs:
✅ **Conflict Markers Resolved**
All conflict markers have been successfully resolved in this pull request.
- name: Fail workflow if conflicts detected
if: steps.conflict-check.outputs.has_conflicts == 'true'
run: |
echo "::error::Workflow failed due to conflict markers in files: ${{ steps.conflict-check.outputs.conflict_files }}"
exit 1
-3
View File
@@ -80,9 +80,6 @@ _data/
# Claude
CLAUDE.md
# LLM's (Until we have a standard one)
AGENTS.md
# MCP Server
mcp_server/prowler_mcp_server/prowler_app/server.py
mcp_server/prowler_mcp_server/prowler_app/utils/schema.yaml
+110
View File
@@ -0,0 +1,110 @@
# Repository Guidelines
## How to Use This Guide
- Start here for cross-project norms, Prowler is a monorepo with several components. Every component should have an `AGENTS.md` file that contains the guidelines for the agents in that component. The file is located beside the code you are touching (e.g. `api/AGENTS.md`, `ui/AGENTS.md`, `prowler/AGENTS.md`).
- Follow the stricter rule when guidance conflicts; component docs override this file for their scope.
- Keep instructions synchronized. When you add new workflows or scripts, update both, the relevant component `AGENTS.md` and this file if they apply broadly.
## Project Overview
Prowler is an open-source cloud security assessment tool that supports multiple cloud providers (AWS, Azure, GCP, Kubernetes, GitHub, M365, etc.). The project consists in a monorepo with the following main components:
- **Prowler SDK**: Python SDK, includes the Prowler CLI, providers, services, checks, compliances, config, etc. (`prowler/`)
- **Prowler API**: Django-based REST API backend (`api/`)
- **Prowler UI**: Next.js frontend application (`ui/`)
- **Prowler MCP Server**: Model Context Protocol server that gives access to the entire Prowler ecosystem for LLMs (`mcp_server/`)
- **Prowler Dashboard**: Prowler CLI feature that allows to visualize the results of the scans in a simple dashboard (`dashboard/`)
### Project Structure (Key Folders & Files)
- `prowler/`: Main source code for Prowler SDK (CLI, providers, services, checks, compliances, config, etc.)
- `api/`: Django-based REST API backend components
- `ui/`: Next.js frontend application
- `mcp_server/`: Model Context Protocol server that gives access to the entire Prowler ecosystem for LLMs
- `dashboard/`: Prowler CLI feature that allows to visualize the results of the scans in a simple dashboard
- `docs/`: Documentation
- `examples/`: Example output formats for providers and scripts
- `permissions/`: Permission-related files and policies
- `contrib/`: Community-contributed scripts or modules
- `tests/`: Prowler SDK test suite
- `docker-compose.yml`: Docker compose file to run the Prowler App (API + UI) production environment
- `docker-compose-dev.yml`: Docker compose file to run the Prowler App (API + UI) development environment
- `pyproject.toml`: Poetry Prowler SDK project file
- `.pre-commit-config.yaml`: Pre-commit hooks configuration
- `Makefile`: Makefile to run the project
- `LICENSE`: License file
- `README.md`: README file
- `CONTRIBUTING.md`: Contributing guide
## Python Development
Most of the code is written in Python, so the main files in the root are focused on Python code.
### Poetry Dev Environment
For developing in Python we recommend using `poetry` to manage the dependencies. The minimal version is `2.1.1`. So it is recommended to run all commands using `poetry run ...`.
To install the core dependencies to develop it is needed to run `poetry install --with dev`.
### Pre-commit hooks
The project has pre-commit hooks to lint and format the code. They are installed by running `poetry run pre-commit install`.
When commiting a change, the hooks will be run automatically. Some of them are:
- Code formatting (black, isort)
- Linting (flake8, pylint)
- Security checks (bandit, safety, trufflehog)
- YAML/JSON validation
- Poetry lock file validation
### Linting and Formatting
We use the following tools to lint and format the code:
- `flake8`: for linting the code
- `black`: for formatting the code
- `pylint`: for linting the code
You can run all using the `make` command:
```bash
poetry run make lint
poetry run make format
```
Or they will be run automatically when you commit your changes using pre-commit hooks.
## Commit & Pull Request Guidelines
For the commit messages and pull requests name follow the conventional-commit style.
Befire creating a pull request, complete the checklist in `.github/pull_request_template.md`. Summaries should explain deployment impact, highlight review steps, and note changelog or permission updates. Run all relevant tests and linters before requesting review and link screenshots for UI or dashboard changes.
### Conventional Commit Style
The Conventional Commits specification is a lightweight convention on top of commit messages. It provides an easy set of rules for creating an explicit commit history; which makes it easier to write automated tools on top of.
The commit message should be structured as follows:
```
<type>[optional scope]: <description>
<BLANK LINE>
[optional body]
<BLANK LINE>
[optional footer(s)]
```
Any line of the commit message cannot be longer 100 characters! This allows the message to be easier to read on GitHub as well as in various git tools
#### Commit Types
- **feat**: code change introuce new functionality to the application
- **fix**: code change that solve a bug in the codebase
- **docs**: documentation only changes
- **chore**: changes related to the build process or auxiliary tools and libraries, that do not affect the application's functionality
- **perf**: code change that improves performance
- **refactor**: code change that neither fixes a bug nor adds a feature
- **style**: changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
- **test**: adding missing tests or correcting existing tests
+5
View File
@@ -7,11 +7,16 @@ 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)
- API Key support [(#8805)](https://github.com/prowler-cloud/prowler/pull/8805)
- 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)
### 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)
- Now at least one user with MANAGE_ACCOUNT permission is required in the tenant [(#8729)](https://github.com/prowler-cloud/prowler/pull/8729)
### Security
- Django updated to the latest 5.1 security release, 5.1.13, due to problems with potential [SQL injection](https://github.com/prowler-cloud/prowler/security/dependabot/104) and [directory traversals](https://github.com/prowler-cloud/prowler/security/dependabot/103) [(#8842)](https://github.com/prowler-cloud/prowler/pull/8842)
---
## [1.13.2] (Prowler 5.12.3)
+3 -1
View File
@@ -18,10 +18,12 @@ Valkey exposes a Redis 7.2 compliant API. Any service that exposes the Redis API
# Modify environment variables
Under the root path of the project, you can find a file called `.env.example`. This file shows all the environment variables that the project uses. You *must* create a new file called `.env` and set the values for the variables.
Under the root path of the project, you can find a file called `.env`. This file shows all the environment variables that the project uses. You should review it and set the values for the variables you want to change.
If you dont set `DJANGO_TOKEN_SIGNING_KEY` or `DJANGO_TOKEN_VERIFYING_KEY`, the API will generate them at `~/.config/prowler-api/` with `0600` and `0644` permissions; back up these files to persist identity across redeploys.
**Important note**: Every Prowler version (or repository branches and tags) could have different variables set in its `.env` file. Please use the `.env` file that corresponds with each version.
## Local deployment
Keep in mind if you export the `.env` file to use it with local deployment that you will have to do it within the context of the Poetry interpreter, not before. Otherwise, variables will not be loaded properly.
+25 -4
View File
@@ -1563,14 +1563,14 @@ with-social = ["django-allauth[socialaccount] (>=64.0.0)"]
[[package]]
name = "django"
version = "5.1.12"
version = "5.1.13"
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
optional = false
python-versions = ">=3.10"
groups = ["main", "dev"]
files = [
{file = "django-5.1.12-py3-none-any.whl", hash = "sha256:9eb695636cea3601b65690f1596993c042206729afb320ca0960b55f8ed4477b"},
{file = "django-5.1.12.tar.gz", hash = "sha256:8a8991b1ec052ef6a44fefd1ef336ab8daa221287bcb91a4a17d5e1abec5bbcc"},
{file = "django-5.1.13-py3-none-any.whl", hash = "sha256:06f257f79dc4c17f3f9e23b106a4c5ed1335abecbe731e83c598c941d14fbeed"},
{file = "django-5.1.13.tar.gz", hash = "sha256:543ff21679f15e80edfc01fe7ea35f8291b6d4ea589433882913626a7c1cf929"},
]
[package.dependencies]
@@ -1924,6 +1924,27 @@ files = [
Django = ">=4.2"
djangorestframework = ">=3.15.0"
[[package]]
name = "drf-simple-apikey"
version = "2.2.1"
description = "API Key authentication and permissions for Django REST."
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "drf_simple_apikey-2.2.1-py2.py3-none-any.whl", hash = "sha256:2a60b35676d14f907c47dee179dd0fa7425a84c34d6ff5b48d08d3b87ff32809"},
{file = "drf_simple_apikey-2.2.1.tar.gz", hash = "sha256:e5a52804bbac12c8db80c10a3d51a8514fc59fc8385b5e751099a2bc944ad25d"},
]
[package.dependencies]
cryptography = ">=38.0.4"
django = ">=4.2"
djangorestframework = ">=3.14.0"
[package.extras]
test = ["coverage", "pytest", "pytest-django"]
tooling = ["black (==22.3.0)", "bump2version", "pylint"]
[[package]]
name = "drf-spectacular"
version = "0.27.2"
@@ -6238,4 +6259,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<3.13"
content-hash = "91058a14382b76136a82f45624a30aece7a6d77c8b36c290bb4c40ea60c8850b"
content-hash = "03442fd4673006c5a74374f90f53621fd1c9d117279fe6cc0355ef833eb7f9bb"
+3 -2
View File
@@ -7,7 +7,7 @@ authors = [{name = "Prowler Engineering", email = "engineering@prowler.com"}]
dependencies = [
"celery[pytest] (>=5.4.0,<6.0.0)",
"dj-rest-auth[with_social,jwt] (==7.0.1)",
"django (==5.1.12)",
"django (==5.1.13)",
"django-allauth[saml] (>=65.8.0,<66.0.0)",
"django-celery-beat (>=2.7.0,<3.0.0)",
"django-celery-results (>=2.5.1,<3.0.0)",
@@ -32,7 +32,8 @@ dependencies = [
"openai (>=1.82.0,<2.0.0)",
"xmlsec==1.3.14",
"h2 (==4.3.0)",
"markdown (>=3.9,<4.0)"
"markdown (>=3.9,<4.0)",
"drf-simple-apikey (==2.2.1)"
]
description = "Prowler's API (Django/DRF)"
license = "Apache-2.0"
+4 -5
View File
@@ -1,14 +1,12 @@
import logging
import os
from pathlib import Path
import sys
from django.apps import AppConfig
from django.conf import settings
from pathlib import Path
from config.custom_logging import BackendLogger
from config.env import env
from django.apps import AppConfig
from django.conf import settings
logger = logging.getLogger(BackendLogger.API)
@@ -30,6 +28,7 @@ class ApiConfig(AppConfig):
name = "api"
def ready(self):
from api import schema_extensions # noqa: F401
from api import signals # noqa: F401
from api.compliance import load_prowler_compliance
+95
View File
@@ -0,0 +1,95 @@
from typing import Optional, Tuple
from uuid import UUID
from cryptography.fernet import InvalidToken
from django.utils import timezone
from drf_simple_apikey.backends import APIKeyAuthentication as BaseAPIKeyAuth
from drf_simple_apikey.crypto import get_crypto
from rest_framework.authentication import BaseAuthentication
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
class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
model = TenantAPIKey
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)
# Split prefix from key (format: pk_xxxxxxxx.encrypted_key)
try:
prefix, key = prefixed_key.split(TenantAPIKeyManager.separator, 1)
except ValueError:
raise AuthenticationFailed("Invalid API Key.")
try:
entity, _ = self._authenticate_credentials(request, key)
except InvalidToken:
raise AuthenticationFailed("Invalid API Key.")
# Get the API key instance to update last_used_at and retrieve tenant info
# We need to decrypt again to get the pk (already validated by _authenticate_credentials)
payload = self.key_crypto.decrypt(key)
api_key_pk = payload["_pk"]
# Convert string UUID back to UUID object for lookup
if isinstance(api_key_pk, str):
api_key_pk = UUID(api_key_pk)
try:
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"], using=MainRouter.admin_db)
return entity, {
"tenant_id": str(api_key_instance.tenant_id),
"sub": str(api_key_instance.entity.id),
"api_key_prefix": prefix,
}
class CombinedJWTOrAPIKeyAuthentication(BaseAuthentication):
jwt_auth = JWTAuthentication()
api_key_auth = TenantAPIKeyAuthentication()
def authenticate(self, request: Request) -> Optional[Tuple[object, dict]]:
auth_header = request.headers.get("Authorization", "")
# Prioritize JWT authentication if both are present
if auth_header.startswith("Bearer "):
return self.jwt_auth.authenticate(request)
if auth_header.startswith("Api-Key "):
return self.api_key_auth.authenticate(request)
# Default fallback
return self.jwt_auth.authenticate(request)
+2 -2
View File
@@ -5,8 +5,8 @@ from rest_framework.exceptions import NotAuthenticated
from rest_framework.filters import SearchFilter
from rest_framework_json_api import filters
from rest_framework_json_api.views import ModelViewSet
from rest_framework_simplejwt.authentication import JWTAuthentication
from api.authentication import CombinedJWTOrAPIKeyAuthentication
from api.db_router import MainRouter
from api.db_utils import POSTGRES_USER_VAR, rls_transaction
from api.filters import CustomDjangoFilterBackend
@@ -15,7 +15,7 @@ from api.rbac.permissions import HasPermissions
class BaseViewSet(ModelViewSet):
authentication_classes = [JWTAuthentication]
authentication_classes = [CombinedJWTOrAPIKeyAuthentication]
required_permissions = []
permission_classes = [permissions.IsAuthenticated, HasPermissions]
filter_backends = [
+7 -1
View File
@@ -61,7 +61,7 @@ def rls_transaction(value: str, parameter: str = POSTGRES_TENANT_VAR):
with transaction.atomic():
with connection.cursor() as cursor:
try:
# just in case the value is an UUID object
# just in case the value is a UUID object
uuid.UUID(str(value))
except ValueError:
raise ValidationError("Must be a valid UUID")
@@ -434,6 +434,12 @@ def drop_index_on_partitions(
schema_editor.execute(sql)
def generate_api_key_prefix():
"""Generate a random 8-character prefix for API keys (e.g., 'pk_abc123de')."""
random_chars = generate_random_token(length=8)
return f"pk_{random_chars}"
# Postgres enum definition for member role
+17 -10
View File
@@ -1,6 +1,10 @@
from django.core.exceptions import ValidationError as django_validation_error
from rest_framework import status
from rest_framework.exceptions import APIException
from rest_framework.exceptions import (
APIException,
AuthenticationFailed,
NotAuthenticated,
)
from rest_framework_json_api.exceptions import exception_handler
from rest_framework_json_api.serializers import ValidationError
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
@@ -68,15 +72,18 @@ def custom_exception_handler(exc, context):
exc = ValidationError(exc.message_dict)
else:
exc = ValidationError(detail=exc.messages[0], code=exc.code)
elif isinstance(exc, (TokenError, InvalidToken)):
if (
hasattr(exc, "detail")
and isinstance(exc.detail, dict)
and "messages" in exc.detail
):
exc.detail["messages"] = [
message_item["message"] for message_item in exc.detail["messages"]
]
# Force 401 status for AuthenticationFailed exceptions regardless of the authentication backend
elif isinstance(exc, (AuthenticationFailed, NotAuthenticated, TokenError)):
exc.status_code = status.HTTP_401_UNAUTHORIZED
if isinstance(exc, (TokenError, InvalidToken)):
if (
hasattr(exc, "detail")
and isinstance(exc.detail, dict)
and "messages" in exc.detail
):
exc.detail["messages"] = [
message_item["message"] for message_item in exc.detail["messages"]
]
return exception_handler(exc, context)
+52 -5
View File
@@ -43,6 +43,7 @@ from api.models import (
StateChoices,
StatusChoices,
Task,
TenantAPIKey,
User,
)
from api.rls import Tenant
@@ -219,10 +220,31 @@ class MembershipFilter(FilterSet):
class ProviderFilter(FilterSet):
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
updated_at = DateFilter(field_name="updated_at", lookup_expr="date")
connected = BooleanFilter()
inserted_at = DateFilter(
field_name="inserted_at",
lookup_expr="date",
help_text="""Filter by date when the provider was added
(format: YYYY-MM-DD)""",
)
updated_at = DateFilter(
field_name="updated_at",
lookup_expr="date",
help_text="""Filter by date when the provider was updated
(format: YYYY-MM-DD)""",
)
connected = BooleanFilter(
help_text="""Filter by connection status. Set to True to return only
connected providers, or False to return only providers with failed
connections. If not specified, both connected and failed providers are
included. Providers with no connection attempt (status is null) are
excluded from this filter."""
)
provider = ChoiceFilter(choices=Provider.ProviderChoices.choices)
provider__in = ChoiceInFilter(
field_name="provider",
choices=Provider.ProviderChoices.choices,
lookup_expr="in",
)
class Meta:
model = Provider
@@ -648,8 +670,16 @@ class LatestFindingFilter(CommonFindingFilters):
class ProviderSecretFilter(FilterSet):
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
updated_at = DateFilter(field_name="updated_at", lookup_expr="date")
inserted_at = DateFilter(
field_name="inserted_at",
lookup_expr="date",
help_text="Filter by date when the secret was added (format: YYYY-MM-DD)",
)
updated_at = DateFilter(
field_name="updated_at",
lookup_expr="date",
help_text="Filter by date when the secret was updated (format: YYYY-MM-DD)",
)
provider = UUIDFilter(field_name="provider__id", lookup_expr="exact")
class Meta:
@@ -880,3 +910,20 @@ class IntegrationJiraFindingsFilter(FilterSet):
}
)
return super().filter_queryset(queryset)
class TenantApiKeyFilter(FilterSet):
inserted_at = DateFilter(field_name="created", lookup_expr="date")
inserted_at__gte = DateFilter(field_name="created", lookup_expr="gte")
inserted_at__lte = DateFilter(field_name="created", lookup_expr="lte")
expires_at = DateFilter(field_name="expiry_date", lookup_expr="date")
expires_at__gte = DateFilter(field_name="expiry_date", lookup_expr="gte")
expires_at__lte = DateFilter(field_name="expiry_date", lookup_expr="lte")
class Meta:
model = TenantAPIKey
fields = {
"prefix": ["exact", "icontains"],
"revoked": ["exact"],
"name": ["exact", "icontains"],
}
+8 -2
View File
@@ -8,9 +8,14 @@ def extract_auth_info(request) -> dict:
if getattr(request, "auth", None) is not None:
tenant_id = request.auth.get("tenant_id", "N/A")
user_id = request.auth.get("sub", "N/A")
api_key_prefix = request.auth.get("api_key_prefix", "N/A")
else:
tenant_id, user_id = "N/A", "N/A"
return {"tenant_id": tenant_id, "user_id": user_id}
tenant_id, user_id, api_key_prefix = "N/A", "N/A", "N/A"
return {
"tenant_id": tenant_id,
"user_id": user_id,
"api_key_prefix": api_key_prefix,
}
class APILoggingMiddleware:
@@ -38,6 +43,7 @@ class APILoggingMiddleware:
extra={
"user_id": auth_info["user_id"],
"tenant_id": auth_info["tenant_id"],
"api_key_prefix": auth_info["api_key_prefix"],
"method": request.method,
"path": request.path,
"query_params": request.GET.dict(),
@@ -0,0 +1,125 @@
# Generated by Django 5.1.12 on 2025-09-30 13:10
import uuid
import django.db.models.deletion
import drf_simple_apikey.models
from django.conf import settings
from django.db import migrations, models
import api.db_utils
import api.rls
class Migration(migrations.Migration):
dependencies = [
("api", "0047_remove_integration_unique_configuration_per_tenant"),
]
operations = [
migrations.CreateModel(
name="TenantAPIKey",
fields=[
("name", models.CharField(blank=True, max_length=255, null=True)),
(
"expiry_date",
models.DateTimeField(
default=drf_simple_apikey.models._expiry_date,
help_text="Once API key expires, entities cannot use it anymore.",
verbose_name="Expires",
),
),
(
"revoked",
models.BooleanField(
blank=True,
default=False,
help_text="If the API key is revoked, entities cannot use it anymore. (This cannot be undone.)",
),
),
("created", models.DateTimeField(auto_now=True)),
(
"whitelisted_ips",
models.JSONField(
blank=True,
help_text="List of allowed IP addresses for this API key.",
null=True,
),
),
(
"blacklisted_ips",
models.JSONField(
blank=True,
help_text="List of denied IP addresses for this API key.",
null=True,
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"prefix",
models.CharField(
default=api.db_utils.generate_api_key_prefix,
editable=False,
help_text="Unique prefix to identify the API key",
max_length=11,
unique=True,
),
),
(
"last_used_at",
models.DateTimeField(
blank=True,
help_text="Last time this API key was used for authentication",
null=True,
),
),
(
"entity",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="user_api_keys",
to=settings.AUTH_USER_MODEL,
),
),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "api_keys",
"abstract": False,
"indexes": [
models.Index(
fields=["tenant_id", "prefix"],
name="api_keys_tenant_prefix_idx",
)
],
"constraints": [
models.UniqueConstraint(
fields=("tenant_id", "prefix"), name="unique_api_key_prefixes"
)
],
},
),
migrations.AddConstraint(
model_name="tenantapikey",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_tenantapikey",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
]
@@ -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),
),
]
+65
View File
@@ -22,6 +22,8 @@ from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django_celery_beat.models import PeriodicTask
from django_celery_results.models import TaskResult
from drf_simple_apikey.crypto import get_crypto
from drf_simple_apikey.models import AbstractAPIKey, AbstractAPIKeyManager
from psqlextra.manager import PostgresManager
from psqlextra.models import PostgresPartitionedModel
from psqlextra.types import PostgresPartitioningMethod
@@ -42,6 +44,7 @@ from api.db_utils import (
StateEnumField,
StatusEnumField,
enum_to_choices,
generate_api_key_prefix,
generate_random_token,
one_week_from_now,
)
@@ -125,6 +128,17 @@ class ActiveProviderPartitionedManager(PostgresManager, ActiveProviderManager):
return super().get_queryset().filter(self.active_provider_filter())
class TenantAPIKeyManager(AbstractAPIKeyManager):
separator = "."
def assign_api_key(self, obj) -> str:
payload = {"_pk": str(obj.pk), "_exp": obj.expiry_date.timestamp()}
key = get_crypto().generate(payload)
prefixed_key = f"{obj.prefix}{self.separator}{key}"
return prefixed_key
class User(AbstractBaseUser):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
name = models.CharField(max_length=150, validators=[MinLengthValidator(3)])
@@ -204,6 +218,55 @@ class Membership(models.Model):
resource_name = "memberships"
class TenantAPIKey(AbstractAPIKey, RowLevelSecurityProtectedModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
prefix = models.CharField(
max_length=11,
unique=True,
default=generate_api_key_prefix,
editable=False,
help_text="Unique prefix to identify the API key",
)
last_used_at = models.DateTimeField(
null=True,
blank=True,
help_text="Last time this API key was used for authentication",
)
entity = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="user_api_keys",
)
objects = TenantAPIKeyManager()
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "api_keys"
constraints = [
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
models.UniqueConstraint(
fields=("tenant_id", "prefix"),
name="unique_api_key_prefixes",
),
]
indexes = [
models.Index(
fields=["tenant_id", "prefix"], name="api_keys_tenant_prefix_idx"
),
]
class JSONAPIMeta:
resource_name = "api-keys"
class Provider(RowLevelSecurityProtectedModel):
objects = ActiveProviderManager()
all_objects = models.Manager()
@@ -1230,6 +1293,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,
+16
View File
@@ -0,0 +1,16 @@
from drf_spectacular.extensions import OpenApiAuthenticationExtension
from drf_spectacular.openapi import AutoSchema
class CombinedJWTOrAPIKeyAuthenticationScheme(OpenApiAuthenticationExtension):
target_class = "api.authentication.CombinedJWTOrAPIKeyAuthentication"
name = "JWT or API Key"
def get_security_definition(self, auto_schema: AutoSchema): # noqa: F841
return {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": "Supports both JWT Bearer tokens and API Key authentication. "
"Use `Bearer <token>` for JWT or `Api-Key <key>` for API keys.",
}
+26 -2
View File
@@ -1,12 +1,12 @@
from celery import states
from celery.signals import before_task_publish
from config.celery import celery_app
from django.db.models.signals import post_delete
from django.db.models.signals import post_delete, pre_delete
from django.dispatch import receiver
from django_celery_results.backends.database import DatabaseBackend
from api.db_utils import delete_related_daily_task
from api.models import Provider
from api.models import Membership, Provider, TenantAPIKey, User
def create_task_result_on_publish(sender=None, headers=None, **kwargs): # noqa: F841
@@ -32,3 +32,27 @@ before_task_publish.connect(
def delete_provider_scan_task(sender, instance, **kwargs): # noqa: F841
# Delete the associated periodic task when the provider is deleted
delete_related_daily_task(instance.id)
@receiver(pre_delete, sender=User)
def revoke_user_api_keys(sender, instance, **kwargs): # noqa: F841
"""
Revoke all API keys associated with a user before deletion.
The entity field will be set to NULL by on_delete=SET_NULL,
but we explicitly revoke the keys to prevent further use.
"""
TenantAPIKey.objects.filter(entity=instance).update(revoked=True)
@receiver(post_delete, sender=Membership)
def revoke_membership_api_keys(sender, instance, **kwargs): # noqa: F841
"""
Revoke all API keys when a user is removed from a tenant.
When a membership is deleted, all API keys created by that user
in that tenant should be revoked to prevent further access.
"""
TenantAPIKey.objects.filter(
entity=instance.user, tenant_id=instance.tenant.id
).update(revoked=True)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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."
@@ -11,6 +11,7 @@ from api.db_utils import (
batch_delete,
create_objects_in_batches,
enum_to_choices,
generate_api_key_prefix,
generate_random_token,
one_week_from_now,
update_objects_in_batches,
@@ -313,3 +314,28 @@ class TestUpdateObjectsInBatches:
qs = Provider.objects.filter(tenant=tenant, uid__endswith="_upd")
assert qs.count() == total
class TestGenerateApiKeyPrefix:
def test_prefix_format(self):
"""Test that generated prefix starts with 'pk_'."""
prefix = generate_api_key_prefix()
assert prefix.startswith("pk_")
def test_prefix_length(self):
"""Test that prefix has correct length (pk_ + 8 random chars = 11)."""
prefix = generate_api_key_prefix()
assert len(prefix) == 11
def test_prefix_uniqueness(self):
"""Test that multiple generations produce unique prefixes."""
prefixes = {generate_api_key_prefix() for _ in range(100)}
assert len(prefixes) == 100
def test_prefix_character_set(self):
"""Test that random part uses only allowed characters."""
allowed_chars = "23456789ABCDEFGHJKMNPQRSTVWXYZ"
for _ in range(50):
prefix = generate_api_key_prefix()
random_part = prefix[3:] # Strip 'pk_'
assert all(char in allowed_chars for char in random_part)
@@ -24,6 +24,7 @@ def test_api_logging_middleware_logging(mock_logger):
mock_extract_auth_info.return_value = {
"user_id": "user123",
"tenant_id": "tenant456",
"api_key_prefix": "pk_test",
}
with patch("api.middleware.logging.getLogger") as mock_get_logger:
@@ -44,6 +45,7 @@ def test_api_logging_middleware_logging(mock_logger):
expected_extra = {
"user_id": "user123",
"tenant_id": "tenant456",
"api_key_prefix": "pk_test",
"method": "GET",
"path": "/test-path",
"query_params": {"param1": "value1", "param2": "value2"},
+788 -1
View File
@@ -12,8 +12,8 @@ from uuid import uuid4
import jwt
import pytest
from allauth.socialaccount.models import SocialAccount, SocialApp
from allauth.account.models import EmailAddress
from allauth.socialaccount.models import SocialAccount, SocialApp
from botocore.exceptions import ClientError, NoCredentialsError
from conftest import (
API_JSON_CONTENT_TYPE,
@@ -48,6 +48,7 @@ from api.models import (
Scan,
StateChoices,
Task,
TenantAPIKey,
User,
UserRoleRelationship,
)
@@ -7405,3 +7406,789 @@ class TestProcessorViewSet:
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
class TestTenantApiKeyViewSet:
"""Tests for TenantAPIKey endpoints."""
def test_api_keys_list(self, authenticated_client, api_keys_fixture):
"""Test listing all API keys for the tenant."""
response = authenticated_client.get(reverse("api-key-list"))
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == len(api_keys_fixture)
def test_api_keys_list_empty(self, authenticated_client, tenants_fixture):
"""Test listing API keys when none exist returns empty list."""
response = authenticated_client.get(reverse("api-key-list"))
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 0
assert isinstance(data, list)
def test_api_keys_list_default_ordering(
self, authenticated_client, api_keys_fixture
):
"""Test that API keys are ordered by -created (newest first) by default."""
response = authenticated_client.get(reverse("api-key-list"))
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
# Verify ordering by comparing inserted_at timestamps
# (newest should be first since ordering = ["-created"])
if len(data) >= 2:
first_date = data[0]["attributes"]["inserted_at"]
second_date = data[1]["attributes"]["inserted_at"]
assert first_date >= second_date
def test_api_keys_list_pagination_page_size(
self, authenticated_client, api_keys_fixture
):
"""Test pagination with custom page size."""
page_size = 1
response = authenticated_client.get(
reverse("api-key-list"), {"page[size]": page_size}
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == page_size
assert response.json()["meta"]["pagination"]["page"] == 1
assert response.json()["meta"]["pagination"]["pages"] == 3
def test_api_keys_list_pagination_page_number(
self, authenticated_client, api_keys_fixture
):
"""Test pagination with specific page number."""
page_size = 1
page_number = 2
response = authenticated_client.get(
reverse("api-key-list"),
{"page[size]": page_size, "page[number]": page_number},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == page_size
assert response.json()["meta"]["pagination"]["page"] == page_number
def test_api_keys_list_pagination_invalid_page(self, authenticated_client):
"""Test pagination with invalid page number returns 404."""
response = authenticated_client.get(
reverse("api-key-list"), {"page[number]": 999}
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_keys_retrieve(self, authenticated_client, api_keys_fixture):
"""Test retrieving a single API key by ID."""
api_key = api_keys_fixture[0]
response = authenticated_client.get(
reverse("api-key-detail", kwargs={"pk": api_key.id})
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert data["id"] == str(api_key.id)
assert data["attributes"]["name"] == api_key.name
assert data["attributes"]["prefix"] == api_key.prefix
assert data["attributes"]["revoked"] == api_key.revoked
assert "expires_at" in data["attributes"]
assert "inserted_at" in data["attributes"]
assert "last_used_at" in data["attributes"]
# Verify api_key field is NOT in response (only on creation)
assert "api_key" not in data["attributes"]
def test_api_keys_retrieve_invalid(self, authenticated_client):
"""Test retrieving non-existent API key returns 404."""
response = authenticated_client.get(
reverse(
"api-key-detail",
kwargs={"pk": "f498b103-c760-4785-9a3e-e23fafbb7b02"},
)
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_keys_retrieve_field_mapping(
self, authenticated_client, api_keys_fixture
):
"""Test that field names are correctly mapped (expires_at, inserted_at)."""
api_key = api_keys_fixture[0]
response = authenticated_client.get(
reverse("api-key-detail", kwargs={"pk": api_key.id})
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]["attributes"]
# Verify field mapping: expires_at -> expiry_date
assert "expires_at" in data
assert "expiry_date" not in data
# Verify field mapping: inserted_at -> created
assert "inserted_at" in data
assert "created" not in data
@pytest.mark.parametrize(
"api_key_payload",
(
[
{"name": "New API Key"},
{"name": ""},
{},
]
),
)
def test_api_keys_create_valid(
self, authenticated_client, create_test_user, api_key_payload
):
data = {
"data": {
"type": "api-keys",
"attributes": api_key_payload,
}
}
response = authenticated_client.post(
reverse("api-key-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
response_data = response.json()["data"]
assert "prefix" in response_data["attributes"]
assert "api_key" in response_data["attributes"]
assert response_data["attributes"]["api_key"] is not None
# Verify the raw API key is returned (only on creation)
assert (
response_data["attributes"]["prefix"]
in response_data["attributes"]["api_key"]
)
# Verify entity is set to current user
assert response_data["relationships"]["entity"]["data"]["id"] == str(
create_test_user.id
)
@pytest.mark.parametrize(
"api_key_payload, error_pointer",
(
[
(
{"name": "Invalid Expiry", "expires_at": "not-a-date"},
"expires_at",
),
]
),
)
def test_api_keys_create_invalid(
self,
authenticated_client,
create_test_user,
api_key_payload,
error_pointer,
):
data = {
"data": {
"type": "api-keys",
"attributes": api_key_payload,
}
}
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()
assert (
response.json()["errors"][0]["source"]["pointer"]
== f"/data/attributes/{error_pointer}"
)
def test_api_keys_create_multiple_unique_prefixes(
self, authenticated_client, api_keys_fixture
):
"""Test creating multiple API keys generates unique prefixes."""
prefixes = set()
for i in range(3):
data = {
"data": {
"type": "api-keys",
"attributes": {
"name": f"Unique Key {i}",
},
}
}
response = authenticated_client.post(
reverse("api-key-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
prefix = response.json()["data"]["attributes"]["prefix"]
prefixes.add(prefix)
# Verify all prefixes are unique
assert len(prefixes) == 3
def test_api_keys_create_invalid_content_type(
self, authenticated_client, create_test_user
):
"""Test creating an API key with wrong content type returns 415."""
data = {"name": "Test Key"}
response = authenticated_client.post(
reverse("api-key-list"),
data=data,
content_type="application/json",
)
assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
def test_api_keys_create_malformed_json(
self, authenticated_client, create_test_user
):
"""Test creating an API key with malformed JSON returns 400."""
response = authenticated_client.post(
reverse("api-key-list"),
data="not valid json",
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_api_keys_create_invalid_structure(
self, authenticated_client, create_test_user
):
"""Test creating an API key with invalid JSON:API structure."""
data = {"invalid": "structure"}
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()
def test_api_keys_revoke(self, authenticated_client, api_keys_fixture):
"""Test revoking an API key."""
api_key = api_keys_fixture[0] # Not revoked
assert api_key.revoked is False
response = authenticated_client.delete(
reverse("api-key-revoke", kwargs={"pk": api_key.id})
)
assert response.status_code == status.HTTP_200_OK
response_data = response.json()["data"]
assert response_data["attributes"]["revoked"] is True
# Verify in database
api_key.refresh_from_db()
assert api_key.revoked is True
def test_api_keys_revoke_already_revoked(
self, authenticated_client, api_keys_fixture
):
"""Test revoking an already revoked API key returns validation error."""
api_key = api_keys_fixture[2] # Already revoked
api_key.refresh_from_db()
assert api_key.revoked is True
response = authenticated_client.delete(
reverse("api-key-revoke", kwargs={"pk": api_key.id})
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "already revoked" in response.json()["errors"][0]["detail"]
def test_api_keys_revoke_nonexistent(self, authenticated_client):
"""Test revoking non-existent API key returns 404."""
response = authenticated_client.delete(
reverse(
"api-key-revoke",
kwargs={"pk": "f498b103-c760-4785-9a3e-e23fafbb7b02"},
)
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_keys_destroy_not_allowed(self, authenticated_client, api_keys_fixture):
"""Test that DELETE (destroy) endpoint is disabled."""
api_key = api_keys_fixture[0]
response = authenticated_client.delete(
reverse("api-key-detail", kwargs={"pk": api_key.id})
)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
def test_api_keys_put_not_allowed(self, authenticated_client, api_keys_fixture):
"""Test that PUT is not allowed."""
api_key = api_keys_fixture[0]
data = {
"data": {
"type": "api-keys",
"id": str(api_key.id),
"attributes": {
"name": "Updated Name",
},
}
}
response = authenticated_client.put(
reverse("api-key-detail", kwargs={"pk": api_key.id}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
@pytest.mark.parametrize(
"filter_name, filter_value, expected_min_count",
(
[
("name", "Test API Key 1", 1),
("name__icontains", "test", 2),
("revoked", "true", 1),
("revoked", "false", 2),
("inserted_at", TODAY, 1),
("inserted_at__gte", "2024-01-01", 3),
("inserted_at__lte", "2099-12-31", 3),
("expires_at__gte", today_after_n_days(50), 1),
]
),
)
def test_api_keys_filters(
self,
authenticated_client,
api_keys_fixture,
filter_name,
filter_value,
expected_min_count,
):
response = authenticated_client.get(
reverse("api-key-list"),
{f"filter[{filter_name}]": filter_value},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) >= expected_min_count
def test_api_keys_filter_combined(self, authenticated_client, api_keys_fixture):
"""Test combining multiple filters."""
response = authenticated_client.get(
reverse("api-key-list"),
{
"filter[revoked]": "false",
"filter[name__icontains]": "test",
},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert all(item["attributes"]["revoked"] is False for item in data)
assert all("test" in item["attributes"]["name"].lower() for item in data)
@pytest.mark.parametrize(
"filter_name",
(
[
"invalid_field",
"nonexistent",
]
),
)
def test_api_keys_filters_invalid(self, authenticated_client, filter_name):
response = authenticated_client.get(
reverse("api-key-list"),
{f"filter[{filter_name}]": "whatever"},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_api_keys_filter_invalid_date_format(self, authenticated_client):
"""Test filtering with invalid date format returns 400."""
response = authenticated_client.get(
reverse("api-key-list"),
{"filter[inserted_at]": "not-a-date"},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_api_keys_filter_empty_result(self, authenticated_client, api_keys_fixture):
"""Test filter that returns no results."""
response = authenticated_client.get(
reverse("api-key-list"),
{"filter[name]": "NonExistent Key Name"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 0
assert isinstance(data, list)
@pytest.mark.parametrize(
"sort_field",
(
[
"name",
"prefix",
"revoked",
"inserted_at",
"expires_at",
"-name",
"-inserted_at",
]
),
)
def test_api_keys_sort(self, authenticated_client, api_keys_fixture, sort_field):
response = authenticated_client.get(
reverse("api-key-list"), {"sort": sort_field}
)
assert response.status_code == status.HTTP_200_OK
def test_api_keys_sort_invalid(self, authenticated_client):
"""Test invalid sort parameter returns 400."""
response = authenticated_client.get(
reverse("api-key-list"),
{"sort": "invalid_field"},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_api_keys_rbac_manage_account_required(
self, authenticated_client_rbac_manage_users_only, api_keys_fixture
):
"""Test that users without MANAGE_ACCOUNT permission are denied."""
response = authenticated_client_rbac_manage_users_only.get(
reverse("api-key-list")
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_api_keys_rbac_manage_account_allowed(
self, authenticated_client_rbac_manage_account, tenants_fixture
):
"""Test that users with MANAGE_ACCOUNT permission can access API keys."""
response = authenticated_client_rbac_manage_account.get(reverse("api-key-list"))
assert response.status_code == status.HTTP_200_OK
def test_api_keys_rbac_create_requires_permission(
self, authenticated_client_rbac_manage_users_only
):
"""Test that creating API keys requires MANAGE_ACCOUNT permission."""
data = {
"data": {
"type": "api-keys",
"attributes": {
"name": "Test Key",
},
}
}
response = authenticated_client_rbac_manage_users_only.post(
reverse("api-key-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_api_keys_rbac_revoke_requires_permission(
self, authenticated_client_rbac_manage_users_only, api_keys_fixture
):
"""Test that revoking API keys requires MANAGE_ACCOUNT permission."""
api_key = api_keys_fixture[0]
response = authenticated_client_rbac_manage_users_only.delete(
reverse("api-key-revoke", kwargs={"pk": api_key.id})
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_api_keys_tenant_isolation(
self, authenticated_client, api_keys_fixture, tenants_fixture
):
"""Test that API keys are isolated by tenant (RLS enforcement)."""
# Create a second tenant with different user
tenant2 = Tenant.objects.create(name="Another Tenant")
user2 = User.objects.create_user(
name="Another User",
email="another@example.com",
password=TEST_PASSWORD,
)
Membership.objects.create(
user=user2,
tenant=tenant2,
role=Membership.RoleChoices.OWNER,
)
# Create API key for tenant2
TenantAPIKey.objects.create_api_key(
name="Tenant 2 Key",
tenant_id=tenant2.id,
entity=user2,
)
# Authenticate as user from tenant 1
response = authenticated_client.get(reverse("api-key-list"))
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
# Should only see keys from tenant 1
assert len(data) == len(api_keys_fixture)
assert all(item["attributes"]["name"] != "Tenant 2 Key" for item in data)
def test_api_keys_tenant_isolation_retrieve(
self, authenticated_client, tenants_fixture
):
"""Test that retrieving API key from another tenant returns 404."""
# Create a second tenant with API key
tenant2 = Tenant.objects.create(name="Another Tenant")
user2 = User.objects.create_user(
name="Another User",
email="another2@example.com",
password=TEST_PASSWORD,
)
Membership.objects.create(
user=user2,
tenant=tenant2,
role=Membership.RoleChoices.OWNER,
)
api_key2, _ = TenantAPIKey.objects.create_api_key(
name="Tenant 2 Key",
tenant_id=tenant2.id,
entity=user2,
)
# Try to retrieve tenant2's API key as tenant1 user
response = authenticated_client.get(
reverse("api-key-detail", kwargs={"pk": api_key2.id})
)
# Should return 404 due to RLS filtering
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_keys_tenant_isolation_revoke(
self, authenticated_client, tenants_fixture
):
"""Test that revoking API key from another tenant returns 404."""
# Create a second tenant with API key
tenant2 = Tenant.objects.create(name="Another Tenant")
user2 = User.objects.create_user(
name="Another User",
email="another3@example.com",
password=TEST_PASSWORD,
)
Membership.objects.create(
user=user2,
tenant=tenant2,
role=Membership.RoleChoices.OWNER,
)
api_key2, _ = TenantAPIKey.objects.create_api_key(
name="Tenant 2 Key",
tenant_id=tenant2.id,
entity=user2,
)
# Try to revoke tenant2's API key as tenant1 user
response = authenticated_client.delete(
reverse("api-key-revoke", kwargs={"pk": api_key2.id})
)
# Should return 404 due to RLS filtering
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_keys_read_only_fields_on_create(
self, authenticated_client, create_test_user
):
"""Test that read-only fields are ignored during creation."""
# Note: Fields not in serializer (like 'prefix', 'revoked') will cause 400
# So we only test that the response has correct read-only values
data = {
"data": {
"type": "api-keys",
"attributes": {
"name": "Test Read-Only",
},
}
}
response = authenticated_client.post(
reverse("api-key-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
response_data = response.json()["data"]
# Verify read-only fields have correct default/auto-generated values
# Prefix should be auto-generated (not empty, not None)
assert response_data["attributes"]["prefix"] is not None
assert len(response_data["attributes"]["prefix"]) > 0
# Revoked should be False (default)
assert response_data["attributes"]["revoked"] is False
# Entity should be set to current user (auto-assigned)
assert response_data["relationships"]["entity"]["data"]["id"] == str(
create_test_user.id
)
def test_api_keys_entity_relationship_included(
self, authenticated_client, api_keys_fixture
):
"""Test that entity (user) relationship is included correctly."""
api_key = api_keys_fixture[0]
response = authenticated_client.get(
reverse("api-key-detail", kwargs={"pk": api_key.id})
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert "entity" in data["relationships"]
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)
# 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
):
"""Test that entity is automatically assigned to current user on creation."""
data = {
"data": {
"type": "api-keys",
"attributes": {
"name": "Auto Entity Key",
},
}
}
response = authenticated_client.post(
reverse("api-key-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
response_data = response.json()["data"]
# Entity should be set to authenticated user
assert response_data["relationships"]["entity"]["data"]["id"] == str(
create_test_user.id
)
# Verify in database
api_key_id = response_data["id"]
api_key = TenantAPIKey.objects.get(id=api_key_id)
assert api_key.entity.id == create_test_user.id
def test_api_keys_list_response_structure(
self, authenticated_client, api_keys_fixture
):
"""Test that list response follows JSON:API structure."""
response = authenticated_client.get(reverse("api-key-list"))
assert response.status_code == status.HTTP_200_OK
response_data = response.json()
# Verify top-level structure
assert "data" in response_data
assert "meta" in response_data
assert isinstance(response_data["data"], list)
# Verify pagination meta
assert "pagination" in response_data["meta"]
assert "count" in response_data["meta"]["pagination"]
assert "page" in response_data["meta"]["pagination"]
assert "pages" in response_data["meta"]["pagination"]
def test_api_keys_retrieve_response_structure(
self, authenticated_client, api_keys_fixture
):
"""Test that retrieve response follows JSON:API structure."""
api_key = api_keys_fixture[0]
response = authenticated_client.get(
reverse("api-key-detail", kwargs={"pk": api_key.id})
)
assert response.status_code == status.HTTP_200_OK
response_data = response.json()
# Verify top-level structure
assert "data" in response_data
data = response_data["data"]
# Verify resource object structure
assert "type" in data
assert data["type"] == "api-keys"
assert "id" in data
assert "attributes" in data
assert "relationships" in data
def test_api_keys_create_response_structure(
self, authenticated_client, create_test_user
):
"""Test that create response follows JSON:API structure."""
data = {
"data": {
"type": "api-keys",
"attributes": {
"name": "Structure Test Key",
},
}
}
response = authenticated_client.post(
reverse("api-key-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
response_data = response.json()
# Verify top-level structure
assert "data" in response_data
data = response_data["data"]
# Verify resource object structure
assert "type" in data
assert data["type"] == "api-keys"
assert "id" in data
assert "attributes" in data
assert "relationships" in data
# Verify api_key is included in creation response only
assert "api_key" in data["attributes"]
assert data["attributes"]["api_key"] is not None
def test_api_keys_error_response_structure(self, authenticated_client):
"""Test that error responses follow JSON:API structure."""
response = authenticated_client.get(
reverse(
"api-key-detail",
kwargs={"pk": "f498b103-c760-4785-9a3e-e23fafbb7b02"},
)
)
assert response.status_code == status.HTTP_404_NOT_FOUND
response_data = response.json()
# Verify error structure
assert "errors" in response_data
assert isinstance(response_data["errors"], list)
assert len(response_data["errors"]) > 0
# Verify error object structure
error = response_data["errors"][0]
assert "detail" in error or "title" in error
+149
View File
@@ -39,6 +39,7 @@ from api.models import (
StateChoices,
StatusChoices,
Task,
TenantAPIKey,
User,
UserRoleRelationship,
)
@@ -316,6 +317,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)
@@ -908,6 +926,17 @@ class ProviderCreateSerializer(RLSSerializer, BaseWriteSerializer):
"uid",
# "scanner_args"
]
extra_kwargs = {
"alias": {
"help_text": "Human readable name to identify the provider, e.g. 'Production AWS Account', 'Dev Environment'",
},
"provider": {
"help_text": "Type of provider to create.",
},
"uid": {
"help_text": "Unique identifier for the provider, set by the provider, e.g. AWS account ID, Azure subscription ID, GCP project ID, etc.",
},
}
class ProviderUpdateSerializer(BaseWriteSerializer):
@@ -922,6 +951,11 @@ class ProviderUpdateSerializer(BaseWriteSerializer):
"alias",
# "scanner_args"
]
extra_kwargs = {
"alias": {
"help_text": "Human readable name to identify the provider, e.g. 'Production AWS Account', 'Dev Environment'",
}
}
# Scans
@@ -1957,6 +1991,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()
@@ -2735,3 +2780,107 @@ class LighthouseConfigUpdateSerializer(BaseWriteSerializer):
instance.api_key_decoded = api_key
instance.save()
return instance
# API Keys
class TenantApiKeySerializer(RLSSerializer):
"""
Serializer for the TenantApiKey model.
"""
# Map database field names to API field names for consistency
expires_at = serializers.DateTimeField(source="expiry_date", read_only=True)
inserted_at = serializers.DateTimeField(source="created", read_only=True)
class Meta:
model = TenantAPIKey
fields = [
"id",
"name",
"prefix",
"expires_at",
"revoked",
"inserted_at",
"last_used_at",
"entity",
]
included_serializers = {
"entity": "api.v1.serializers.UserIncludeSerializer",
}
class TenantApiKeyCreateSerializer(RLSSerializer, BaseWriteSerializer):
"""Serializer for creating new API keys."""
# Map database field names to API field names for consistency
expires_at = serializers.DateTimeField(source="expiry_date", required=False)
inserted_at = serializers.DateTimeField(source="created", read_only=True)
api_key = serializers.SerializerMethodField()
class Meta:
model = TenantAPIKey
fields = [
"id",
"name",
"prefix",
"expires_at",
"revoked",
"entity",
"inserted_at",
"last_used_at",
"api_key",
]
extra_kwargs = {
"id": {"read_only": True},
"prefix": {"read_only": True},
"revoked": {"read_only": True},
"entity": {"read_only": True},
"inserted_at": {"read_only": True},
"last_used_at": {"read_only": True},
"api_key": {"read_only": True},
}
def get_api_key(self, obj):
"""Return the raw API key if it was stored during creation."""
return getattr(obj, "_raw_api_key", None)
def create(self, validated_data):
instance, raw_api_key = TenantAPIKey.objects.create_api_key(
**validated_data,
tenant_id=self.context.get("tenant_id"),
entity=self.context.get("request").user,
)
# Store the raw API key temporarily on the instance for the serializer
instance._raw_api_key = raw_api_key
return instance
class TenantApiKeyUpdateSerializer(RLSSerializer, BaseWriteSerializer):
"""Serializer for updating API keys - only allows changing the name."""
# Map database field names to API field names for consistency
expires_at = serializers.DateTimeField(source="expiry_date", read_only=True)
inserted_at = serializers.DateTimeField(source="created", read_only=True)
class Meta:
model = TenantAPIKey
fields = [
"id",
"name",
"prefix",
"expires_at",
"entity",
"inserted_at",
"last_used_at",
]
extra_kwargs = {
"id": {"read_only": True},
"prefix": {"read_only": True},
"entity": {"read_only": True},
"expires_at": {"read_only": True},
"inserted_at": {"read_only": True},
"last_used_at": {"read_only": True},
}
+2
View File
@@ -39,6 +39,7 @@ from api.v1.views import (
TenantViewSet,
UserRoleRelationshipView,
UserViewSet,
TenantApiKeyViewSet,
)
router = routers.DefaultRouter(trailing_slash=False)
@@ -65,6 +66,7 @@ router.register(
LighthouseConfigViewSet,
basename="lighthouseconfiguration",
)
router.register(r"api-keys", TenantApiKeyViewSet, basename="api-key")
tenants_router = routers.NestedSimpleRouter(router, r"tenants", lookup="tenant")
tenants_router.register(
+110 -2
View File
@@ -95,6 +95,7 @@ from api.filters import (
ScanSummarySeverityFilter,
ServiceOverviewFilter,
TaskFilter,
TenantApiKeyFilter,
TenantFilter,
UserFilter,
)
@@ -124,6 +125,7 @@ from api.models import (
SeverityChoices,
StateChoices,
Task,
TenantAPIKey,
User,
UserRoleRelationship,
)
@@ -140,6 +142,7 @@ from api.v1.mixins import PaginateByPkMixin, TaskManagementMixin
from api.v1.serializers import (
ComplianceOverviewAttributesSerializer,
ComplianceOverviewDetailSerializer,
ComplianceOverviewDetailThreatscoreSerializer,
ComplianceOverviewMetadataSerializer,
ComplianceOverviewSerializer,
FindingDynamicFilterSerializer,
@@ -189,6 +192,9 @@ from api.v1.serializers import (
ScanUpdateSerializer,
ScheduleDailyCreateSerializer,
TaskSerializer,
TenantApiKeyCreateSerializer,
TenantApiKeySerializer,
TenantApiKeyUpdateSerializer,
TenantSerializer,
TokenRefreshSerializer,
TokenSerializer,
@@ -387,6 +393,11 @@ class SchemaView(SpectacularAPIView):
"description": "Endpoints for Single Sign-On authentication management via SAML for seamless user "
"authentication.",
},
{
"name": "API Keys",
"description": "Endpoints for API keys management. These can be used as an alternative to JWT "
"authorization.",
},
]
return super().get(request, *args, **kwargs)
@@ -3428,7 +3439,12 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
all_requirements = (
filtered_queryset.values(
"requirement_id", "framework", "version", "description"
"requirement_id",
"framework",
"version",
"description",
"passed_findings",
"total_findings",
)
.distinct()
.annotate(
@@ -3453,6 +3469,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"]
total_findings = requirement["total_findings"]
if is_manual:
requirement_status = "MANUAL"
elif passed_count == total_instances:
@@ -3467,10 +3485,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")
@@ -4190,3 +4217,84 @@ class ProcessorViewSet(BaseRLSViewSet):
elif self.action == "partial_update":
return ProcessorUpdateSerializer
return super().get_serializer_class()
@extend_schema_view(
list=extend_schema(
tags=["API Keys"],
summary="List API keys",
description="Retrieve a list of API keys for the tenant, with filtering support.",
),
retrieve=extend_schema(
tags=["API Keys"],
summary="Retrieve API key details",
description="Fetch detailed information about a specific API key by its ID.",
),
create=extend_schema(
tags=["API Keys"],
summary="Create a new API key",
description="Create a new API key for the tenant.",
),
partial_update=extend_schema(
tags=["API Keys"],
summary="Partially update an API key",
description="Modify certain fields of an existing API key without affecting other settings.",
),
revoke=extend_schema(
tags=["API Keys"],
summary="Revoke an API key",
description="Revoke an API key by its ID. This action is irreversible and will prevent the key from being "
"used.",
request=None,
responses={
200: OpenApiResponse(
response=TenantApiKeySerializer,
description="API key was successfully revoked",
)
},
),
)
class TenantApiKeyViewSet(BaseRLSViewSet):
queryset = TenantAPIKey.objects.all()
serializer_class = TenantApiKeySerializer
filterset_class = TenantApiKeyFilter
http_method_names = ["get", "post", "patch", "delete"]
ordering = ["revoked", "-created"]
ordering_fields = ["name", "prefix", "revoked", "inserted_at", "expires_at"]
# RBAC required permissions
required_permissions = [Permissions.MANAGE_ACCOUNT]
def get_queryset(self):
queryset = TenantAPIKey.objects.filter(
tenant_id=self.request.tenant_id
).annotate(inserted_at=F("created"), expires_at=F("expiry_date"))
return queryset
def get_serializer_class(self):
if self.action == "create":
return TenantApiKeyCreateSerializer
elif self.action == "partial_update":
return TenantApiKeyUpdateSerializer
return super().get_serializer_class()
@extend_schema(exclude=True)
def destroy(self, request, *args, **kwargs):
raise MethodNotAllowed(method="DESTROY")
@action(detail=True, methods=["delete"])
def revoke(self, request, *args, **kwargs):
instance = self.get_object()
# Check if already revoked
if instance.revoked:
raise ValidationError(
{
"detail": "API key is already revoked",
}
)
TenantAPIKey.objects.revoke_api_key(instance.pk)
instance.refresh_from_db()
serializer = self.get_serializer(instance)
return Response(data=serializer.data, status=status.HTTP_200_OK)
+7
View File
@@ -48,6 +48,10 @@ class NDJSONFormatter(logging.Formatter):
log_record["user_id"] = record.user_id
if hasattr(record, "tenant_id"):
log_record["tenant_id"] = record.tenant_id
if hasattr(record, "api_key_prefix"):
log_record["api_key_prefix"] = (
record.api_key_prefix if record.api_key_prefix != "N/A" else None
)
if hasattr(record, "method"):
log_record["method"] = record.method
if hasattr(record, "path"):
@@ -90,6 +94,9 @@ class HumanReadableFormatter(logging.Formatter):
# Add REST API extra fields
if hasattr(record, "user_id"):
log_components.append(f"({record.user_id})")
if hasattr(record, "api_key_prefix"):
if record.api_key_prefix != "N/A":
log_components.append(f"(API-Key {record.api_key_prefix})")
if hasattr(record, "tenant_id"):
log_components.append(f"[{record.tenant_id}]")
if hasattr(record, "method"):
+11 -2
View File
@@ -43,6 +43,7 @@ INSTALLED_APPS = [
"allauth.socialaccount.providers.saml",
"dj_rest_auth.registration",
"rest_framework.authtoken",
"drf_simple_apikey",
]
MIDDLEWARE = [
@@ -84,7 +85,7 @@ TEMPLATES = [
REST_FRAMEWORK = {
"DEFAULT_SCHEMA_CLASS": "drf_spectacular_jsonapi.schemas.openapi.JsonApiAutoSchema",
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework_simplejwt.authentication.JWTAuthentication",
"api.authentication.CombinedJWTOrAPIKeyAuthentication",
),
"PAGE_SIZE": 10,
"EXCEPTION_HANDLER": "api.exceptions.custom_exception_handler",
@@ -220,7 +221,8 @@ SIMPLE_JWT = {
"JTI_CLAIM": "jti",
"USER_ID_FIELD": "id",
"USER_ID_CLAIM": "sub",
# Issuer and Audience claims, for the moment we will keep these values as default values, they may change in the future.
# Issuer and Audience claims, for the moment we will keep these values as default values, they may change in the
# future.
"AUDIENCE": env.str("DJANGO_JWT_AUDIENCE", "https://api.prowler.com"),
"ISSUER": env.str("DJANGO_JWT_ISSUER", "https://api.prowler.com"),
# Additional security settings
@@ -229,6 +231,13 @@ SIMPLE_JWT = {
SECRETS_ENCRYPTION_KEY = env.str("DJANGO_SECRETS_ENCRYPTION_KEY", "")
# DRF Simple API Key settings
DRF_API_KEY = {
"FERNET_SECRET": SECRETS_ENCRYPTION_KEY,
"API_KEY_LIFETIME": 365,
"AUTHENTICATION_KEYWORD_HEADER": "Api-Key",
}
# Internationalization
# https://docs.djangoproject.com/en/5.0/topics/i18n/
+7
View File
@@ -20,6 +20,13 @@ DATABASE_ROUTERS = []
TESTING = True
SECRETS_ENCRYPTION_KEY = "ZMiYVo7m4Fbe2eXXPyrwxdJss2WSalXSv3xHBcJkPl0="
# DRF Simple API Key settings
DRF_API_KEY = {
"FERNET_SECRET": SECRETS_ENCRYPTION_KEY,
"API_KEY_LIFETIME": 365,
"AUTHENTICATION_KEYWORD_HEADER": "Api-Key",
}
# JWT
SIMPLE_JWT["ALGORITHM"] = "HS256" # noqa: F405
+51
View File
@@ -38,6 +38,7 @@ from api.models import (
StateChoices,
StatusChoices,
Task,
TenantAPIKey,
User,
UserRoleRelationship,
)
@@ -1368,6 +1369,56 @@ def saml_sociallogin(users_fixture):
return sociallogin
@pytest.fixture
def api_keys_fixture(tenants_fixture, create_test_user):
"""Create test API keys for testing."""
tenant = tenants_fixture[0]
user = create_test_user
# Create and assign role to user for API key authentication
role = Role.objects.create(
tenant_id=tenant.id,
name="Test API Key Role",
unlimited_visibility=True,
manage_account=True,
)
UserRoleRelationship.objects.create(
user=user,
role=role,
tenant_id=tenant.id,
)
# Create API keys with different states
api_key1, raw_key1 = TenantAPIKey.objects.create_api_key(
name="Test API Key 1",
tenant_id=tenant.id,
entity=user,
)
api_key2, raw_key2 = TenantAPIKey.objects.create_api_key(
name="Test API Key 2",
tenant_id=tenant.id,
entity=user,
expiry_date=datetime.now(timezone.utc) + timedelta(days=60),
)
# Revoked API key
api_key3, raw_key3 = TenantAPIKey.objects.create_api_key(
name="Revoked API Key",
tenant_id=tenant.id,
entity=user,
)
api_key3.revoked = True
api_key3.save()
# Store raw keys on instances for testing
api_key1._raw_key = raw_key1
api_key2._raw_key = raw_key2
api_key3._raw_key = raw_key3
return [api_key1, api_key2, api_key3]
def get_authorization_header(access_token: str) -> dict:
return {"Authorization": f"Bearer {access_token}"}
+39 -2
View File
@@ -590,7 +590,7 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
# 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,7 +601,9 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
.iterator(chunk_size=1000)
)
findings_count_by_compliance = {}
check_status_by_region = {}
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
with rls_transaction(tenant_id):
for finding in findings:
for resource in finding.small_resources:
@@ -609,6 +611,27 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
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
@@ -644,6 +667,13 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
compliance_requirement_objects = []
for region, compliance_data in compliance_overview_by_region.items():
for compliance_id, compliance in compliance_data.items():
modeled_framework = (
compliance["framework"].lower().replace("-", "").replace("_", "")
)
modeled_version = (
compliance["version"].lower().replace("-", "").replace("_", "")
)
modeled_compliance_id = f"{modeled_framework}{modeled_version}"
# Create an overview record for each requirement within each compliance framework
for requirement_id, requirement in compliance["requirements"].items():
compliance_requirement_objects.append(
@@ -660,9 +690,16 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
failed_checks=requirement["checks_status"]["fail"],
total_checks=requirement["checks_status"]["total"],
requirement_status=requirement["status"],
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
+533
View File
@@ -0,0 +1,533 @@
Always that you are writting documentation try to follow this text/communication style guide:
# Prowler's Brand Voice
Prowler is the open cloud security platform trusted by thousands of organizations automating security monitoring and compliance with hundreds of built-in security checks, remediation solutions, and compliance frameworks. With over 10 million downloads, thousands of contributors, and a vibrant global community, Prowler is driving the open-source cloud security movement by providing transparent, customizable, and user-friendly solutions that help teams secure AWS, Azure, GCP, Kubernetes, and Microsoft 365 environments. Leveraging open-source innovation and cost savings, the Prowler platform makes cloud security 10 times more cost-effective and accessible than alternatives.
These values must be demonstrated in all our conversations and communications.
---
## Unbiased Communication
Prowler aims to reach every person in the globe. Our communications must be as inclusive and diverse as possible every time. We are guided by the following principles:
### Avoid Gendered Pronouns
Reference to gendered pronouns (she/her/hers, he/his/his, they/them/theirs) must be avoided whenever possible.
* Use second person for communications (you/your/yours).
* Use a third-person reference instead of a gendered pronoun (the customer, the user).
* In case a gendered pronoun must be forcibly used, use they/them/theirs.
* Avoid double references like she/he, s/he, etc.
### Use Alternatives for Gendered Nouns
Avoid nouns that include gendered components. Examples:
* Businessman 🡪 Entrepreneur, businessperson, executive
* Salesman 🡪 Sales executive, sales representative
* Mankind 🡪 Humanity, people
* Penmanship 🡪 Calligraphy, handwriting
* Middleman 🡪 Intermediary, negotiator
### Diversity, Equity, and Inclusion
All communications must prioritize diversity and inclusivity. When incorporating examples, ensure representation across sex, gender, age, identity, race, culture, background, ability, and socioeconomic status. Strive for balanced and respectful depictions.
### Cultural and Geographical Awareness
Before referencing a region, country, culture, national status, political status, or socioeconomic realities, conduct thorough research. Maintain a respectful, informed approach and avoid unnecessary conflicts.
### Avoiding Generalizations
Avoid broad assumptions about gender, sex, race, sexual orientation, nationality, or culture. Generalizations can introduce bias and misrepresentation. Example to avoid: "Cybersecurity is of the utmost importance in the country, where corruption runs amok."
### Respectful Language
Derogatory terms must not be used. If uncertain about terminology, consult individuals from the relevant region or community to ensure accuracy and appropriateness.
### Clear and Accessible Language
* **Jargon:** Use technical terminology only when the audience is expected to understand it. If uncertain, opt for clear and universally accessible language.
* **Slang:** Minimize slang usage. Even when confident about the audience, prefer formal and neutral language to enhance clarity.
### Militaristic Language
Current tendencies in a topic as sensitive as cybersecurity avoid violent and militaristic references save for explicit reference to combat. These are some alternatives:
* Combat, fight, eliminate 🡪 Address, protect, safeguard, ward
* Kill chain 🡪 Cyberattack chain
* Attacker 🡪 Cyberattacker, bad actor, threat actor
* Defense-in-depth approach 🡪 Multilayered approach
* First line of defense, frontline 🡪 Security, protection, defense
* External attack surface 🡪 Vulnerabilities, point of access, external exposure
### Note on Safety and Security
“Safety” and “security” are terms often misunderstood. “Safety” is the microscopic, personal and individual term, while “security” is the macroscopic, broader, national term. Examples:
a. Seat belts are great for personal safety.
b. National security is of the utmost concern nowadays.
---
## Naming Conventions
### Prowler Features
Prowler Features are considered proper nouns. They are to be referenced without articles in all pieces of writing.
This is a list of Prowler Features:
* **Prowler App**
* **Prowler CLI**
* **Prowler SDK**
* **Built-in Compliance Checks**
* **Multi-cloud Security Scanning**
* **Autonomous Cloud Security Analyst (AI)**
* **Threat & Misconfiguration Detection**
* **Role-Based Access Control (RBAC)**
* **Identity & Access Risk Detection**
* **Tag-Based Scanning & Filtering**
* **Audit Logs & Security Reports**
* **Agentless & Works Anywhere**
* **Automated Scans & Continuous Monitoring**
* **Chat-based Security Querying (AI)**
* **AI-Generated Detections & Remediations**
* **Prowler Studio**
* **Custom Security Policies**
* **Prowler Cloud**
* **Prowler Registry**
* **Open Source & Full APIs**
---
## Verbal Constructions in Technical Writing
Choosing verbal constructions (using verbs) over nominal (using nouns) constructions can significantly impact the clarity, conciseness and especially readability of the content.
Nominal constructions often introduce unnecessary complexity or vagueness. For example:
* Nominal: "The creation of the report was successful."
* Verbal: "The report was successfully created."
Verbal constructions also tend to use fewer words, resulting in a more polished and concise style:
* Nominal: "The implementation of the solution reduced system downtime."
* Verbal: "The solution reduced system downtime."
Verbal constructions are to be chosen over nominal constructions whenever possible.
---
### Addendum: Verbal Structures Actually State your Purpose
* **Example 1:** Recommendation for multiple subscriptions
* **Example 2:** Recommendation for Managing Multiple Subscriptions
Example 1 is vague and even potentially ambiguous. Verbs state your purpose and they must be used whenever possible.
---
## Avoiding The Second Person Except for Imperative Instructions
Explicit use of second-person pronouns (you) and possessives (your) should be minimized whenever possible. Those constructions are best reserved for cases when instructions are directly given in an imperative form:
**Example of Improvement Through Avoiding Second Person Pronouns**
**Original:**
Prowler App can be installed in different ways, depending on your environment:
**Improved Version:**
Prowler App offers flexible installation methods tailored to various environments:
---
## Title-Case Capitalization
We use title case.
**Example:** This Is an Example on Title Case
Title case tends to be better for SEO because it improves readability and makes headlines more visually distinct, which can lead to higher click-through rates (CTR).
---
### Other Considerations on Capitalization
Follow these additional guidelines for capitalization:
### Inner Capitalization
Avoid internal capitalization of words in body text unless it is part of a proper name or brand denomination. Example: instead of E-mail and e-Book use email/e-mail and e-book.
### Acronym Capitalization
Do not capitalize the individual words of the spelled-out form of acronyms. Example: instead of CTI (Cyber Threat Intelligence) use CTI (cyber threat intelligence), but AWS (Amazon Web Services) is to be kept as is.
### Avoid Capitalization for Emphasis
Do not capitalize words in order to emphasize them.
### Capitalization of Languages and Standards
Check for the proper capitalization of language and standard names.
Language examples: HTML, JSON, YAML, XML, etc., must be capitalized.
Standard examples: standards follow title-case capitalization: Industrial Automation and Control Systems (IACS).
### Capitalization of Laws and Regulations
Laws and Regulations follow title-case capitalization. If referring to a non-domestic law or regulation, add the nationality and the original name.
**Example:** Code for the Cybersecurity Law published in the Spanish Official State Bulletin (BOE, Boletín Oficial del Estado).
Most UE Regulations have an official translation for all UE languages; please check it on EUR-Lex portal and choose the proper language: https://eur-lex.europa.eu/.
The different languages can be chosen on the portal under Languages, formats and link to OJ.
---
## Hyphenation
Hyphenation is to be used for noun modifiers in prenominal position, i.e., placed before nouns.
**Example:** Prowler is a world-leading company in open-source software.
It is not to be used for predicate adjectives in postnominal position.
**Example:** Prowler has many features built in.
### Note on Hyphenation and SEO
Google treats hyphens as word separators, as if they were blank spaces, i.e., the term `high quality checks` is treated as if it was the same term as `high-quality checks`. Hyphenation does not affect SEO on body text, thus the grammatically correct approach is recommended as sign of good writing. However, underscores (`_`) are treated as different words. This has implications particularly for URLs.
Hyphens are preferred for URLs as they improve readability and indexing.
**Example:**
* Better approach: `example.com/this-is-an-URL`
* Less ideal approach: `example.com/this_is_an_URL`
---
## Bullet Points
Bullet points offer several advantages:
* **Improved readability:** Bullet points make information scannable and enable vertical reading, allowing users to easily spot relevant details and breaking content into more digestible pieces.
* **Highlighting of relevant information:** They emphasize the most salient points, improving focus and enabling quick summarization at a glance.
* **Improved retention:** Bullet points enhance memory retention and contribute to a clearer, more polished final product.
* **Structured presentation:** They improve user experience through the logical organization of content.
* **SEO relevance (Search Engine Optimization):** Bullet points make content easier to consume and offer the following SEO benefits:
* Reduced bounce rates
* Increased time spent on page
* Strategic keyword optimization
* Improved chances of being featured in search engine snippets
* Enhanced crawlability for search engines.
---
### When to Use Bullet Points
The use of bullet points is highly recommended when:
* Information can be logically divided into multiple categories, each sharing characteristics, features, or other relevant classifications.
* Items are significant enough as standalone concepts to deserve their own bullet point.
**Example of Improvement Through Bullet Points**
**Original:**
It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMS, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme), and your custom security frameworks.
**Improved with Bullet Points:**
**Prowler CLI Features:**
Prowler CLI includes hundreds of built-in controls to ensure compliance with standards and frameworks, including:
* **Industry standards:** CIS, NIST 800, NIST CSF, and CISA
* **Regulatory compliance and governance:** RBI, FedRAMP, and PCI-DSS
* **Frameworks for sensitive data and privacy:** GDPR, HIPAA, and FFIEC
* **Frameworks for organizational governance and quality control:** SOC2 and GXP
* **AWS-specific guidance:** AWS Foundational Technical Review (FTR) and AWS Well-Architected Framework (Security Pillar)
* **Regional compliance:** ENS (Spanish National Security Scheme)
* **Custom security frameworks:** Tailored to meet your organizations specific needs
---
### Punctuation of Bullet Points
There are several options for punctuating bullet points. Regardless of the style chosen, it is imperative to maintain consistency throughout the text.
* **No punctuation (minimalistic):** This strategy is suitable when no verbs are involved and is best used to highlight products or features in isolation. For example:
Prowler App is composed of three key components:
* Prowler UI
* Prowler API
* Prowler SDK
This example highlights each element individually and fosters retention with a noise-free approach.
* **Periods for full sentences:** This approach works best when each bullet point forms a full sentence or includes verbs. For example:
Prowler App is composed of three key components:
* Prowler UI, a web-based interface, built with Next.js, providing a user-friendly experience for executing Prowler scans and visualizing results.
* Prowler API, a backend service, developed with Django REST Framework, responsible for running Prowler scans and storing the generated results.
* Prowler SDK, a Python SDK designed to extend the functionality of the Prowler CLI for advanced capabilities.
This example demonstrates a polished list using proper punctuation.
* **Semi-colons with final period:** This approach was traditionally used for those cases when bullet points were part of a continuous sentence or logical succession. However, it is being deprecated, consistent with the declining use of semi-colons in modern writing. It is to be avoided whenever possible. For example:
Prowler UI:
* is a web-based interface;
* is built with Next.js;
* provides a user-friendly experience for executing Prowler scans and visualizing results.
---
### Advantages of Adding Headers to Bullet Points
Adding headers to bullet points in technical writing is a powerful technique that enhances both the clarity and usability of the content. It has also advantages for SEO:
* Increased crawlability of search engines
* Enhanced keyword integration
* Improved user engagement
* Enhanced snippetting by search engines
* Reduced bounce rates
It is recommended to add headers to bullet points whenever possible.
---
## Quotation Marks
### Quotation Marks Usage in Technical Documentation
Proper use of quotation marks enhances clarity and consistency in technical writing. Below are key guidelines for using double and single quotation marks, following American English conventions.
### Double Quotation Marks
* Use for titles of books, movies, songs, and articles.
* Enclose direct quotes:
* **Example:** The developer said, “We will try to fix this issue.”
* Capitalize the first word if quoting a full sentence.
* When quoting a phrase within a sentence, do not capitalize:
* **Example:** The news portal called our product “one of the most efficient online help authoring tools.”
* Use scare quotes when words acquire a different or ironic meaning:
* **Example:** The update is “scheduled” to release next week.
* To refer to a term without applying its meaning, use double quotes (or italics):
* **Example:** Avoid terms like “dont worry” in pop-ups to prevent user anxiety.
### Single Quotation Marks
* Used inside double quotes:
* **Example:** He said, “I am not sure what single sourcing means.”
* In British English, the order is often reversed (single quotation marks on the outside).
---
### Double Quoting in Software Documentation
Double quoting is to be used through software documentation where their use does not interfere with formatting restrictions.
1. **Menu Items & UI Options**
* Use double quotation marks when referring to selectable items in software interfaces.
* **Example:** Click “File” and select “Save As” to export your document.
2. **Buttons & Commands**
* Use double quotes for labeled interface elements that users interact with.
* **Example:** Select “Submit” to finalize the form.
3. **Exact Input & User Actions**
* If users need to enter exact text, enclose it in double quotes.
* **Example:** Type “admin” in the username field.
4. **Avoid Quoting Software Names**
* Do not use quotation marks for software product names unless required for clarity.
* **Correct:** Open Microsoft Excel.
* **Incorrect:** Open “Microsoft Excel.”
---
## Interaction Verbs
The following are the correct verbs that must be used when referring to user interactions with the software.
### Mouse & Trackpad Actions (Desktop/Laptop)
* **Click:** Press and release the left mouse button or trackpad without moving the pointer.
* **Example:** Click the “OK” button to confirm. 🡪 Transitive
* **Click on:** Often interchangeable with "Click," but less commonly used in technical writing for UI interactions.
* **Example:** Click on the "Settings" icon to open preferences. (Less recommended—“Click” is preferred.)
* **Double-click:** Press and release twice in quick succession, usually to open files or applications.
* **Example:** Double-click the document to open it. 🡪 Transitive
* **Right-click:** Press and release the right mouse button to open a context menu.
* **Example:** Right-click the folder and select “Properties.” 🡪 Transitive
### Touchscreen Actions (Mobile & Touch)
* **Tap:** Touch the screen lightly with a finger or stylus, equivalent to "Click" on a mouse.
* **Example:** Tap the “Sign in” button.
* **Double-tap:** Quickly touch the screen twice, often used for zooming or selecting text.
* **Example:** Double-tap an image to zoom in.
* **Press and hold:** Touch and hold the screen for a moment to access additional options.
* **Example:** Press and hold an app icon to see more actions. (Similar to “Right-click” in desktop environments.)
### Additional Actions
* **Drag:** Click or tap an item and move it while holding down the button or finger.
* **Example:** Drag the file into the folder.
* **Swipe:** Move a finger across the touchscreen horizontally or vertically.
* **Example:** Swipe left to dismiss the notification.
* **Pinch to zoom:** Use two fingers to zoom in or out.
* **Example:** Pinch the screen to zoom in on the image.
* **Scroll:** Move the mouse wheel, swipe, or use the arrow keys to navigate up/down.
* **Example:** Scroll down to see more results.
The widely-accepted terminology for gestures is Windows: https://support.microsoft.com/en-us/windows/touch-gestures-for-windows-a9d28305-4818-a5df-4e2b-e5590f850741
---
## Sentence Structure for Technical Writing and SEO
When writing technical documentation, clarity, conciseness, and searchability (SEO) are key factors. Lets compare the following two sentence structures, extracted from Prowlers documentation:
**Option 1:**
"Open a terminal and execute the following command to create a new custom role."
**Option 2:**
"To create a new custom role, open a terminal and execute the following command."
### SEO Optimization
* Search engines prioritize clear intent at the beginning of a sentence.
* Option 2 starts with the action users are likely to search for (e.g., "Create a custom role"), which improves SEO rankings and makes the content more likely to match search queries.
* Option 1 places the primary search term toward the end, making it less effective for keyword optimization.
### Technical Writing Best Practices
* Technical writing emphasizes clear objectives first, followed by actions.
* Option 2 follows this best practice by stating the goal first ("To create a new custom role") and then providing instructions.
* Option 1 is still acceptable for step-by-step guides, but Option 2 is more effective for tutorials, manuals, and documentation.
### Key Takeaways
* Draft trying to mimic the most likely way users are to find the information (“Ctrl + F approach”).
* Place keywords and key terms at the beginning of sentences so that they rank better SEO-wise.
* Rule of thumb: “In order to what” precedes the “what”. “What” must mirror the users most likely way of drafting or searching.
---
## Section Titles and Headers in Technical Writing
Effective headers and section titles enhance document readability and structure, making content more accessible to the reader. This chapter outlines best practices for crafting clear, consistent, and meaningful headings.
1. **Purpose of Headers**
Headers serve several key functions:
* **Improve Navigation:** Allow users to quickly locate relevant information.
* **Enhance Readability:** Break down complex topics into manageable sections.
* **Establish Hierarchy:** Define the logical flow of content.
* **SEO:** Headers impact SEO both directly and indirectly:
* Search engines use headings to determine the hierarchy and relevance of content.
* **H1:** The primary heading (should be unique and descriptive).
* **H2-H6:** Subheadings that break down content logically.
* Best practices for SEO-friendly headers:
* Include keywords naturally in headings.
* Avoid keyword stuffing—keep it clear and readable.
* Use structured hierarchy (H1 → H2 → H3, etc.).
2. **Header Levels and Formatting**
Use a structured approach for organizing section titles. Common conventions include:
* **Title:** The primary heading of the document (e.g., H1).
* **Main Sections:** First-level headers (H2), introducing key content areas.
* **Subsections:** Second-level headers (H3) to detail specific topics within sections.
* **Subtopics:** Third-level headers (H4+) used sparingly for finer details.
**Example:**
```markdown
# Document Title (H1)
## Main Section (H2)
### Subsection (H3)
#### Subtopic (H4)
```
3. **Writing Effective Headers**
When crafting headers and section titles, follow these guidelines:
* **Be Descriptive:** Clearly indicate what the section covers.
* **Poor:** Introduction (too vague)
* **Good:** Introduction to AWS CloudShell Installation (informative)
* **Keep It Concise:** Use precise language without unnecessary words.
* **Maintain Consistency:** Apply uniform formatting and style conventions throughout.
* **Avoid Special Characters:** Limit punctuation for clarity—avoid excessive symbols, dashes, or underscores.
4. **Capitalization Rules**
Use Title Case for headers to ensure a professional look:
* **Good:** How to Clone and Install Prowler from GitHub
* **Poor:** How to clone and install Prowler from GitHub
For technical documentation, sentence case may be used for readability in subheadings. Please note this differs from headers and it is only a recommendation, but consistency is to be kept throughout the documentation:
* **Example:**
* How to Clone and Install Prowler from GitHub (header: Title case)
* How to install poetry dependencies (subheading: Sentence case)
5. **Using Keywords in Headers**
Headers should include relevant keywords to improve document searchability:
* **Good:** Scanning AWS Accounts in Parallel
* **Poor:** Ways to scan on AWS (vague and imprecise)
6. **Consistency Across Documents**
Ensure uniformity in section titles across related documentation:
* **Standardized Header Naming:** Use consistent wording for common sections (e.g., "Installation," "Setup," "Configuration").
* **Numbering Sections (If Necessary):** For structured guides, include numbering where appropriate (e.g., "Step 1: Install Prowler").
---
## Avoid Assumptions Regarding Audiences Expertise
### Understand Your Audiences Expertise
Despite knowing your target audience, assumptions on target audiences expertise or knowledge are to be avoided.
Adjust the level of detail based on expected reader proficiency, but make sure to be as explanatory as humanly possible.
### Define Key Terms and Acronyms on First Use
Even if your audience is technical, some domain-specific terms may vary.
* Introduce jargon only after defining it clearly.
* If using acronyms (e.g., IAM, MFA), spell them out on first mention:
* AWS Identity and Access Management (IAM)
* Multifactor Authentication (MFA)
### Dont Assume Unwritten Knowledge
Even experienced readers may not know every prerequisite. If a process relies on prior steps, briefly reference them:
* Before configuring security groups, ensure VPC networking is set up.
### Use Consistent Formatting
### Provide as Many Examples as Deemed Right… and Then Some
### Anticipate Common Knowledge Gaps
### Avoid Excessive Notes
Notes are often omitted by readers and they clutter text, so use them sparingly and only for additional information that is not essential or prompts any error or mistake.
---
## Using Warnings and Danger Calls for High-Severity Information
In technical documentation, warnings and danger calls highlight critical risks, guiding users in preventing security breaches or system failures. Proper usage ensures clarity and actionable guidance.
1. **Define Severity Levels**
Before applying Note, Warning, or Danger, clearly define their significance:
* **Note:** Provides general information or best practices (low severity).
* **Warning:** Indicates potential issues if instructions are not followed (moderate severity).
* **Danger:** Highlights actions that could result in severe consequences, such as system corruption or data loss (high severity).
2. **Explain Consequences**
Each warning or danger call should explicitly describe the impact of ignoring the caution:
* **Good:** Disabling encryption may expose sensitive data to unauthorized access.
* **Poor:** Avoid disabling encryption.
3. **Provide Remediation and Troubleshooting**
Whenever possible, direct users to troubleshooting guides or mitigation steps to resolve the issue.
**Example:**
**Danger:** Running this command will **permanently delete all data**. Refer to @Data Recovery Guide for restoration steps.
-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
+3
View File
@@ -4,6 +4,9 @@ Prowler App supports multiple installation methods based on your environment.
Refer to the [Prowler App Tutorial](../tutorials/prowler-app.md) for detailed usage instructions.
???+ warning
Prowler configuration is based in `.env` files. Every version of Prowler can have differences on that file, so, please, use the file that corresponds with that version or repository branch or tag.
=== "Docker Compose"
_Requirements_:
+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).
+3 -4
View File
@@ -1,4 +1,3 @@
PROWLER_APP_EMAIL="your_registered@email.com"
PROWLER_APP_PASSWORD="your_user_pass"
PROWLER_APP_TENANT_ID="optional_tenant_to_login"
PROWLER_API_BASE_URL=https://api.prowler.com
PROWLER_APP_API_KEY="pk_your_api_key_here"
PROWLER_API_BASE_URL="https://api.prowler.com"
PROWLER_MCP_MODE="stdio"
+5
View File
@@ -51,4 +51,9 @@ COPY --from=builder --chown=prowler /app/pyproject.toml /app/pyproject.toml
ENV PATH="/app/.venv/bin:$PATH"
# Entry point for the MCP server
# Default to stdio mode, but allow overriding via command arguments
# Examples:
# docker run -p 8000:8000 prowler-mcp --transport http --host 0.0.0.0 --port 8000
# docker run prowler-mcp --transport stdio
ENTRYPOINT ["prowler-mcp"]
CMD ["--transport", "stdio"]
+173 -17
View File
@@ -43,6 +43,43 @@ docker run --rm --env-file ./.env -it prowler-mcp
## Running
The Prowler MCP server supports two transport modes:
- **STDIO mode** (default): For direct integration with MCP clients like Claude Desktop
- **HTTP mode**: For remote access over HTTP with Bearer token authentication
### Transport Modes
#### STDIO Mode (Default)
STDIO mode is the standard MCP transport for direct client integration:
```bash
cd prowler/mcp_server
uv run prowler-mcp
# or
uv run prowler-mcp --transport stdio
```
#### HTTP Mode (Remote Server)
HTTP mode allows the server to run as a remote service accessible over HTTP:
```bash
cd prowler/mcp_server
# Run on default host and port (127.0.0.1:8000)
uv run prowler-mcp --transport http
# Run on custom host and port
uv run prowler-mcp --transport http --host 0.0.0.0 --port 8080
```
For self-deployed MCP remote server, you can use also configure the server to use a custom API base URL with the environment variable `PROWLER_API_BASE_URL`; and the transport mode with the environment variable `PROWLER_MCP_MODE`.
```bash
export PROWLER_API_BASE_URL="https://api.prowler.com"
export PROWLER_MCP_MODE="http"
```
### Using uv directly
After installation, start the MCP server via the console script:
@@ -60,13 +97,61 @@ uvx /path/to/prowler/mcp_server/
### Using Docker
Run the pre-built Docker container:
#### STDIO Mode (Default)
Run the pre-built Docker container in STDIO mode:
```bash
cd prowler/mcp_server
docker run --rm --env-file ./.env -it prowler-mcp
```
#### HTTP Mode (Remote Server)
Run as a remote HTTP server:
```bash
cd prowler/mcp_server
# Run on port 8000 (accessible from host)
docker run --rm --env-file ./.env -p 8000:8000 -it prowler-mcp --transport http --host 0.0.0.0 --port 8000
# Run on custom port
docker run --rm --env-file ./.env -p 8080:8080 -it prowler-mcp --transport http --host 0.0.0.0 --port 8080
```
## Command Line Arguments
The Prowler MCP server supports the following command line arguments:
```
prowler-mcp [--transport {stdio,http}] [--host HOST] [--port PORT]
```
**Arguments:**
- `--transport {stdio,http}`: Transport method (default: stdio)
- `stdio`: Standard input/output transport for direct MCP client integration
- `http`: HTTP transport for remote server access
- `--host HOST`: Host to bind to for HTTP transport (default: 127.0.0.1)
- `--port PORT`: Port to bind to for HTTP transport (default: 8000)
**Examples:**
```bash
# Default STDIO mode
prowler-mcp
# Explicit STDIO mode
prowler-mcp --transport stdio
# HTTP mode with default host and port (127.0.0.1:8000)
prowler-mcp --transport http
# HTTP mode accessible from any network interface
prowler-mcp --transport http --host 0.0.0.0
# HTTP mode with custom port
prowler-mcp --transport http --host 0.0.0.0 --port 8080
```
## Available Tools
### Prowler Hub
@@ -130,27 +215,64 @@ All tools are exposed under the `prowler_app` prefix.
## Configuration
### Environment Variables
### Prowler Cloud and Prowler App (Self-Managed) Authentication
For Prowler Cloud and Prowler App (Self-Managed) features, you need to set the following environment variables:
> [!IMPORTANT]
> Authentication is not needed for using Prowler Hub features.
The Prowler MCP server supports different authentication in Prowler Cloud and Prowler App (Self-Managed) methods depending on the transport mode:
#### STDIO Mode Authentication
For STDIO mode, authentication is handled via environment variables using an API key:
```bash
# Required for Prowler Cloud and Prowler App (Self-Managed) authentication
export PROWLER_APP_EMAIL="your-email@example.com"
export PROWLER_APP_PASSWORD="your-password"
# Optional - in case not provided the first membership that was added to the user will be used. This can be found as `Organization ID` in your User Profile in Prowler App
export PROWLER_APP_TENANT_ID="your-tenant-id"
export PROWLER_APP_API_KEY="pk_your_api_key_here"
# Optional - for custom API endpoint, in case not provided Prowler Cloud API will be used
export PROWLER_API_BASE_URL="https://api.prowler.com"
```
#### HTTP Mode Authentication
For HTTP mode (remote server), authentication is handled via Bearer tokens. The MCP server supports both JWT tokens and API keys:
**Option 1: Using API Keys (Recommended)**
Use your Prowler API key directly in the MCP client configuration with Bearer token format:
```
Authorization: Bearer pk_your_api_key_here
```
**Option 2: Using JWT Tokens**
You need to obtain a JWT token from Prowler Cloud/App and include the generated token in the MCP client configuration. To get a valid token, you can use the following command (replace the email and password with your own credentials):
```bash
curl -X POST https://api.prowler.com/api/v1/tokens \
-H "Content-Type: application/vnd.api+json" \
-H "Accept: application/vnd.api+json" \
-d '{
"data": {
"type": "tokens",
"attributes": {
"email": "your-email@example.com",
"password": "your-password"
}
}
}'
```
The response will be a JWT token that you can use to [authenticate your MCP client](#http-mode-configuration-remote-server).
### MCP Client Configuration
Configure your MCP client, like Claude Desktop, Cursor, etc, to launch the server. Below are examples for both direct execution and Docker deployment; consult your client's documentation for exact locations.
Configure your MCP client, like Claude Desktop, Cursor, etc, to connect to the server. The configuration depends on whether you're running in STDIO mode (local) or HTTP mode (remote).
#### Using uvx (Direct Execution)
#### STDIO Mode Configuration
For local execution, configure your MCP client to launch the server directly. Below are examples for both direct execution and Docker deployment; consult your client's documentation for exact locations.
##### Using uvx (Direct Execution)
```json
{
@@ -159,9 +281,7 @@ Configure your MCP client, like Claude Desktop, Cursor, etc, to launch the serve
"command": "uvx",
"args": ["/path/to/prowler/mcp_server/"],
"env": {
"PROWLER_APP_EMAIL": "your-email@example.com",
"PROWLER_APP_PASSWORD": "your-password",
"PROWLER_APP_TENANT_ID": "your-tenant-id", // Optional, this can be found as `Organization ID` in your User Profile in Prowler App,
"PROWLER_APP_API_KEY": "pk_your_api_key_here",
"PROWLER_API_BASE_URL": "https://api.prowler.com" // Optional, in case not provided Prowler Cloud API will be used
}
}
@@ -169,7 +289,7 @@ Configure your MCP client, like Claude Desktop, Cursor, etc, to launch the serve
}
```
#### Using Docker
##### Using Docker
```json
{
@@ -178,9 +298,7 @@ Configure your MCP client, like Claude Desktop, Cursor, etc, to launch the serve
"command": "docker",
"args": [
"run", "--rm", "-i",
"--env", "PROWLER_APP_EMAIL=your-email@example.com",
"--env", "PROWLER_APP_PASSWORD=your-password",
"--env", "PROWLER_APP_TENANT_ID=your-tenant-id", // Optional, this can be found as `Organization ID` in your User Profile in Prowler App
"--env", "PROWLER_APP_API_KEY=pk_your_api_key_here",
"--env", "PROWLER_API_BASE_URL=https://api.prowler.com", // Optional, in case not provided Prowler Cloud API will be used
"prowler-mcp"
]
@@ -189,6 +307,44 @@ Configure your MCP client, like Claude Desktop, Cursor, etc, to launch the serve
}
```
#### HTTP Mode Configuration (Remote Server)
For HTTP mode, you can configure your MCP client to connect to a remote Prowler MCP server.
**Important Limitations:**
- HTTP mode support varies by client - some clients may not support HTTP transport yet.
- Some MCP clients like Claude Desktop only support OAuth authentication for HTTP connections, which is not currently supported by our MCP server.
Example configuration for clients that support HTTP transport:
**Using API Key (Recommended):**
```json
{
"mcpServers": {
"prowler": {
"url": "http://mcp.prowler.com/mcp", // Replace with your own MCP server URL, by default when server is run in local it is http://localhost:8000/mcp
"headers": {
"Authorization": "Bearer pk_your_api_key_here"
}
}
}
}
```
**Using JWT Token:**
```json
{
"mcpServers": {
"prowler": {
"url": "http://mcp.prowler.com/mcp", // Replace with your own MCP server URL, by default when server is run in local it is http://localhost:8000/mcp
"headers": {
"Authorization": "Bearer <your-jwt-token-here>"
}
}
}
}
```
### Claude Desktop (macOS/Windows)
Add the example server to Claude Desktop's config file, then restart the app.
+36 -3
View File
@@ -1,15 +1,48 @@
import argparse
import asyncio
import os
import sys
from prowler_mcp_server.lib.logger import logger
from prowler_mcp_server.server import prowler_mcp_server, setup_main_server
from prowler_mcp_server.server import setup_main_server
def parse_arguments():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(description="Prowler MCP Server")
parser.add_argument(
"--transport",
choices=["stdio", "http"],
default=os.getenv("PROWLER_MCP_MODE", "stdio"),
help="Transport method (default: stdio)",
)
parser.add_argument(
"--host",
default="127.0.0.1",
help="Host to bind to for HTTP transport (default: 127.0.0.1)",
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="Port to bind to for HTTP transport (default: 8000)",
)
return parser.parse_args()
def main():
"""Main entry point for the MCP server."""
try:
asyncio.run(setup_main_server())
prowler_mcp_server.run()
args = parse_arguments()
# Set up server with configuration
prowler_mcp_server = asyncio.run(setup_main_server(transport=args.transport))
if args.transport == "stdio":
prowler_mcp_server.run(transport="stdio")
elif args.transport == "http":
prowler_mcp_server.run(transport="http", host=args.host, port=args.port)
except KeyboardInterrupt:
logger.info("Shutting down Prowler MCP server...")
sys.exit(0)
@@ -1,38 +1,36 @@
"""Authentication manager for Prowler App API."""
import base64
import json
import os
from datetime import datetime
from typing import Dict, Optional
import httpx
from fastmcp.server.dependencies import get_http_headers
from prowler_mcp_server import __version__
from prowler_mcp_server.lib.logger import logger
class ProwlerAppAuth:
"""Handles authentication and token management for Prowler App API."""
def __init__(self):
self.base_url = os.getenv(
"PROWLER_API_BASE_URL", "https://api.prowler.com"
).rstrip("/")
self.email = os.getenv("PROWLER_APP_EMAIL")
self.password = os.getenv("PROWLER_APP_PASSWORD")
self.tenant_id = os.getenv("PROWLER_APP_TENANT_ID", None)
"""Handles authentication for Prowler App API using API keys or JWT tokens."""
def __init__(
self,
mode: str = os.getenv("PROWLER_MCP_MODE", "stdio"),
base_url: str = os.getenv("PROWLER_API_BASE_URL", "https://api.prowler.com"),
):
self.base_url = base_url.rstrip("/")
logger.info(f"Using Prowler App API base URL: {self.base_url}")
self.mode = mode
self.access_token: Optional[str] = None
self.refresh_token: Optional[str] = None
self.api_key: Optional[str] = None
self._validate_credentials()
if mode == "stdio": # STDIO mode
self.api_key = os.getenv("PROWLER_APP_API_KEY")
def _validate_credentials(self):
"""Validate that all required credentials are present."""
if not self.email:
raise ValueError("PROWLER_APP_EMAIL environment variable is required")
if not self.password:
raise ValueError("PROWLER_APP_PASSWORD environment variable is required")
if not self.api_key:
raise ValueError("PROWLER_APP_API_KEY environment variable is required")
if not self.api_key.startswith("pk_"):
raise ValueError("Prowler App API key format is incorrect")
def _parse_jwt(self, token: str) -> Optional[Dict]:
"""Parse JWT token and return payload, similar to JS parseJwt function."""
@@ -61,140 +59,61 @@ class ProwlerAppAuth:
return None
async def authenticate(self) -> str:
"""Authenticate with Prowler App API and return access token."""
logger.info("Starting authentication with Prowler App API")
async with httpx.AsyncClient() as client:
try:
# Prepare JSON:API formatted request body
auth_attributes = {"email": self.email, "password": self.password}
if self.tenant_id:
auth_attributes["tenant_id"] = self.tenant_id
"""Authenticate and return token (API key for STDIO, API key or JWT for HTTP)."""
if self.mode == "http":
headers = get_http_headers()
authorization_header = headers.get("authorization", None)
request_body = {
"data": {
"type": "tokens",
"attributes": auth_attributes,
}
}
if not authorization_header:
raise ValueError("No authorization header provided")
response = await client.post(
f"{self.base_url}/api/v1/tokens",
json=request_body,
headers={
"Content-Type": "application/vnd.api+json",
"Accept": "application/vnd.api+json",
},
)
response.raise_for_status()
data = response.json()
# Extract token from JSON:API response format
self.access_token = (
data.get("data", {}).get("attributes", {}).get("access")
)
self.refresh_token = (
data.get("data", {}).get("attributes", {}).get("refresh")
# Extract token from Bearer header
if authorization_header.startswith("Bearer "):
token = authorization_header.replace("Bearer ", "")
else:
raise ValueError(
"Invalid authorization header format. Expected 'Bearer <token>'"
)
logger.debug(f"Access token: {self.access_token}")
# Check if it's an API key or JWT token
if token.startswith("pk_"):
# API key - no expiration check needed
return token
else:
# JWT token - validate and check expiration
payload = self._parse_jwt(token)
if not payload:
raise ValueError("Invalid JWT token format")
if not self.access_token:
raise ValueError("Token not found in response")
# Check if token is expired
now = int(datetime.now().timestamp())
exp = payload.get("exp", 0)
if exp <= now:
raise ValueError("Token has expired")
logger.info("Authentication successful")
return self.access_token
except httpx.HTTPStatusError as e:
logger.error(
f"Authentication failed with HTTP status {e.response.status_code}: {e.response.text}"
)
raise ValueError(f"Authentication failed: {e.response.text}")
except Exception as e:
logger.error(f"Authentication failed with error: {e}")
raise ValueError(f"Authentication failed: {e}")
async def refresh_access_token(self) -> str:
"""Refresh the access token using the refresh token."""
if not self.refresh_token:
logger.info("No refresh token available, performing full authentication")
return await self.authenticate()
logger.info("Refreshing access token")
async with httpx.AsyncClient() as client:
try:
# Prepare JSON:API formatted request body for refresh
request_body = {
"data": {
"type": "tokens",
"attributes": {"refresh": self.refresh_token},
}
}
response = await client.post(
f"{self.base_url}/api/v1/tokens/refresh",
json=request_body,
headers={
"Content-Type": "application/vnd.api+json",
"Accept": "application/vnd.api+json",
},
)
response.raise_for_status()
data = response.json()
# Extract new access token from JSON:API response
self.access_token = (
data.get("data", {}).get("attributes", {}).get("access")
)
logger.info("Token refresh successful")
return self.access_token
except httpx.HTTPStatusError as e:
logger.warning(
f"Token refresh failed, attempting re-authentication: {e}"
)
# If refresh fails, re-authenticate
return await self.authenticate()
return token
else:
raise ValueError(f"Invalid mode: {self.mode}")
async def get_valid_token(self) -> str:
"""Get a valid access token, checking JWT expiry."""
current_token = self.access_token
need_new_token = True
if current_token:
payload = self._parse_jwt(current_token)
if payload:
now = int(datetime.now().timestamp())
time_left = payload.get("exp", 0) - now
if time_left > 120: # 2 minutes margin
need_new_token = False
if need_new_token:
token = await self.authenticate()
# Verify the new token
payload = self._parse_jwt(token)
return token
"""Get a valid token (API key or JWT token)."""
if self.mode == "stdio" and self.api_key:
return self.api_key
else:
return current_token
return await self.authenticate()
def get_headers(self, token: str) -> Dict[str, str]:
"""Get headers for API requests with authentication."""
if token.startswith("pk_"):
authorization_header = f"Api-Key {token}"
else:
authorization_header = f"Bearer {token}"
headers = {
"Authorization": f"Bearer {token}",
"Authorization": authorization_header,
"Content-Type": "application/vnd.api+json",
"Accept": "application/vnd.api+json",
"User-Agent": f"prowler-mcp-server/{__version__}",
}
# Add tenant ID header if available
if self.tenant_id:
headers["X-Tenant-Id"] = self.tenant_id
return headers
@@ -11,11 +11,10 @@ import os
import re
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from typing import Optional
import requests
import yaml
from prowler_mcp_server.lib.logger import logger
class OpenAPIToMCPGenerator:
@@ -23,10 +22,10 @@ class OpenAPIToMCPGenerator:
self,
spec_file: str,
custom_auth_module: Optional[str] = None,
exclude_patterns: Optional[List[str]] = None,
exclude_operations: Optional[List[str]] = None,
exclude_tags: Optional[List[str]] = None,
include_only_tags: Optional[List[str]] = None,
exclude_patterns: Optional[list[str]] = None,
exclude_operations: Optional[list[str]] = None,
exclude_tags: Optional[list[str]] = None,
include_only_tags: Optional[list[str]] = None,
config_file: Optional[str] = None,
):
"""
@@ -35,9 +34,9 @@ class OpenAPIToMCPGenerator:
Args:
spec_file: Path to OpenAPI specification file
custom_auth_module: Module path for custom authentication
exclude_patterns: List of regex patterns to exclude endpoints (matches against path)
exclude_operations: List of operation IDs to exclude
exclude_tags: List of tags to exclude
exclude_patterns: list of regex patterns to exclude endpoints (matches against path)
exclude_operations: list of operation IDs to exclude
exclude_tags: list of tags to exclude
include_only_tags: If specified, only include endpoints with these tags
config_file: Path to JSON configuration file for custom mappings
"""
@@ -54,26 +53,24 @@ class OpenAPIToMCPGenerator:
self.imports = set()
self.type_mapping = {
"string": "str",
"integer": "int",
"number": "float",
"boolean": "bool",
"array": "str",
"object": "Dict[str, Any]",
"integer": "int | str",
"number": "float | str",
"boolean": "bool | str",
"array": "list[Any] | str",
"object": "dict[str, Any] | str",
}
def _load_config(self) -> Dict:
def _load_config(self) -> dict:
"""Load configuration from JSON file."""
try:
with open(self.config_file, "r") as f:
return json.load(f)
except FileNotFoundError:
# print(f"Warning: Config file {self.config_file} not found. Using defaults.")
return {}
except json.JSONDecodeError:
# print(f"Warning: Error parsing config file: {e}. Using defaults.")
return {}
def _load_spec(self) -> Dict:
def _load_spec(self) -> dict:
"""Load OpenAPI specification from file."""
with open(self.spec_file, "r") as f:
if self.spec_file.endswith(".yaml") or self.spec_file.endswith(".yml"):
@@ -81,7 +78,7 @@ class OpenAPIToMCPGenerator:
else:
return json.load(f)
def _get_endpoint_config(self, path: str, method: str) -> Dict:
def _get_endpoint_config(self, path: str, method: str) -> dict:
"""Get endpoint configuration from config file with pattern matching and inheritance.
Configuration resolution order (most to least specific):
@@ -153,7 +150,7 @@ class OpenAPIToMCPGenerator:
return merged_config
def _merge_configs(self, base_config: Dict, override_config: Dict) -> Dict:
def _merge_configs(self, base_config: dict, override_config: dict) -> dict:
"""Merge two configurations, with override_config taking precedence.
Special handling for parameters: merges parameter configurations deeply.
@@ -194,15 +191,19 @@ class OpenAPIToMCPGenerator:
name = f"op_{name}"
return name.lower()
def _get_python_type(self, schema: Dict) -> str:
"""Convert OpenAPI schema to Python type hint."""
def _get_python_type(self, schema: dict) -> tuple[str, str]:
"""Convert OpenAPI schema to Python type hint.
Returns:
Tuple of (type_hint, original_type) where original_type is used for casting
"""
if not schema:
return "Any"
return "Any", "any"
# Handle oneOf/anyOf/allOf schemas - these are typically objects
if "oneOf" in schema or "anyOf" in schema or "allOf" in schema:
# These are complex schemas, typically representing different object variants
return "Dict[str, Any]"
return "dict[str, Any] | str", "object"
schema_type = schema.get("type", "string")
@@ -210,30 +211,26 @@ class OpenAPIToMCPGenerator:
if "enum" in schema:
enum_values = schema["enum"]
if all(isinstance(v, str) for v in enum_values):
# Create Literal type for string enums
# Create Literal type for string enums - already strings, no casting needed
self.imports.add("from typing import Literal")
enum_str = ", ".join(f'"{v}"' for v in enum_values)
return f"Literal[{enum_str}]"
return f"Literal[{enum_str}]", "string"
else:
return self.type_mapping.get(schema_type, "Any")
return self.type_mapping.get(schema_type, "Any"), schema_type
# Handle arrays
if schema_type == "array":
return "str"
return "list[Any] | str", "array"
# Handle format specifications
if schema_type == "string":
format_type = schema.get("format", "")
if format_type in ["date", "date-time"]:
return "str" # Keep as string for API calls
elif format_type == "uuid":
return "str"
elif format_type == "email":
return "str"
if format_type in ["date", "date-time", "uuid", "email"]:
return "str", "string"
return self.type_mapping.get(schema_type, "Any")
return self.type_mapping.get(schema_type, "Any"), schema_type
def _resolve_ref(self, ref: str) -> Dict:
def _resolve_ref(self, ref: str) -> dict:
"""Resolve a $ref reference in the OpenAPI spec."""
if not ref.startswith("#/"):
return {}
@@ -249,8 +246,8 @@ class OpenAPIToMCPGenerator:
return resolved
def _extract_parameters(
self, operation: Dict, endpoint_config: Optional[Dict] = None
) -> List[Dict]:
self, operation: dict, endpoint_config: Optional[dict] = None
) -> list[dict]:
"""Extract and process parameters from an operation."""
parameters = []
@@ -264,13 +261,15 @@ class OpenAPIToMCPGenerator:
.replace("-", "_")
) # Also replace hyphens
type_hint, original_type = self._get_python_type(param.get("schema", {}))
param_info = {
"name": param.get("name", ""),
"python_name": python_name,
"in": param.get("in", "query"),
"required": param.get("required", False),
"description": param.get("description", ""),
"type": self._get_python_type(param.get("schema", {})),
"type": type_hint,
"original_type": original_type,
"original_schema": param.get("schema", {}),
}
@@ -323,7 +322,7 @@ class OpenAPIToMCPGenerator:
return parameters
def _extract_body_parameters(self, schema: Dict, is_required: bool) -> List[Dict]:
def _extract_body_parameters(self, schema: dict, is_required: bool) -> list[dict]:
"""Extract individual parameters from request body schema."""
parameters = []
@@ -346,6 +345,7 @@ class OpenAPIToMCPGenerator:
# Check if this field is required
is_field_required = prop_name in required_attrs
type_hint, original_type = self._get_python_type(prop_schema)
param_info = {
"name": prop_name, # Keep original name for API
"python_name": python_name,
@@ -355,7 +355,8 @@ class OpenAPIToMCPGenerator:
"description",
prop_schema.get("title", f"{prop_name} parameter"),
),
"type": self._get_python_type(prop_schema),
"type": type_hint,
"original_type": original_type,
"original_schema": prop_schema,
"resource_type": (
data["properties"]
@@ -383,6 +384,7 @@ class OpenAPIToMCPGenerator:
"required": is_rel_required,
"description": f"ID of the related {rel_name}",
"type": "str",
"original_type": "string",
"original_schema": rel_schema,
}
parameters.append(param_info)
@@ -396,7 +398,8 @@ class OpenAPIToMCPGenerator:
"in": "body",
"required": is_required,
"description": "Request body data",
"type": "Dict[str, Any]",
"type": "dict[str, Any] | str",
"original_type": "object",
"original_schema": schema,
}
)
@@ -405,11 +408,11 @@ class OpenAPIToMCPGenerator:
def _generate_docstring(
self,
operation: Dict,
parameters: List[Dict],
operation: dict,
parameters: list[dict],
path: str,
method: str,
endpoint_config: Optional[Dict] = None,
endpoint_config: Optional[dict] = None,
) -> str:
"""Generate a comprehensive docstring for the tool function."""
lines = []
@@ -447,7 +450,7 @@ class OpenAPIToMCPGenerator:
lines.append(" Args:")
for param in parameters:
# Use custom description if available
param_desc = param["description"] or "No description provided"
param_desc = param["description"] or "Self-explanatory parameter"
# Handle multi-line descriptions properly
required_text = "(required)" if param["required"] else "(optional)"
@@ -481,13 +484,13 @@ class OpenAPIToMCPGenerator:
# Returns section
lines.append("")
lines.append(" Returns:")
lines.append(" Dict containing the API response")
lines.append(" dict containing the API response")
lines.append(' """')
return "\n".join(lines)
def _generate_function_signature(
self, func_name: str, parameters: List[Dict]
self, func_name: str, parameters: list[dict]
) -> str:
"""Generate the function signature with proper type hints."""
# Sort parameters: required first, then optional
@@ -506,12 +509,38 @@ class OpenAPIToMCPGenerator:
if param_strings:
params_str = ",\n".join(param_strings)
return f"async def {func_name}(\n{params_str}\n) -> Dict[str, Any]:"
return f"async def {func_name}(\n{params_str}\n) -> dict[str, Any]:"
else:
return f"async def {func_name}() -> Dict[str, Any]:"
return f"async def {func_name}() -> dict[str, Any]:"
def _get_cast_expression(self, param: dict) -> str:
"""Generate type casting expression for a parameter.
Args:
param: Parameter dict with 'python_name' and 'original_type'
Returns:
Expression string that casts the parameter value to the correct type
"""
python_name = param["python_name"]
original_type = param.get("original_type", "string")
if original_type == "integer":
return f"int({python_name}) if isinstance({python_name}, str) else {python_name}"
elif original_type == "number":
return f"float({python_name}) if isinstance({python_name}, str) else {python_name}"
elif original_type == "boolean":
return f"({python_name}.lower() in ['true', '1', 'yes'] if isinstance({python_name}, str) else bool({python_name}))"
elif original_type == "array":
return f"json.loads({python_name}) if isinstance({python_name}, str) else {python_name}"
elif original_type == "object":
return f"json.loads({python_name}) if isinstance({python_name}, str) else {python_name}"
else:
# string or any other type - no casting needed
return python_name
def _generate_function_body(
self, path: str, method: str, parameters: List[Dict], operation_id: str
self, path: str, method: str, parameters: list[dict], operation_id: str
) -> str:
"""Generate the function body for making API calls."""
lines = []
@@ -529,26 +558,28 @@ class OpenAPIToMCPGenerator:
path_params = [p for p in parameters if p["in"] == "path"]
body_params = [p for p in parameters if p["in"] == "body"]
# Add json import if needed for object or array type casting
if any(p.get("original_type") in ["object", "array"] for p in parameters):
self.imports.add("import json")
# Build query parameters
if query_params:
lines.append(" params = {}")
for param in query_params:
cast_expr = self._get_cast_expression(param)
if param["required"]:
lines.append(
f" params['{param['name']}'] = {param['python_name']}"
)
lines.append(f" params['{param['name']}'] = {cast_expr}")
else:
lines.append(f" if {param['python_name']} is not None:")
lines.append(
f" params['{param['name']}'] = {param['python_name']}"
)
lines.append(f" params['{param['name']}'] = {cast_expr}")
lines.append("")
# Build path with path parameters
final_path = path
for param in path_params:
cast_expr = self._get_cast_expression(param)
lines.append(
f" path = '{path}'.replace('{{{param['name']}}}', str({param['python_name']}))"
f" path = '{path}'.replace('{{{param['name']}}}', str({cast_expr}))"
)
final_path = "path"
@@ -556,8 +587,9 @@ class OpenAPIToMCPGenerator:
if body_params:
# Check if we have individual params or a single body param
if len(body_params) == 1 and body_params[0]["python_name"] == "body":
# Single body parameter - use it directly
lines.append(" request_body = body")
# Single body parameter - use it directly with casting
cast_expr = self._get_cast_expression(body_params[0])
lines.append(f" request_body = {cast_expr}")
else:
# Get resource type from first body param (they should all have the same)
resource_type = (
@@ -598,16 +630,17 @@ class OpenAPIToMCPGenerator:
lines.append("")
lines.append(" # Add attributes")
for param in attribute_params:
cast_expr = self._get_cast_expression(param)
if param["required"]:
lines.append(
f' request_body["data"]["attributes"]["{param["name"]}"] = {param["python_name"]}'
f' request_body["data"]["attributes"]["{param["name"]}"] = {cast_expr}'
)
else:
lines.append(
f" if {param['python_name']} is not None:"
)
lines.append(
f' request_body["data"]["attributes"]["{param["name"]}"] = {param["python_name"]}'
f' request_body["data"]["attributes"]["{param["name"]}"] = {cast_expr}'
)
if relationship_params:
@@ -616,15 +649,14 @@ class OpenAPIToMCPGenerator:
lines.append(' request_body["data"]["relationships"] = {}')
for param in relationship_params:
rel_name = param["python_name"].replace("_id", "")
cast_expr = self._get_cast_expression(param)
if param["required"]:
lines.append(
f' request_body["data"]["relationships"]["{rel_name}"] = {{'
)
lines.append(' "data": {')
lines.append(f' "type": "{rel_name}s",')
lines.append(
f' "id": {param["python_name"]}'
)
lines.append(f' "id": {cast_expr}')
lines.append(" }")
lines.append(" }")
else:
@@ -636,24 +668,22 @@ class OpenAPIToMCPGenerator:
)
lines.append(' "data": {')
lines.append(f' "type": "{rel_name}s",')
lines.append(
f' "id": {param["python_name"]}'
)
lines.append(f' "id": {cast_expr}')
lines.append(" }")
lines.append(" }")
lines.append("")
# Prepare HTTP client call
lines.append(" async with httpx.AsyncClient() as client:")
# Build the request URL
url_line = (
f'f"{{auth_manager.base_url}}{{{final_path}}}"'
if final_path == "path"
else f'f"{{auth_manager.base_url}}{path}"'
)
lines.append(f" url = {url_line}")
lines.append("")
# Build the request
request_params = [
(
f'f"{{auth_manager.base_url}}{{{final_path}}}"'
if final_path == "path"
else f'f"{{auth_manager.base_url}}{path}"'
)
]
# Build request parameters
request_params = ["url"]
if self.custom_auth_module:
request_params.append("headers=auth_manager.get_headers(token)")
@@ -664,24 +694,21 @@ class OpenAPIToMCPGenerator:
if body_params:
request_params.append("json=request_body")
request_params.append("timeout=30.0")
params_str = ",\n ".join(request_params)
params_str = ",\n ".join(request_params)
lines.append(f" response = await client.{method}(")
lines.append(f" {params_str}")
lines.append(" )")
lines.append(" response.raise_for_status()")
lines.append(f" response = await prowler_app_client.{method}(")
lines.append(f" {params_str}")
lines.append(" )")
lines.append(" response.raise_for_status()")
lines.append("")
# Parse response
lines.append(" data = response.json()")
lines.append(" data = response.json()")
lines.append("")
lines.append(" return {")
lines.append(' "success": True,')
lines.append(' "data": data.get("data", data),')
lines.append(' "meta": data.get("meta", {})')
lines.append(" }")
lines.append(" return {")
lines.append(' "success": True,')
lines.append(' "data": data.get("data", data),')
lines.append(" }")
lines.append("")
# Exception handling
@@ -695,7 +722,7 @@ class OpenAPIToMCPGenerator:
return "\n".join(lines)
def _should_exclude_endpoint(self, path: str, operation: Dict) -> bool:
def _should_exclude_endpoint(self, path: str, operation: dict) -> bool:
"""
Determine if an endpoint should be excluded from generation.
@@ -730,7 +757,6 @@ class OpenAPIToMCPGenerator:
# Check excluded tags
if any(tag in self.exclude_tags for tag in tags):
logger.debug(f"Excluding endpoint {path} due to tag {tags}")
return True
return False
@@ -750,7 +776,7 @@ class OpenAPIToMCPGenerator:
output_lines.append("")
# Add imports
self.imports.add("from typing import Dict, Any, Optional")
self.imports.add("from typing import Any, Optional")
self.imports.add("import httpx")
self.imports.add("from fastmcp import FastMCP")
@@ -848,6 +874,11 @@ class OpenAPIToMCPGenerator:
output_lines.append("# Initialize authentication manager")
output_lines.append("auth_manager = ProwlerAppAuth()")
output_lines.append("")
output_lines.append("# Initialize HTTP client")
output_lines.append("prowler_app_client = httpx.AsyncClient(")
output_lines.append(" timeout=30.0,")
output_lines.append(")")
output_lines.append("")
# Write tools grouped by tag
for tag, tools in tools_by_tag.items():
@@ -867,45 +898,6 @@ class OpenAPIToMCPGenerator:
"""Save the generated code to a file."""
generated_code = self.generate_tools()
Path(output_file).write_text(generated_code)
# print(f"Generated FastMCP server saved to: {output_file}")
# # Report statistics
# paths = self.spec.get("paths", {})
# total_endpoints = sum(
# len(
# [m for m in ["get", "post", "put", "patch", "delete"] if m in path_item]
# )
# for path_item in paths.values()
# )
# # Count excluded endpoints by reason
# excluded_count = 0
# deprecated_count = 0
# for path, path_item in paths.items():
# for method in ["get", "post", "put", "patch", "delete"]:
# if method in path_item:
# operation = path_item[method]
# if operation.get("deprecated", False):
# deprecated_count += 1
# if self._should_exclude_endpoint(path, operation):
# excluded_count += 1
# generated_count = total_endpoints - excluded_count
# print(f"Total endpoints in spec: {total_endpoints}")
# print(f"Endpoints excluded: {excluded_count}")
# if deprecated_count > 0:
# print(f" - Deprecated: {deprecated_count}")
# print(f"Endpoints generated: {generated_count}")
# Show exclusion rules if any
# if self.exclude_patterns:
# # print(f"Excluded patterns: {self.exclude_patterns}")
# if self.exclude_operations:
# # print(f"Excluded operations: {self.exclude_operations}")
# if self.exclude_tags:
# # print(f"Excluded tags: {self.exclude_tags}")
# if self.include_only_tags:
# # print(f"Including only tags: {self.include_only_tags}")
def generate_server_file():
@@ -0,0 +1,160 @@
import urllib.parse
from typing import List, Optional
import requests
from pydantic import BaseModel, Field
class SearchResult(BaseModel):
"""Search result model."""
path: str = Field(description="Document path")
title: str = Field(description="Document title")
url: str = Field(description="Documentation URL")
highlights: List[str] = Field(
description="Highlighted content snippets showing query matches with <span> tags",
default_factory=list,
)
class ProwlerDocsSearchEngine:
"""Prowler documentation search using ReadTheDocs API."""
def __init__(self):
"""Initialize the search engine."""
self.api_base_url = "https://docs.prowler.com/_/api/v3/search/"
self.project_name = "prowler-prowler"
self.github_raw_base = (
"https://raw.githubusercontent.com/prowler-cloud/prowler/master/docs"
)
def search(self, query: str, page_size: int = 5) -> List[SearchResult]:
"""
Search documentation using ReadTheDocs API.
Args:
query: Search query string
page_size: Maximum number of results to return
Returns:
List of search results
"""
try:
# Construct the search query with project filter
search_query = f"project:{self.project_name} {query}"
# Make request to ReadTheDocs API with page_size to limit results
params = {"q": search_query, "page_size": page_size}
response = requests.get(
self.api_base_url,
params=params,
timeout=10,
)
response.raise_for_status()
data = response.json()
# Parse results
results = []
for hit in data.get("results", []):
# Extract relevant fields from API response
blocks = hit.get("blocks", [])
# Get the document path from the hit's path field
hit_path = hit.get("path", "")
doc_path = self._extract_doc_path(hit_path)
# Construct full URL to docs
domain = hit.get("domain", "https://docs.prowler.com")
full_url = f"{domain}{hit_path}" if hit_path else ""
# Extract highlights from API response
highlights = []
# Add title highlights
page_highlights = hit.get("highlights", {})
if page_highlights.get("title"):
highlights.extend(page_highlights["title"])
# Add block content highlights (up to 3 snippets)
for block in blocks[:3]:
block_highlights = block.get("highlights", {})
if block_highlights.get("content"):
highlights.extend(block_highlights["content"])
results.append(
SearchResult(
path=doc_path,
title=hit.get("title", ""),
url=full_url,
highlights=highlights,
)
)
return results
except Exception as e:
# Return empty list on error
print(f"Search error: {e}")
return []
def get_document(self, doc_path: str) -> Optional[str]:
"""
Get full document content from GitHub raw API.
Args:
doc_path: Path to the documentation file (e.g., "getting-started/installation")
Returns:
Full markdown content of the documentation, or None if not found
"""
try:
# Clean up the path
doc_path = doc_path.rstrip("/")
# Add .md extension if not present
if not doc_path.endswith(".md"):
doc_path = f"{doc_path}.md"
# Construct GitHub raw URL
url = f"{self.github_raw_base}/{doc_path}"
# Fetch the raw markdown
response = requests.get(url, timeout=10)
response.raise_for_status()
return response.text
except Exception as e:
print(f"Error fetching document: {e}")
return None
def _extract_doc_path(self, url: str) -> str:
"""
Extract the document path from a full URL.
Args:
url: Full documentation URL
Returns:
Document path relative to docs base
"""
if not url:
return ""
# Parse URL and extract path
try:
parsed = urllib.parse.urlparse(url)
path = parsed.path
# Remove the base path prefix if present
base_path = "/projects/prowler-open-source/en/latest/"
if path.startswith(base_path):
path = path[len(base_path) :]
# Remove .html extension
if path.endswith(".html"):
path = path[:-5]
return path.lstrip("/")
except Exception:
return url
@@ -0,0 +1,61 @@
from typing import List
from fastmcp import FastMCP
from prowler_mcp_server.prowler_documentation.search_engine import (
ProwlerDocsSearchEngine,
SearchResult,
)
# Initialize FastMCP server
docs_mcp_server = FastMCP("prowler-docs")
prowler_docs_search_engine = ProwlerDocsSearchEngine()
@docs_mcp_server.tool()
def search(
query: str,
page_size: int = 5,
) -> List[SearchResult]:
"""
Search in Prowler documentation.
This tool searches through the official Prowler documentation
to find relevant information about security checks, cloud providers,
compliance frameworks, and usage instructions.
Supports advanced search syntax:
- Exact phrases: "custom css"
- Prefix search: test*
- Fuzzy search: doks~1
- Proximity search: "dashboard admin"~2
Args:
query: The search query
page_size: Number of top results to return (default: 5)
Returns:
List of search results with highlights showing matched terms (in <span> tags)
"""
return prowler_docs_search_engine.search(query, page_size)
@docs_mcp_server.tool()
def get_document(
doc_path: str,
) -> str:
"""
Retrieve the full content of a Prowler documentation file.
Use this after searching to get the complete content of a specific
documentation file.
Args:
doc_path: Path to the documentation file. It is the same as the "path" field of the search results.
Returns:
Full content of the documentation file
"""
content = prowler_docs_search_engine.get_document(doc_path)
if content is None:
raise ValueError(f"Document not found: {doc_path}")
return content
+18 -4
View File
@@ -3,13 +3,13 @@ import os
from fastmcp import FastMCP
from prowler_mcp_server.lib.logger import logger
# Initialize main Prowler MCP server
prowler_mcp_server = FastMCP("prowler-mcp-server")
async def setup_main_server():
async def setup_main_server(transport: str) -> FastMCP:
"""Set up the main Prowler MCP server with all available integrations."""
# Initialize main Prowler MCP server
prowler_mcp_server = FastMCP("prowler-mcp-server")
# Import Prowler Hub tools with prowler_hub_ prefix
try:
logger.info("Importing Prowler Hub server...")
@@ -23,6 +23,9 @@ async def setup_main_server():
try:
logger.info("Importing Prowler App server...")
if os.getenv("PROWLER_MCP_MODE", None) is None:
os.environ["PROWLER_MCP_MODE"] = transport
if not os.path.exists(
os.path.join(os.path.dirname(__file__), "prowler_app", "server.py")
):
@@ -39,3 +42,14 @@ async def setup_main_server():
logger.info("Successfully imported Prowler App server")
except Exception as e:
logger.error(f"Failed to import Prowler App server: {e}")
try:
logger.info("Importing Prowler Documentation server...")
from prowler_mcp_server.prowler_documentation.server import docs_mcp_server
await prowler_mcp_server.import_server(docs_mcp_server, prefix="prowler_docs")
logger.info("Successfully imported Prowler Documentation server")
except Exception as e:
logger.error(f"Failed to import Prowler Documentation server: {e}")
return prowler_mcp_server
+2 -1
View File
@@ -5,7 +5,8 @@ requires = ["setuptools>=61.0", "wheel"]
[project]
dependencies = [
"fastmcp>=2.11.3",
"httpx>=0.27.0"
"httpx>=0.27.0",
"requests>=2.31.0"
]
description = "MCP server for Prowler ecosystem"
name = "prowler-mcp"
+2
View File
@@ -634,12 +634,14 @@ source = { editable = "." }
dependencies = [
{ name = "fastmcp" },
{ name = "httpx" },
{ name = "requests" },
]
[package.metadata]
requires-dist = [
{ name = "fastmcp", specifier = ">=2.11.3" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "requests", specifier = ">=2.31.0" },
]
[[package]]
+11 -2
View File
@@ -13,24 +13,31 @@ 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
- Update AWS Neptune service metadata to new format [(#8494)](https://github.com/prowler-cloud/prowler/pull/8494)
- Update AWS Config service metadata to new format [(#8641)](https://github.com/prowler-cloud/prowler/pull/8641)
- Update AWS Account service metadata to new format [(#8715)](https://github.com/prowler-cloud/prowler/pull/8715)
- Update AWS AccessAnalyzer service metadata to new format [(#8688)](https://github.com/prowler-cloud/prowler/pull/8688)
- Update AWS Api Gateway V2 service metadata to new format [(#8719)](https://github.com/prowler-cloud/prowler/pull/8719)
- Update AWS AppSync service metadata to new format [(#8721)](https://github.com/prowler-cloud/prowler/pull/8721)
- Update AWS ACM service metadata to new format [(#8716)](https://github.com/prowler-cloud/prowler/pull/8716)
- HTML output now properly renders markdown syntax in Risk and Recommendation fields [(#8727)](https://github.com/prowler-cloud/prowler/pull/8727)
- Update `moto` dependency from 5.0.28 to 5.1.11 [(#7100)](https://github.com/prowler-cloud/prowler/pull/7100)
- 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 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)
- 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)
- GitHub App authentication inconsistency where `--github-app-key` and `--github-app-key-path` were incorrectly aliased to the same parameter, causing confusion between file paths and key content [(#8422)](https://github.com/prowler-cloud/prowler/pull/8422)
- Prowler ThreatScore scoring calculation CLI [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582)
---
@@ -38,6 +45,8 @@ All notable changes to the **Prowler SDK** are documented in this file.
### Fixed
- Fix KeyError in `elb_ssl_listeners_use_acm_certificate` check and handle None cluster version in `eks_cluster_uses_a_supported_version` check [(#8791)](https://github.com/prowler-cloud/prowler/pull/8791)
- Fix file extension parsing for compliance reports [(#8791)](https://github.com/prowler-cloud/prowler/pull/8791)
- Added user pagination to Entra and Admincenter services [(#8858)](https://github.com/prowler-cloud/prowler/pull/8858)
---
@@ -433,4 +442,4 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Handle projects without ID in GCP [(#7496)](https://github.com/prowler-cloud/prowler/pull/7496)
- Restore packages location in PyProject [(#7510)](https://github.com/prowler-cloud/prowler/pull/7510)
---
---
@@ -42,7 +42,10 @@ class ComplianceOutput(Output):
self._from_cli = from_cli
if not file_extension and file_path:
self._file_extension = "".join(Path(file_path).suffixes)
# Compliance reports are always CSV, so just use the last suffix
# e.g., "cis_5.0_aws.csv" should have extension ".csv", not ".0_aws.csv"
path_obj = Path(file_path)
self._file_extension = path_obj.suffix if path_obj.suffix else ""
if file_extension:
self._file_extension = file_extension
self.file_path = f"{file_path}{self.file_extension}"
@@ -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:
@@ -1247,6 +1247,7 @@
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-6",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
@@ -5461,6 +5462,7 @@
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
"eu-central-1",
@@ -5474,6 +5476,7 @@
"il-central-1",
"me-central-1",
"me-south-1",
"mx-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -9145,6 +9148,7 @@
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-6",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
@@ -9426,23 +9430,6 @@
]
}
},
"robomaker": {
"regions": {
"aws": [
"ap-northeast-1",
"ap-southeast-1",
"eu-central-1",
"eu-west-1",
"us-east-1",
"us-east-2",
"us-west-2"
],
"aws-cn": [],
"aws-us-gov": [
"us-gov-west-1"
]
}
},
"rolesanywhere": {
"regions": {
"aws": [
@@ -1,28 +1,40 @@
{
"Provider": "aws",
"CheckID": "account_maintain_current_contact_details",
"CheckTitle": "Maintain current contact details.",
"CheckTitle": "AWS account contact information is current",
"CheckType": [
"IAM"
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
],
"ServiceName": "account",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:service:region:account-id",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsAccount",
"Description": "Maintain current contact details.",
"Risk": "Ensure contact email and telephone details for AWS accounts are current and map to more than one individual in your organization. An AWS account supports a number of contact details, and AWS will use these to contact the account owner if activity judged to be in breach of Acceptable Use Policy. If an AWS account is observed to be behaving in a prohibited or suspicious manner, AWS will attempt to contact the account owner by email and phone using the contact details listed. If this is unsuccessful and the account behavior needs urgent mitigation, proactive measures may be taken, including throttling of traffic between the account exhibiting suspicious behavior and the AWS API endpoints and the Internet. This will result in impaired service to and from the account in question.",
"ResourceType": "Other",
"Description": "**AWS account contact information** is current for the **primary contact** and the **alternate contacts** for `security`, `billing`, and `operations`, with accurate email addresses and phone numbers.",
"Risk": "Outdated or single-person contacts delay **security notifications**, slow **incident response**, and complicate **account recovery**.\n\nAWS may throttle services during abuse mitigation, reducing **availability**. Missed alerts enable ongoing misuse, risking **data exfiltration** and unauthorized changes (**integrity**).",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.prowler.com/checks/aws/iam-policies/iam_18-maintain-contact-details#aws-console",
"https://repost.aws/knowledge-center/update-phone-number",
"https://support.stax.io/docs/accounts/update-aws-account-contact-details",
"https://maartenbruntink.nl/blog/2022/09/26/aws-account-hygiene-101-mass-updating-alternate-account-contacts/",
"https://docs.aws.amazon.com/security-ir/latest/userguide/update-account-contact-info.html",
"https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact-primary.html",
"https://repost.aws/knowledge-center/add-update-billing-contact",
"https://aws.amazon.com/blogs/security/update-the-alternate-security-contact-across-your-aws-accounts-for-timely-security-notifications/",
"https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_accounts_update_contacts.html",
"https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact-alternate.html"
],
"Remediation": {
"Code": {
"CLI": "No command available.",
"CLI": "aws account put-alternate-contact --alternate-contact-type=SECURITY --email-address=<SECURITY_EMAIL> --name=\"<SECURITY_CONTACT_NAME>\" --phone-number=\"<SECURITY_PHONE>\" --title=\"Security\"",
"NativeIaC": "",
"Other": "https://docs.prowler.com/checks/aws/iam-policies/iam_18-maintain-contact-details#aws-console",
"Terraform": ""
"Other": "1. Sign in to the AWS Management Console\n2. Open Billing and Cost Management, then click your account name > Account\n3. Under Contact information, click Edit, update details, then Update\n4. Under Alternate contacts, click Edit\n5. Enter Security contact name, email, and phone (use a team alias), then Update\n6. Repeat for Billing and Operations if needed",
"Terraform": "```hcl\n# Set the Security alternate contact for the AWS account\nresource \"aws_account_alternate_contact\" \"<example_resource_name>\" {\n alternate_contact_type = \"SECURITY\" # Critical: ensures AWS can reach your security team\n email_address = \"<SECURITY_EMAIL>\" # Critical: contact destination\n name = \"<SECURITY_CONTACT_NAME>\"\n phone_number = \"<SECURITY_PHONE>\"\n title = \"Security\"\n}\n```"
},
"Recommendation": {
"Text": "Using the Billing and Cost Management console complete contact details.",
"Url": "https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html"
"Text": "Adopt:\n- **Primary** and **alternate contacts** for `security`, `billing`, `operations`\n- Shared, monitored aliases and SMS-capable phone numbers (non-personal)\n- Centralized management across accounts with periodic reviews\n- **Least privilege** for who can modify contact data\n- Regular reachability tests and documented ownership",
"Url": "https://hub.prowler.com/check/account_maintain_current_contact_details"
}
},
"Categories": [],
@@ -1,31 +1,43 @@
{
"Provider": "aws",
"CheckID": "account_maintain_different_contact_details_to_security_billing_and_operations",
"CheckTitle": "Maintain different contact details to security, billing and operations.",
"CheckTitle": "AWS account has distinct Security, Billing, and Operations contact details, different from each other and from the root contact",
"CheckType": [
"IAM"
"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": "account",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:service:region:account-id",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsAccount",
"Description": "Maintain different contact details to security, billing and operations.",
"Risk": "Ensure contact email and telephone details for AWS accounts are current and map to more than one individual in your organization. An AWS account supports a number of contact details, and AWS will use these to contact the account owner if activity judged to be in breach of Acceptable Use Policy. If an AWS account is observed to be behaving in a prohibited or suspicious manner, AWS will attempt to contact the account owner by email and phone using the contact details listed. If this is unsuccessful and the account behavior needs urgent mitigation, proactive measures may be taken, including throttling of traffic between the account exhibiting suspicious behavior and the AWS API endpoints and the Internet. This will result in impaired service to and from the account in question.",
"RelatedUrl": "https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html",
"ResourceType": "Other",
"Description": "**AWS account alternate contacts** are defined for **Security**, **Billing**, and **Operations** with `name`, `email`, and `phone`. The finding evaluates that all three exist, are distinct from one another, and differ from the **primary (root) contact**.",
"Risk": "Missing or shared contacts can delay response to abuse alerts, credential compromise, or billing anomalies, reducing **availability** (possible AWS traffic throttling) and raising **confidentiality** and **integrity** risk through extended exposure. If AWS cannot reach you, urgent mitigation may disrupt service.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/account_alternate_contact",
"https://docs.prowler.com/checks/aws/iam-policies/iam_18-maintain-contact-details#aws-console",
"https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact-alternate.html",
"https://builder.aws.com/content/2qRw97fe8JFwfk2AbpJ3sYNpNvM/aws-bulk-update-alternate-contacts-across-organization",
"https://github.com/aws-samples/aws-account-alternate-contact-with-terraform",
"https://trendmicro.com/cloudoneconformity/knowledge-base/aws/IAM/account-security-alternate-contacts.html",
"https://repost.aws/articles/ARDFbpt-bvQ8iuErnqVVcCXQ/managing-aws-organization-alternate-contacts-via-csv"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "https://docs.prowler.com/checks/aws/iam-policies/iam_18-maintain-contact-details#aws-console",
"Terraform": ""
"Other": "1. Sign in to the AWS Management Console with a user that can edit account contacts (root, or IAM with account:PutAlternateContact)\n2. In the upper right, click your account name > Account\n3. Scroll to \"Alternate contacts\" and click Edit\n4. Add all three contacts with unique details:\n - Billing contact (distinct name, email, phone)\n - Operations contact (distinct name, email, phone)\n - Security contact (distinct name, email, phone)\n5. Ensure each contacts email/phone differs from each other and from the primary (root) contact, then click Update",
"Terraform": "```hcl\n# Set distinct alternate contacts for SECURITY, BILLING, and OPERATIONS\n# Critical: each resource sets a required contact type to satisfy the check\n\nresource \"aws_account_alternate_contact\" \"security\" {\n alternate_contact_type = \"SECURITY\" # Sets SECURITY contact\n email_address = \"security@example.com\"\n name = \"Security Team\"\n phone_number = \"+1-555-0100\"\n title = \"Security\"\n}\n\nresource \"aws_account_alternate_contact\" \"billing\" {\n alternate_contact_type = \"BILLING\" # Sets BILLING contact\n email_address = \"billing@example.com\"\n name = \"Billing Team\"\n phone_number = \"+1-555-0101\"\n title = \"Billing\"\n}\n\nresource \"aws_account_alternate_contact\" \"operations\" {\n alternate_contact_type = \"OPERATIONS\" # Sets OPERATIONS contact\n email_address = \"operations@example.com\"\n name = \"Operations Team\"\n phone_number = \"+1-555-0102\"\n title = \"Operations\"\n}\n```"
},
"Recommendation": {
"Text": "Using the Billing and Cost Management console complete contact details.",
"Url": "https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html"
"Text": "Maintain distinct, monitored **Security**, **Billing**, and **Operations** alternate contacts that differ from the root contact.\n- Use team aliases and 24x7 phones\n- Review and test contact paths regularly\n- Centralize at org level for consistency\n\nApplies **operational resilience** and **separation of duties**.",
"Url": "https://hub.prowler.com/check/account_maintain_different_contact_details_to_security_billing_and_operations"
}
},
"Categories": [],
"Categories": [
"resilience"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
@@ -1,28 +1,36 @@
{
"Provider": "aws",
"CheckID": "account_security_contact_information_is_registered",
"CheckTitle": "Ensure security contact information is registered.",
"CheckTitle": "AWS account has security alternate contact registered",
"CheckType": [
"IAM"
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
],
"ServiceName": "account",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:service:region:account-id",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsAccount",
"Description": "Ensure security contact information is registered.",
"Risk": "AWS provides customers with the option of specifying the contact information for accounts security team. It is recommended that this information be provided. Specifying security-specific contact information will help ensure that security advisories sent by AWS reach the team in your organization that is best equipped to respond to them.",
"ResourceType": "Other",
"Description": "Account settings contain a **Security alternate contact** in Alternate Contacts (name, `EmailAddress`, `PhoneNumber`) for targeted AWS security notifications.",
"Risk": "Missing or outdated **security contact** can delay or prevent AWS advisories from reaching responders, increasing risk to:\n- Confidentiality: data exfiltration from undetected compromise\n- Integrity: unauthorized changes persist longer\n- Availability: resource abuse (e.g., cryptomining) and outages",
"RelatedUrl": "",
"AdditionalURLs": [
"https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/account_alternate_contact",
"https://docs.prowler.com/checks/aws/iam-policies/iam_19/",
"https://support.icompaas.com/support/solutions/articles/62000234161-1-2-ensure-security-contact-information-is-registered-manual-",
"https://www.plerion.com/cloud-knowledge-base/ensure-security-contact-information-is-registered",
"https://repost.aws/articles/ARDFbpt-bvQ8iuErnqVVcCXQ/managing-aws-organization-alternate-contacts-via-csv"
],
"Remediation": {
"Code": {
"CLI": "No command available.",
"CLI": "aws account put-alternate-contact --alternate-contact-type SECURITY --email-address <EMAIL_ADDRESS> --name <CONTACT_NAME> --phone-number <PHONE_NUMBER>",
"NativeIaC": "",
"Other": "https://docs.prowler.com/checks/aws/iam-policies/iam_19#aws-console",
"Terraform": ""
"Other": "1. Sign in to the AWS Management Console as the root user or an admin with account:PutAlternateContact\n2. Click your account name (top-right) and select My Account (or Account)\n3. Scroll to Alternate Contacts and click Edit in the Security section\n4. Enter Security Email, Name, and Phone Number\n5. Click Update (or Save changes)",
"Terraform": "```hcl\n# Set the SECURITY alternate contact for the current AWS account\nresource \"aws_account_alternate_contact\" \"<example_resource_name>\" {\n alternate_contact_type = \"SECURITY\" # Critical: sets Security contact type\n email_address = \"security@example.com\" # Contact email\n name = \"Security Team\" # Contact name\n phone_number = \"+1-555-0100\" # Contact phone\n}\n```"
},
"Recommendation": {
"Text": "Go to the My Account section and complete alternate contacts.",
"Url": "https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html"
"Text": "Define and maintain a **Security alternate contact**:\n- Use a monitored alias (e.g., `security@domain`) and team phone\n- Apply to every account (prefer Org-wide automation)\n- Review after org/personnel changes and test delivery\n- Document ownership and escalation paths\nAlign with **incident response** and **least privilege** principles.",
"Url": "https://hub.prowler.com/check/account_security_contact_information_is_registered"
}
},
"Categories": [],
@@ -1,28 +1,32 @@
{
"Provider": "aws",
"CheckID": "account_security_questions_are_registered_in_the_aws_account",
"CheckTitle": "Ensure security questions are registered in the AWS account.",
"CheckTitle": "[DEPRECATED] AWS root user has security challenge questions configured",
"CheckType": [
"IAM"
"Software and Configuration Checks/AWS Security Best Practices"
],
"ServiceName": "account",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:service:region:account-id",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsAccount",
"Description": "Ensure security questions are registered in the AWS account.",
"Risk": "The AWS support portal allows account owners to establish security questions that can be used to authenticate individuals calling AWS customer service for support. It is recommended that security questions be established. When creating a new AWS account a default super user is automatically created. This account is referred to as the root account. It is recommended that the use of this account be limited and highly controlled. During events in which the root password is no longer accessible or the MFA token associated with root is lost/destroyed it is possible through authentication using secret questions and associated answers to recover root login access.",
"ResourceType": "Other",
"Description": "[DEPRECATED] **AWS account root** configuration may include legacy **security challenge questions** for support identity verification. This evaluates whether those questions are set on the account. *New configuration is discontinued by AWS and remaining support for this feature is time-limited.*",
"Risk": "Absence of these questions can limit support-assisted recovery if root credentials or MFA are lost, reducing **availability** and slowing **incident response**. Reliance on KBA also weakens **confidentiality** due to **social engineering**. Treat this as a recovery gap and adopt stronger, phishing-resistant factors.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.prowler.com/checks/aws/iam-policies/iam_15",
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/IAM/security-challenge-questions.html"
],
"Remediation": {
"Code": {
"CLI": "No command available.",
"CLI": "",
"NativeIaC": "",
"Other": "https://docs.prowler.com/checks/aws/iam-policies/iam_15",
"Other": "1. Sign in to the AWS Management Console\n2. Navigate to your AWS account settings page at https://console.aws.amazon.com/billing/home?#/account/\n3. Scroll down to Configure Security Challenge Questions section and click the Edit link\n4. Select three different questions made available by Amazon and provide appropriate answers\n5. Store the answers in a secure but accessible location\n6. Click the Update button to save the changes",
"Terraform": ""
},
"Recommendation": {
"Text": "Login as root account and from My Account configure Security questions.",
"Url": "https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-security-challenge.html"
"Text": "Favor stronger recovery instead of KBA:\n- Enforce **MFA for root** and minimize root use\n- Keep **alternate contacts** and root email current and protected\n- Establish a tightly controlled **break-glass role**, applying least privilege and separation of duties\n- Document and test recovery procedures; monitor root activity",
"Url": "https://hub.prowler.com/check/account_security_questions_are_registered_in_the_aws_account"
}
},
"Categories": [],
@@ -1,35 +1,42 @@
{
"Provider": "aws",
"CheckID": "apigateway_restapi_authorizers_enabled",
"CheckTitle": "Check if API Gateway has configured authorizers at api or method level.",
"CheckAliases": [
"apigateway_authorizers_enabled"
],
"CheckTitle": "API Gateway REST API has an authorizer at API level or all methods are authorized",
"CheckType": [
"IAM"
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
"TTPs/Initial Access"
],
"ServiceName": "apigateway",
"SubServiceName": "rest_api",
"ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsApiGatewayRestApi",
"Description": "Check if API Gateway has configured authorizers at api or method level.",
"Risk": "If no authorizer is enabled anyone can use the service.",
"Description": "**API Gateway REST APIs** are evaluated for **access control**: an **API-level authorizer** is present, or all resource methods use an authorization mechanism. Methods marked `NONE` indicate unauthenticated access.",
"Risk": "**Unauthenticated API methods** enable:\n- Arbitrary reads exposing data (**confidentiality**)\n- Unauthorized actions against backends (**integrity**)\n- Abuse and high traffic causing cost spikes or outages (**availability**)\n\nAttackers can enumerate endpoints and invoke integrations without tokens.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "https://docs.prowler.com/checks/aws/public-policies/public_6-api-gateway-authorizer-set#cloudformation",
"Other": "",
"Terraform": "https://docs.prowler.com/checks/aws/public-policies/public_6-api-gateway-authorizer-set#terraform"
"NativeIaC": "```yaml\n# CloudFormation: set method authorization so it's not public\nResources:\n <example_resource_name>:\n Type: AWS::ApiGateway::Method\n Properties:\n RestApiId: <example_resource_id>\n ResourceId: <example_resource_id>\n HttpMethod: GET\n AuthorizationType: AWS_IAM # Critical: authorizes the method (not NONE)\n```",
"Other": "1. In the AWS Console, go to API Gateway > APIs (REST) and select your API\n2. Open Resources, select a resource, then select a method (e.g., GET)\n3. Click Method Request\n4. Set Authorization to AWS_IAM (or an existing Cognito/Lambda authorizer)\n5. Repeat for every method so none show Authorization = NONE\n6. Deploy the API to apply changes",
"Terraform": "```hcl\n# Terraform: set method authorization so it's not public\nresource \"aws_api_gateway_method\" \"<example_resource_name>\" {\n rest_api_id = \"<example_resource_id>\"\n resource_id = \"<example_resource_id>\"\n http_method = \"GET\"\n authorization = \"AWS_IAM\" # Critical: authorizes the method (not NONE)\n}\n```"
},
"Recommendation": {
"Text": "Implement Amazon Cognito or a Lambda function to control access to your API.",
"Url": "https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html"
"Text": "Require **authentication** on every method: use **Cognito user pools**, **Lambda authorizers**, or **IAM**; avoid `NONE`.\n- Enforce **least privilege** with scoped policies\n- Use **private endpoints** or resource policies for internal APIs\n- Add **rate limiting** and **WAF** for defense in depth",
"Url": "https://hub.prowler.com/check/apigateway_restapi_authorizers_enabled"
}
},
"Categories": [],
"Categories": [
"identity-access"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
"Notes": "",
"CheckAliases": [
"apigateway_authorizers_enabled"
]
}
@@ -1,28 +1,38 @@
{
"Provider": "aws",
"CheckID": "apigateway_restapi_cache_encrypted",
"CheckTitle": "Check if API Gateway REST API cache data is encrypted at rest.",
"CheckTitle": "API Gateway REST API stage cache data 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"
],
"ServiceName": "apigateway",
"SubServiceName": "",
"ResourceIdTemplate": "arn:aws:apigateway:region:account-id:/restapis/restapi-id/stages/stage-name",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsApiGatewayStage",
"Description": "This control checks whether all methods in API Gateway REST API stages that have cache enabled are encrypted. The control fails if any method in an API Gateway REST API stage is configured to cache and the cache is not encrypted.",
"Risk": "Without encryption, cached data in API Gateway REST APIs may be vulnerable to unauthorized access, potentially exposing sensitive information to users not authenticated to AWS.",
"RelatedUrl": "https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-caching.html#enable-api-gateway-caching",
"Description": "API Gateway REST API stages with caching have **cache data encrypted at rest**. The evaluation targets stages where caching is enabled and verifies that stored responses are protected via the `Encrypt cache data` setting.",
"Risk": "Unencrypted cache contents can expose response payloads, tokens, or PII if cache storage, backups, or admin tooling are accessed outside normal controls, harming **confidentiality** and enabling replay or session hijacking.\n\nDisclosure also reveals API patterns, aiding **lateral movement** and targeted abuse.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://www.clouddefense.ai/compliance-rules/nist-800-53-5/au/apigateway-stage-cache-encryption-at-rest-enabled",
"https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-caching.html#enable-api-gateway-caching",
"https://support.icompaas.com/support/solutions/articles/62000233641-ensure-api-gateway-rest-api-cache-data-is-encrypted-at-rest",
"https://docs.fortifyfox.com/docs/aws-foundational-security-best-practices/apigateway/api-gw-cache-encrypted/index.html",
"https://docs.aws.amazon.com/securityhub/latest/userguide/apigateway-controls.html#apigateway-5",
"https://www.clouddefense.ai/compliance-rules/aws-fs-practices/apigateway/foundational-security-apigateway-5",
"https://www.cloudanix.com/docs/aws/audit/apigatewaymonitoring/rules/apigateway_enable_encryption_api_cache"
],
"Remediation": {
"Code": {
"CLI": "aws apigateway update-stage --rest-api-id <restapi-id> --stage-name <stage-name> --patch-operations op=replace,path=/<resourcePath>/<httpMethod>/caching/enabled,value=true op=replace,path=/<resourcePath>/<httpMethod>/caching/dataEncrypted,value=true",
"NativeIaC": "",
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/apigateway-controls.html#apigateway-5",
"Terraform": ""
"CLI": "aws apigateway update-stage --rest-api-id <restapi-id> --stage-name <stage-name> --patch-operations op=replace,path=/*/*/caching/dataEncrypted,value=true",
"NativeIaC": "```yaml\n# CloudFormation: enable encryption for all cached methods in a stage\nResources:\n <example_resource_name>:\n Type: AWS::ApiGateway::Stage\n Properties:\n StageName: <example_resource_name>\n RestApiId: <example_resource_id>\n DeploymentId: <example_resource_id>\n MethodSettings:\n - ResourcePath: /*\n HttpMethod: \"*\"\n CacheDataEncrypted: true # Critical: encrypt cached responses at rest for all methods\n```",
"Other": "1. Open the AWS Console and go to API Gateway\n2. Select your REST API, then click Stages and choose the affected stage\n3. In Method overrides (or Cache settings), enable Encrypt cache data\n4. Save changes",
"Terraform": "```hcl\n# Enable encryption for all cached methods in the stage\nresource \"aws_api_gateway_stage\" \"<example_resource_name>\" {\n rest_api_id = \"<example_resource_id>\"\n stage_name = \"<example_resource_name>\"\n deployment_id = \"<example_resource_id>\"\n\n method_settings {\n resource_path = \"/*\"\n http_method = \"*\"\n cache_data_encrypted = true # Critical: encrypt cached responses at rest\n }\n}\n```"
},
"Recommendation": {
"Text": "Ensure that API Gateway REST API cache data is encrypted at rest by enabling the 'Encrypt cache data' setting in the API Gateway stage configuration.",
"Url": "https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-caching.html#enable-api-gateway-caching"
"Text": "- Enable **encryption at rest** for any cached stage (`Encrypt cache data`).\n- Apply **least privilege** to stage administration and cache invalidation.\n- Avoid caching sensitive endpoints; use short TTLs and scheduled cache flushes for **defense in depth**.",
"Url": "https://hub.prowler.com/check/apigateway_restapi_cache_encrypted"
}
},
"Categories": [
@@ -1,35 +1,43 @@
{
"Provider": "aws",
"CheckID": "apigateway_restapi_client_certificate_enabled",
"CheckTitle": "Check if API Gateway Stage has client certificate enabled to access your backend endpoint.",
"CheckAliases": [
"apigateway_client_certificate_enabled"
],
"CheckTitle": "API Gateway REST API stage has client certificate enabled",
"CheckType": [
"Data Protection"
"Software and Configuration Checks/AWS Security Best Practices/Encryption in Transit",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/PCI-DSS",
"Software and Configuration Checks/Industry and Regulatory Standards/NIST 800-53 Controls (USA)"
],
"ServiceName": "apigateway",
"SubServiceName": "rest_api",
"ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsApiGatewayRestApi",
"Description": "Check if API Gateway Stage has client certificate enabled to access your backend endpoint.",
"Risk": "Possible man in the middle attacks and other similar risks.",
"ResourceType": "AwsApiGatewayStage",
"Description": "**API Gateway stage** has a **client certificate** configured so HTTP/S integrations can perform **mutual TLS** and authenticate API Gateway to the backend",
"Risk": "Without client authentication to the backend, requests cannot be proven to originate from API Gateway. Direct calls to the backend may bypass gateway policies, enabling unauthorized access and data tampering. This degrades **integrity** and **confidentiality** and reduces auditability.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://aws.amazon.com/blogs/compute/introducing-mutual-tls-authentication-for-amazon-api-gateway/"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": ""
"CLI": "aws apigateway update-stage --rest-api-id <REST_API_ID> --stage-name <STAGE_NAME> --patch-operations op=replace,path=/clientCertificateId,value=<CLIENT_CERT_ID>",
"NativeIaC": "```yaml\n# CloudFormation: attach a client certificate to a REST API stage\nResources:\n ClientCert:\n Type: AWS::ApiGateway::ClientCertificate\n\n ApiStage:\n Type: AWS::ApiGateway::Stage\n Properties:\n StageName: <example_resource_name>\n RestApiId: <example_resource_id>\n DeploymentId: <example_resource_id>\n ClientCertificateId: !Ref ClientCert # Critical: enables client certificate on the stage\n```",
"Other": "1. In the AWS Console, go to API Gateway > REST APIs and select your API\n2. In the left menu, click Client Certificates and create one (Generate)\n3. In the left menu, click Stages and select the target stage\n4. In Settings, find Client certificate and select the created certificate\n5. Click Save Changes",
"Terraform": "```hcl\n# Terraform: attach a client certificate to a REST API stage\nresource \"aws_api_gateway_client_certificate\" \"example\" {}\n\nresource \"aws_api_gateway_stage\" \"<example_resource_name>\" {\n stage_name = \"<example_resource_name>\"\n rest_api_id = \"<example_resource_id>\"\n deployment_id = \"<example_resource_id>\"\n client_certificate_id = aws_api_gateway_client_certificate.example.id # Critical: enables client certificate on the stage\n}\n```"
},
"Recommendation": {
"Text": "Enable client certificate. Mutual TLS is recommended and commonly used for business-to-business (B2B) applications. It is used in standards such as Open Banking. API Gateway now provides integrated mutual TLS authentication at no additional cost.",
"Url": "https://aws.amazon.com/blogs/compute/introducing-mutual-tls-authentication-for-amazon-api-gateway/"
"Text": "Enable **mutual TLS** from API Gateway to the backend with a **client certificate**, and configure the backend to trust only that identity. Apply **zero trust** and **least privilege**: block public access to the backend, restrict networks, rotate certificates, and monitor authentication failures.",
"Url": "https://hub.prowler.com/check/apigateway_restapi_client_certificate_enabled"
}
},
"Categories": [],
"Categories": [
"encryption"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
"Notes": "",
"CheckAliases": [
"apigateway_client_certificate_enabled"
]
}
@@ -1,38 +1,49 @@
{
"Provider": "aws",
"CheckID": "apigateway_restapi_logging_enabled",
"CheckTitle": "Check if API Gateway Stage has logging enabled.",
"CheckAliases": [
"apigateway_logging_enabled"
],
"CheckTitle": "API Gateway REST API stage has logging enabled",
"CheckType": [
"Logging and Monitoring"
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
"TTPs/Defense Evasion"
],
"ServiceName": "apigateway",
"SubServiceName": "rest_api",
"ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsApiGatewayRestApi",
"Description": "Check if API Gateway Stage has logging enabled.",
"Risk": "If not enabled, monitoring of service use is not possible. Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs and establishing corresponding metric filters and alarms.",
"ResourceType": "AwsApiGatewayStage",
"Description": "**API Gateway REST API stages** with **stage logging** enabled to emit execution or access logs to CloudWatch",
"Risk": "Without stage logging, API activity lacks visibility, hindering detection of abuse and incident response.\nAttackers can probe endpoints, exfiltrate data, or tamper integrations without traces, impacting confidentiality, integrity, and availability and blocking forensic investigation.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/apigateway/latest/developerguide/security-monitoring.html",
"https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-logging.html",
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/APIGateway/cloudwatch-logs.html",
"https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-logging.html",
"https://repost.aws/knowledge-center/api-gateway-cloudwatch-logs",
"https://repost.aws/knowledge-center/api-gateway-missing-cloudwatch-logs",
"https://docs.aws.amazon.com/apigateway/latest/developerguide/view-cloudwatch-log-events-in-cloudwatch-console.html"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": "https://docs.prowler.com/checks/aws/logging-policies/ensure-api-gateway-stage-have-logging-level-defined-as-appropiate#terraform"
"CLI": "aws apigateway update-stage --rest-api-id <REST_API_ID> --stage-name <STAGE_NAME> --patch-operations op=replace,path='/*/*/logging/loglevel',value=ERROR",
"NativeIaC": "```yaml\n# CloudFormation: enable execution logging on a REST API stage\nResources:\n <example_resource_name>:\n Type: AWS::ApiGateway::Stage\n Properties:\n StageName: <example_resource_name>\n RestApiId: <example_resource_id>\n DeploymentId: <example_resource_id>\n MethodSettings:\n - ResourcePath: \"/*\"\n HttpMethod: \"*\"\n LoggingLevel: ERROR # CRITICAL: turns on execution logging for all methods\n```",
"Other": "1. In the API Gateway console, open Settings and set CloudWatch log role ARN if prompted\n2. Go to APIs > select your REST API > Stages > select the stage\n3. Click Logs and tracing > CloudWatch Logs > choose Errors only (or Errors and info)\n4. Save changes",
"Terraform": "```hcl\n# Enable execution logging for all methods in a REST API stage\nresource \"aws_api_gateway_method_settings\" \"<example_resource_name>\" {\n rest_api_id = \"<example_resource_id>\"\n stage_name = \"<example_resource_name>\"\n method_path = \"*/*\"\n settings {\n logging_level = \"ERROR\" # CRITICAL: enables stage execution logging\n }\n}\n```"
},
"Recommendation": {
"Text": "Monitoring is an important part of maintaining the reliability, availability and performance of API Gateway and your AWS solutions. You should collect monitoring data from all of the parts of your AWS solution. CloudTrail provides a record of actions taken by a user, role, or an AWS service in API Gateway. Using the information collected by CloudTrail, you can determine the request that was made to API Gateway, the IP address from which the request was made, who made the request, etc.",
"Url": "https://docs.aws.amazon.com/apigateway/latest/developerguide/security-monitoring.html"
"Text": "Enable **CloudWatch Logs** for all API Gateway stages, using `ERROR` or `INFO` as appropriate. Include request IDs (e.g., `$context.requestId`). Enforce **least privilege** on logs, set **retention** and **alerts** for anomalies. Avoid sensitive data in logs and use **defense in depth** with tracing.",
"Url": "https://hub.prowler.com/check/apigateway_restapi_logging_enabled"
}
},
"Categories": [
"forensics-ready",
"logging"
"logging",
"forensics-ready"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
"Notes": "",
"CheckAliases": [
"apigateway_logging_enabled"
]
}
@@ -1,31 +1,36 @@
{
"Provider": "aws",
"CheckID": "apigateway_restapi_public",
"CheckTitle": "Check if API Gateway endpoint is public or private.",
"CheckAliases": [
"apigateway_public"
],
"CheckTitle": "API Gateway REST API endpoint is private",
"CheckType": [
"Infrastructure Security"
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
"TTPs/Initial Access"
],
"ServiceName": "apigateway",
"SubServiceName": "rest_api",
"ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsApiGatewayRestApi",
"Description": "Check if API Gateway endpoint is public or private.",
"Risk": "If accessible from internet without restrictions opens up attack / abuse surface for any malicious user.",
"Description": "**Amazon API Gateway REST APIs** are evaluated for endpoint exposure: **internet-accessible** endpoints versus **private VPC-only** access via interface VPC endpoints (`AWS PrivateLink`).",
"Risk": "Internet exposure increases attack surface:\n- **Confidentiality**: misconfigured or anonymous methods can leak data\n- **Integrity**: unauthorized calls can change backend state\n- **Availability/cost**: bots or DDoS can exhaust capacity and spike spend",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-private-apis.html",
"https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-resource-policies-examples.html#apigateway-resource-policies-source-vpc-example",
"https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-control-access-to-api.html",
"https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-resource-policies.html"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": ""
"CLI": "aws apigateway update-rest-api --rest-api-id <REST_API_ID> --patch-operations op=replace,path=/endpointConfiguration/types/0,value=PRIVATE",
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::ApiGateway::RestApi\n Properties:\n Name: <example_resource_name>\n EndpointConfiguration:\n Types:\n - PRIVATE # Critical: sets the REST API endpoint to Private, removing public access\n```",
"Other": "1. Open the AWS console and go to API Gateway\n2. Under REST APIs, select your API\n3. In the left menu, click Settings\n4. Set Endpoint Type to Private\n5. Click Save changes",
"Terraform": "```hcl\nresource \"aws_api_gateway_rest_api\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n\n endpoint_configuration {\n types = [\"PRIVATE\"] # Critical: makes the REST API private\n }\n}\n```"
},
"Recommendation": {
"Text": "Verify that any public Api Gateway is protected and audited. Detective controls for common risks should be implemented.",
"Url": "https://d1.awsstatic.com/whitepapers/api-gateway-security.pdf?svrd_sip6"
"Text": "Prefer **private** REST APIs reachable via interface VPC endpoints (`PRIVATE`).\n\n*If public access is required*, apply **least privilege** and **defense in depth**:\n- Restrict with resource policies (`aws:SourceVpc`/`aws:SourceVpce`)\n- Enforce strong auth (IAM, Cognito, or authorizers)\n- Add AWS WAF, throttling, usage plans, and comprehensive logging",
"Url": "https://hub.prowler.com/check/apigateway_restapi_public"
}
},
"Categories": [
@@ -33,5 +38,8 @@
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
"Notes": "",
"CheckAliases": [
"apigateway_public"
]
}
@@ -1,37 +1,50 @@
{
"Provider": "aws",
"CheckID": "apigateway_restapi_public_with_authorizer",
"CheckTitle": "Check if API Gateway public endpoint has an authorizer configured.",
"CheckAliases": [
"apigateway_public_with_authorizer"
],
"CheckTitle": "API Gateway REST API with a public endpoint has an authorizer configured",
"CheckType": [
"Infrastructure Security"
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
"TTPs/Initial Access/Unauthorized Access",
"Effects/Data Exposure"
],
"ServiceName": "apigateway",
"SubServiceName": "rest_api",
"ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsApiGatewayRestApi",
"Description": "Check if API Gateway public endpoint has an authorizer configured.",
"Risk": "If accessible from internet without restrictions opens up attack / abuse surface for any malicious user.",
"RelatedUrl": "https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-api-endpoint-types.html",
"Description": "**API Gateway REST APIs** exposed to the Internet are evaluated for an attached **authorizer** that enforces caller identity (Lambda authorizer or Cognito user pool) on method invocations.\n\nFocus is on whether public endpoints require authenticated requests rather than accepting anonymous calls.",
"Risk": "Without an **authorizer** on a public API, anonymous callers can:\n- Read or alter data (confidentiality/integrity)\n- Trigger backend actions, impacting systems\n- Abuse traffic, degrading availability and inflating costs\n\nEndpoint enumeration also enables broader discovery and lateral movement.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.icompaas.com/support/solutions/articles/62000233640-check-if-api-gateway-public-endpoint-has-an-authorizer-configured",
"https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-api-endpoint-types.html",
"https://api7.ai/blog/secure-rest-api-in-aws-api-gateway",
"https://supertokens.com/blog/lambda-authorizers",
"https://clerk.com/blog/how-to-secure-api-gateway-using-jwt-and-lambda-authorizers-with-clerk",
"https://aws.plainenglish.io/6-rest-api-security-best-practices-you-can-achieve-with-amazon-api-gateway-2-authentication-62b5171989bd",
"https://stackoverflow.com/questions/68512642/how-to-configure-aws-api-gateway-without-authorizer",
"https://auth0.com/docs/customize/integrations/aws/aws-api-gateway-custom-authorizers"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": ""
"CLI": "aws apigateway create-authorizer --rest-api-id <rest_api_id> --name <example_resource_name> --type TOKEN --authorizer-uri arn:aws:apigateway:<region>:lambda:path/2015-03-31/functions/arn:aws:lambda:<region>:<account-id>:function:<example_resource_name>/invocations --identity-source 'method.request.header.Authorization'",
"NativeIaC": "```yaml\n# CloudFormation: Create a minimal Lambda TOKEN authorizer for a public REST API\nResources:\n <example_resource_name>:\n Type: AWS::ApiGateway::Authorizer\n Properties:\n Name: <example_resource_name>\n RestApiId: <example_resource_id>\n Type: TOKEN # Critical: adds an authorizer to the REST API\n IdentitySource: method.request.header.Authorization # Critical: header to read token from\n AuthorizerUri: arn:aws:apigateway:<region>:lambda:path/2015-03-31/functions/arn:aws:lambda:<region>:<account-id>:function/<example_resource_name>/invocations # Critical: Lambda authorizer function URI\n```",
"Other": "1. In the AWS Console, open API Gateway and select your REST API\n2. In the left pane, click Authorizers > Create authorizer\n3. Choose Lambda (TOKEN) or Cognito User Pool\n4. For Lambda: select the function and set Identity source to method.request.header.Authorization; for Cognito: select the user pool\n5. Click Create authorizer to add it to the API",
"Terraform": "```hcl\n# Terraform: Minimal Lambda TOKEN authorizer for API Gateway REST API\nresource \"aws_api_gateway_authorizer\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n rest_api_id = \"<example_resource_id>\"\n type = \"TOKEN\" # Critical: enables a Lambda authorizer on the REST API\n identity_source = \"method.request.header.Authorization\" # Critical: header to read token\n authorizer_uri = \"arn:aws:apigateway:<region>:lambda:path/2015-03-31/functions/arn:aws:lambda:<region>:<account-id>:function/<example_resource_name>/invocations\" # Critical: Lambda authorizer function URI\n}\n```"
},
"Recommendation": {
"Text": "Verify that any public API Gateway is protected and audited. Detective controls for common risks should be implemented.",
"Url": "https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-api-endpoint-types.html"
"Text": "Enforce **authentication** on all Internet-facing APIs by attaching an **authorizer** (Cognito user pool or Lambda) that validates tokens and scopes.\n\nApply defense in depth:\n- Restrictive resource policies and IP controls\n- WAF, throttling, quotas, rate limits\n- Least-privilege backend access and comprehensive logging",
"Url": "https://hub.prowler.com/check/apigateway_restapi_public_with_authorizer"
}
},
"Categories": [
"internet-exposed"
"internet-exposed",
"identity-access"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
"Notes": "",
"CheckAliases": [
"apigateway_public_with_authorizer"
]
}
@@ -1,31 +1,39 @@
{
"Provider": "aws",
"CheckID": "apigateway_restapi_tracing_enabled",
"CheckTitle": "Check if AWS X-Ray tracing is enabled for API Gateway REST API stages.",
"CheckTitle": "API Gateway REST API stage has X-Ray tracing enabled",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices"
"Software and Configuration Checks/AWS Security Best Practices/Runtime Behavior Analysis",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
],
"ServiceName": "apigateway",
"SubServiceName": "",
"ResourceIdTemplate": "arn:aws:apigateway:region:account-id:/restapis/restapi-id/stages/stage-name",
"ResourceIdTemplate": "",
"Severity": "low",
"ResourceType": "AwsApiGatewayStage",
"Description": "This control checks whether AWS X-Ray active tracing is enabled for your Amazon API Gateway REST API stages.",
"Risk": "Without X-Ray active tracing, it may be difficult to quickly identify and respond to performance issues that could lead to decreased availability or degradation of the API's performance.",
"RelatedUrl": "https://docs.aws.amazon.com/xray/latest/devguide/xray-services-apigateway.html",
"Description": "**API Gateway REST API stages** have **AWS X-Ray active tracing** enabled to sample incoming requests and produce distributed traces across connected services.",
"Risk": "Without X-Ray tracing, you lose end-to-end visibility, hindering detection of timeouts, errors, and anomalous latency.\n\nThis delays incident response and root-cause analysis, increasing MTTR and risking partial outages (availability) and undetected integration failures (integrity).",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/securityhub/latest/userguide/apigateway-controls.html#apigateway-3",
"https://docs.aws.amazon.com/xray/latest/devguide/xray-services-apigateway.html",
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/APIGateway/tracing.html"
],
"Remediation": {
"Code": {
"CLI": "aws apigateway update-stage --rest-api-id <restapi-id> --stage-name <stage-name> --patch-operations op=replace,path=/tracingEnabled,value=true",
"NativeIaC": "",
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/apigateway-controls.html#apigateway-3",
"Terraform": ""
"NativeIaC": "```yaml\n# CloudFormation: Enable X-Ray tracing on an API Gateway REST API stage\nResources:\n <example_resource_name>:\n Type: AWS::ApiGateway::Stage\n Properties:\n RestApiId: <example_resource_id>\n DeploymentId: <example_resource_id>\n StageName: <example_resource_name>\n TracingEnabled: true # Critical: enables AWS X-Ray tracing for this stage\n```",
"Other": "1. Open the AWS Console and go to API Gateway\n2. Select your REST API and choose Stages\n3. Select the target stage\n4. Open the Logs/Tracing tab, check Enable X-Ray Tracing\n5. Click Save",
"Terraform": "```hcl\n# Enable X-Ray tracing on an API Gateway REST API stage\nresource \"aws_api_gateway_stage\" \"example\" {\n rest_api_id = \"<example_resource_id>\"\n deployment_id = \"<example_resource_id>\"\n stage_name = \"<example_resource_name>\"\n xray_tracing_enabled = true # Critical: enables AWS X-Ray tracing for this stage\n}\n```"
},
"Recommendation": {
"Text": "Enable AWS X-Ray tracing for API Gateway REST API stages to monitor and analyze performance in real time.",
"Url": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/APIGateway/tracing.html"
"Text": "Enable **X-Ray active tracing** on all API Gateway stages and propagate trace context through downstream services.\n\nUse prudent sampling, correlate traces with logs/metrics, and alert on errors/latency. Apply **least privilege** to X-Ray access and use **defense in depth** for observability.",
"Url": "https://hub.prowler.com/check/apigateway_restapi_tracing_enabled"
}
},
"Categories": [],
"Categories": [
"logging"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
@@ -1,35 +1,41 @@
{
"Provider": "aws",
"CheckID": "apigateway_restapi_waf_acl_attached",
"CheckTitle": "Check if API Gateway Stage has a WAF ACL attached.",
"CheckAliases": [
"apigateway_waf_acl_attached"
],
"CheckTitle": "API Gateway stage has a WAF Web ACL attached",
"CheckType": [
"Infrastructure Security"
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
],
"ServiceName": "apigateway",
"SubServiceName": "rest_api",
"ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsApiGatewayRestApi",
"Description": "Check if API Gateway Stage has a WAF ACL attached.",
"Risk": "Potential attacks and / or abuse of service, more even for even for internet reachable services.",
"ResourceType": "AwsApiGatewayStage",
"Description": "**Amazon API Gateway (REST API)** stages are assessed for an associated **AWS WAF web ACL**. The finding reflects whether a `web ACL` is linked at the stage level.",
"Risk": "Absent a **WAF web ACL**, APIs are exposed to application-layer threats that impact CIA:\n- Confidentiality: data exfiltration via injection\n- Integrity: parameter tampering and path traversal\n- Availability: L7 floods, bot abuse, resource exhaustion\n*Public endpoints face heightened risk.*",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/apigateway/latest/developerguide/security-monitoring.html"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": ""
"CLI": "aws wafv2 associate-web-acl --web-acl-arn <WEB_ACL_ARN> --resource-arn arn:aws:apigateway:<REGION>::/restapis/<REST_API_ID>/stages/<STAGE_NAME>",
"NativeIaC": "```yaml\n# CloudFormation: Attach a WAFv2 Web ACL to an API Gateway REST API stage\nResources:\n <example_resource_name>:\n Type: AWS::WAFv2::WebACLAssociation\n Properties:\n ResourceArn: arn:aws:apigateway:<example_region>::/restapis/<example_resource_id>/stages/<example_stage_name> # CRITICAL: target API Gateway stage\n WebACLArn: <example_resource_arn> # CRITICAL: Web ACL to attach\n```",
"Other": "1. Open the AWS Console and go to WAF & Shield\n2. Select Web ACLs (Scope: Regional), choose your Web ACL\n3. Click Add AWS resource\n4. Select API Gateway, choose the REST API and the specific Stage\n5. Click Add/Associate to attach the Web ACL",
"Terraform": "```hcl\n# Attach a WAFv2 Web ACL to an API Gateway REST API stage\nresource \"aws_wafv2_web_acl_association\" \"<example_resource_name>\" {\n resource_arn = \"arn:aws:apigateway:<example_region>::/restapis/<example_resource_id>/stages/<example_stage_name>\" # CRITICAL: target API Gateway stage\n web_acl_arn = \"<example_resource_arn>\" # CRITICAL: Web ACL to attach\n}\n```"
},
"Recommendation": {
"Text": "Use AWS WAF to protect your API Gateway API from common web exploits, such as SQL injection and cross-site scripting (XSS) attacks. These could affect API availability and performance, compromise security or consume excessive resources.",
"Url": "https://docs.aws.amazon.com/apigateway/latest/developerguide/security-monitoring.html"
"Text": "Attach an **AWS WAF web ACL** to each exposed stage and apply **defense in depth**:\n- Use managed rule groups and tailored allow/deny lists\n- Apply rate limiting to throttle abuse\n- Enforce least-privilege network exposure\n- Continuously tune rules using logs and metrics\n*Validate changes to reduce false positives.*",
"Url": "https://hub.prowler.com/check/apigateway_restapi_waf_acl_attached"
}
},
"Categories": [],
"Categories": [
"threat-detection"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
"Notes": "",
"CheckAliases": [
"apigateway_waf_acl_attached"
]
}
@@ -1,33 +1,41 @@
{
"Provider": "aws",
"CheckID": "appstream_fleet_default_internet_access_disabled",
"CheckTitle": "Ensure default Internet Access from your Amazon AppStream fleet streaming instances should remain unchecked.",
"CheckTitle": "AppStream fleet has default internet access disabled",
"CheckType": [
"Software and Configuration Checks",
"Industry and Regulatory Standards",
"CIS AWS Foundations Benchmark"
"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": "appstream",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:appstream:region:account-id:fleet/resource-id",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Other",
"Description": "Ensure default Internet Access from your Amazon AppStream fleet streaming instances should remain unchecked.",
"Risk": "Default Internet Access from your fleet streaming instances should be controlled using a NAT gateway in the VPC.",
"RelatedUrl": "https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html",
"Description": "**Amazon AppStream fleets** are assessed for the `EnableDefaultInternetAccess` setting, identifying fleets where streaming instances have default Internet connectivity.",
"Risk": "**Direct Internet access** gives streaming instances public exposure. Threats include:\n- Remote exploitation and malware, undermining **confidentiality** and **integrity**\n- Uncontrolled egress enabling **data exfiltration**\n\nIt also enforces ~100-instance limits, reducing **availability** for high-concurrency deployments.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html",
"https://support.icompaas.com/support/solutions/articles/62000233540-ensure-default-internet-access-from-your-amazon-appstream-fleet-streaming-instances-remains-unchecked",
"https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-appstream-fleet.html",
"https://docs.aws.amazon.com/appstream2/latest/developerguide/internet-access.html"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": ""
"CLI": "aws appstream update-fleet --name <example_resource_name> --no-enable-default-internet-access",
"NativeIaC": "```yaml\n# CloudFormation: disable default internet access on an AppStream fleet\nResources:\n <example_resource_name>:\n Type: AWS::AppStream::Fleet\n Properties:\n Name: <example_resource_name>\n InstanceType: <INSTANCE_TYPE>\n EnableDefaultInternetAccess: false # Critical: disables default internet access to pass the check\n```",
"Other": "1. In the AWS console, go to Amazon AppStream 2.0 > Fleets\n2. Select the target fleet\n3. If the fleet is RUNNING, click Actions > Stop and wait until the state is Stopped\n4. Click Edit (or Modify)\n5. Uncheck \"Default internet access\" (Disable \"Enable default internet access\")\n6. Save/Update the fleet and start it if needed",
"Terraform": "```hcl\n# Terraform: disable default internet access on an AppStream fleet\nresource \"aws_appstream_fleet\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n instance_type = \"stream.standard.small\"\n image_name = \"<IMAGE_NAME>\"\n compute_capacity { desired_instances = 1 }\n\n enable_default_internet_access = false # Critical: disables default internet access to pass the check\n}\n```"
},
"Recommendation": {
"Text": "Uncheck the default internet access for the AppStream Fleet.",
"Url": "https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html"
"Text": "Disable default Internet access (`EnableDefaultInternetAccess=false`) and place fleets in **private subnets**. Provide egress via **NAT gateways** or proxies, enforce **egress filtering**, and apply **least privilege** and **zero trust** to restrict outbound traffic. Use private connectivity to AWS services where possible.",
"Url": "https://hub.prowler.com/check/appstream_fleet_default_internet_access_disabled"
}
},
"Categories": [],
"Categories": [
"internet-exposed"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Infrastructure Security"
@@ -1,28 +1,31 @@
{
"Provider": "aws",
"CheckID": "appstream_fleet_maximum_session_duration",
"CheckTitle": "Ensure user maximum session duration is no longer than 10 hours.",
"CheckTitle": "AppStream fleet maximum user session duration is less than 10 hours",
"CheckType": [
"Infrastructure Security"
"Software and Configuration Checks/AWS Security Best Practices"
],
"ServiceName": "appstream",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:appstream:region:account-id:fleet/resource-id",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Other",
"Description": "Ensure user maximum session duration is no longer than 10 hours.",
"Risk": "Having a session duration lasting longer than 10 hours should not be necessary and if running for any malicious reasons provides a greater time for usage than should be allowed.",
"RelatedUrl": "https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html",
"Description": "**AppStream fleets** enforce a **maximum user session duration**. This finding evaluates each fleet's configured limit against a threshold-default `10 hours` (`36000` seconds)-and identifies fleets whose session duration exceeds that limit.",
"Risk": "Overlong sessions widen the window for **session hijacking**, **lateral movement**, and **data exfiltration** if endpoints or tokens are compromised. Reduced reauthentication weakens **confidentiality** and **integrity**, and extended access can increase **costs** and resource contention.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": ""
"CLI": "aws appstream update-fleet --name <example_resource_name> --max-user-duration-in-seconds 3600",
"NativeIaC": "```yaml\n# CloudFormation: Set AppStream Fleet session duration below 10 hours\nResources:\n AppStreamFleet:\n Type: AWS::AppStream::Fleet\n Properties:\n Name: \"<example_resource_name>\"\n MaxUserDurationInSeconds: 3600 # CRITICAL: ensures max session duration is < 10h to pass the check\n```",
"Other": "1. Open the AWS Console and go to Amazon AppStream 2.0\n2. Click Fleets and select <example_resource_name>\n3. Click Edit\n4. Set Maximum session duration to a value under 10 hours (e.g., 3600 seconds)\n5. Save changes",
"Terraform": "```hcl\n# Terraform: Set AppStream Fleet session duration below 10 hours\nresource \"aws_appstream_fleet\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n max_user_duration_in_seconds = 3600 # CRITICAL: ensures max session duration is < 10h to pass the check\n}\n```"
},
"Recommendation": {
"Text": "Change the Maximum session duration is set to 600 minutes or less for the AppStream Fleet.",
"Url": "https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html"
"Text": "Configure the **maximum session duration** to `<= 10 hours` (e.g., `600` minutes) or less based on data sensitivity. Prefer shorter limits, enforce **reauthentication** on renewal, apply **least privilege**, and enable **idle timeouts**. Monitor session activity as part of **defense in depth**.",
"Url": "https://hub.prowler.com/check/appstream_fleet_maximum_session_duration"
}
},
"Categories": [],
@@ -1,30 +1,33 @@
{
"Provider": "aws",
"CheckID": "appstream_fleet_session_disconnect_timeout",
"CheckTitle": "Ensure session disconnect timeout is set to 5 minutes or less.",
"CheckTitle": "AppStream fleet session disconnect timeout is 5 minutes or less",
"CheckType": [
"Software and Configuration Checks",
"Industry and Regulatory Standards",
"CIS AWS Foundations Benchmark"
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark"
],
"ServiceName": "appstream",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:appstream:region:account-id:fleet/resource-id",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Other",
"Description": "Ensure session disconnect timeout is set to 5 minutes or less",
"Risk": "Disconnect timeout in minutes, is the amount of of time that a streaming session remains active after users disconnect.",
"RelatedUrl": "https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html",
"Description": "**AppStream fleets** are evaluated for `DisconnectTimeoutInSeconds` being at or below `300` seconds (5 minutes), which defines how long a streaming session remains active after a user disconnects.",
"Risk": "Long disconnect times keep sessions active, enabling **session hijacking** or unintended reconnection on lost/stolen devices. This raises data exposure (confidentiality), permits unauthorized actions (integrity), and ties up capacity and costs (availability/operations).",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html",
"https://awscli.amazonaws.com/v2/documentation/api/2.9.6/reference/appstream/update-fleet.html"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": ""
"CLI": "aws appstream update-fleet --name <example_resource_name> --disconnect-timeout-in-seconds 300",
"NativeIaC": "```yaml\n# CloudFormation: Set AppStream Fleet disconnect timeout to 5 minutes or less\nResources:\n ExampleFleet:\n Type: AWS::AppStream::Fleet\n Properties:\n Name: <example_resource_name>\n InstanceType: stream.standard.medium\n ImageName: <example_image_name>\n ComputeCapacity:\n DesiredInstances: 1\n DisconnectTimeoutInSeconds: 300 # CRITICAL: ensures session disconnect timeout is <= 300s\n```",
"Other": "1. In the AWS console, go to Amazon AppStream 2.0 > Fleets\n2. Select the fleet <example_resource_name> and choose Edit\n3. Set Disconnect timeout to 5 minutes (300 seconds) or less\n4. Save changes",
"Terraform": "```hcl\n# Terraform: Set AppStream Fleet disconnect timeout to 5 minutes or less\nresource \"aws_appstream_fleet\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n instance_type = \"stream.standard.medium\"\n image_name = \"<example_image_name>\"\n\n compute_capacity {\n desired_instances = 1\n }\n\n disconnect_timeout_in_seconds = 300 # CRITICAL: ensures timeout is <= 300s\n}\n```"
},
"Recommendation": {
"Text": "Change the Disconnect timeout to 5 minutes or less for the AppStream Fleet.",
"Url": "https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html"
"Text": "Set `DisconnectTimeoutInSeconds` to `300` or less across fleets. Pair with a short `IdleDisconnectTimeoutInSeconds`, require re-authentication on reconnect, and enforce **least privilege**. Monitor session events and use **defense in depth** (network restrictions, device posture) to reduce takeover risk.",
"Url": "https://hub.prowler.com/check/appstream_fleet_session_disconnect_timeout"
}
},
"Categories": [],
@@ -1,33 +1,38 @@
{
"Provider": "aws",
"CheckID": "appstream_fleet_session_idle_disconnect_timeout",
"CheckTitle": "Ensure session idle disconnect timeout is set to 10 minutes or less.",
"CheckTitle": "AppStream fleet session idle disconnect timeout is 10 minutes or less",
"CheckType": [
"Software and Configuration Checks",
"Industry and Regulatory Standards",
"CIS AWS Foundations Benchmark"
"Software and Configuration Checks/AWS Security Best Practices",
"Effects/Data Exposure"
],
"ServiceName": "appstream",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:appstream:region:account-id:fleet/resource-id",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Other",
"Description": "Ensure session idle disconnect timeout is set to 10 minutes or less.",
"Risk": "Idle disconnect timeout in minutes is the amount of time that users can be inactive before they are disconnected from their streaming session and the Disconnect timeout in minutes time begins.",
"RelatedUrl": "https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html",
"Description": "**Amazon AppStream fleets** are evaluated for the **idle disconnect timeout** setting, confirming it is configured to `10 minutes` (`<=600s`) or less before inactive users are dropped and the session's `disconnect_timeout` window begins.",
"Risk": "**Long idle sessions** keep desktops/apps accessible without user presence, enabling **session hijacking**, **shoulder surfing**, and **data exposure**. They also **consume capacity** and extend **billing**, reducing **availability** for other users.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html",
"https://awscli.amazonaws.com/v2/documentation/api/2.9.6/reference/appstream/update-fleet.html"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": ""
"CLI": "aws appstream update-fleet --name <FLEET_NAME> --idle-disconnect-timeout-in-seconds 600",
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::AppStream::Fleet\n Properties:\n Name: <example_resource_name>\n InstanceType: stream.standard.small\n ComputeCapacity:\n DesiredInstances: 1\n IdleDisconnectTimeoutInSeconds: 600 # Critical: set to 10 minutes or less to pass the check\n```",
"Other": "1. Open the AWS Console and go to AppStream 2.0 > Fleets\n2. Select your fleet and click Edit\n3. Find \"Idle disconnect timeout\"\n4. Set it to 10 minutes (600 seconds) or less\n5. Click Save",
"Terraform": "```hcl\nresource \"aws_appstream_fleet\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n instance_type = \"stream.standard.small\"\n image_name = \"<IMAGE_NAME>\"\n\n compute_capacity {\n desired_instances = 1\n }\n\n idle_disconnect_timeout_in_seconds = 600 # Critical: enforce <= 10 minutes to pass the check\n}\n```"
},
"Recommendation": {
"Text": "Change the session idle timeout to 10 minutes or less for the AppStream Fleet.",
"Url": "https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html"
"Text": "Configure an **idle disconnect timeout 10 minutes**. Pair with a short `disconnect_timeout`, require **re-authentication** on reconnect, and enforce **least privilege**. Monitor session metrics and adjust per role to balance **security**, **cost**, and **user experience**.",
"Url": "https://hub.prowler.com/check/appstream_fleet_session_idle_disconnect_timeout"
}
},
"Categories": [],
"Categories": [
"identity-access"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Infrastructure Security"
@@ -1,29 +1,42 @@
{
"Provider": "aws",
"CheckID": "awslambda_function_inside_vpc",
"CheckTitle": "Ensure AWS Lambda Functions Are Deployed Inside a VPC",
"CheckType": [],
"CheckTitle": "Lambda function is deployed inside a VPC",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
],
"ServiceName": "awslambda",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:lambda:region:account-id:function/function-name",
"ResourceIdTemplate": "",
"Severity": "low",
"ResourceType": "AwsLambdaFunction",
"Description": "This check verifies whether an AWS Lambda function is deployed within a Virtual Private Cloud (VPC). Deploying Lambda functions inside a VPC improves security by allowing control over the network environment, reducing the exposure to public internet threats.",
"Risk": "Lambda functions not deployed in a VPC may expose your application to increased security risks, including unauthorized access and data breaches. Without the network isolation provided by a VPC, your Lambda functions are more vulnerable to attacks.",
"RelatedUrl": "https://docs.aws.amazon.com/lambda/latest/dg/configuration-vpc.html",
"Description": "**AWS Lambda function** uses **VPC networking** with specified subnets and security groups, rather than the default Lambda-managed network.\n\nPresence of a VPC association (`vpc_id`) indicates private connectivity to VPC resources.",
"Risk": "Without VPC attachment, functions lack network isolation and granular egress control, weakening **confidentiality** and **integrity**.\n\nTraffic must use public endpoints, raising risks of data exfiltration and SSRF via unrestricted outbound. If private databases are required, missing VPC access can impact **availability**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/lambda/latest/dg/configuration-vpc.html",
"https://repost.aws/pt/knowledge-center/lambda-dedicated-vpc",
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/Lambda/function-in-vpc.html",
"https://docs.aws.amazon.com/securityhub/latest/userguide/lambda-controls.html#lambda-3",
"https://stackoverflow.com/questions/55074793/how-can-we-force-aws-lamda-to-run-securely-in-a-vpc",
"https://www.techtarget.com/searchCloudComputing/answer/How-do-I-configure-AWS-Lambda-functions-in-a-VPC/"
],
"Remediation": {
"Code": {
"CLI": "aws lambda update-function-configuration --region <region-name> --function-name <function-name> --vpc-config SubnetIds=<subnet-id-1>,<subnet-id-2>,SecurityGroupIds=<security-group-id>",
"NativeIaC": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/Lambda/function-in-vpc.html",
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/lambda-controls.html#lambda-3",
"Terraform": "https://docs.prowler.com/checks/aws/general-policies/ensure-that-aws-lambda-function-is-configured-inside-a-vpc-1/"
"CLI": "aws lambda update-function-configuration --function-name <example_resource_name> --vpc-config SubnetIds=<example_subnet_id>,SecurityGroupIds=<example_security_group_id>",
"NativeIaC": "```yaml\nAWSTemplateFormatVersion: '2010-09-09'\nResources:\n LambdaFunction:\n Type: AWS::Lambda::Function\n Properties:\n FunctionName: <example_resource_name>\n Role: <example_role_arn>\n Handler: index.handler\n Runtime: python3.12\n Code:\n S3Bucket: <example_code_bucket>\n S3Key: <example_code_key>\n # Critical: Attach the function to a VPC by specifying at least one subnet and one security group\n # This sets VpcConfig, which gives the function a VPC ID and makes the check PASS\n VpcConfig:\n SubnetIds:\n - <example_subnet_id>\n SecurityGroupIds:\n - <example_security_group_id>\n```",
"Other": "1. In the AWS Lambda console, open your function\n2. Go to Configuration > VPC and click Edit\n3. Select the target VPC\n4. Choose at least one Subnet and one Security group\n5. Click Save",
"Terraform": "```hcl\nresource \"aws_lambda_function\" \"example\" {\n function_name = \"<example_resource_name>\"\n role = \"<example_role_arn>\"\n handler = \"index.handler\"\n runtime = \"python3.12\"\n filename = \"<example_package.zip>\"\n\n # Critical: VPC config attaches the function to a VPC, providing a VPC ID so the check passes\n vpc_config {\n subnet_ids = [\"<example_subnet_id>\"] # at least one subnet\n security_group_ids = [\"<example_security_group_id>\"]\n }\n}\n```"
},
"Recommendation": {
"Text": "Configure your AWS Lambda functions to operate within a Virtual Private Cloud (VPC) to enhance security and control network access.",
"Url": ""
"Text": "Attach functions to a VPC with private subnets and restrictive security groups to enforce **least privilege** and egress control.\n- Prefer **VPC endpoints** for AWS services\n- Use NAT only when necessary\n- Spread subnets across AZs for resilience\n- Govern with IAM conditions requiring `VpcIds`, `SubnetIds`, and `SecurityGroupIds`.",
"Url": "https://hub.prowler.com/check/awslambda_function_inside_vpc"
}
},
"Categories": [],
"Categories": [
"trust-boundaries"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
@@ -1,30 +1,37 @@
{
"Provider": "aws",
"CheckID": "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled",
"CheckTitle": "Check if Lambda functions invoke API operations are being recorded by CloudTrail.",
"CheckType": [],
"CheckTitle": "Lambda function Invoke API calls are recorded by CloudTrail",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
"TTPs/Defense Evasion"
],
"ServiceName": "awslambda",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:lambda:region:account-id:function/function-name",
"ResourceIdTemplate": "",
"Severity": "low",
"ResourceType": "AwsLambdaFunction",
"Description": "Check if Lambda functions invoke API operations are being recorded by CloudTrail.",
"Risk": "If logs are not enabled, monitoring of service use and threat analysis is not possible.",
"RelatedUrl": "https://docs.aws.amazon.com/lambda/latest/dg/logging-using-cloudtrail.html",
"Description": "**AWS Lambda** function invocations are recorded as **CloudTrail data events** when trails include `AWS::Lambda::Function` resources.\n\nThe finding reflects whether a function's `Invoke` activity is being logged by an eligible trail.",
"Risk": "Without Lambda `Invoke` data events, per-invocation accountability is lost. Adversaries or misused automation can run code without an audit trail, obscuring actor, time, and source. This hinders forensics and enables covert exfiltration or unauthorized changes, impacting **confidentiality** and **integrity**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/lambda/latest/dg/logging-using-cloudtrail.html",
"https://support.icompaas.com/support/solutions/articles/62000127055-ensure-lambda-functions-invoke-api-operations-are-being-recorded-by-cloudtrail"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": ""
"CLI": "aws cloudtrail put-event-selectors --trail-name <example_resource_name> --advanced-event-selectors '[{\"FieldSelectors\":[{\"Field\":\"eventCategory\",\"Equals\":[\"Data\"]},{\"Field\":\"resources.type\",\"Equals\":[\"AWS::Lambda::Function\"]}]}]'",
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::CloudTrail::Trail\n Properties:\n S3BucketName: <example_resource_name>\n IsLogging: true\n EventSelectors:\n - DataResources:\n - Type: AWS::Lambda::Function # Critical: enables Lambda data event logging\n Values:\n - arn:aws:lambda:<REGION>:<ACCOUNT_ID>:function # Critical: logs Invoke events for all functions in the specified account/region\n```",
"Other": "1. In the AWS Console, go to CloudTrail > Trails\n2. Select your trail and click Edit or Event logging\n3. Under Data events, choose Add data event selector (or Edit)\n4. Select Lambda function and choose to log data events for all functions (or specify functions)\n5. Save changes",
"Terraform": "```hcl\nresource \"aws_cloudtrail\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n s3_bucket_name = \"<example_resource_name>\"\n\n event_selector {\n data_resource {\n type = \"AWS::Lambda::Function\" # Critical: enable Lambda data events\n values = [\"arn:aws:lambda:<REGION>:<ACCOUNT_ID>:function\"] # Critical: capture Invoke for all functions in this account/region\n }\n }\n}\n```"
},
"Recommendation": {
"Text": "Make sure you are logging information about Lambda operations. Create a lifecycle and use cases for each trail.",
"Url": "https://docs.aws.amazon.com/lambda/latest/dg/logging-using-cloudtrail.html"
"Text": "Enable **CloudTrail data event logging** for `AWS::Lambda::Function` to capture `Invoke` calls across required Regions and accounts. Apply **least privilege** selectors to scope events, centralize logs with strong retention, and integrate alerts for anomalous invokes as part of **defense in depth**.",
"Url": "https://hub.prowler.com/check/awslambda_function_invoke_api_operations_cloudtrail_logging_enabled"
}
},
"Categories": [
"forensics-ready",
"logging"
],
"DependsOn": [],
@@ -1,26 +1,35 @@
{
"Provider": "aws",
"CheckID": "awslambda_function_no_secrets_in_code",
"CheckTitle": "Find secrets in Lambda functions code.",
"CheckType": [],
"CheckTitle": "Lambda function code contains no hardcoded secrets",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Sensitive Data Identifications/Passwords",
"Effects/Data Exposure"
],
"ServiceName": "awslambda",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:lambda:region:account-id:function/function-name",
"ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "AwsLambdaFunction",
"Description": "Find secrets in Lambda functions code.",
"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.",
"RelatedUrl": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/lambda-functions.html",
"Description": "**Lambda function code** is analyzed for **embedded secrets** across files in the deployment package, detecting patterns like API keys, passwords, tokens, and connection strings. Findings reference file names and line numbers where potential secrets appear.",
"Risk": "**Hardcoded secrets** undermine confidentiality and integrity: if code, layers, or artifacts are exposed, attackers can reuse credentials to access databases, APIs, or cloud resources, enabling data exfiltration and unauthorized changes.\n\nRotation is harder, increasing dwell time and blast radius of compromises.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/secretsmanager/latest/userguide/best-practices.html",
"https://aws.amazon.com/blogs/security/how-to-securely-provide-database-credentials-to-lambda-functions-by-using-aws-secrets-manager/",
"https://www.cloudcurls.com/2025/08/how-to-manage-secrets-securely-with-aws-secrets-manager.html"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Other": "1. In AWS Secrets Manager, click Store a new secret and create a secret for the value you hardcoded. Note the secret name/ARN.\n2. In IAM > Roles, open your Lambda execution role and add an inline policy allowing secretsmanager:GetSecretValue on that secret only.\n3. Edit your Lambda function code to remove the hardcoded value and retrieve it at runtime using the AWS SDK (GetSecretValue) with the secret name/ARN.\n4. Deploy the updated function code.",
"Terraform": ""
},
"Recommendation": {
"Text": "Use Secrets Manager to securely provide database credentials to Lambda functions and secure the databases as well as use the credentials to connect and query them without hardcoding the secrets in code or passing them through environmental variables.",
"Url": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/lambda-functions.html"
"Text": "Use **AWS Secrets Manager** (or Parameter Store) to store secrets and retrieve at runtime; never put them in code or Lambda env vars.\n- Apply **least privilege** IAM\n- Enable **rotation**\n- Prevent secret logging; encrypt\n- Add CI/CD secret scanning",
"Url": "https://hub.prowler.com/check/awslambda_function_no_secrets_in_code"
}
},
"Categories": [
@@ -1,26 +1,34 @@
{
"Provider": "aws",
"CheckID": "awslambda_function_no_secrets_in_variables",
"CheckTitle": "Find secrets in Lambda functions variables.",
"CheckType": [],
"CheckTitle": "Lambda function environment variables do not contain secrets",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Sensitive Data Identifications/Passwords",
"Effects/Data Exposure"
],
"ServiceName": "awslambda",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:lambda:region:account-id:function/function-name",
"ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "AwsLambdaFunction",
"Description": "Find secrets in Lambda functions variables.",
"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.",
"RelatedUrl": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/lambda-functions.html",
"Description": "AWS Lambda function environment variables are analyzed for content that resembles **secrets** (API keys, tokens, passwords). Pattern-based detection highlights potential hardcoded credentials present in the function's environment.",
"Risk": "Secrets in Lambda environment variables weaken **confidentiality**: users with config read access, runtime introspection, or logs may obtain them. Exposure can grant access to downstream systems, enable **lateral movement**, and allow tampering, impacting **integrity** and **availability**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/secretsmanager/latest/userguide/best-practices.html",
"https://support.icompaas.com/support/solutions/articles/62000129505-ensure-there-are-no-secrets-in-lambda-functions-variables"
],
"Remediation": {
"Code": {
"CLI": "aws lambda get-function-configuration --region <REGION> --function-name <FUNCTION_NAME> --query Environment.Variables",
"NativeIaC": "https://docs.prowler.com/checks/aws/secrets-policies/bc_aws_secrets_3#cloudformation",
"Other": "https://docs.prowler.com/checks/aws/secrets-policies/bc_aws_secrets_3",
"Terraform": "https://docs.prowler.com/checks/aws/secrets-policies/bc_aws_secrets_3#terraform"
"CLI": "aws lambda update-function-configuration --region <REGION> --function-name <FUNCTION_NAME> --environment \"Variables={}\"",
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::Lambda::Function\n Properties:\n Environment:\n Variables: {} # CRITICAL: clears environment variables to ensure no secrets are stored\n```",
"Other": "1. Open the AWS Lambda console and select the function\n2. Go to Configuration > Environment variables\n3. Click Edit\n4. Delete variables that contain secrets (or remove all variables)\n5. Click Save",
"Terraform": "```hcl\nresource \"aws_lambda_function\" \"<example_resource_name>\" {\n environment {\n variables = {} # CRITICAL: remove all env vars so no secrets are present\n }\n}\n```"
},
"Recommendation": {
"Text": "Use Secrets Manager to securely provide database credentials to Lambda functions and secure the databases as well as use the credentials to connect and query them without hardcoding the secrets in code or passing them through environmental variables.",
"Url": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/lambda-functions.html"
"Text": "Do not store secrets in environment variables or code. Use **AWS Secrets Manager** or **Parameter Store** with encryption, fetch at runtime using **least privilege** IAM, and prefer short-lived creds via **IAM roles**.\n\nRotate keys, limit configuration read access, and apply **defense in depth** with logging and alerts for secret access.",
"Url": "https://hub.prowler.com/check/awslambda_function_no_secrets_in_variables"
}
},
"Categories": [
@@ -1,26 +1,35 @@
{
"Provider": "aws",
"CheckID": "awslambda_function_not_publicly_accessible",
"CheckTitle": "Check if Lambda functions have resource-based policy set as Public.",
"CheckType": [],
"CheckTitle": "Lambda function resource-based policy does not allow public access",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
],
"ServiceName": "awslambda",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:lambda:region:account-id:function/function-name",
"ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "AwsLambdaFunction",
"Description": "Check if Lambda functions have resource-based policy set as Public.",
"Risk": "Publicly accessible services could expose sensitive data to bad actors.",
"RelatedUrl": "https://docs.aws.amazon.com/lambda/latest/dg/access-control-resource-based.html",
"Description": "**AWS Lambda** function resource-based policies are assessed for **public access**. The finding identifies policies with wildcard or empty `Principal` that allow actions like `lambda:InvokeFunction` to any principal.",
"Risk": "**Public invocation** lets outsiders run code under the function's IAM role.\n\nImpacts:\n- **Confidentiality**: data exfiltration via backend access\n- **Integrity**: unauthorized state changes from side effects\n- **Availability/cost**: invocation floods causing throttling and spend spikes",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/config/latest/developerguide/lambda-function-public-access-prohibited.html",
"https://docs.aws.amazon.com/lambda/latest/dg/access-control-resource-based.html",
"https://docs.aws.amazon.com/securityhub/latest/userguide/lambda-controls.html",
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/Lambda/function-exposed.html"
],
"Remediation": {
"Code": {
"CLI": "aws lambda remove-permission --region <REGION> --function-name <QUEUE_NAME> --statement-id FullAccess",
"NativeIaC": "",
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/Lambda/function-exposed.html",
"Terraform": ""
"CLI": "aws lambda remove-permission --function-name <example_function_name> --statement-id <example_statement_id>",
"NativeIaC": "```yaml\n# CloudFormation: restrict Lambda permission to a non-public principal\nResources:\n <example_resource_name>Permission:\n Type: AWS::Lambda::Permission\n Properties:\n Action: lambda:InvokeFunction\n FunctionName: <example_resource_name>\n Principal: 123456789012 # Critical: not \"*\"; limits invoke permission to a specific account to prevent public access\n```",
"Other": "1. Open the AWS Lambda console and select the function\n2. Go to Configuration > Permissions\n3. Under Resource-based policy, view the policy statements\n4. Find any statement with Principal set to \"*\" (or { \"AWS\": \"*\" })\n5. Delete that statement and save\n6. If access is needed, re-add a permission for a specific principal only (for example, an AWS account ID or a service principal)",
"Terraform": "```hcl\n# Restrict Lambda permission to a non-public principal\nresource \"aws_lambda_permission\" \"<example_resource_name>\" {\n statement_id = \"AllowSpecificPrincipal\"\n action = \"lambda:InvokeFunction\"\n function_name = \"<example_resource_name>\"\n principal = \"123456789012\" # Critical: not \"*\"; prevents public access\n}\n```"
},
"Recommendation": {
"Text": "Grant usage permission on a per-resource basis and applying least privilege principle.",
"Url": "https://docs.aws.amazon.com/lambda/latest/dg/access-control-resource-based.html"
"Text": "Remove public principals from function policies. Grant access only to specific accounts, roles, or services using fixed ARNs and **least privilege**. Add conditions like `AWS:SourceAccount` and `AWS:SourceArn` to constrain service triggers. Enforce **separation of duties** and monitor access for **defense in depth**.",
"Url": "https://hub.prowler.com/check/awslambda_function_not_publicly_accessible"
}
},
"Categories": [
@@ -1,29 +1,40 @@
{
"Provider": "aws",
"CheckID": "awslambda_function_url_cors_policy",
"CheckTitle": "Check Lambda Function URL CORS configuration.",
"CheckType": [],
"CheckTitle": "Lambda function URL CORS does not allow wildcard origins (*)",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
"Effects/Data Exposure"
],
"ServiceName": "awslambda",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:lambda:region:account-id:function/function-name",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsLambdaFunction",
"Description": "Check Lambda Function URL CORS configuration.",
"Risk": "Publicly accessible services could expose sensitive data to bad actors.",
"RelatedUrl": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/lambda-functions.html",
"Description": "**Lambda function URL** CORS policy is reviewed for `AllowOrigins`. The presence of `*` indicates a wide origin allowance in the CORS configuration.",
"Risk": "**Wildcard origins** allow any website to call the endpoint from a browser and read responses, weakening origin isolation.\n\nThis can lead to data exposure (C) and unauthorized actions (I) if state-changing methods are reachable, enabling scripted abuse and cross-origin attacks.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.icompaas.com/support/solutions/articles/62000229584-ensure-lambda-function-url-cors-configurations-were-checked",
"https://docs.aws.amazon.com/lambda/latest/api/API_Cors.html",
"https://tutorialsdojo.com/how-to-configure-aws-lambda-function-url-with-cross-origin-resource-sharing/",
"https://dev.to/rimutaka/aws-lambda-function-url-with-cors-explained-by-example-14df"
],
"Remediation": {
"Code": {
"CLI": "aws lambda update-function-url-config --region AWS_REGION --function-name FUNCTION-NAME --auth-type AWS_IAM --cors 'AllowOrigins=https://www.example.com,AllowMethods=*,ExposeHeaders=keep-alive,MaxAge=3600,AllowCredentials=false'",
"NativeIaC": "",
"Other": "",
"Terraform": ""
"CLI": "aws lambda update-function-url-config --function-name <example_resource_name> --cors AllowOrigins=https://www.example.com",
"NativeIaC": "```yaml\n# CloudFormation: restrict Lambda Function URL CORS to a specific origin\nResources:\n FunctionUrl:\n Type: AWS::Lambda::Url\n Properties:\n TargetFunctionArn: <example_resource_arn>\n AuthType: AWS_IAM\n Cors:\n AllowOrigins:\n - https://www.example.com # Critical: removes '*' wildcard by allowing only this origin\n```",
"Other": "1. In the AWS Console, go to Lambda > Functions and select <example_resource_name>\n2. Open Configuration > Function URL > Edit\n3. In CORS, remove '*' from Allowed origins and enter https://www.example.com\n4. Save changes",
"Terraform": "```hcl\n# Terraform: restrict Lambda Function URL CORS to a specific origin\nresource \"aws_lambda_function_url\" \"example\" {\n function_name = \"<example_resource_name>\"\n authorization_type = \"AWS_IAM\"\n cors {\n allow_origins = [\"https://www.example.com\"] # Critical: removes '*' wildcard by allowing only this origin\n }\n}\n```"
},
"Recommendation": {
"Text": "Grant usage permission on a per-resource basis and applying least privilege principle.",
"Url": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/lambda-functions.html"
"Text": "Apply least privilege to CORS:\n- Restrict `AllowOrigins` to trusted domains; avoid `*`\n- Limit `AllowMethods`/`AllowHeaders`; disable `AllowCredentials` unless required\n- Prefer authenticated access (e.g., `AWS_IAM`) and enforce resource policies for defense in depth",
"Url": "https://hub.prowler.com/check/awslambda_function_url_cors_policy"
}
},
"Categories": [],
"Categories": [
"internet-exposed"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
@@ -1,26 +1,36 @@
{
"Provider": "aws",
"CheckID": "awslambda_function_url_public",
"CheckTitle": "Check Public Lambda Function URL.",
"CheckType": [],
"CheckTitle": "Lambda function URL is not publicly accessible",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
"Effects/Data Exposure"
],
"ServiceName": "awslambda",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:lambda:region:account-id:function/function-name",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "AwsLambdaFunction",
"Description": "Check Public Lambda Function URL.",
"Risk": "Publicly accessible services could expose sensitive data to bad actors.",
"RelatedUrl": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/lambda-functions.html",
"Description": "**AWS Lambda function URLs** are assessed to determine whether `AuthType` enforces **AWS IAM authentication** or permits **public invocation**.\n\nApplies to functions with a function URL and highlights when requests must be authenticated and authorized via IAM principals.",
"Risk": "An unauthenticated function URL lets anyone invoke code:\n- Confidentiality: data exposure\n- Integrity: unintended changes via over-privileged logic\n- Availability: DoS/denial-of-wallet through high request rates\n\nAttackers can script calls, exfiltrate data, and pivot using the function's permissions.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/Lambda/iam-auth-function-url.html",
"https://www.roastdev.com/post/aws-lambda-url-invocations-with-iam-authentication-and-throttling-limits",
"https://docs.aws.amazon.com/secretsmanager/latest/userguide/lambda-functions.html",
"https://dev.to/aws-builders/hands-on-aws-lambda-function-url-with-aws-iam-authentication-type-180g",
"https://www.rahulpnath.com/blog/how-to-secure-and-authenticate-lambda-function-urls/"
],
"Remediation": {
"Code": {
"CLI": "aws lambda update-function-url-config --region AWS_REGION --function-name FUNCTION-NAME --auth-type AWS_IAM",
"NativeIaC": "",
"Other": "",
"Terraform": ""
"CLI": "aws lambda update-function-url-config --function-name <FUNCTION_NAME> --auth-type AWS_IAM",
"NativeIaC": "```yaml\n# CloudFormation: set Lambda Function URL to require IAM auth\nResources:\n FunctionUrl:\n Type: AWS::Lambda::Url\n Properties:\n TargetFunctionArn: arn:aws:lambda:<region>:<account-id>:function/<example_resource_name>\n AuthType: AWS_IAM # CRITICAL: requires IAM authentication, preventing public access\n```",
"Other": "1. In AWS Console, go to Lambda > Functions and open <example_resource_name>\n2. Select Configuration > Function URL > Edit\n3. Set Auth type to AWS_IAM\n4. Click Save",
"Terraform": "```hcl\n# Set Lambda Function URL to require IAM authentication\nresource \"aws_lambda_function_url\" \"example\" {\n function_name = \"<example_resource_name>\"\n authorization_type = \"AWS_IAM\" # CRITICAL: blocks public access by requiring IAM auth\n}\n```"
},
"Recommendation": {
"Text": "Grant usage permission on a per-resource basis and applying least privilege principle.",
"Url": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/lambda-functions.html"
"Text": "Enforce `AWS_IAM` on function URLs and apply **least privilege**:\n- Grant `lambda:InvokeFunctionUrl` only to required principals\n- Avoid `*` principals or broad conditions\n- Limit CORS to trusted origins and methods\n- Set reserved concurrency to contain abuse\n\nConsider **defense in depth** (WAF/CDN or private access) for Internet use.",
"Url": "https://hub.prowler.com/check/awslambda_function_url_public"
}
},
"Categories": [
@@ -1,29 +1,40 @@
{
"Provider": "aws",
"CheckID": "awslambda_function_using_supported_runtimes",
"CheckTitle": "Find obsolete Lambda runtimes.",
"CheckType": [],
"CheckTitle": "Lambda function uses a supported runtime",
"CheckType": [
"Software and Configuration Checks/Patch Management",
"Software and Configuration Checks/AWS Security Best Practices"
],
"ServiceName": "awslambda",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:lambda:region:account-id:function/function-name",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsLambdaFunction",
"Description": "Find obsolete Lambda runtimes.",
"Risk": "If you have functions running on a runtime that will be deprecated in the next 60 days, Lambda notifies you by email that you should prepare by migrating your function to a supported runtime. In some cases, such as security issues that require a backwards-incompatible update, or software that does not support a long-term support (LTS) schedule, advance notice might not be possible. After a runtime is deprecated, Lambda might retire it completely at any time by disabling invocation. Deprecated runtimes are not eligible for security updates or technical support.",
"RelatedUrl": "https://docs.aws.amazon.com/lambda/latest/dg/runtime-support-policy.html",
"Description": "**Lambda functions** using **obsolete runtimes**-such as `python3.8`, `nodejs14.x`, `go1.x`, `ruby2.7`-are identified against a curated list of deprecated runtime identifiers.",
"Risk": "Unmaintained runtimes lack security patches, exposing code and libraries to known CVEs (**confidentiality, integrity**).\n\nDeprecation can block create/update and break builds, causing failed deployments or runtime errors (**availability**). Tooling may stop supporting builds, slowing fixes and recovery.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://aws.amazon.com/blogs/compute/managing-aws-lambda-runtime-upgrades/",
"https://docs.aws.amazon.com/lambda/latest/dg/runtime-support-policy.html",
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/Lambda/supported-runtime-environment.html",
"https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html"
],
"Remediation": {
"Code": {
"CLI": "aws lambda update-function-configuration --region AWS-REGION --function-name FUNCTION-NAME --runtime 'RUNTIME'",
"NativeIaC": "",
"Other": "",
"Terraform": ""
"CLI": "aws lambda update-function-configuration --function-name <FUNCTION_NAME> --runtime <SUPPORTED_RUNTIME>",
"NativeIaC": "```yaml\n# CloudFormation: set Lambda to a supported runtime\nResources:\n <example_resource_name>:\n Type: AWS::Lambda::Function\n Properties:\n Role: <example_role_arn>\n Handler: <example_handler>\n Runtime: <SUPPORTED_RUNTIME> # FIX: change to a supported runtime (e.g., python3.12) to pass the check\n Code:\n S3Bucket: <example_bucket_name>\n S3Key: <example_object_key>\n```",
"Other": "1. Open the AWS Lambda console and select the function\n2. Go to Configuration > Runtime settings > Edit\n3. In Runtime, choose a supported runtime (e.g., python3.12) and click Save",
"Terraform": "```hcl\n# Set Lambda to a supported runtime\nresource \"aws_lambda_function\" \"<example_resource_name>\" {\n function_name = \"<example_resource_name>\"\n role = \"<example_role_arn>\"\n handler = \"<example_handler>\"\n runtime = \"<SUPPORTED_RUNTIME>\" # FIX: use a supported runtime (e.g., python3.12) to pass the check\n filename = \"<example_package.zip>\"\n}\n```"
},
"Recommendation": {
"Text": "Test new runtimes as they are made available. Implement them in production as soon as possible.",
"Url": "https://docs.aws.amazon.com/lambda/latest/dg/runtime-support-policy.html"
"Text": "Upgrade to **supported LTS runtimes** (AL2/AL2023) and include runtime upgrades in a secure SDLC.\n\nTest in staging, deploy via versions/aliases, and keep dependencies current. Monitor deprecation notices. Apply guardrails to block deprecated `runtime` values and allow only approved runtimes, aligning with **defense in depth**.",
"Url": "https://hub.prowler.com/check/awslambda_function_using_supported_runtimes"
}
},
"Categories": [],
"Categories": [
"container-security"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
@@ -1,29 +1,39 @@
{
"Provider": "aws",
"CheckID": "awslambda_function_vpc_multi_az",
"CheckTitle": "Check if AWS Lambda Function VPC is deployed Across Multiple Availability Zones",
"CheckType": [],
"CheckTitle": "Lambda function is configured with VPC subnets in at least two Availability Zones",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability"
],
"ServiceName": "awslambda",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:lambda:region:account-id:function/function-name",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsLambdaFunction",
"Description": "This control checks whether an AWS Lambda function connected to a VPC operates in at least the specified number of Availability Zones (AZs). A failure occurs if the function does not operate in the required number of AZs, which by default is two.",
"Risk": "A Lambda function not deployed across multiple AZs increases the risk of a single point of failure, which can result in a complete disruption of the function's operations if an AZ becomes unavailable.",
"RelatedUrl": "https://docs.aws.amazon.com/lambda/latest/operatorguide/networking-vpc.html",
"Description": "**AWS Lambda** functions attached to a VPC use subnets that span at least the required number of **Availability Zones** (`2` by default).\n\nThe evaluation counts the unique AZs of the function's configured subnets.",
"Risk": "Single-AZ placement limits **availability**. An AZ outage or subnet/IP exhaustion can block ENI creation and VPC access, causing failed invocations, timeouts, and event backlogs.\n\nThis degrades uptime and can delay processing of critical events.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/lambda/latest/operatorguide/networking-vpc.html",
"https://stackzonecom.tawk.help/article/aws-config-rule-lambda-vpc-multi-az-check",
"https://stackoverflow.com/questions/62052490/why-aws-lambda-suggests-to-set-up-two-subnets-if-vpc-is-configured",
"https://docs.aws.amazon.com/securityhub/latest/userguide/lambda-controls.html#lambda-5"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/lambda-controls.html#lambda-5",
"Terraform": ""
"CLI": "aws lambda update-function-configuration --function-name <example_resource_name> --vpc-config SubnetIds=<subnet_id_az1>,<subnet_id_az2>,SecurityGroupIds=<example_security_group_id>",
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::Lambda::Function\n Properties:\n Role: <example_role_arn>\n Handler: index.handler\n Runtime: python3.12\n Code:\n ZipFile: |\n def handler(event, context):\n return \"\"\n VpcConfig:\n SecurityGroupIds:\n - <example_security_group_id>\n SubnetIds:\n - <subnet_id_az1> # Critical: select subnets in different AZs\n - <subnet_id_az2> # Critical: ensures function operates in >=2 AZs\n```",
"Other": "1. Open the Lambda console and select the function\n2. Go to Configuration > VPC > Edit\n3. Select the target VPC and choose at least two subnets in different Availability Zones\n4. Select a security group\n5. Click Save",
"Terraform": "```hcl\nresource \"aws_lambda_function\" \"<example_resource_name>\" {\n function_name = \"<example_resource_name>\"\n role = \"<example_role_arn>\"\n handler = \"index.handler\"\n runtime = \"python3.12\"\n filename = \"function.zip\"\n\n vpc_config {\n subnet_ids = [\"<subnet_id_az1>\", \"<subnet_id_az2>\"] # Critical: subnets in different AZs\n security_group_ids = [\"<example_security_group_id>\"]\n }\n}\n```"
},
"Recommendation": {
"Text": "Ensure that your AWS Lambda functions connected to a VPC are distributed across multiple Availability Zones (AZs) to enhance availability and resilience.",
"Url": "https://docs.aws.amazon.com/lambda/latest/dg/configuration-vpc.html"
"Text": "Distribute VPC-connected functions across subnets in `2` distinct AZs to ensure **fault tolerance**.\n- Choose subnets from different AZs\n- Avoid AZ-pinned configs or fixed IPs\n- Provide per-AZ egress/endpoints and routing\n- Regularly test AZ failover\nAligns with **resilience** and **defense in depth**.",
"Url": "https://hub.prowler.com/check/awslambda_function_vpc_multi_az"
}
},
"Categories": [],
"Categories": [
"resilience"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
@@ -1,29 +1,40 @@
{
"Provider": "aws",
"CheckID": "cloudformation_stack_cdktoolkit_bootstrap_version",
"CheckTitle": "Ensure that CDKToolkit stacks have a Bootstrap version of 21 or higher to mitigate security risks.",
"CheckType": [],
"CheckTitle": "CDKToolkit CloudFormation stack has Bootstrap version 21 or higher",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Patch Management"
],
"ServiceName": "cloudformation",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:cloudformation:region:account-id:stack/resource-id",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "AwsCloudFormationStack",
"Description": "Ensure that CDKToolkit stacks have a Bootstrap version of 21 or higher to mitigate security risks.",
"Risk": "Using outdated CDKToolkit Bootstrap versions can expose accounts to risks such as bucket takeover or privilege escalation.",
"RelatedUrl": "https://docs.aws.amazon.com/cdk/latest/guide/bootstrapping.html",
"Description": "**CloudFormation CDKToolkit** stack's `BootstrapVersion` is compared to a recommended minimum (default `21`). A lower value indicates the environment uses legacy bootstrap resources and IAM roles from older templates.",
"Risk": "**Outdated bootstrap stacks** can lack recent hardening. Asset buckets or ECR repos may be easier to misuse, and deployment roles may have broader trust.\n\nAdversaries could tamper artifacts or assume privileged roles, compromising integrity/confidentiality and enabling privilege escalation.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://towardsthecloud.com/blog/aws-cdk-bootstrap",
"https://support.icompaas.com/support/solutions/articles/62000233694-ensure-that-cdktoolkit-stacks-have-a-bootstrap-version-of-21-or-higher-to-mitigate-security-risks",
"https://docs.aws.amazon.com/cdk/v2/guide/ref-cli-cmd-bootstrap.html",
"https://docs.aws.amazon.com/cdk/v2/guide/bootstrapping-customizing.html"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": ""
"CLI": "cdk bootstrap aws://<ACCOUNT_ID>/<REGION>",
"NativeIaC": "```yaml\n# Minimal CloudFormation to expose BootstrapVersion >= 21 for CDKToolkit\n# Deploy this template as a stack named \"CDKToolkit\"\nResources:\n CdkBootstrapVersion:\n Type: AWS::SSM::Parameter\n Properties:\n Type: String\n Name: /cdk-bootstrap/hnb659fds/version # critical: stores the bootstrap version used by CDK\n Value: \"21\" # critical: set to 21 (or higher) to satisfy the check\nOutputs:\n BootstrapVersion:\n Value: !GetAtt CdkBootstrapVersion.Value # critical: exposes the version in stack outputs so the check passes\n```",
"Other": "1. Sign in to the AWS Console and open CloudShell\n2. Run: cdk bootstrap aws://<ACCOUNT_ID>/<REGION>\n3. In the console, go to CloudFormation > Stacks > CDKToolkit > Outputs\n4. Confirm Output \"BootstrapVersion\" is 21 or higher",
"Terraform": "```hcl\n# Create/Update the CDKToolkit stack with BootstrapVersion >= 21\nresource \"aws_cloudformation_stack\" \"cdktoolkit\" {\n name = \"CDKToolkit\"\n # critical: template sets the BootstrapVersion output to 21 (or higher) so the check passes\n template_body = <<YAML\nResources:\n CdkBootstrapVersion:\n Type: AWS::SSM::Parameter\n Properties:\n Type: String\n Name: /cdk-bootstrap/hnb659fds/version # critical: stores the bootstrap version\n Value: \"21\" # critical: version must be >= 21\nOutputs:\n BootstrapVersion:\n Value: !GetAtt CdkBootstrapVersion.Value # critical: exposes version via stack output\nYAML\n}\n```"
},
"Recommendation": {
"Text": "Update the CDKToolkit stack Bootstrap version to 21 or later by running the cdk bootstrap command with the latest CDK version.",
"Url": "https://docs.aws.amazon.com/cdk/latest/guide/bootstrapping.html"
"Text": "Standardize on the modern bootstrap at or above the recommended version (e.g., `>= 21`) in every account and Region.\n\nApply **least privilege** to bootstrap roles, limit trusted accounts, enable termination protection, and periodically review for version drift to strengthen **defense in depth**.",
"Url": "https://hub.prowler.com/check/cloudformation_stack_cdktoolkit_bootstrap_version"
}
},
"Categories": [],
"Categories": [
"vulnerabilities"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
@@ -1,26 +1,36 @@
{
"Provider": "aws",
"CheckID": "cloudformation_stack_outputs_find_secrets",
"CheckTitle": "Find secrets in CloudFormation outputs",
"CheckType": [],
"CheckTitle": "CloudFormation stack outputs do not contain secrets",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Sensitive Data Identifications/Passwords",
"Sensitive Data Identifications/Security",
"Effects/Data Exposure"
],
"ServiceName": "cloudformation",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:cloudformation:region:account-id:stack/resource-id",
"ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "AwsCloudFormationStack",
"Description": "Find secrets in CloudFormation outputs",
"Risk": "Secrets hardcoded into CloudFormation outputs can be used by malware and bad actors to gain lateral access to other services.",
"RelatedUrl": "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/outputs-section-structure.html",
"Description": "**CloudFormation stack Outputs** are analyzed for hardcoded secrets-passwords, API keys, tokens-using pattern-based detection across output values. A finding indicates potential secret strings present within `Outputs` of the template or stack.",
"Risk": "**Secrets in Outputs** are readable to anyone with stack metadata access, enabling credential theft, unauthorized API calls, and lateral movement. Exposure via consoles, exports, or CI logs undermines confidentiality and can lead to privilege escalation and data exfiltration.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/secretsmanager/latest/userguide/best-practices.html",
"https://support.icompaas.com/support/solutions/articles/62000127093-ensure-no-secrets-are-found-in-cloudformation-outputs",
"https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/outputs-section-structure.html"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "https://docs.prowler.com/checks/aws/secrets-policies/bc_aws_secrets_2",
"Terraform": ""
"CLI": "aws cloudformation update-stack --stack-name <STACK_NAME> --template-body file://<TEMPLATE_WITHOUT_SENSITIVE_OUTPUTS>.yaml",
"NativeIaC": "```yaml\nAWSTemplateFormatVersion: '2010-09-09'\nOutputs:\n # Critical: remove outputs that expose secrets (passwords/tokens/keys)\n # Keeping only non-sensitive values in Outputs remediates the finding\n SafeInfo:\n Value: \"non-sensitive\"\n```",
"Other": "1. In the AWS Console, go to CloudFormation > Stacks and select the stack\n2. Click Update > Replace current template\n3. Upload or paste the template with any secret-bearing Outputs removed (do not output passwords/tokens/keys)\n4. Click Next through the wizard and choose Submit to apply the change set\n5. Verify the stack Outputs tab no longer shows sensitive values",
"Terraform": "```hcl\n# Critical: the embedded CloudFormation template removes secret outputs\nresource \"aws_cloudformation_stack\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n template_body = <<-YAML\n AWSTemplateFormatVersion: '2010-09-09'\n # Critical: delete Outputs that expose secrets; keep only non-sensitive values\n Outputs:\n SafeInfo:\n Value: \"non-sensitive\" # Avoids exposing secrets in stack outputs\n YAML\n}\n```"
},
"Recommendation": {
"Text": "Implement automated detective control to scan accounts for passwords and secrets. Use secrets manager service to store and retrieve passwords and secrets.",
"Url": "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-secretsmanager-secret-generatesecretstring.html"
"Text": "Remove secrets from `Outputs`. Store credentials in **Secrets Manager** or **Parameter Store** and reference them via dynamic references; set `NoEcho` for sensitive parameters. Apply **least privilege** to view stack metadata, avoid exporting sensitive values, and add automated IaC secret scanning for **defense in depth**.",
"Url": "https://hub.prowler.com/check/cloudformation_stack_outputs_find_secrets"
}
},
"Categories": [
@@ -1,29 +1,38 @@
{
"Provider": "aws",
"CheckID": "cloudformation_stacks_termination_protection_enabled",
"CheckTitle": "Enable termination protection for Cloudformation Stacks",
"CheckType": [],
"CheckTitle": "CloudFormation stack has termination protection enabled",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Effects/Data Destruction"
],
"ServiceName": "cloudformation",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:cloudformation:region:account-id:stack/resource-id",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsCloudFormationStack",
"Description": "Enable termination protection for Cloudformation Stacks",
"Risk": "Without termination protection enabled, a critical cloudformation stack can be accidently deleted.",
"RelatedUrl": "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-protect-stacks.html",
"Description": "**AWS CloudFormation root stacks** are evaluated for **termination protection**. The detection identifies whether `termination protection` is enabled to block stack deletions on non-nested stacks.",
"Risk": "Without **termination protection**, human error or automation can delete entire stacks, causing immediate **availability** loss and potential **data destruction** of managed resources.\n\nAttackers with delete rights can more easily trigger outages and hinder recovery.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-protect-stacks.html",
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFormation/stack-termination-protection.html"
],
"Remediation": {
"Code": {
"CLI": "aws cloudformation update-termination-protection --region <REGION_NAME> --stack-name <STACK_NAME> --enable-termination-protection",
"CLI": "aws cloudformation update-termination-protection --stack-name <STACK_NAME> --enable-termination-protection",
"NativeIaC": "",
"Other": "",
"Terraform": ""
"Other": "1. Open the AWS CloudFormation console\n2. Select the target stack\n3. Choose Stack actions > Edit termination protection\n4. Select Enable and Save",
"Terraform": "```hcl\nresource \"aws_cloudformation_stack\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n template_url = \"https://s3.amazonaws.com/<bucket>/<template>.json\"\n enable_termination_protection = true # Critical: enables termination protection to prevent stack deletion\n}\n```"
},
"Recommendation": {
"Text": "Ensure termination protection is enabled for the cloudformation stacks.",
"Url": "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-protect-stacks.html"
"Text": "Enable **termination protection** on root stacks for critical workloads. Enforce **least privilege** on who can alter this setting or delete stacks, require **change review** via change sets, and apply **stack policies** plus `DeletionPolicy: Retain` for data stores for defense in depth.",
"Url": "https://hub.prowler.com/check/cloudformation_stacks_termination_protection_enabled"
}
},
"Categories": [],
"Categories": [
"resilience"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Infrastructure Protection"
@@ -1,4 +1,5 @@
from asyncio import gather, get_event_loop
import asyncio
from asyncio import gather
from typing import List, Optional
from uuid import UUID
@@ -15,7 +16,23 @@ class Entra(AzureService):
def __init__(self, provider: AzureProvider):
super().__init__(GraphServiceClient, provider)
loop = get_event_loop()
created_loop = False
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
created_loop = True
if loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
created_loop = True
if loop.is_running():
raise RuntimeError(
"Cannot initialize Entra service while event loop is running"
)
# Get users first alone because it is a dependency for other attributes
self.users = loop.run_until_complete(self._get_users())
@@ -38,36 +55,48 @@ class Entra(AzureService):
self.directory_roles = attributes[4]
self.conditional_access_policy = attributes[5]
if created_loop:
asyncio.set_event_loop(None)
loop.close()
async def _get_users(self):
logger.info("Entra - Getting users...")
users = {}
try:
for tenant, client in self.clients.items():
users_list = await client.users.get()
users.update({tenant: {}})
users_response = await client.users.get()
try:
for user in users_list.value:
users[tenant].update(
{
user.id: User(
id=user.id,
name=user.display_name,
authentication_methods=[
AuthMethod(
id=auth_method.id,
type=getattr(
auth_method, "odata_type", None
),
)
for auth_method in (
await client.users.by_user_id(
user.id
).authentication.methods.get()
).value
],
)
}
)
while users_response:
for user in getattr(users_response, "value", []) or []:
users[tenant].update(
{
user.id: User(
id=user.id,
name=user.display_name,
authentication_methods=[
AuthMethod(
id=auth_method.id,
type=getattr(
auth_method, "odata_type", None
),
)
for auth_method in (
await client.users.by_user_id(
user.id
).authentication.methods.get()
).value
],
)
}
)
next_link = getattr(users_response, "odata_next_link", None)
if not next_link:
break
users_response = await client.users.with_url(next_link).get()
except Exception as error:
if (
error.__class__.__name__ == "ODataError"
-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 = ""
@@ -1,4 +1,4 @@
from asyncio import gather, get_event_loop
import asyncio
from typing import List, Optional
from pydantic.v1 import BaseModel
@@ -20,13 +20,29 @@ class AdminCenter(M365Service):
self.sharing_policy = self._get_sharing_policy()
self.powershell.close()
loop = get_event_loop()
created_loop = False
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
created_loop = True
if loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
created_loop = True
if loop.is_running():
raise RuntimeError(
"Cannot initialize AdminCenter service while event loop is running"
)
# Get users first alone because it is a dependency for other attributes
self.users = loop.run_until_complete(self._get_users())
attributes = loop.run_until_complete(
gather(
asyncio.gather(
self._get_directory_roles(),
self._get_groups(),
self._get_domains(),
@@ -37,6 +53,10 @@ class AdminCenter(M365Service):
self.groups = attributes[1]
self.domains = attributes[2]
if created_loop:
asyncio.set_event_loop(None)
loop.close()
def _get_organization_config(self):
logger.info("Microsoft365 - Getting Exchange Organization configuration...")
organization_config = None
@@ -77,27 +97,36 @@ class AdminCenter(M365Service):
logger.info("M365 - Getting users...")
users = {}
try:
users_list = await self.client.users.get()
users.update({})
for user in users_list.value:
license_details = await self.client.users.by_user_id(
user.id
).license_details.get()
users.update(
{
user.id: User(
id=user.id,
name=getattr(user, "display_name", ""),
license=(
getattr(
license_details.value[0], "sku_part_number", None
)
if license_details.value
else None
),
)
}
)
users_response = await self.client.users.get()
while users_response:
for user in getattr(users_response, "value", []) or []:
license_details = await self.client.users.by_user_id(
user.id
).license_details.get()
users.update(
{
user.id: User(
id=user.id,
name=getattr(user, "display_name", ""),
license=(
getattr(
license_details.value[0],
"sku_part_number",
None,
)
if license_details.value
else None
),
)
}
)
next_link = getattr(users_response, "odata_next_link", None)
if not next_link:
break
users_response = await self.client.users.with_url(next_link).get()
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -1,5 +1,5 @@
import asyncio
from asyncio import gather, get_event_loop
from asyncio import gather
from enum import Enum
from typing import List, Optional
from uuid import UUID
@@ -20,7 +20,24 @@ class Entra(M365Service):
self.user_accounts_status = self.powershell.get_user_account_status()
self.powershell.close()
loop = get_event_loop()
created_loop = False
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
created_loop = True
if loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
created_loop = True
if loop.is_running():
raise RuntimeError(
"Cannot initialize Entra service while event loop is running"
)
self.tenant_domain = provider.identity.tenant_domain
attributes = loop.run_until_complete(
gather(
@@ -41,6 +58,10 @@ class Entra(M365Service):
self.users = attributes[5]
self.user_accounts_status = {}
if created_loop:
asyncio.set_event_loop(None)
loop.close()
async def _get_authorization_policy(self):
logger.info("Entra - Getting authorization policy...")
authorization_policy = None
@@ -364,7 +385,7 @@ class Entra(M365Service):
logger.info("Entra - Getting users...")
users = {}
try:
users_list = await self.client.users.get()
users_response = await self.client.users.get()
directory_roles = await self.client.directory_roles.get()
async def fetch_role_members(directory_role):
@@ -396,23 +417,29 @@ class Entra(M365Service):
)
registration_details = {}
for user in users_list.value:
users[user.id] = User(
id=user.id,
name=user.display_name,
on_premises_sync_enabled=(
True if (user.on_premises_sync_enabled) else False
),
directory_roles_ids=user_roles_map.get(user.id, []),
is_mfa_capable=(
registration_details.get(user.id, {}).is_mfa_capable
if registration_details.get(user.id, None) is not None
else False
),
account_enabled=not self.user_accounts_status.get(user.id, {}).get(
"AccountDisabled", False
),
)
while users_response:
for user in getattr(users_response, "value", []) or []:
users[user.id] = User(
id=user.id,
name=user.display_name,
on_premises_sync_enabled=(
True if (user.on_premises_sync_enabled) else False
),
directory_roles_ids=user_roles_map.get(user.id, []),
is_mfa_capable=(
registration_details.get(user.id, {}).is_mfa_capable
if registration_details.get(user.id, None) is not None
else False
),
account_enabled=not self.user_accounts_status.get(
user.id, {}
).get("AccountDisabled", False),
)
next_link = getattr(users_response, "odata_next_link", None)
if not next_link:
break
users_response = await self.client.users.with_url(next_link).get()
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -1,5 +1,5 @@
import asyncio
import uuid
from asyncio import gather, get_event_loop
from typing import List, Optional
from msgraph.generated.models.o_data_errors.o_data_error import ODataError
@@ -16,15 +16,36 @@ class SharePoint(M365Service):
if self.powershell:
self.powershell.close()
loop = get_event_loop()
created_loop = False
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
created_loop = True
if loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
created_loop = True
if loop.is_running():
raise RuntimeError(
"Cannot initialize SharePoint service while event loop is running"
)
self.tenant_domain = provider.identity.tenant_domain
attributes = loop.run_until_complete(
gather(
asyncio.gather(
self._get_settings(),
)
)
self.settings = attributes[0]
if created_loop:
asyncio.set_event_loop(None)
loop.close()
async def _get_settings(self):
logger.info("M365 - Getting SharePoint global settings...")
settings = None
@@ -391,3 +391,54 @@ class TestCompliance:
assert get_check_compliance(finding, "github", bulk_checks_metadata) == {
"CIS-1.0": ["1.1.11"],
}
class TestComplianceOutput:
"""Test ComplianceOutput file extension parsing fix."""
def test_compliance_output_file_extension_with_dots(self):
"""Test that ComplianceOutput correctly parses file extensions when framework names contain dots."""
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
compliance = Compliance(
Framework="CIS",
Version="5.0",
Provider="AWS",
Name="CIS Amazon Web Services Foundations Benchmark v5.0",
Description="Test compliance framework",
Requirements=[],
)
# Test with problematic file path that contains dots in framework name
# This simulates the real scenario from Prowler App S3 integration
problematic_file_path = "output/compliance/prowler-output-123456789012-20250101120000_cis_5.0_aws.csv"
# Create GenericCompliance object with file_path (no explicit file_extension)
compliance_output = GenericCompliance(
findings=[], compliance=compliance, file_path=problematic_file_path
)
assert compliance_output.file_extension == ".csv"
assert compliance_output.file_extension != ".0_aws.csv"
def test_compliance_output_file_extension_explicit(self):
"""Test that ComplianceOutput uses explicit file_extension when provided."""
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
compliance = Compliance(
Framework="CIS",
Version="5.0",
Provider="AWS",
Name="CIS Amazon Web Services Foundations Benchmark v5.0",
Description="Test compliance framework",
Requirements=[],
)
compliance_output = GenericCompliance(
findings=[],
compliance=compliance,
file_path="output/compliance/test",
file_extension=".csv",
)
assert compliance_output.file_extension == ".csv"
+177 -19
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"""
@@ -747,24 +791,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 +832,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 +875,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"]
@@ -1,4 +1,6 @@
from unittest.mock import patch
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
from prowler.providers.azure.models import AzureIdentityInfo
@@ -223,3 +225,64 @@ class Test_Entra_Service:
]
== []
)
def test_azure_entra__get_users_handles_pagination():
entra_service = Entra.__new__(Entra)
users_page_one = [
SimpleNamespace(id="user-1", display_name="User 1"),
SimpleNamespace(id="user-2", display_name="User 2"),
]
users_page_two = [
SimpleNamespace(id="user-3", display_name="User 3"),
]
users_response_page_one = SimpleNamespace(
value=users_page_one,
odata_next_link="next-link",
)
users_response_page_two = SimpleNamespace(
value=users_page_two, odata_next_link=None
)
users_with_url_builder = SimpleNamespace(
get=AsyncMock(return_value=users_response_page_two)
)
with_url_mock = MagicMock(return_value=users_with_url_builder)
def by_user_id_side_effect(user_id):
auth_methods_response = SimpleNamespace(
value=[
SimpleNamespace(
id=f"{user_id}-method",
odata_type="#microsoft.graph.passwordAuthenticationMethod",
)
]
)
return SimpleNamespace(
authentication=SimpleNamespace(
methods=SimpleNamespace(
get=AsyncMock(return_value=auth_methods_response)
)
)
)
users_builder = SimpleNamespace(
get=AsyncMock(return_value=users_response_page_one),
with_url=with_url_mock,
by_user_id=MagicMock(side_effect=by_user_id_side_effect),
)
entra_service.clients = {"tenant-1": SimpleNamespace(users=users_builder)}
users = asyncio.run(entra_service._get_users())
assert len(users["tenant-1"]) == 3
assert users_builder.get.await_count == 1
with_url_mock.assert_called_once_with("next-link")
assert users["tenant-1"]["user-1"].authentication_methods[0].id == "user-1-method"
assert (
users["tenant-1"]["user-3"].authentication_methods[0].type
== "#microsoft.graph.passwordAuthenticationMethod"
)
@@ -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"""

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