Compare commits

...
36 Commits
Author SHA1 Message Date
Prowler Botandprowler-bot 1bb6b3cb39 chore(api): Update prowler dependency to v5.39 for release 5.39.0 (#12434)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-13 09:18:08 +02:00
Prowler Botandprowler-bot 5f109bc00e chore(changelog): v5.39.0 (#12433)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-13 09:01:51 +02:00
Pepe Fagoaga 472e04f4cc docs(triage): manual PASS verification for MANUAL findings (#12431) 2026-08-12 15:46:54 +02:00
Rubén De la Torre Vico b848aace33 docs(mcp): document the Cloud organization tools and Jira dispatch options (#12427) 2026-08-12 15:05:24 +02:00
Pedro Martín 94c20eb9fe feat(ui): add CMMC compliance framework (#12414) 2026-08-12 14:52:09 +02:00
lydiavilchezpedroootcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>pedrooot
02df22ca19 fix(html): escape provider identity fields in report header (#12424)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pedrooot <56402503+pedrooot@users.noreply.github.com>
2026-08-12 13:58:31 +02:00
Hugo Pereira BritoandJosema Camacho de64df11b9 fix(api): normalize social account names (#12413)
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-08-12 12:41:44 +01:00
Pedro Martín 37ebd9b6fd fix(cmmc): remove stale config_requirements (#12425) 2026-08-12 12:29:43 +02:00
Pedro Martín a28487cbff fix(ci): suppress .NET runtime CVE temporarily (#12426) 2026-08-12 12:16:14 +02:00
Rubén De la Torre Vico 68471d2a0e feat(ui): add Manage Lighthouse AI role permission (#12412) 2026-08-12 10:19:20 +02:00
Pablo Fernandez Guerra (PFE) d41b2eaa0f docs: add the Azure Management Groups onboarding tutorial (#12389) 2026-08-12 09:15:32 +02:00
Pablo Fernandez Guerra (PFE) b480907484 feat(ui): onboard Azure subscriptions from a Management Group (#12386) 2026-08-12 09:07:43 +02:00
Pablo Fernandez Guerra (PFE) 6d7bc8a86e test(ui): consolidate the providers page integration suites (#12383) 2026-08-11 18:50:10 +02:00
Hugo Pereira Britoandalejandrobailo 8bfca81e4b feat(ui): add manual pass triage workflow (#12253)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-11 15:09:08 +01:00
Pablo Fernandez Guerra (PFE) 931612443a test(ui): drop organization unit tests restated by integration (#12382) 2026-08-11 15:41:34 +02:00
Daniel Barranqueroandalejandrobailo 3074f02a63 feat(ui): grouped Attack Paths graph with expandable resource classes and outcome node (#12381)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-11 14:05:12 +02:00
Pablo Fernandez Guerra (PFE) 5cfc22040a fix(ui): open scan findings using the scan's UTC day (#12411) 2026-08-11 12:54:47 +02:00
Hugo Pereira Brito 48ba1692e1 docs: update provider check counts (#12418) 2026-08-11 10:37:48 +01:00
Alejandro Bailo a8b12813f9 feat(ui): add Lighthouse AI Skills on findings (#12355) 2026-08-11 11:05:04 +02:00
Pedro Martín 85c36bb812 feat(compliance): add CMMC 2.0 compliance framework (#12401) 2026-08-10 12:48:26 -07:00
ce037318cd feat(m365): add CIS M365 v7.0.0 entra authentication method, PIM and access review checks (#12155)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-10 15:37:48 +01:00
Pedro MartínandHugo P.Brito 356036fe1f feat(m365): add CIS M365 v7.0.0 entra conditional access and session checks (#12154)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-10 11:17:19 +01:00
Andoni AlonsoandLydia Vilchez 286685a4f3 feat(github): scale organization_repository_creation_limited severity by repository visibility (#12164)
Co-authored-by: Lydia Vilchez <lydiavilchezlopez@gmail.com>
2026-08-10 11:51:38 +02:00
Hugo Pereira Brito 9daca2e4df fix(ci): suppress Trivy go-git vulnerability temporarily (#12405) 2026-08-10 10:41:19 +01:00
Hugo Pereira Brito 3ca3a977a9 test(m365): avoid Lob secret pattern in test name (#12395) 2026-08-10 08:35:28 +01:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 561a1390be docs: fix typos and grammar (#12404)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-08-10 09:22:41 +02:00
Pedro MartínandHugo P.Brito f2a00f19aa feat(m365): add CIS M365 v7.0.0 entra password protection and default user permission checks (#12153)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-07 13:42:52 +01:00
Adrián Peña 34b4e6f016 fix(api): enforce POST on SAML ACS endpoint (#12393) 2026-08-07 14:24:45 +02:00
praneetrajvandDaniel Barranquero 94594d6766 feat(batch): add batch_job_definition_no_secrets check (#12117)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-07 11:57:58 +02:00
SaiGaneshcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Daniel BarranqueroClaude Opus 5
6e71dee85d feat(awslambda): add awslambda_layer_no_secrets_in_content check (#12233)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:33:54 +02:00
Alejandro Bailo 90712c9ad7 test(ui): preload lazy survey chunk to fix flaky feedback tests (#12387) 2026-08-07 11:04:38 +02:00
Josema Camacho 3672b17a00 feat(api): identify active membership in current user response (#12388) 2026-08-07 10:40:21 +02:00
Adrián Peña fd555e2989 docs: v5.38.0 changelog highlights (#12363) 2026-08-06 18:38:57 +02:00
Adrián Peña cf558c5f0a fix(api): make tenant deletion cleanup atomic (#12379) 2026-08-06 17:36:52 +02:00
lydiavilchez e2cae35d38 feat(ui): pre-fill Cloudflare and GitHub token creation URLs (#12349) 2026-08-06 16:36:10 +02:00
Prowler Botandprowler-bot d8c8027215 chore(release): Bump versions to v5.39.0 (#12376)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-06 15:59:35 +02:00
373 changed files with 27719 additions and 2058 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.38.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.39.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
@@ -111,6 +111,7 @@ jobs:
with:
files: |
api/**
.trivyignore.yaml
.github/actions/trivy-scan/**
.github/actions/grype-scan/**
.grype.yaml
@@ -103,6 +103,7 @@ jobs:
with:
files: |
mcp_server/**
.trivyignore.yaml
.github/actions/trivy-scan/**
.github/actions/grype-scan/**
.grype.yaml
@@ -10,6 +10,7 @@ on:
- 'Dockerfile*'
- 'pyproject.toml'
- 'uv.lock'
- '.trivyignore.yaml'
- '.github/workflows/sdk-container-checks.yml'
pull_request:
branches:
@@ -116,6 +117,7 @@ jobs:
Dockerfile*
pyproject.toml
uv.lock
.trivyignore.yaml
.github/workflows/sdk-container-checks.yml
.github/actions/trivy-scan/**
.github/actions/grype-scan/**
@@ -104,6 +104,7 @@ jobs:
with:
files: |
ui/**
.trivyignore.yaml
.github/actions/trivy-scan/**
.github/actions/grype-scan/**
.grype.yaml
+21
View File
@@ -9,6 +9,14 @@ ignore:
# Modules compiled into the Trivy binary we ship.
# Only a Trivy rebuild by its vendor can change these; the version is pinned in our Dockerfile.
# CVE-2026-71556 is the same temporary exception documented in .trivyignore.yaml:
# Trivy 0.73.0 still embeds go-git 5.19.1, while the 5.19.2 fix is merged only on
# Trivy main. Remove this entry with the Trivy exception by 2026-09-15.
# https://github.com/aquasecurity/trivy/blob/v0.73.0/go.mod#L46
# https://github.com/aquasecurity/trivy/commit/a2edba9a03987ba0d2ebc8212c1a9a1e6979497b
- vulnerability: CVE-2026-71556
package:
name: github.com/go-git/go-git/v5
- vulnerability: CVE-2026-56852
package:
name: golang.org/x/text
@@ -25,6 +33,19 @@ ignore:
package:
name: Microsoft.Bcl.Memory
# The .NET runtime bundled inside the PowerShell tarball the Dockerfile pins.
# CVE-2026-62901 is the same temporary exception documented in .trivyignore.yaml:
# fixed in .NET 9.0.19 / 10.0.11 (2026-08-11), but no published PowerShell release
# ships a patched runtime yet (7.5.9 bundles 9.0.18; 7.6.4 bundles 10.0.x < 10.0.11).
# pwsh runs only local M365 module cmdlets; nothing listens for inbound WebSocket
# connections. Remove with the Trivy exception by 2026-09-15.
- vulnerability: CVE-2026-62901
package:
name: Microsoft.NETCore.App.Runtime.linux-x64
- vulnerability: CVE-2026-62901
package:
name: Microsoft.NETCore.App.Runtime.linux-arm64
# The CPython interpreter, compiled into the official base image.
# TEMPORARY, unlike the entries above: moving to Python 3.13 clears seven of these, and
+28 -1
View File
@@ -118,8 +118,36 @@ vulnerabilities:
- "pkg:npm/ip-address"
expired_at: 2027-01-31
# CVE-2026-62901 is a DoS in System.Net.WebSockets (unchecked input for loop condition,
# CWE-606), fixed in .NET 9.0.19 / 10.0.11 (published 2026-08-11). The vulnerable runtime
# ships inside the PowerShell tarball the Dockerfile pins: 7.5.9 is the latest 7.5.x and
# bundles .NET 9.0.18; 7.6.4 bundles .NET 10.0.x < 10.0.11, so no published PowerShell
# release contains the fix yet. Prowler only invokes pwsh locally to run M365 module
# cmdlets; the image does not accept inbound WebSocket connections, so the DoS path is
# not reachable from the network. Remove this temporary suppression as soon as a
# PowerShell release shipping .NET 9.0.19+ is available.
- id: CVE-2026-62901
purls:
- "pkg:nuget/Microsoft.NETCore.App.Runtime.linux-x64"
- "pkg:nuget/Microsoft.NETCore.App.Runtime.linux-arm64"
expired_at: 2026-09-15
# Modules compiled into the Trivy binary the images ship. The binary is pinned by version
# and verified by checksum in the Dockerfile; only a rebuild by its vendor moves these.
# CVE-2026-71556 affects go-git worktree operations that can follow symlinks outside a
# cloned repository. Trivy 0.72.0 contains go-git 5.19.1, and even the latest published
# Trivy release, 0.73.0, still pins that vulnerable version:
# https://github.com/aquasecurity/trivy/blob/v0.73.0/go.mod#L46
# Trivy main already contains the 5.19.2 fix, but no published release includes it yet:
# https://github.com/aquasecurity/trivy/commit/a2edba9a03987ba0d2ebc8212c1a9a1e6979497b
# Prowler invokes Trivy only with `fs` on an existing local path or with `image`; it does
# not ask Trivy to clone or mutate a Git worktree, so the affected path is not reachable.
# Remove this temporary suppression as soon as a fixed Trivy release is available.
- id: CVE-2026-71556
purls:
- "pkg:golang/github.com/go-git/go-git/v5"
expired_at: 2026-09-15
- id: CVE-2026-56852
purls:
- "pkg:golang/golang.org/x/text"
@@ -140,4 +168,3 @@ vulnerabilities:
purls:
- "pkg:golang/stdlib"
expired_at: 2026-12-31
+3 -3
View File
@@ -126,12 +126,12 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/user-guide/compliance/tutorials/compliance) | [Categories](https://docs.prowler.com/user-guide/cli/tutorials/misc#categories) | Support | Interface |
|---|---|---|---|---|---|---|
| AWS | 621 | 86 | 47 | 19 | Official | UI, API, CLI |
| AWS | 639 | 86 | 47 | 19 | Official | UI, API, CLI |
| Azure | 191 | 22 | 21 | 16 | Official | UI, API, CLI |
| GCP | 109 | 20 | 19 | 12 | Official | UI, API, CLI |
| Kubernetes | 92 | 7 | 8 | 11 | Official | UI, API, CLI |
| GitHub | 24 | 3 | 2 | 5 | Official | UI, API, CLI |
| M365 | 111 | 10 | 6 | 10 | Official | UI, API, CLI |
| M365 | 143 | 10 | 6 | 10 | Official | UI, API, CLI |
| OCI | 52 | 14 | 5 | 10 | Official | UI, API, CLI |
| Alibaba Cloud | 63 | 9 | 6 | 9 | Official | UI, API, CLI |
| Cloudflare | 29 | 3 | 2 | 5 | Official | UI, API, CLI |
@@ -147,7 +147,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
| Huawei Cloud [Contact us](https://prowler.com/contact) | 25 | 10 | 1 | 6 | Unofficial | CLI |
| E2E Networks [Contact us](https://prowler.com/contact) | 27 | 6 | 0 | 2 | Unofficial | CLI |
| Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 1 | 1 | Unofficial | CLI |
| StackIT [Contact us](https://prowler.com/contact) | 7 | 2 | 1 | 3 | Unofficial | CLI |
| StackIT [Contact us](https://prowler.com/contact) | 8 | 2 | 1 | 3 | Unofficial | CLI |
| NHN | 6 | 2 | 2 | 0 | Unofficial | CLI |
> [!Note]
+14
View File
@@ -4,6 +4,20 @@ All notable changes to the **Prowler API** are documented in this file.
<!-- changelog: release notes start -->
## [1.40.0] (Prowler v5.39.0)
### 🔄 Changed
- `GET /api/v1/users/me` membership relationships identify the active tenant with `meta.active` for JWT and API key authentication [(#12388)](https://github.com/prowler-cloud/prowler/pull/12388)
### 🐞 Fixed
- Tenant deletion no longer leaves memberships partially removed when exclusive-user cleanup fails [(#12379)](https://github.com/prowler-cloud/prowler/pull/12379)
- `/api/v1/accounts/saml/{organization_slug}/acs/` rejects non-POST requests before SAML response processing [(#12393)](https://github.com/prowler-cloud/prowler/pull/12393)
- Social login derives a valid user name when identity providers omit the profile name [(#12413)](https://github.com/prowler-cloud/prowler/pull/12413)
---
## [1.39.0] (Prowler v5.38.0)
### 🚀 Added
+2 -2
View File
@@ -45,7 +45,7 @@ dependencies = [
"gunicorn==26.0.0",
"uvloop==0.22.1",
"lxml==6.1.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.39",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (==1.3.0)",
"sentry-sdk[django] (==2.56.0)",
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.39.0"
version = "1.40.0"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
+27 -4
View File
@@ -12,11 +12,37 @@ from api.models import (
UserRoleRelationship,
)
from api.utils import accept_invitation_for_user
from django.core.exceptions import ValidationError
from django.db import transaction
from django.http import HttpResponseForbidden
class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
@staticmethod
def _get_social_account_name(extra_data: dict, email: str) -> str:
name_field = User._meta.get_field("name")
for value in (
extra_data.get("name"),
extra_data.get("login"),
extra_data.get("username"),
email,
):
if not isinstance(value, str):
continue
candidate = value.strip()[: name_field.max_length].rstrip()
if not candidate:
continue
try:
name_field.run_validators(candidate)
except ValidationError:
continue
return candidate
raise ValueError("Social account does not provide a valid user identity.")
@staticmethod
def get_user_by_email(email: str):
try:
@@ -116,11 +142,8 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
if provider != "saml":
# Handle other providers (e.g., GitHub, Google)
user.name = self._get_social_account_name(extra, user.email)
user.save(using=MainRouter.admin_db)
social_account_name = extra.get("name")
if social_account_name:
user.name = social_account_name
user.save(using=MainRouter.admin_db)
invitation_token = self._get_invitation_token(request)
if invitation_token:
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.39.0
version: 1.40.0
description: |-
Prowler API specification.
@@ -1590,8 +1590,8 @@ class TestAPIKeyMultiTenantWorkflows:
tenant1 = tenants_fixture[0]
tenant2 = tenants_fixture[1]
Membership.objects.create(user=user, tenant=tenant1)
Membership.objects.create(user=user, tenant=tenant2)
membership1 = Membership.objects.create(user=user, tenant=tenant1)
membership2 = Membership.objects.create(user=user, tenant=tenant2)
role1 = Role.objects.create(
tenant_id=tenant1.id,
@@ -1646,6 +1646,27 @@ class TestAPIKeyMultiTenantWorkflows:
assert me_response1.json()["data"]["id"] == str(user.id)
assert me_response2.json()["data"]["id"] == str(user.id)
memberships1 = {
item["id"]: item["meta"]["active"]
for item in me_response1.json()["data"]["relationships"]["memberships"][
"data"
]
}
memberships2 = {
item["id"]: item["meta"]["active"]
for item in me_response2.json()["data"]["relationships"]["memberships"][
"data"
]
}
assert memberships1 == {
str(membership1.id): True,
str(membership2.id): False,
}
assert memberships2 == {
str(membership1.id): False,
str(membership2.id): True,
}
def test_api_key_cannot_access_different_tenant_resources(
self, tenants_fixture, aws_provider
):
+104
View File
@@ -111,6 +111,110 @@ def _verify_local_email(user):
)
def test_social_account_name_falls_back_to_login_for_blank_name():
adapter = ProwlerSocialAccountAdapter()
name = adapter._get_social_account_name(
{"name": " ", "login": "octocat"},
"verified@example.com",
)
assert name == "octocat"
@pytest.mark.parametrize("provider_name", [None, "", " ", 123, ["name"]])
def test_social_account_name_ignores_unusable_provider_names(provider_name):
adapter = ProwlerSocialAccountAdapter()
name = adapter._get_social_account_name(
{"name": provider_name, "login": "octocat"},
"verified@example.com",
)
assert name == "octocat"
def test_social_account_name_uses_login_when_name_is_missing():
adapter = ProwlerSocialAccountAdapter()
name = adapter._get_social_account_name(
{"login": "octocat"},
"verified@example.com",
)
assert name == "octocat"
def test_social_account_name_falls_back_to_username_then_email():
adapter = ProwlerSocialAccountAdapter()
username_name = adapter._get_social_account_name(
{"name": "ab", "login": None, "username": " monalisa "},
"verified@example.com",
)
email_name = adapter._get_social_account_name({}, " verified@example.com ")
assert username_name == "monalisa"
assert email_name == "verified@example.com"
def test_social_account_name_trims_and_limits_provider_name():
adapter = ProwlerSocialAccountAdapter()
max_length = User._meta.get_field("name").max_length
trimmed_name = adapter._get_social_account_name(
{"name": " Ada Lovelace "},
"verified@example.com",
)
limited_name = adapter._get_social_account_name(
{"name": "a" * (max_length + 1)},
"verified@example.com",
)
assert trimmed_name == "Ada Lovelace"
assert limited_name == "a" * max_length
def test_social_account_name_rejects_missing_identity():
adapter = ProwlerSocialAccountAdapter()
with pytest.raises(
ValueError,
match="Social account does not provide a valid user identity",
):
adapter._get_social_account_name({}, "")
def test_save_user_applies_normalized_social_account_name(rf):
adapter = ProwlerSocialAccountAdapter()
request = rf.post("/")
request.session = {}
sociallogin = MagicMock(spec=SocialLogin)
sociallogin.provider = MagicMock()
sociallogin.provider.id = "github"
sociallogin.account = MagicMock()
sociallogin.account.extra_data = {"name": None, "login": " octocat "}
user = User(email="verified@example.com")
user.save = MagicMock()
invitation = SimpleNamespace(tenant_id="tenant-id")
with (
patch("api.adapters.super") as mock_super,
patch("api.adapters.transaction.atomic"),
patch("api.adapters.write_db_alias"),
patch.object(adapter, "_get_invitation_token", return_value="token"),
patch(
"api.adapters.accept_invitation_for_user",
return_value=(invitation, True),
),
):
mock_super.return_value.save_user.return_value = user
saved_user = adapter.save_user(request, sociallogin)
assert saved_user.name == "octocat"
assert request.prowler_invitation_token == "token"
@pytest.mark.django_db
class TestProwlerSocialAccountAdapter:
def test_get_user_by_email_returns_user(self, create_test_user):
+77 -1
View File
@@ -78,8 +78,9 @@ from conftest import (
today_after_n_days,
)
from django.conf import settings
from django.db import close_old_connections, connection
from django.db import close_old_connections, connection, connections
from django.db.models import Count
from django.db.models.signals import pre_delete
from django.http import JsonResponse
from django.test import RequestFactory
from django.test.utils import CaptureQueriesContext
@@ -519,6 +520,50 @@ class TestUserViewSet:
assert error_field in response.json()["errors"][0]["source"]["pointer"]
@pytest.mark.requires_test_admin_alias
@pytest.mark.django_db(transaction=True, databases=["default", "admin"])
class TestTenantDeletionTransactions:
@patch("api.v1.views.delete_tenant_task.apply_async")
def test_delete_rolls_back_memberships_when_user_cleanup_fails(
self,
delete_tenant_mock,
authenticated_client,
tenants_fixture,
):
assert connections["default"] is not connections["admin"]
_, tenant, _ = tenants_fixture
exclusive_user = User.objects.create_user(
name="exclusive user",
password=TEST_PASSWORD,
email="exclusive-user@example.com",
)
membership = Membership.objects.create(
user=exclusive_user,
tenant=tenant,
role=Membership.RoleChoices.MEMBER,
)
def fail_user_cleanup(*, instance, **kwargs):
if instance.pk == exclusive_user.pk:
raise RuntimeError("Simulated user cleanup failure.")
pre_delete.connect(fail_user_cleanup, sender=User)
try:
with (
patch.object(MainRouter, "admin_db", "admin"),
pytest.raises(RuntimeError, match=r"Simulated user cleanup failure\."),
):
authenticated_client.delete(
reverse("tenant-detail", kwargs={"pk": tenant.id})
)
finally:
pre_delete.disconnect(fail_user_cleanup, sender=User)
assert Membership.objects.using("admin").filter(pk=membership.pk).exists()
delete_tenant_mock.assert_not_called()
@pytest.mark.django_db
class TestTenantViewSet:
@pytest.fixture
@@ -14628,6 +14673,37 @@ class TestSAMLConfigurationViewSet:
assert not SAMLConfiguration.objects.filter(id=config.id).exists()
@pytest.mark.django_db
class TestSAMLACSView:
def test_get_is_not_allowed(self, client, saml_setup):
response = client.get(
reverse(
"saml_acs",
kwargs={"organization_slug": saml_setup["domain"]},
)
)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
assert response.headers["Allow"] == "POST"
assert "saml-acs-session" not in response.cookies
def test_post_is_forwarded_to_allauth(self, client, saml_setup):
response = client.post(
reverse(
"saml_acs",
kwargs={"organization_slug": saml_setup["domain"]},
),
data={"SAMLResponse": "test-saml-response"},
)
assert response.status_code == status.HTTP_302_FOUND
assert response.url == reverse(
"saml_finish_acs",
kwargs={"organization_slug": saml_setup["domain"]},
)
assert "saml-acs-session" in response.cookies
@pytest.mark.django_db
class TestTenantFinishACSView:
def test_dispatch_skips_if_user_not_authenticated(self, monkeypatch):
+15
View File
@@ -329,6 +329,15 @@ class TokenSwitchTenantSerializer(BaseSerializerV1):
# Users
class ActiveMembershipRelatedField(SerializerMethodResourceRelatedField):
def to_representation(self, value):
representation = super().to_representation(value)
representation["meta"] = {
"active": str(value.tenant_id) == str(self.context["request"].tenant_id),
}
return representation
class UserSerializer(BaseModelSerializerV1):
"""
Serializer for the User model.
@@ -390,6 +399,12 @@ class UserSerializer(BaseModelSerializerV1):
)
class UserMeSerializer(UserSerializer):
memberships = ActiveMembershipRelatedField(
many=True, read_only=True, source="memberships", method_name="get_memberships"
)
class UserIncludeSerializer(UserSerializer):
class Meta:
model = User
+2 -1
View File
@@ -46,6 +46,7 @@ from api.v1.views import (
from django.http import JsonResponse
from django.urls import include, path
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from drf_spectacular.views import SpectacularRedocView
from rest_framework_nested import routers
@@ -194,7 +195,7 @@ urlpatterns = [
),
path(
"accounts/saml/<organization_slug>/acs/",
ACSView.as_view(),
require_POST(ACSView.as_view()),
name="saml_acs",
),
path(
+5 -2
View File
@@ -238,6 +238,7 @@ from api.v1.serializers import (
TokenSocialLoginSerializer,
TokenSwitchTenantSerializer,
UserCreateSerializer,
UserMeSerializer,
UserRoleRelationshipSerializer,
UserSerializer,
UserUpdateSerializer,
@@ -1113,6 +1114,8 @@ class UserViewSet(BaseUserViewset):
return UserCreateSerializer
elif self.action == "partial_update":
return UserUpdateSerializer
elif self.action == "me":
return UserMeSerializer
else:
return UserSerializer
@@ -1130,7 +1133,7 @@ class UserViewSet(BaseUserViewset):
@action(detail=False, methods=["get"], url_name="me")
def me(self, request):
user = self.request.user
serializer = UserSerializer(user, context=self.get_serializer_context())
serializer = self.get_serializer(user)
return Response(
data=serializer.data,
status=status.HTTP_200_OK,
@@ -1442,7 +1445,7 @@ class TenantViewSet(BaseTenantViewset):
if not membership or membership.role != Membership.RoleChoices.OWNER:
raise PermissionDenied("Only owners can delete a tenant.")
with transaction.atomic():
with transaction.atomic(using=MainRouter.admin_db):
# Collect user IDs from this tenant's memberships before deleting them
tenant_user_ids = set(
Membership.objects.using(MainRouter.admin_db)
Generated
+5 -5
View File
@@ -4202,7 +4202,7 @@ wheels = [
[package.optional-dependencies]
broker = [
{ name = "pymsalruntime" },
{ name = "pymsalruntime", marker = "sys_platform == 'win32'" },
]
[[package]]
@@ -4835,8 +4835,8 @@ wheels = [
[[package]]
name = "prowler"
version = "5.38.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b3d174d0c1eb202ed7cb9a9daf0500683f4443be" }
version = "5.39.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.39#5f109bc00e97490e36a030d9cdd1702fe4d2fad5" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4935,7 +4935,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.39.0"
version = "1.40.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -5035,7 +5035,7 @@ requires-dist = [
{ name = "matplotlib", specifier = "==3.10.8" },
{ name = "neo4j", specifier = "==6.1.0" },
{ name = "openai", specifier = "==1.109.1" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.39" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
{ name = "reportlab", specifier = "==4.4.10" },
+75
View File
@@ -4,6 +4,81 @@ description: "New features and improvements in each Prowler release"
rss: true
---
<Update label="v5.38.0" description="August 6, 2026">
### 📌 Compliance Watchlist
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Compliance Watchlist keeps the frameworks an organization tracks in one shared list. Pin frameworks from any compliance view, manage several at once through a searchable catalog, and filter the Compliance section to show only the pinned frameworks.
The Overview page now reports the latest score for every pinned framework, while finding details highlight the watched frameworks associated with each check. Universal frameworks remain a single watchlist entry across provider views, keeping the organization's priorities consistent everywhere.
![Compliance Watchlist editor](/images/compliance/prowler-app-compliance-watchlist-editor.png)
Read more in the [Compliance Watchlist documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance#tracking-frameworks-with-the-compliance-watchlist).
### 🔐 SAML SSO - Multiple Email Domains
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
One SAML configuration can now authorize a primary email domain and up to 19 additional domains through the same Identity Provider. Every domain shares one stable Assertion Consumer Service (ACS) URL based on the primary domain, so subsidiaries, acquired companies, regional domains, and multiple brands no longer require separate tenants or duplicated SAML applications.
Domain ownership remains tenant-bound throughout the authentication flow. During service provider-initiated sign-in, the discovery domain and the domain asserted by the Identity Provider must resolve to the same tenant before provisioning continues.
![SAML configuration with multiple email domains](/images/prowler-app/saml/saml-multiple-domains.png)
Read more in the [SAML SSO documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-sso#add-multiple-saml-domains).
### 👥 User Sign-In Methods
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
The Users table now shows each account's sign-in methods as tags, including email/password, Google, GitHub, SAML with linked domains, and Partner SSO. Accounts without a reported method display a placeholder.
![Users table showing sign-in method tags](/images/changelog/v5.38.0-user-sign-in-methods.png)
### 🕸️ Attack Paths - Expanded AWS Privilege-Escalation Coverage
Attack Paths adds 20 AWS privilege-escalation queries from [pathfinding.cloud](https://pathfinding.cloud), while `iam_policy_allows_privilege_escalation` gains 22 additional escalation combinations.
The new coverage includes service `iam:PassRole` paths across AWS Batch, Braket, Cognito Identity, ECS, EMR, EMR Serverless, GameLift, Glue, EC2 Image Builder, Kinesis Analytics, HealthOmics, EventBridge Scheduler, Systems Manager, and Step Functions. It also covers existing-resource abuse, permissions-boundary removal, role assumption, and IAM Identity Center permission-set policy injection.
The query catalog now exposes each AWS query's outcome category, distinguishing code execution, privilege escalation, public exposure, and resource inventory.
Explore the full Attack Paths query catalog at [Prowler Hub](https://hub.prowler.com/attack-paths).
Read more in the [Attack Paths documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-attack-paths).
### 🔍 Checks
#### Microsoft 365
Twelve new checks expand the coverage of CIS Microsoft 365 Foundations Benchmark v7.0.0:
- **Admin Center:** Shared Bookings is disabled.
- **Defender:** Priority account protection and strict preset security policies are enabled.
- **Entra ID:** Six checks cover device registration restrictions, local administrator behavior, device limits, LAPS, and BitLocker key visibility.
- **Exchange Online:** Personal accounts in Outlook on the web are disabled and Direct Send is rejected.
- **Microsoft Teams:** External access from trial-only tenants is blocked.
Explore all Microsoft 365 checks at [Prowler Hub](https://hub.prowler.com/check?provider=m365).
### 🔐 Security
- Prowler API, UI, SDK, and MCP container images now publish per-architecture Software Bills of Materials (SBOMs) and build-provenance attestations. Prowler Cloud production and Prowler Private Cloud images carry the same attestations.
- SDK and API container builds verify the checksums of downloaded PowerShell, Trivy, and zizmor binaries before installation.
- Grype now complements Trivy across the container-image security gates, detecting components and vulnerabilities that manifest-based scanners can miss and blocking fixable high and critical findings.
- `aiohttp` was upgraded to 3.14.3 to address CVE-2026-69244. `cryptography` was upgraded to 50.0.0 to address CVE-2026-69247 and CVE-2026-69249.
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.38.0) for the complete list of changes.
</Update>
<Update label="v5.37.0" description="August 3, 2026">
### 💬 Lighthouse AI — Context-Aware Chat and a Bigger Toolbox
@@ -184,7 +184,7 @@ Prowler enables security scanning of Kubernetes clusters, supporting both **in-c
```
<Note>
By default, Prowler scans all namespaces in the active Kubernetes context. Use the `--context`flag to specify the context to be scanned and `--namespaces` to restrict scanning to specific namespaces.
By default, Prowler scans all namespaces in the active Kubernetes context. Use the `--context` flag to specify the context to be scanned and `--namespaces` to restrict scanning to specific namespaces.
</Note>
## Microsoft 365
@@ -11,10 +11,10 @@ Complete reference guide for all tools available in the Prowler MCP Server. Tool
| Prowler Hub | 10 tools | No | Cloud and Local MCP Server |
| Prowler Documentation | 2 tools | No | Cloud and Local MCP Server |
| Prowler Cloud, Private Cloud & Local Server | 49 tools | Yes | Cloud and Local MCP Server |
| Prowler Cloud management | 32 tools | Yes | Cloud MCP Server only |
| Prowler Cloud management | 40 tools | Yes | Cloud MCP Server only |
<Note>
48 of the 49 Prowler tools are available on both servers. `prowler_schedule_daily_scan` is the exception: it is Local-only, because the Cloud MCP Server supersedes it with the `prowler_cloud_*` [Scan Scheduling](#scan-scheduling) tools.
48 of the 49 Prowler tools are available on both servers. `prowler_schedule_daily_scan` is the exception: it is Local-only, because the Cloud MCP Server supersedes it with the `prowler_cloud_*` [Scan Scheduling](#scan-scheduling) tools. `prowler_send_findings_to_jira` is exposed by both servers but accepts two [extra parameters](#jira-operations) on the Cloud MCP Server.
</Note>
## Tool Naming Convention
@@ -124,7 +124,20 @@ Tools for managing where Prowler sends its results: Amazon S3 buckets, AWS Secur
#### Jira Operations
- **`prowler_get_jira_issue_types`** - List the issue types available in a Jira project, fetched live from Jira
- **`prowler_send_findings_to_jira`** - Create one Jira work item per finding, with its severity, resource, risk, and remediation steps
- **`prowler_send_findings_to_jira`** - Create Jira work items from findings, each carrying the check title, severity, status, provider, region, resource, risk, and remediation steps. Select the findings either by ID with `finding_ids`, or — on Prowler Cloud only — by check with `check_ids`, and choose between one work item per finding or one per check with `dispatch_mode`
<Note>
`check_ids` and `dispatch_mode` are **Prowler Cloud only**:
- **`check_ids`** - Send the failing findings of a check (for example `s3_bucket_public_access`) without listing their IDs. Prowler resolves them server-side, taking only the failed findings of the latest completed scan of every provider. Get the check IDs from `prowler_list_finding_groups`. Exactly one of `finding_ids` or `check_ids` is required — Prowler combines both filters, so sending both would only dispatch their intersection. A Local MCP Server rejects `check_ids` with a client error.
- **`dispatch_mode`** - `individual` (the default) creates one work item per finding. `grouped` creates one work item per check instead, listing up to 50 affected resources and linking back to the finding group in Prowler Cloud, which keeps a noisy check to a single ticket. Grouped dispatch only covers failed, unmuted findings of the latest completed scan of every provider. A Local MCP Server ignores `dispatch_mode` instead of rejecting it, and creates one work item per finding.
In `grouped` mode the response counters change meaning: `created_count` counts work items (one per check) rather than findings, `failed_count` counts the entries of the new `failed_groups` field, and `failed_groups` details each failure with its reason and the `check_id` whose work item could not be created.
</Note>
<Warning>
`prowler_send_findings_to_jira` creates real work items that Prowler cannot delete or update afterwards. Only retry the same dispatch when the previous response returned `safe_to_retry: true`, otherwise the work items already created are duplicated. Combining `check_ids` with the default `individual` mode opens one work item per failing resource, which can be hundreds of them — use `dispatch_mode="grouped"` to keep it to one per check.
</Warning>
### Attack Paths Analysis
@@ -167,6 +180,23 @@ Manage Prowler Cloud-only features and configuration. **Requires authentication.
These tools are available **only on the Cloud MCP Server** (`https://mcp.prowler.com/mcp`). A Local MCP Server does not expose them, because the features they manage exist only in Prowler Cloud.
</Note>
### Organizations
Tools for onboarding a cloud provider organization as a whole — an AWS Organization, an Azure tenant with its management groups, or a GCP organization with its folders. An organization holds org-level credentials, discovers the real account, subscription, or project structure in the cloud, and turns a selection from that discovery into Prowler providers linked into a hierarchy of nodes. Every tool that changes something — creating, updating, deleting, discovering, applying a discovery, or adjusting provider membership — requires the **Manage Providers** permission; listing and reading do not.
<Note>
Use these tools for the whole organization. To register providers one by one, use the [Provider Management](#provider-management) tools instead; to build arbitrary RBAC buckets of providers, use provider groups.
</Note>
- **`prowler_cloud_list_organizations`** - Browse the registered organizations with lightweight data (name, type, external id, provider and node counts), filtered by type or cloud-side external id
- **`prowler_cloud_get_organization`** - Get one organization in full: attributes, linked providers, credentials status, latest discovery, and the OU / management group / folder hierarchy. Set `include_hierarchy` to `false` to skip the tree on large organizations
- **`prowler_cloud_create_organization`** - Register an organization, optionally storing its org-level credentials in the same call. Idempotent: an organization with the same type and external id is reused and its credentials rotated, reported as `created: false`
- **`prowler_cloud_update_organization`** - Rename an organization, replace its metadata, and/or create or rotate its org-level credentials. `org_type` and `external_id` are immutable after creation
- **`prowler_cloud_delete_organization`** - Delete an organization, its entire hierarchy, and every linked provider
- **`prowler_cloud_discover_organization`** - Enumerate the real cloud structure: AWS accounts and OUs, Azure subscriptions and management groups, or GCP projects and folders. Each item comes back with its registration state so you can choose what to onboard
- **`prowler_cloud_apply_organization_discovery`** - Turn a discovery selection into Prowler providers and hierarchy nodes
- **`prowler_cloud_manage_organization_providers`** - Manually `add`, `replace`, or `remove` the providers linked to an organization or to one of its hierarchy nodes. Providers are detached, never deleted
### Scan Configurations
Tools for managing reusable scan configurations — per-provider check and compliance selections — and attaching them to providers. Providers without a configuration attached use the default.
@@ -128,8 +128,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.37.0"
PROWLER_API_VERSION="5.37.0"
PROWLER_UI_VERSION="5.38.0"
PROWLER_API_VERSION="5.38.0"
```
<Note>
Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

@@ -4,13 +4,13 @@ title: "Custom Checks Metadata"
In certain organizations, the severity of specific checks might differ from the default values defined in the check's metadata. For instance, while `s3_bucket_level_public_access_block` could be deemed `critical` for some organizations, others might assign a different severity level to it.
The custom metadata option offers a means to override default metadata set by Prowler
The custom metadata option offers a means to override default metadata set by Prowler.
You can utilize `--custom-checks-metadata-file` followed by the path to your custom checks metadata YAML file.
## Available Fields
The list of supported check's metadata fields that can be override are listed as follows:
The list of supported check's metadata fields that can be overridden are listed as follows:
- Severity
- CheckTitle
+1 -1
View File
@@ -322,7 +322,7 @@ The Mutelist Table must have the following columns:
- Checks (String): This field can contain either a Prowler Check Name or an `*` (which applies to all the scanned checks).
- Regions (List): This field contains a list of regions where this mutelist rule is applied (it can also contains an `*` to apply all scanned regions).
- Regions (List): This field contains a list of regions where this mutelist rule is applied (it can also contain an `*` to apply all scanned regions).
- Resources (List): This field contains a list of regular expressions (regex) that applies to the resources that are wanted to be muted.
@@ -2,7 +2,7 @@
title: 'Scanning Multiple AWS Accounts with Prowler'
---
Prowler enables security scanning across multiple AWS accounts by utilizing the [Assume Role feature](/user-guide/providers/aws/role-assumption) and [integration with AWS Organizations feature](/user-guide/providers/aws/organizations).
Prowler enables security scanning across multiple AWS accounts by utilizing the [Assume Role feature](/user-guide/providers/aws/role-assumption) and [integration with AWS Organizations feature](/user-guide/providers/aws/organizations).
This approach allows execution from a single account with permissions to assume roles in the target accounts.
@@ -2,7 +2,7 @@
title: 'AWS Security Hub Integration with Prowler'
---
Prowler natively supports **official integration** with [AWS Security Hub](https://aws.amazon.com/security-hub), allowing security findings to be sent directly. This integration enables **Prowler** to import its findings into AWS Security Hub.
Prowler natively supports **official integration** with [AWS Security Hub](https://aws.amazon.com/security-hub), allowing security findings to be sent directly. This integration enables **Prowler** to import its findings into AWS Security Hub.
To activate the integration, follow these steps in at least one AWS region within your AWS account:
@@ -39,10 +39,12 @@ When using service principal authentication, add these **Application Permissions
**Microsoft Graph API Permissions:**
- `AccessReview.Read.All`: Required for `entra_access_review_guest_users_configured` and `entra_access_review_privileged_roles_configured` checks. A Microsoft Entra ID P2 or Microsoft Entra ID Governance license is also required.
- `AuditLog.Read.All`: Required for Entra service.
- `Directory.Read.All`: Required for all services.
- `OnPremDirectorySynchronization.Read.All`: Required for `entra_seamless_sso_disabled` check (hybrid deployments).
- `Policy.Read.All`: Required for all services.
- `RoleManagementPolicy.Read.Directory`: Required for `entra_pim_global_administrator_approval_required` and `entra_pim_privileged_role_administrator_approval_required` checks. A Microsoft Entra ID P2 or Microsoft Entra ID Governance license is also required.
- `SecurityIdentitiesHealth.Read.All`: Required for `defenderidentity_health_issues_no_open` check.
- `SecurityIdentitiesSensors.Read.All`: Required for `defenderidentity_health_issues_no_open` check.
- `SharePointTenantSettings.Read.All`: Required for SharePoint service.
@@ -110,10 +112,12 @@ Browser and Azure CLI authentication methods limit scanning capabilities to chec
3. Search and select the required permissions:
- `AccessReview.Read.All`: Required for `entra_access_review_guest_users_configured` and `entra_access_review_privileged_roles_configured` checks. A Microsoft Entra ID P2 or Microsoft Entra ID Governance license is also required
- `AuditLog.Read.All`: Required for Entra service
- `Directory.Read.All`: Required for all services
- `OnPremDirectorySynchronization.Read.All`: Required for `entra_seamless_sso_disabled` check (hybrid deployments)
- `Policy.Read.All`: Required for all services
- `RoleManagementPolicy.Read.Directory`: Required for `entra_pim_global_administrator_approval_required` and `entra_pim_privileged_role_administrator_approval_required` checks. A Microsoft Entra ID P2 or Microsoft Entra ID Governance license is also required
- `SecurityIdentitiesHealth.Read.All`: Required for `defenderidentity_health_issues_no_open` check
- `SecurityIdentitiesSensors.Read.All`: Required for `defenderidentity_health_issues_no_open` check
- `SharePointTenantSettings.Read.All`: Required for SharePoint service
@@ -42,12 +42,12 @@ The status selector includes manual statuses. Prowler also sets automatic status
| **Remediating** | Manual | Work is in progress to fix the finding. |
| **Risk Accepted** | Manual | The team accepts the risk and wants to mute the finding. |
| **False Positive** | Manual | The finding does not apply and should be muted. |
| **Resolved** | Automatic | A finding changed from `FAIL` to `PASS` in a later scan. A passed finding with no saved triage state also appears as **Resolved**. |
| **Resolved** | Automatic / Manual | A finding changed from `FAIL` to `PASS` in a later scan. A passed finding with no saved triage state also appears as **Resolved**. On `MANUAL` findings, select it to verify the finding as passing (see [Verify a MANUAL Finding as Pass](#verify-a-manual-finding-as-pass)). |
| **Reopened** | Automatic | A finding changed from `PASS` to `FAIL` in a later scan. |
![Findings Triage Status Selector](/images/prowler-app/findings-triage/findings-triage-status-dropdown.png)
Resolved and Reopened are not manual selector options.
**Reopened** is never a manual selector option. **Resolved** appears in the selector only on `MANUAL` findings, where it starts the [Manual Pass verification](#verify-a-manual-finding-as-pass).
These automatic states keep triage tied to the finding UID across scans, even when each scan creates a new finding snapshot.
@@ -93,6 +93,39 @@ Triage notes are visible only to the team in the current organization. Each note
To remove an existing note, clear the note text and save the change.
## Verify a MANUAL Finding as Pass
<VersionBadge version="5.39.0" />
Checks that Prowler cannot judge automatically report `MANUAL` findings. When a team verifies such a control outside Prowler, the triage selector on that finding offers **Resolved**: choosing it records a Manual Pass attestation, and the finding reports an effective `PASS` while keeping the raw `MANUAL` scan result.
![Triage selector offering Resolved on a MANUAL finding](/images/prowler-app/findings-triage/findings-triage-manual-pass-selector.png)
<Steps>
<Step title="Filter MANUAL findings">
Go to **Findings** and filter by status **Manual**.
</Step>
<Step title="Open the triage selector">
Expand a Finding Group and click the current status in the **Triage** column of an individual finding.
</Step>
<Step title="Choose Resolved">
Select **Resolved**. Prowler opens the triage note modal with a required **Manual pass evidence** field.
</Step>
<Step title="Record the evidence">
Describe how the control was verified, then click **Save**. The evidence supports up to 500 characters.
</Step>
</Steps>
![Manual Pass attestation with required evidence](/images/prowler-app/findings-triage/findings-triage-manual-pass-note.png)
After saving, the finding reports `PASS` in finding tables, finding groups, compliance reports, and scans. While the attestation is active, the triage status is managed automatically and cannot be changed. **View Manual Pass details** shows who verified the finding, the evidence, the attestation time, and its expiration.
![Manual Pass details showing evidence, author, and validity](/images/prowler-app/findings-triage/findings-triage-manual-pass-details.png)
### Attestation Expiration
A Manual Pass attestation is valid for 90 days. It also ends early when a later scan reports a real failure for the finding. In both cases the finding returns to its raw `MANUAL` status for a new review.
## Mutelist Behavior
Findings Triage uses Mutelist when a status means the finding should be muted:
@@ -118,7 +151,7 @@ Confirm that the user role has **Manage Scans** permission. Prowler Local Server
### Resolved or Reopened is missing from the selector
This is expected. Prowler sets **Resolved** and **Reopened** automatically from scan result changes.
**Reopened** is always automatic. **Resolved** is set automatically from scan result changes and appears as a selector option only on `MANUAL` findings, where it records a [Manual Pass](#verify-a-manual-finding-as-pass). On findings with any other status, this is expected.
### Risk Accepted or False Positive muted a finding
+1 -1
View File
@@ -136,7 +136,7 @@ To view all `new` findings that have not been seen prior to this scan, click the
## Step 9: Download the Outputs
Once a scan is complete, navigate to the `Scans` section to download the output files generated by Prowler:
You can download the output files generated by Prowler as a single `zip` file. This archive contains the CSV, JSON-OSCF, and HTML reports detailing the findings.
You can download the output files generated by Prowler as a single `zip` file. This archive contains the CSV, JSON-OCSF, and HTML reports detailing the findings.
To download these files, click the **Download** button. This button becomes available only after the scan has finished.
@@ -66,7 +66,7 @@ The Prowler wizard walks you through the entire flow: deploying both roles from
<Frame>
<img src="/images/organizations/aws-console-org-id.png" alt="AWS Organizations Console showing the Organization ID in the left sidebar" />
</Frame>
- **Name** (optional): A display name for the organization. If left blank, Prowler uses the name stored in AWS.
- **Name** (optional): A display name for the organization. If left blank, Prowler uses the AWS organization ID.
<Frame>
<img src="/images/organizations/organization-details-form.png" alt="Organization Details form with Organization ID and Name fields" />
@@ -280,7 +280,7 @@ Open the row actions menu on the organization row on the **Providers** page.
| Action | What it does |
|--------|--------------|
| **Edit Organization Name** | Renames the organization in Prowler. Leave it blank to fall back to the name stored in AWS. |
| **Edit Organization Name** | Renames the organization in Prowler. Leave it blank to fall back to the AWS organization ID. |
| **Update Credentials** | Reopens the Authentication Details step to store a new Role ARN. |
| **Edit Scan Schedule** | Applies one schedule to every connected account in the organization. |
| **Test Connections (N)** | Re-tests every account in the organization. |
@@ -1,11 +1,467 @@
---
title: 'Azure Management Groups'
description: 'Onboard all Azure subscriptions in your management groups through a single guided wizard'
tag: "Coming Soon"
description: 'Discover the Azure subscriptions in your management groups and onboard the ones you select through a single guided wizard'
---
Onboarding Azure management groups through a single guided wizard is coming soon to Prowler Cloud.
import { VersionBadge } from "/snippets/version-badge.mdx"
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
Today, Azure subscriptions are onboarded individually. See [Getting Started with Azure](/user-guide/providers/azure/getting-started-azure) and [Bulk Provider Provisioning](/user-guide/tutorials/bulk-provider-provisioning) to automate onboarding multiple subscriptions.
<VersionBadge version="5.39.0" />
Keep an eye on the [changelog](https://github.com/prowler-cloud/prowler/releases) for updates.
Prowler Cloud discovers every Azure subscription in your Microsoft Entra tenant and onboards the eligible ones you select, through a single guided wizard. Instead of connecting subscriptions one by one, you can discover every Management Group and subscription under the tenant root Management Group, select the ones you want to monitor, test connectivity, and launch scans — all from the Prowler Cloud UI.
<SubscriptionBanner>
For Command-Line Interface (CLI) scanning of several subscriptions, see [Configuring Specific Subscription Scans in Prowler](/user-guide/providers/azure/subscriptions).
</SubscriptionBanner>
To follow this guide you need an active [Prowler Cloud](https://cloud.prowler.com) account and an Azure service principal with read access granted **at the tenant root Management Group**.
## Overview
### Individual Subscriptions vs Azure Management Groups
| Approach | Best for | How it works |
|----------|----------|--------------|
| **Individual subscriptions** | A few Azure subscriptions | Connect each subscription one by one with its own service principal credential. |
| **Azure Management Groups** | 10+ subscriptions, or any tenant organized into Management Groups | Connect once with a tenant-wide credential, discover every Management Group and subscription automatically, and scan them in bulk. |
### How It Works
Onboarding runs in four stages:
1. **Grant read access** to one service principal at the tenant root Management Group.
2. **Discover** — Prowler walks the Management Group hierarchy and returns every Management Group and subscription under the tenant root.
3. **Select and connect** — choose the subscriptions to monitor. Prowler creates one provider per subscription and tests every connection.
4. **Launch scans** — apply a scan schedule across the connected subscriptions.
<Note>
**Nothing is deployed into your tenant.** Unlike AWS Organizations onboarding, Azure onboarding creates no resources in Azure. Prowler reuses the service principal credential you provide as the credential of every subscription it onboards, so one credential covers discovery and scanning once all the required permissions are granted.
</Note>
<Note>
**Onboarding always covers the whole tenant.** Prowler scopes the organization to the tenant root Management Group, which it derives from the Microsoft Entra tenant ID. The wizard therefore asks for the tenant, never for a Management Group — there is no partial onboarding from a Management Group further down the hierarchy.
</Note>
## Before You Start
### Create a Service Principal
Prowler Cloud authenticates against Azure with a service principal application. To create one, follow [Creating a Prowler Service Principal Application](/user-guide/providers/azure/create-prowler-service-principal). Keep the **client ID** and the **client secret** — the wizard asks for both.
### Grant Read Access at the Tenant Root Management Group
Discovery reads the Management Group hierarchy and the subscriptions inside it. Assign the built-in **Reader** role to the service principal **on the tenant root Management Group**, so the grant covers every Management Group and subscription beneath it:
```bash
TENANT_ID=<microsoft-entra-tenant-id>
CLIENT_ID=<prowler-service-principal-client-id>
# Resolve the exact service principal from its client ID. A display-name search
# is a prefix match, so it can return a different principal than intended.
SP_OBJECT_ID=$(az ad sp show --id "$CLIENT_ID" --query id -o tsv)
az role assignment create \
--role "Reader" \
--assignee-object-id "$SP_OBJECT_ID" \
--assignee-principal-type ServicePrincipal \
--scope "/providers/Microsoft.Management/managementGroups/$TENANT_ID"
```
<Note>
The tenant root Management Group is named after the tenant ID, so its resource identifier is always `/providers/Microsoft.Management/managementGroups/<tenant-id>`. Creating a role assignment there requires **Owner** or **User Access Administrator** at that scope; a Global Administrator who holds neither can grant themselves access through the elevated-access toggle in Microsoft Entra ID.
</Note>
### Grant the Scanning Permissions
Scanning each subscription additionally needs the permissions described in [Azure Authentication in Prowler](/user-guide/providers/azure/authentication#required-permissions):
| Permission | Scope | Used for |
|------------|-------|----------|
| **Reader** | Subscription, or inherited from the Management Group | Reading Azure resources during a scan. |
| **ProwlerRole** | Subscription, or inherited from the Management Group | The read-only actions the Reader role does not cover. |
| `AuditLog.Read.All`, `Directory.Read.All`, `Policy.Read.All` | Microsoft Entra ID (Microsoft Graph) | The Microsoft Entra ID checks. |
Role assignments made at the tenant root Management Group are inherited by every subscription beneath it, so assigning **Reader** and **ProwlerRole** there means every subscription you onboard is scannable without a per-subscription grant. To create the custom role, see [Assigning "ProwlerRole" Permissions at the Subscription Level](/user-guide/providers/azure/authentication#assigning-prowlerrole-permissions-at-the-subscription-level) — and set its `assignableScopes` to the Management Group, `/providers/Microsoft.Management/managementGroups/<tenant-id>`, rather than to a single subscription, so it can be assigned once for the whole tenant.
### Find Your Microsoft Entra Tenant ID
Prowler identifies the organization by the Microsoft Entra tenant ID, a Universally Unique Identifier (UUID):
```bash
az account show --query tenantId -o tsv
```
In the Azure portal, the same value sits on your service principal's **App registrations** > **Overview** blade as **Directory (tenant) ID**, next to the **Application (client) ID** the wizard also asks for. [Adding Azure credentials to Prowler Cloud](/user-guide/providers/azure/getting-started-azure#step-3-add-credentials-to-prowler-cloud) shows both on the annotated blade.
## Step 1: Start the Organization Wizard
### Open the Wizard
1. Navigate to **Providers** and click **Add Provider**.
<Frame>
<img src="/images/organizations/cloud-providers-add.png" alt="Providers page showing the Add Provider button" />
</Frame>
2. Select **Microsoft Azure** as the provider.
<Frame>
<img src="/images/organizations/azure/select-azure-provider.png" alt="Provider selection modal with Microsoft Azure highlighted" />
</Frame>
3. Choose **Add Multiple Subscriptions With Azure Management Group**.
<Frame>
<img src="/images/organizations/azure/select-azure-management-groups-method.png" alt="Method selector showing the Add Multiple Subscriptions With Azure Management Group option highlighted" />
</Frame>
<Note>
In Prowler Local Server the Management Group option is marked **Cloud** and opens an upgrade panel instead of the wizard. Management Group onboarding is a Prowler Cloud feature; the single-subscription method remains available.
</Note>
### Enter Organization Details
- **Tenant ID**: the Microsoft Entra tenant ID (for example, `8b3c9a41-5f27-4d6e-9c18-7ae204f5b6d2`). Values that are not valid UUIDs are rejected before submission.
- **Name** (optional): a display name for the organization in Prowler. If left blank, Prowler uses the tenant ID.
<Frame>
<img src="/images/organizations/azure/azure-organization-details-form.png" alt="Organization Details form with the Microsoft Entra tenant ID and Name fields" />
</Frame>
Click **Next** to proceed to the authentication phase. Prowler matches the organization by tenant ID, so submitting a tenant that is already onboarded reuses it instead of creating a duplicate.
## Step 2: Authenticate With Azure
The **Authentication Details** step collects the service principal Prowler uses to read the Management Group hierarchy and, later, to scan each subscription:
- **Client ID**: the application (client) ID of the service principal, a UUID.
- **Client Secret**: a client secret of that service principal.
<Frame>
<img src="/images/organizations/azure/azure-authentication-details.png" alt="Authentication Details step showing the Client ID and Client Secret fields" />
</Frame>
The tenant is not repeated here: Prowler takes it from the organization created in the previous step.
<Warning>
Every subscription you onboard inherits this credential. Revoking it, rotating the client secret, or deleting the service principal stops the scans of every subscription in the organization.
</Warning>
### Authenticate and Discover
Click **Authenticate**. Prowler then:
- Creates the organization and stores the credential securely.
- Triggers an asynchronous discovery that walks the Management Group hierarchy.
- Shows a **"Gathering Azure Subscriptions..."** spinner while it waits.
<Frame>
<img src="/images/organizations/azure/azure-gathering-subscriptions.png" alt="Gathering Azure Subscriptions spinner shown while discovery runs" />
</Frame>
Discovery usually takes seconds to a couple of minutes, depending on how many Management Groups and subscriptions the tenant holds.
#### When Discovery Takes Too Long
Prowler waits up to **3 minutes** for a result. Past that, the wizard stops waiting — but the discovery keeps running in Azure — and offers two actions:
- **Keep waiting** — resume the same discovery. Nothing is re-read from Azure.
- **Retry** — start a fresh discovery, which reads the hierarchy again.
<Frame>
<img src="/images/organizations/azure/azure-discovery-timeout.png" alt="Discovery timeout notice offering Keep waiting and Retry" />
</Frame>
If discovery fails outright, the wizard explains why and offers **Retry discovery**. See [Troubleshooting](#troubleshooting) for each message.
## Step 3: Select Subscriptions to Scan
### Understanding the Tree View
Once discovery completes, the wizard renders the tenant as a hierarchical tree:
<Frame>
<img src="/images/organizations/azure/azure-tree-view-subscriptions.png" alt="Hierarchical tree view showing Management Groups and subscriptions with selection checkboxes" />
</Frame>
- **Management Groups** nest under the tenant root; subscriptions that sit directly under the tenant root appear at the top level. The tenant root itself is not shown as a row — the whole tree is its content.
- Each Management Group row shows its Management Group name, with the Azure display name beside it. Hover the name to read the full Azure Resource Manager (ARM) resource identifier.
- **Selecting a Management Group** selects every selectable subscription beneath it. A Management Group whose subscriptions are only partly selected renders in an indeterminate state.
- **Individual overrides**: deselect single subscriptions even when the parent Management Group is selected.
- The header tracks the selection as **"X of Y subscriptions selected"**.
- Management Group hierarchies are read up to **six levels** below the tenant root Management Group, which is Azure's own platform limit. Deeper tenants report an error at discovery — see [Troubleshooting](#troubleshooting).
### Blocked Subscriptions
A subscription is shown grayed out and cannot be selected when Azure reports it as inactive, or when onboarding it would conflict with something Prowler already stores. Hover the subscription to see the reason:
| Reason | What it means |
|--------|---------------|
| `subscription_not_enabled` | The subscription is not in the **Enabled** state in Azure — it is disabled, expired, or otherwise inactive. Nothing in Prowler conflicts with it: resolve whatever Azure reports against the subscription, billing included, then run discovery again. |
| `organization_conflict` | The subscription is already connected under a **different** Prowler organization. |
| `organization_node_conflict` | The subscription is already grouped under a different Management Group in Prowler — for example, it moved in Azure after it was onboarded. |
| `provider_type_conflict` | A provider with the same identifier exists in Prowler for another cloud provider. |
<Frame>
<img src="/images/organizations/azure/azure-blocked-subscription.png" alt="Blocked subscription row with the reason shown in a tooltip" />
</Frame>
### Management Groups With Nothing to Select
A Management Group that holds no subscriptions, or whose subscriptions are all blocked, is shown disabled with the note *"No subscriptions available to select in this management group."* The Management Group still expands, so you can see the blocked subscriptions it holds and why they are blocked.
<Frame>
<img src="/images/organizations/azure/azure-inert-group.png" alt="Disabled Management Group row noting that no subscriptions are available to select" />
</Frame>
### Custom Aliases
Each subscription row carries an editable name, prefilled with the subscription's display name. The alias is used only inside Prowler — it does not rename anything in Azure. Management Group names are read-only: Prowler stores the display name from Azure.
### Subscriptions That Already Have Credentials
Applying your selection stores the organization credential on every selected subscription. When a selected subscription is already connected to Prowler with its own credential, that credential is **overwritten** — so the wizard asks first, naming the affected subscriptions:
<Frame>
<img src="/images/organizations/azure/azure-replace-credentials-apply.png" alt="Replace existing credentials modal listing the subscriptions whose credentials will be replaced" />
</Frame>
Click **Replace and continue** to proceed, or **Cancel** to adjust your selection.
<Note>
**Your existing data is safe.** A subscription already connected as an individual provider is **linked** to the organization, never duplicated: its historical scans and findings are preserved, and it does not count twice toward your subscription.
</Note>
## Step 4: Test Connections
Click **Test Connections** to verify that Prowler can authenticate against each selected subscription. Prowler creates one provider per subscription — identified by its Azure subscription ID — and then tests every connection.
<Frame>
<img src="/images/organizations/azure/azure-test-connections.png" alt="Connection testing in progress with status icons on each subscription" />
</Frame>
Each subscription shows a real-time status indicator:
- **Spinner** — test in progress
- **Green checkmark (✓)** — connection successful
- **Red icon (✗)** — connection failed (hover to see the error)
If every subscription connects successfully, you advance to the next step automatically.
### When Some Tests Fail
An error banner appears: **"There was a problem connecting to some subscriptions. Hover each subscription to check the error."** You have two options:
**a) Fix and retry:**
1. Confirm the service principal holds **Reader** and **ProwlerRole** on the failing subscriptions, or on a Management Group above them.
2. Confirm the Microsoft Graph permissions have been granted admin consent, as described in [Azure Authentication in Prowler](/user-guide/providers/azure/authentication#assigning-required-api-permissions).
3. Click **Test Connections** again — only the **failed subscriptions are re-tested**. Subscriptions that already passed are not tested again.
**b) Skip and continue:**
Click **Skip Connection Validation** to proceed with the subscriptions that connected successfully. Failed subscriptions stay onboarded and visible on the Providers page, but they are not scanned. This option appears only when at least one subscription connected.
If **no subscription** connects, the banner instead reads *"No subscriptions connected successfully. Fix the connection errors and retry before launching scans."* and you cannot proceed. Fix the underlying problem — see [Troubleshooting](#troubleshooting) — and retry.
## Step 5: Launch Scans
The Organizations wizard uses the same schedule controls described in [Scan Scheduling](/user-guide/tutorials/prowler-scan-scheduling#schedule-options).
Click **Save**, **Save and launch scan**, or **Launch scan**, depending on the selected schedule option. A toast notification confirms whether the schedule was saved, scans were launched, or both, and links to the **Scans** page. Prowler then redirects to the **Providers** page. Scans launch only for subscriptions that passed connection testing.
<Frame>
<img src="/images/organizations/azure/azure-launch-scan.png" alt="Launch Scan step showing the Subscriptions Connected confirmation and the scan schedule selector" />
</Frame>
After launching:
- Scans appear on the **Scans** page as they start and complete.
- Results populate the **Overview** and **Findings** pages.
- On the **Providers** page, your subscriptions are grouped under the organization and, when they live in a Management Group, under that Management Group.
<Frame>
<img src="/images/organizations/azure/azure-providers-grouping.png" alt="Providers page showing subscriptions grouped under Azure Management Groups and the organization" />
</Frame>
## Manage Your Organization After Onboarding
Open the row actions menu on the organization row on the **Providers** page.
<Frame>
<img src="/images/organizations/azure/azure-organization-row-actions.png" alt="Row actions menu on an Azure organization row" />
</Frame>
| Action | What it does |
|--------|--------------|
| **Edit Organization Name** | Renames the organization in Prowler. Leave it blank to fall back to the tenant ID. |
| **Update Credentials** | Reopens the Authentication Details step to store a new service principal credential. |
| **Edit Scan Schedule** | Applies one schedule to every connected subscription in the organization. |
| **Test Connections (N)** | Re-tests every subscription in the organization. |
| **Delete Organization** | Deletes the organization and cascades to its providers. |
### Onboard Subscriptions Created Later
Subscriptions added to the tenant after onboarding are not picked up automatically. Run the wizard again with the same tenant ID: discovery returns the current hierarchy, already-connected subscriptions come back preselected, and the new ones are ready to select.
### Update Organization Credentials
Choosing **Update Credentials** re-enters the Authentication Details step. Because the organization already holds a credential, Prowler warns before overwriting it and names how many providers re-authenticate with the new one:
<Frame>
<img src="/images/organizations/azure/azure-replace-credentials-setup.png" alt="Replace existing credentials modal showing how many providers re-authenticate" />
</Frame>
Storing a new credential runs a fresh discovery, so any discovery already in progress is discarded — discovery authenticates with the credential it started from.
### Delete an Organization or Management Group
Deleting an organization or a Management Group **cascades to every provider grouped under it**, along with their scans and findings. Both dialogs state how many providers are affected before you confirm.
<Frame>
<img src="/images/organizations/azure/azure-delete-organization.png" alt="Delete organization dialog showing how many providers are deleted with it" />
</Frame>
Deletion runs in the background. Prowler confirms with a **"Deletion started"** notification; if any part of it fails, the affected rows reappear on a later refresh.
<Danger>
Deleting an organization **permanently deletes every subscription provider grouped under it**, including their historical scans and findings. This action cannot be undone.
</Danger>
### When Grouping Is Unavailable
If Prowler cannot read the hierarchy while loading the Providers page, a notice reads *"Organization grouping is incomplete. Some providers may appear ungrouped."* Your providers are still listed, just flat. Reload the page to try again.
## Billing Impact
Each Azure subscription you connect through the Organizations wizard counts as one **provider** in your Prowler Cloud subscription.
- **Already-connected subscriptions**: linking an existing provider to the organization does **not** add billing. The existing provider is reused.
- **Large tenants**: connecting a 500-subscription tenant results in up to 500 providers on your subscription. Review your plan limits before proceeding.
- **Deleted providers**: a subscription you later remove no longer counts toward your subscription.
For pricing details, see [Prowler Cloud Pricing](https://prowler.com/pricing).
## Troubleshooting
### Those Service Principal Credentials Were Rejected
*"Those service principal credentials were rejected. Check the client ID and client secret, then try again."*
Azure refused the sign-in. Confirm the client ID belongs to the service principal you granted access to, and that the client secret is current — secrets expire, and a rotated one invalidates the old value immediately.
```bash
az ad app credential list --id <client-id> --query "[].{name:displayName, expires:endDateTime}" -o table
```
### The Service Principal Cannot Read the Complete Management Group Hierarchy
*"The service principal cannot read the complete Management Group hierarchy. Grant it the Reader role at the Management Group level, then try again."*
The credential authenticated but lacks read access to part of the hierarchy. Grant **Reader** at the tenant root Management Group, as described in [Grant Read Access at the Tenant Root Management Group](#grant-read-access-at-the-tenant-root-management-group), and confirm the assignment landed on the Management Group and not on a single subscription:
```bash
az role assignment list \
--assignee <client-id> \
--scope "/providers/Microsoft.Management/managementGroups/<tenant-id>" \
--query "[].{role:roleDefinitionName, scope:scope}" -o table
```
### Azure Returned an Incomplete Management Group Hierarchy
*"Azure returned an incomplete Management Group hierarchy. This usually clears on a retry; if it does not, check that the service principal can read every Management Group in the tenant."*
Azure answered, but the hierarchy it returned was missing Management Groups that Prowler expected to find. This is usually a transient inconsistency in Azure Resource Manager, so click **Retry discovery** first.
If it repeats, treat it as a permissions problem. Unlike the error above, Azure did not refuse the read — a partial grant can surface as missing data rather than as an outright denial. Confirm that the **Reader** assignment sits on the tenant root Management Group and not on individual Management Groups beneath it, using the command in [The Service Principal Cannot Read the Complete Management Group Hierarchy](#the-service-principal-cannot-read-the-complete-management-group-hierarchy).
### The Tenant Root Management Group Could Not Be Found
*"The tenant root Management Group could not be found. Check the tenant ID, and that the service principal has been granted access at the tenant root."*
Prowler derives the tenant root Management Group from the tenant ID you entered, and could not read it. Either the tenant ID is wrong, or the service principal cannot see the root. Confirm the tenant ID and list what the credential can reach:
```bash
az account management-group list --query "[].{name:name, displayName:displayName}" -o table
```
### Those Credentials Belong to a Different Microsoft Entra Tenant
*"Those credentials belong to a different Microsoft Entra tenant. Use a service principal from the tenant you entered."*
The service principal is registered in another tenant. Register the application in the tenant being onboarded, or correct the tenant ID in the first step of the wizard.
### Azure Did Not Respond
*"Azure did not respond while reading the Management Group hierarchy. Nothing is wrong with your credentials — try again in a few minutes."*
A transient Azure Resource Manager error. Click **Retry discovery**.
### Azure Rate Limited the Hierarchy Read
*"Azure rate limited the hierarchy read. Nothing is wrong with your credentials — try again in a few minutes."*
Azure Resource Manager throttled Prowler's requests while it walked the hierarchy. Large tenants and back-to-back discoveries are the common causes. Wait a few minutes, then click **Retry discovery**.
### The Hierarchy Is Too Deep
*"This tenant's Management Group hierarchy is deeper than Prowler can read. Contact support so we can help you onboard it."*
The tenant nests Management Groups deeper than Prowler reads in one pass. Contact [Prowler Support](mailto:support@prowler.com).
### Authentication Failed
*"Authentication failed. Please verify the service principal permissions or credentials, then try again."*
The generic authentication failure, shown when Azure reports no more specific reason. Confirm that the service principal still exists, that its client secret has not expired, and that the Reader role assignment at the tenant root Management Group is still in place.
### Azure Rejected the Hierarchy Read
*"Azure rejected the hierarchy read. Try again, and contact support if it keeps failing."*
Azure refused the request for a reason Prowler could not narrow down to credentials, permissions, or throttling. Click **Retry discovery**, and contact [Prowler Support](mailto:support@prowler.com) if the failure repeats. This message names Azure as the source of the failure; the one below is shown when the failure cannot be attributed to Azure at all.
### Discovery Could Not Be Completed
*"Discovery could not be completed. Try again, and contact support if it keeps failing."*
Discovery failed for a reason Prowler cannot attribute to the credential or to Azure. Click **Retry discovery**, and contact [Prowler Support](mailto:support@prowler.com) if the failure repeats.
### Discovery Never Finishes
The wizard stops waiting after 3 minutes, but the discovery keeps running in Azure. Click **Keep waiting** to resume the same discovery rather than **Retry**, which starts over and re-reads the whole hierarchy.
## Key Concepts
### How Subscriptions Map to Prowler Providers
Each selected subscription becomes one Prowler provider:
| Prowler field | Comes from |
|---------------|------------|
| Provider identifier | The Azure subscription ID (for example, `22222222-2222-4222-8222-222222222222`). |
| Alias | The name you typed in the tree, or the subscription's display name. |
| Credential | A copy of the organization credential. |
Management Groups that hold selected subscriptions become grouping rows on the Providers page. You select subscriptions only — Prowler derives the Management Group ancestors itself.
### Organization Credential vs Subscription Credential
One credential, stored twice: on the organization, where discovery reads it, and on each subscription provider, where scans read it. That is why replacing the organization credential re-authenticates every subscription under it, and why the wizard asks before overwriting a subscription's own credential.
## What's Next
<Columns cols={2}>
<Card title="Prowler Cloud" icon="cloud" href="/user-guide/tutorials/prowler-app">
Full guide to using Prowler Cloud features.
</Card>
<Card title="Azure Subscriptions (CLI)" icon="terminal" href="/user-guide/providers/azure/subscriptions">
CLI-based scanning of specific Azure subscriptions.
</Card>
<Card title="Azure Authentication" icon="key" href="/user-guide/providers/azure/authentication">
Credential types and the permissions Prowler needs in Azure.
</Card>
<Card title="Bulk Provider Provisioning" icon="upload" href="/user-guide/tutorials/bulk-provider-provisioning">
Script-based bulk provisioning for advanced automation.
</Card>
</Columns>
@@ -120,7 +120,7 @@ In Prowler Local Server the organization option is marked **Cloud** and opens an
### Enter Organization Details
- **Organization ID**: the numeric ID of your Google Cloud organization (for example, `123456789012`). Non-numeric values are rejected before submission.
- **Name** (optional): a display name for the organization in Prowler. If left blank, Prowler uses the name stored in Google Cloud.
- **Name** (optional): a display name for the organization in Prowler. If left blank, Prowler uses the organization ID.
<Frame>
<img src="/images/organizations/gcp/gcp-organization-details-form.png" alt="Organization Details form with the Google Cloud organization ID and Name fields" />
@@ -307,7 +307,7 @@ Open the row actions menu on the organization row on the **Providers** page.
| Action | What it does |
|--------|--------------|
| **Edit Organization Name** | Renames the organization in Prowler. Leave it blank to fall back to the name stored in Google Cloud. |
| **Edit Organization Name** | Renames the organization in Prowler. Leave it blank to fall back to the organization ID. |
| **Update Credentials** | Reopens the Authentication Details step to store a new credential. |
| **Edit Scan Schedule** | Applies one schedule to every connected project in the organization. |
| **Test Connections (N)** | Re-tests every project in the organization. |
@@ -37,6 +37,7 @@
"glue:SearchTables",
"glue:GetMLTransforms",
"lambda:GetFunction*",
"lambda:GetLayerVersion",
"logs:FilterLogEvents",
"lightsail:GetRelationalDatabases",
"macie2:GetMacieSession",
+21
View File
@@ -4,6 +4,27 @@ All notable changes to the **Prowler SDK** are documented in this file.
<!-- changelog: release notes start -->
## [5.39.0] (Prowler v5.39.0)
### 🚀 Added
- `batch_job_definition_no_secrets` check for AWS provider, scanning Batch job definition environment variables and command parameters for hardcoded secrets [(#12117)](https://github.com/prowler-cloud/prowler/pull/12117)
- 7 M365 Entra checks covering CIS Microsoft 365 Foundations Benchmark v7.0.0 password protection, default user permissions, and guest invitation domain restrictions [(#12153)](https://github.com/prowler-cloud/prowler/pull/12153)
- 7 M365 entra checks covering CIS Microsoft 365 Foundations Benchmark v7.0.0 Conditional Access (5.2.2.x) and idle session timeout controls [(#12154)](https://github.com/prowler-cloud/prowler/pull/12154)
- `entra_authentication_method_email_otp_disabled`, `entra_authentication_method_authenticator_show_context`, `entra_pim_global_administrator_approval_required`, `entra_pim_privileged_role_administrator_approval_required`, `entra_access_review_guest_users_configured` and `entra_access_review_privileged_roles_configured` checks for M365 provider covering CIS Microsoft 365 Foundations Benchmark v7.0.0 authentication method, PIM approval and access review controls [(#12155)](https://github.com/prowler-cloud/prowler/pull/12155)
- `awslambda_layer_no_secrets_in_content` check for AWS provider, scanning Lambda layer package content for hardcoded secrets [(#12233)](https://github.com/prowler-cloud/prowler/pull/12233)
- CMMC 2.0 universal compliance framework (`cmmc_2.0`) with the 149 official requirements from 32 CFR Part 170 — Level 1 (15, 48 CFR 52.204-21), Level 2 (110, NIST SP 800-171 Rev 2) and Level 3 (24, NIST SP 800-172) — with AWS, Azure, GCP, Alibaba Cloud, Oracle Cloud and M365 check mappings and config guardrails [(#12401)](https://github.com/prowler-cloud/prowler/pull/12401)
### 🔄 Changed
- GitHub `organization_repository_creation_limited` check now reports low severity for FAIL findings when repository creation is provably limited to private/internal visibility, instead of always reporting high [(#12164)](https://github.com/prowler-cloud/prowler/pull/12164)
### 🔐 Security
- HTML report header now HTML-escapes every provider identity field across all 23 providers, closing a stored XSS in the header block (Secur0, CWE-79) that was left unaddressed by the earlier finding-row fix in #12221 [(#12424)](https://github.com/prowler-cloud/prowler/pull/12424)
---
## [5.38.0] (Prowler v5.38.0)
### 🚀 Added
File diff suppressed because it is too large Load Diff
+61 -20
View File
@@ -170,7 +170,10 @@
{
"Id": "1.3.2",
"Description": "Idle session timeout allows the configuration of a setting which will timeout inactive users after a pre-determined amount of time. When a user reaches the set idle timeout session, they'll get a notification that they're about to be signed out. They must choose to stay signed in or they'll be automatically signed out of all Microsoft 365 web apps. Combined with a Conditional Access rule this will only impact unmanaged devices. A managed device is considered a device managed by Intune MDM or joined to a domain (Entra ID or Hybrid joined). The following Microsoft 365 web apps are supported. - Outlook Web App - OneDrive - SharePoint - Microsoft Fabric - Microsoft365.com and other start pages - Microsoft 365 web apps (Word, Excel, PowerPoint) - Microsoft 365 Admin Center - M365 Defender Portal - Microsoft Purview Compliance Portal The recommended setting is 3 hours (or less) for unmanaged devices. Note: Idle session timeout doesn't affect Microsoft 365 desktop and mobile apps.",
"Checks": [],
"Checks": [
"entra_idle_session_timeout_configured",
"entra_conditional_access_policy_app_enforced_restrictions"
],
"Attributes": [
{
"Section": "1 Microsoft 365 admin center",
@@ -1166,7 +1169,9 @@
{
"Id": "5.1.3.1",
"Description": "This setting allows users in the organization to create new security groups and add members to these groups in the Azure portal, API, or PowerShell. These new groups also show up in the Access Panel for all other users. If the policy setting on the group allows it, other users can create requests to join these groups. The recommended state is Users can create security groups in Azure portals, API or PowerShell set to No.",
"Checks": [],
"Checks": [
"entra_policy_default_user_cannot_create_security_groups"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -1229,7 +1234,9 @@
{
"Id": "5.1.3.4",
"Description": "All users within a Microsoft Entra organization are permitted to create new Microsoft 365 groups and add members to those groups through the Azure portal, API, or PowerShell. Newly created groups also appear in the Access Panel for all other users. When the applicable group policy settings allow it, users can submit requests to join these groups. The recommended state is No.",
"Checks": [],
"Checks": [
"entra_policy_default_user_cannot_create_m365_groups"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -1526,7 +1533,9 @@
{
"Id": "5.1.6.1",
"Description": "B2B collaboration is a feature within Microsoft Entra External ID that allows for guest invitations to an organization. Ensure users can only send invitations to specified domains. Note: This list works independently from OneDrive for Business and SharePoint Online allow/block lists. To restrict individual file sharing in SharePoint Online, set up an allow or blocklist for OneDrive for Business and SharePoint Online. For instance, in SharePoint or OneDrive users can still share with external users from prohibited domains by using Anyone links if they haven't been disabled.",
"Checks": [],
"Checks": [
"entra_policy_guest_invitations_restricted_to_allowed_domains"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -1777,7 +1786,9 @@
{
"Id": "5.2.2.8",
"Description": "Microsoft Entra ID Protection sign-in risk detects risks in real-time and offline. A risky sign-in is an indicator for a sign-in attempt that might not have been performed by the legitimate owner of a user account. Note: While Identity Protection also provides two risk policies with limited conditions, Microsoft highly recommends setting up risk-based policies in Conditional Access as opposed to the \"legacy method\" for the following benefits: - Enhanced diagnostic data - Report-only mode integration - Graph API support - Use more Conditional Access attributes like sign-in frequency in the policy",
"Checks": [],
"Checks": [
"entra_conditional_access_policy_block_high_medium_sign_in_risk"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -1890,7 +1901,9 @@
{
"Id": "5.2.2.13",
"Description": "Sign-in frequency defines the time period before a user is asked to sign in again when attempting to access a resource. The Microsoft Entra ID default configuration for user sign-in frequency is a rolling window of 90 days. The recommended state for all users is to enforce periodic reauthentication for 7 days or less.",
"Checks": [],
"Checks": [
"entra_conditional_access_policy_sign_in_frequency_all_users"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -1911,7 +1924,9 @@
{
"Id": "5.2.2.14",
"Description": "Microsoft Entra ID Conditional Access allows an organization to configure Named locations and configure whether those locations are trusted or untrusted. These settings provide organizations the means to specify Geographical locations for use in conditional access policies, or define actual IP addresses and IP ranges and whether or not those IP addresses and/or ranges are trusted by the organization. The recommended state is to define at least one trusted, IP range named location.",
"Checks": [],
"Checks": [
"entra_conditional_access_trusted_named_location_exists"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -1932,7 +1947,9 @@
{
"Id": "5.2.2.15",
"Description": "Conditional Access Policies can be used to block access from geographic locations that are deemed out-of-scope for your organization or application. The scope and variables for this policy should be carefully examined and defined. The recommended state is to configure at least one policy to block access from untrusted locations.",
"Checks": [],
"Checks": [
"entra_conditional_access_policy_untrusted_locations_blocked"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -1953,7 +1970,9 @@
{
"Id": "5.2.2.16",
"Description": "Token Protection is a Conditional Access session control that attempts to reduce token replay attacks by ensuring only device bound sign-in session tokens, like Primary Refresh Tokens (PRTs), are accepted by Microsoft Entra ID when applications request access to protected resources. When a user registers a supported device with Microsoft Entra, a PRT is issued and cryptographically bound to that device. This binding ensures that even if a threat actor steals the token, it can't be used from another device. With Token Protection enforced, Microsoft Entra validates that only these bound sign-in session tokens are used by supported applications. The recommended state is to enforce Token Protection for Office 365 Exchange Online, Office 365 SharePoint Online and Microsoft Teams Services.",
"Checks": [],
"Checks": [
"entra_conditional_access_policy_token_protection_enforced"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -1974,7 +1993,9 @@
{
"Id": "5.2.2.17",
"Description": "Authentication transfer is a flow that lets users seamlessly transfer authenticated state from one device to another. For example, users might see a QR code in the desktop version of Outlook that, when scanned on their mobile device, transfers their authenticated state to the mobile device. The recommended state is to block Authentication transfer.",
"Checks": [],
"Checks": [
"entra_conditional_access_policy_authentication_transfer_blocked"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -1995,7 +2016,9 @@
{
"Id": "5.2.3.1",
"Description": "Microsoft provides supporting settings to enhance the configuration of the Microsoft Authenticator application. These settings provide users with additional information and context when they receive MFA passwordless and push requests, including the geographic location of the request, the requesting application, and a requirement for number matching. The recommended state is Enabled for the following: - Show application name in push and passwordless notifications - Show geographic location in push and passwordless notifications Note: On February 27, 2023 Microsoft started enforcing number matching tenant-wide for all users using Microsoft Authenticator.",
"Checks": [],
"Checks": [
"entra_authentication_method_authenticator_show_context"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -2016,7 +2039,9 @@
{
"Id": "5.2.3.2",
"Description": "With Entra Password Protection, default global banned password lists are automatically applied to all users in an Entra ID tenant. To support business and security needs, custom banned password lists can be defined. When users change or reset their passwords, these banned password lists are checked to enforce the use of strong passwords. A custom banned password list should include some of the following examples: - Brand names - Product names - Locations, such as company headquarters - Company-specific internal terms - Abbreviations that have specific company meaning",
"Checks": [],
"Checks": [
"entra_password_protection_custom_banned_list_enforced"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -2037,7 +2062,9 @@
{
"Id": "5.2.3.3",
"Description": "Microsoft Entra Password Protection provides a global and custom banned password list. A password change request fails if there's a match in these banned password list. To protect on-premises Active Directory Domain Services (AD DS) environment, install and configure Entra Password Protection. Note: This recommendation applies to Hybrid deployments only and will have no impact unless working with on-premises Active Directory.",
"Checks": [],
"Checks": [
"entra_password_protection_on_premises_enforced"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -2125,7 +2152,9 @@
{
"Id": "5.2.3.7",
"Description": "Authentication methods support a wide variety of scenarios for signing in to Microsoft 365 resources. Some of these methods are inherently more secure than others but require more investment in time to get users enrolled and operational. The email one-time passcode feature is a way to authenticate B2B collaboration users when they can't be authenticated through other means, such as Microsoft Entra ID, Microsoft account (MSA), or social identity providers. When a B2B guest user tries to redeem your invitation or sign in to your shared resources, they can request a temporary passcode, which is sent to their email address. Then they enter this passcode to continue signing in. The recommended state is to Disable email OTP.",
"Checks": [],
"Checks": [
"entra_authentication_method_email_otp_disabled"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -2146,7 +2175,9 @@
{
"Id": "5.2.3.8",
"Description": "The account lockout threshold determines how many failed login attempts are permitted prior to placing the account in a locked-out state and initiating a variable lockout duration. The recommended Lockout threshold is 10 or less.",
"Checks": [],
"Checks": [
"entra_password_protection_lockout_threshold_limited"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -2167,7 +2198,9 @@
{
"Id": "5.2.3.9",
"Description": "The account lockout duration value determines how long an account retains the status of lockout, and therefore how long before a user can continue to attempt to login after passing the lockout threshold. The recommended state is Lockout duration in seconds is at least 60.",
"Checks": [],
"Checks": [
"entra_password_protection_lockout_duration_configured"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -2335,7 +2368,9 @@
{
"Id": "5.3.2",
"Description": "Access reviews enable administrators to establish an efficient automated process for reviewing group memberships, access to enterprise applications, and role assignments. These reviews can be scheduled to recur regularly, with flexible options for delegating the task of reviewing membership to different members of the organization. When configured for guest users, access reviews can automatically remove access if no reviewer responds within the review period, enforcing a fail-closed posture for external identities.",
"Checks": [],
"Checks": [
"entra_access_review_guest_users_configured"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -2356,7 +2391,9 @@
{
"Id": "5.3.3",
"Description": "Access reviews in Microsoft Entra Privileged Identity Management (PIM) enable administrators to periodically validate whether users still require their privileged role assignments. These reviews can be scheduled to recur on a regular cadence and can be delegated to reviewers other than the role holders themselves, such as security auditors.",
"Checks": [],
"Checks": [
"entra_access_review_privileged_roles_configured"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -2377,7 +2414,9 @@
{
"Id": "5.3.4",
"Description": "Microsoft Entra Privileged Identity Management can be used to audit roles, allow just in time activation of roles and allow for periodic role attestation. Requiring approval before activation allows one of the selected approvers to first review and then approve the activation prior to PIM granted the role. The approver doesn't have to be a group member or owner. The recommended state is Require approval to activate for the Global Administrator role.",
"Checks": [],
"Checks": [
"entra_pim_global_administrator_approval_required"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -2398,7 +2437,9 @@
{
"Id": "5.3.5",
"Description": "Microsoft Entra Privileged Identity Management can be used to audit roles, allow just in time activation of roles and allow for periodic role attestation. Requiring approval before activation allows one of the selected approvers to first review and then approve the activation prior to PIM granted the role. The approver doesn't have to be a group member or owner. The recommended state is Require approval to activate for the Privileged Role Administrator role.",
"Checks": [],
"Checks": [
"entra_pim_privileged_role_administrator_approval_required"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
+1 -1
View File
@@ -49,7 +49,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.38.0"
prowler_version = "5.39.0"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
+2
View File
@@ -27,6 +27,8 @@ aws:
max_lambda_functions: null
# aws.max_ecs_task_definitions --> ecs_task_definitions_* checks
max_ecs_task_definitions: null
# aws.max_batch_job_definitions --> batch_job_definition_* checks
max_batch_job_definitions: null
# aws.max_codeartifact_packages --> codeartifact_packages_* checks
max_codeartifact_packages: null
# aws.disallowed_regions --> List of AWS regions to exclude from the scan.
+6
View File
@@ -153,6 +153,12 @@ class AWSProviderConfig(ProviderConfigBase):
le=1_000_000,
description="Resource scan limit for ECS task definitions. Use 0 or -1 to disable.",
)
max_batch_job_definitions: ResourceScanLimit = Field(
default=None,
ge=-1,
le=1_000_000,
description="Resource scan limit for Batch job definitions. Use 0 or -1 to disable.",
)
max_codeartifact_packages: ResourceScanLimit = Field(
default=None,
ge=-1,
+150 -65
View File
@@ -463,6 +463,11 @@ class HTML(Output):
audited_regions = "All Regions"
else:
audited_regions = ", ".join(provider.identity.audited_regions)
account = escape(str(provider.identity.account))
profile = escape(str(profile))
audited_regions = escape(str(audited_regions))
user_id = escape(str(provider.identity.user_id))
identity_arn = escape(str(provider.identity.identity_arn))
return f"""
<div class="col-md-2">
<div class="card">
@@ -471,7 +476,7 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>AWS Account:</b> {provider.identity.account}
<b>AWS Account:</b> {account}
</li>
<li class="list-group-item">
<b>AWS-CLI Profile:</b> {profile}
@@ -489,10 +494,10 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>User Id:</b> {provider.identity.user_id}
<b>User Id:</b> {user_id}
</li>
<li class="list-group-item">
<b>Caller Identity ARN:</b> {provider.identity.identity_arn}
<b>Caller Identity ARN:</b> {identity_arn}
</li>
</ul>
</div>
@@ -530,6 +535,11 @@ class HTML(Output):
)
else:
html_identity = provider.identity.identity_id
tenant_ids = escape(" ".join(provider.identity.tenant_ids))
tenant_domain = escape(str(provider.identity.tenant_domain))
subscriptions = escape(" ".join(printed_subscriptions))
identity_type = escape(str(provider.identity.identity_type))
html_identity = escape(str(html_identity))
return f"""
<div class="col-md-2">
<div class="card">
@@ -538,13 +548,13 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Azure Tenant IDs:</b> {" ".join(provider.identity.tenant_ids)}
<b>Azure Tenant IDs:</b> {tenant_ids}
</li>
<li class="list-group-item">
<b>Azure Tenant Domain:</b> {provider.identity.tenant_domain}
<b>Azure Tenant Domain:</b> {tenant_domain}
</li>
<li class="list-group-item">
<b>Azure Subscriptions:</b> {" ".join(printed_subscriptions)}
<b>Azure Subscriptions:</b> {subscriptions}
</li>
</ul>
</div>
@@ -556,7 +566,7 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Azure Identity Type:</b> {provider.identity.identity_type}
<b>Azure Identity Type:</b> {identity_type}
</li>
<li class="list-group-item">
<b>Azure Identity ID:</b> {html_identity}
@@ -591,6 +601,8 @@ class HTML(Output):
)
except AttributeError:
profile = "default"
project_ids = escape(", ".join(provider.project_ids))
profile = escape(str(profile))
return f"""
<div class="col-md-2">
<div class="card">
@@ -599,7 +611,7 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>GCP Project IDs:</b> {", ".join(provider.project_ids)}
<b>GCP Project IDs:</b> {project_ids}
</li>
</ul>
</div>
@@ -634,6 +646,8 @@ class HTML(Output):
str: the HTML assessment summary
"""
try:
cluster = escape(str(provider.identity.cluster))
context = escape(str(provider.identity.context))
return f"""
<div class="col-md-2">
<div class="card">
@@ -643,7 +657,7 @@ class HTML(Output):
<ul class="list-group
list-group-flush">
<li class="list-group-item">
<b>Kubernetes Cluster:</b> {provider.identity.cluster}
<b>Kubernetes Cluster:</b> {cluster}
</li>
</ul>
</div>
@@ -656,7 +670,7 @@ class HTML(Output):
<ul class="list-group
list-group-flush">
<li class="list-group-item">
<b>Kubernetes Context:</b> {provider.identity.context}
<b>Kubernetes Context:</b> {context}
</li>
</ul>
</div>
@@ -679,11 +693,13 @@ class HTML(Output):
str: the HTML assessment summary
"""
try:
auth_method = escape(str(provider.auth_method))
if hasattr(provider.identity, "account_name"):
# GithubIdentityInfo (Personal Access Token, OAuth)
account_name = escape(str(provider.identity.account_name))
account_info_items = f"""
<li class="list-group-item">
<b>GitHub account:</b> {provider.identity.account_name}
<b>GitHub account:</b> {account_name}
</li>
"""
# Add email if available
@@ -691,23 +707,27 @@ class HTML(Output):
hasattr(provider.identity, "account_email")
and provider.identity.account_email
):
account_email = escape(str(provider.identity.account_email))
account_info_items += f"""
<li class="list-group-item">
<b>GitHub account email:</b> {provider.identity.account_email}
<b>GitHub account email:</b> {account_email}
</li>"""
elif hasattr(provider.identity, "app_id"):
# GithubAppIdentityInfo (GitHub App)
# Assessment items: App Name and Installations
app_name = escape(str(provider.identity.app_name))
account_info_items = f"""
<li class="list-group-item">
<b>GitHub App Name:</b> {provider.identity.app_name}
<b>GitHub App Name:</b> {app_name}
</li>"""
# Add installations if available
if (
hasattr(provider.identity, "installations")
and provider.identity.installations
):
installations_display = ", ".join(provider.identity.installations)
installations_display = escape(
", ".join(provider.identity.installations)
)
account_info_items += f"""
<li class="list-group-item">
<b>Installations:</b> {installations_display}
@@ -719,26 +739,27 @@ class HTML(Output):
</li>"""
# Credentials items: Authentication method and App ID
app_id = escape(str(provider.identity.app_id))
credentials_items = f"""
<li class="list-group-item">
<b>GitHub authentication method:</b> {provider.auth_method}
<b>GitHub authentication method:</b> {auth_method}
</li>
<li class="list-group-item">
<b>GitHub App ID:</b> {provider.identity.app_id}
<b>GitHub App ID:</b> {app_id}
</li>"""
else:
# Fallback for other identity types
account_info_items = ""
credentials_items = f"""
<li class="list-group-item">
<b>GitHub authentication method:</b> {provider.auth_method}
<b>GitHub authentication method:</b> {auth_method}
</li>"""
# For PAT/OAuth, use default credentials structure
if hasattr(provider.identity, "account_name"):
credentials_items = f"""
<li class="list-group-item">
<b>GitHub authentication method:</b> {provider.auth_method}
<b>GitHub authentication method:</b> {auth_method}
</li>"""
return f"""
@@ -779,6 +800,18 @@ class HTML(Output):
str: the HTML assessment summary
"""
try:
tenant_domain = escape(str(provider.identity.tenant_domain))
identity_type = escape(str(provider.identity.identity_type))
identity_id = escape(str(provider.identity.identity_id))
user_item = ""
if (
hasattr(provider.identity, "user")
and provider.identity.user is not None
):
user = escape(str(provider.identity.user))
user_item = f"""<li class="list-group-item">
<b>M365 User:</b> {user}
</li>"""
return f"""
<div class="col-md-2">
<div class="card">
@@ -787,9 +820,7 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>M365 Tenant Domain:</b> {
provider.identity.tenant_domain
}
<b>M365 Tenant Domain:</b> {tenant_domain}
</li>
</ul>
</div>
@@ -801,19 +832,12 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>M365 Identity Type:</b> {provider.identity.identity_type}
<b>M365 Identity Type:</b> {identity_type}
</li>
<li class="list-group-item">
<b>M365 Identity ID:</b> {provider.identity.identity_id}
<b>M365 Identity ID:</b> {identity_id}
</li>
{
f'''<li class="list-group-item">
<b>M365 User:</b> {provider.identity.user}
</li>'''
if hasattr(provider.identity, "user")
and provider.identity.user is not None
else ""
}
{user_item}
</ul>
</div>
</div>"""
@@ -834,6 +858,9 @@ class HTML(Output):
str: the HTML assessment summary
"""
try:
tenant_domain = escape(str(provider.identity.tenant_domain))
identity_type = escape(str(provider.identity.identity_type))
identity_id = escape(str(provider.identity.identity_id))
return f"""
<div class="col-md-2">
<div class="card">
@@ -842,7 +869,7 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>NHN Tenant Domain:</b> {provider.identity.tenant_domain}
<b>NHN Tenant Domain:</b> {tenant_domain}
</li>
</ul>
</div>
@@ -854,10 +881,10 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>NHN Identity Type:</b> {provider.identity.identity_type}
<b>NHN Identity Type:</b> {identity_type}
</li>
<li class="list-group-item">
<b>NHN Identity ID:</b> {provider.identity.identity_id}
<b>NHN Identity ID:</b> {identity_id}
</li>
</ul>
</div>
@@ -880,6 +907,7 @@ class HTML(Output):
str: the HTML assessment summary
"""
try:
organization_name = escape(str(provider.identity.organization_name))
return f"""
<div class="col-md-2">
<div class="card">
@@ -889,7 +917,7 @@ class HTML(Output):
<ul class="list-group
list-group-flush">
<li class="list-group-item">
<b>MongoDB Atlas organization:</b> {provider.identity.organization_name}
<b>MongoDB Atlas organization:</b> {organization_name}
</li>
</ul>
</div>
@@ -925,6 +953,13 @@ class HTML(Output):
str: the HTML assessment summary
"""
try:
if provider.scan_repository_url:
target_info = "<b>IAC repository URL:</b> " + str(
escape(str(provider.scan_repository_url))
)
else:
target_info = "<b>IAC path:</b> " + str(escape(str(provider.scan_path)))
auth_method = escape(str(provider.auth_method))
return f"""
<div class="col-md-2">
<div class="card">
@@ -934,7 +969,7 @@ class HTML(Output):
<ul class="list-group
list-group-flush">
<li class="list-group-item">
{"<b>IAC repository URL:</b> " + provider.scan_repository_url if provider.scan_repository_url else "<b>IAC path:</b> " + provider.scan_path}
{target_info}
</li>
</ul>
</div>
@@ -947,7 +982,7 @@ class HTML(Output):
<ul class="list-group
list-group-flush">
<li class="list-group-item">
<b>IAC authentication method:</b> {provider.auth_method}
<b>IAC authentication method:</b> {auth_method}
</li>
</ul>
</div>
@@ -971,10 +1006,13 @@ class HTML(Output):
"""
try:
if provider.registry:
target_info = f"<b>Registry URL:</b> {provider.registry}"
registry = escape(str(provider.registry))
target_info = f"<b>Registry URL:</b> {registry}"
else:
target_info = f'<b>Images:</b> {", ".join(provider.images)}'
images = escape(", ".join(provider.images))
target_info = f"<b>Images:</b> {images}"
auth_method = escape(str(provider.auth_method))
return f"""
<div class="col-md-2">
<div class="card">
@@ -997,7 +1035,7 @@ class HTML(Output):
<ul class="list-group
list-group-flush">
<li class="list-group-item">
<b>Image authentication method:</b> {provider.auth_method}
<b>Image authentication method:</b> {auth_method}
</li>
</ul>
</div>
@@ -1020,6 +1058,14 @@ class HTML(Output):
str: HTML assessment summary for the LLM provider
"""
try:
model = escape(str(provider.model))
plugins = escape(", ".join(provider.plugins))
max_concurrency = escape(str(provider.max_concurrency))
config_file = escape(
str(provider.config_path)
if provider.config_path
else "Using promptfoo defaults"
)
return f"""
<div class="card">
<div class="card-header">
@@ -1031,16 +1077,16 @@ class HTML(Output):
<ul class="list-group
list-group-flush">
<li class="list-group-item">
<b>Target LLM:</b> {provider.model}
<b>Target LLM:</b> {model}
</li>
<li class="list-group-item">
<b>Plugins:</b> {", ".join(provider.plugins)}
<b>Plugins:</b> {plugins}
</li>
<li class="list-group-item">
<b>Max concurrency:</b> {provider.max_concurrency}
<b>Max concurrency:</b> {max_concurrency}
</li>
<li class="list-group-item">
<b>Config file:</b> {provider.config_path if provider.config_path else "Using promptfoo defaults"}
<b>Config file:</b> {config_file}
</li>
</ul>
</div>
@@ -1069,6 +1115,10 @@ class HTML(Output):
tenancy_name = getattr(provider.identity, "tenancy_name", "unknown")
tenancy_id = getattr(provider.identity, "tenancy_id", "unknown")
tenancy = escape(
str(tenancy_name if tenancy_name != "unknown" else tenancy_id)
)
profile = escape(str(profile))
return f"""
<div class="col-md-2">
<div class="card">
@@ -1077,7 +1127,7 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>OracleCloud Tenancy:</b> {tenancy_name if tenancy_name != "unknown" else tenancy_id}
<b>OracleCloud Tenancy:</b> {tenancy}
</li>
</ul>
</div>
@@ -1116,10 +1166,11 @@ class HTML(Output):
project_name = getattr(provider.identity, "project_name", "")
audited_regions = getattr(provider.identity, "audited_regions", set())
project_id = escape(str(project_id))
project_name_item = (
f"""
<li class="list-group-item">
<b>Project Name:</b> {project_name}
<b>Project Name:</b> {escape(str(project_name))}
</li>"""
if project_name
else ""
@@ -1128,7 +1179,7 @@ class HTML(Output):
regions_item = (
f"""
<li class="list-group-item">
<b>Regions:</b> {", ".join(sorted(audited_regions))}
<b>Regions:</b> {escape(", ".join(sorted(audited_regions)))}
</li>"""
if audited_regions
else ""
@@ -1182,7 +1233,7 @@ class HTML(Output):
# Build assessment summary items (only non-None values)
assessment_items = ""
if provider.accounts:
accounts = ", ".join([acc.id for acc in provider.accounts])
accounts = escape(", ".join([str(acc.id) for acc in provider.accounts]))
assessment_items += f"""
<li class="list-group-item">
<b>Accounts:</b> {accounts}
@@ -1208,6 +1259,7 @@ class HTML(Output):
provider.session, "api_email", None
)
if email:
email = escape(str(email))
credentials_items += f"""
<li class="list-group-item">
<b>Email:</b> {email}
@@ -1261,11 +1313,15 @@ class HTML(Output):
account_name_item = (
f"""
<li class="list-group-item">
<b>Account Name:</b> {account_name}
<b>Account Name:</b> {escape(str(account_name))}
</li>"""
if account_name
else ""
)
account_id = escape(str(account_id))
audited_regions = escape(str(audited_regions))
user_name = escape(str(user_name))
identity_arn = escape(str(identity_arn))
return f"""
<div class="col-md-2">
@@ -1326,7 +1382,7 @@ class HTML(Output):
project_name_item = (
f"""
<li class="list-group-item">
<b>Project Name:</b> {project_name}
<b>Project Name:</b> {escape(str(project_name))}
</li>"""
if project_name
else ""
@@ -1335,11 +1391,14 @@ class HTML(Output):
user_id_item = (
f"""
<li class="list-group-item">
<b>User ID:</b> {user_id}
<b>User ID:</b> {escape(str(user_id))}
</li>"""
if user_id
else ""
)
project_id = escape(str(project_id))
region_name = escape(str(region_name))
username = escape(str(username))
return f"""
<div class="col-md-2">
@@ -1389,6 +1448,9 @@ class HTML(Output):
str: HTML assessment summary for the Google Workspace provider
"""
try:
domain = escape(str(provider.identity.domain))
customer_id = escape(str(provider.identity.customer_id))
delegated_user = escape(str(provider.identity.delegated_user))
return f"""
<div class="col-md-2">
<div class="card">
@@ -1397,10 +1459,10 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Domain:</b> {provider.identity.domain}
<b>Domain:</b> {domain}
</li>
<li class="list-group-item">
<b>Customer ID:</b> {provider.identity.customer_id}
<b>Customer ID:</b> {customer_id}
</li>
</ul>
</div>
@@ -1412,7 +1474,7 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Delegated User:</b> {provider.identity.delegated_user}
<b>Delegated User:</b> {delegated_user}
</li>
<li class="list-group-item">
<b>Authentication Method:</b> Service Account with Domain-Wide Delegation
@@ -1482,9 +1544,11 @@ class HTML(Output):
team = getattr(provider.identity, "team", None)
if team:
team_name = escape(str(team.name))
team_id = escape(str(team.id))
assessment_items += f"""
<li class="list-group-item">
<b>Team:</b> {team.name} ({team.id})
<b>Team:</b> {team_name} ({team_id})
</li>"""
credentials_items = """
@@ -1494,6 +1558,7 @@ class HTML(Output):
email = getattr(provider.identity, "email", None)
if email:
email = escape(str(email))
credentials_items += f"""
<li class="list-group-item">
<b>Email:</b> {email}
@@ -1501,6 +1566,7 @@ class HTML(Output):
username = getattr(provider.identity, "username", None)
if username:
username = escape(str(username))
credentials_items += f"""
<li class="list-group-item">
<b>Username:</b> {username}
@@ -1543,17 +1609,20 @@ class HTML(Output):
str: HTML assessment summary for the Okta provider
"""
try:
org_domain = escape(str(provider.identity.org_domain))
auth_method = escape(str(provider.auth_method))
client_id = escape(str(provider.identity.client_id))
assessment_items = f"""
<li class="list-group-item">
<b>Okta Domain:</b> {provider.identity.org_domain}
<b>Okta Domain:</b> {org_domain}
</li>"""
credentials_items = f"""
<li class="list-group-item">
<b>Authentication:</b> {provider.auth_method}
<b>Authentication:</b> {auth_method}
</li>
<li class="list-group-item">
<b>Client ID:</b> {provider.identity.client_id}
<b>Client ID:</b> {client_id}
</li>"""
return f"""
@@ -1593,9 +1662,10 @@ class HTML(Output):
str: HTML assessment summary for the Scaleway provider
"""
try:
organization_id = escape(str(provider.identity.organization_id))
assessment_items = f"""
<li class="list-group-item">
<b>Organization ID:</b> {provider.identity.organization_id}
<b>Organization ID:</b> {organization_id}
</li>"""
credentials_items = """
@@ -1605,6 +1675,7 @@ class HTML(Output):
access_key = getattr(provider.session, "access_key", None)
if access_key:
access_key = escape(str(access_key))
credentials_items += f"""
<li class="list-group-item">
<b>Access Key:</b> {access_key}
@@ -1615,6 +1686,8 @@ class HTML(Output):
bearer_id = getattr(provider.identity, "bearer_id", None)
if bearer_type:
bearer_label = bearer_email or bearer_id or "-"
bearer_type = escape(str(bearer_type))
bearer_label = escape(str(bearer_label))
credentials_items += f"""
<li class="list-group-item">
<b>Bearer:</b> {bearer_type} ({bearer_label})
@@ -1622,6 +1695,7 @@ class HTML(Output):
region = getattr(provider.session, "default_region", None)
if region:
region = escape(str(region))
credentials_items += f"""
<li class="list-group-item">
<b>Default Region:</b> {region}
@@ -1668,6 +1742,9 @@ class HTML(Output):
email = getattr(provider.identity, "email", None) or "-"
account_id = getattr(provider.identity, "account_id", None) or "-"
username = escape(str(username))
email = escape(str(email))
account_id = escape(str(account_id))
assessment_items = f"""
<li class="list-group-item">
<b>Account ID:</b> {account_id}
@@ -1732,6 +1809,14 @@ class HTML(Output):
audited_regions = "All Regions"
else:
audited_regions = ", ".join(provider.identity.regions)
account_id = escape(str(provider.identity.account_id))
account_name = escape(str(provider.identity.account_name))
profile = escape(str(profile))
audited_regions = escape(str(audited_regions))
domain_id = escape(str(provider.identity.domain_id))
user_id = escape(str(provider.identity.user_id))
user_name = escape(str(provider.identity.user_name))
identity_type = escape(str(provider.identity.identity_type))
return f"""
<div class="col-md-2">
<div class="card">
@@ -1740,10 +1825,10 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Account ID:</b> {provider.identity.account_id}
<b>Account ID:</b> {account_id}
</li>
<li class="list-group-item">
<b>Account Name:</b> {provider.identity.account_name}
<b>Account Name:</b> {account_name}
</li>
<li class="list-group-item">
<b>Profile:</b> {profile}
@@ -1761,16 +1846,16 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Domain ID:</b> {provider.identity.domain_id}
<b>Domain ID:</b> {domain_id}
</li>
<li class="list-group-item">
<b>User ID:</b> {provider.identity.user_id}
<b>User ID:</b> {user_id}
</li>
<li class="list-group-item">
<b>User Name:</b> {provider.identity.user_name}
<b>User Name:</b> {user_name}
</li>
<li class="list-group-item">
<b>Identity Type:</b> {provider.identity.identity_type}
<b>Identity Type:</b> {identity_type}
</li>
</ul>
</div>
@@ -0,0 +1,41 @@
{
"Provider": "aws",
"CheckID": "awslambda_layer_no_secrets_in_content",
"CheckTitle": "Lambda layer content contains no hardcoded secrets",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Sensitive Data Identifications/Passwords",
"Effects/Data Exposure"
],
"ServiceName": "awslambda",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "AwsLambdaLayerVersion",
"ResourceGroup": "serverless",
"Description": "**Lambda layer content** is analyzed for **embedded secrets** across files in the layer's 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: a secret baked into a layer is pulled into every function that uses it, and is not covered by a function-code-only scan. If exposed, attackers can reuse credentials to access databases, APIs, or cloud resources, enabling data exfiltration and unauthorized changes. Rotation is harder, increasing dwell time and blast radius.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/lambda/latest/dg/chapter-layers.html",
"https://docs.aws.amazon.com/secretsmanager/latest/userguide/best-practices.html"
],
"Remediation": {
"Code": {
"CLI": "aws secretsmanager create-secret --name <secret-name> --secret-string <value>\naws iam put-role-policy --role-name <function-execution-role> --policy-name allow-get-secret --policy-document '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"secretsmanager:GetSecretValue\",\"Resource\":\"<secret-arn>\"}]}'\n# Remove the hardcoded value from the layer's code, then:\naws lambda publish-layer-version --layer-name <layer-name> --zip-file fileb://layer.zip",
"NativeIaC": "",
"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 the execution role of every function that uses this layer and add an inline policy allowing secretsmanager:GetSecretValue on that secret only.\n3. Remove the hardcoded value from the layer's code and repackage it, retrieving the secret at runtime using the AWS SDK (GetSecretValue) with the secret name/ARN.\n4. Publish a new layer version and update dependent functions to use it.",
"Terraform": ""
},
"Recommendation": {
"Text": "Use **AWS Secrets Manager** (or Parameter Store) to store secrets and retrieve at runtime; never put them in layer code or packaged dependencies.\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_layer_no_secrets_in_content"
}
},
"Categories": [
"secrets"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,164 @@
import fnmatch
import os
import tempfile
from collections import defaultdict
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.lib.logger import logger
from prowler.lib.utils.utils import (
SecretsScanError,
annotate_verified_secrets,
detect_secrets_scan_batch,
)
from prowler.providers.aws.services.awslambda.awslambda_client import awslambda_client
class awslambda_layer_no_secrets_in_content(Check):
"""Check if Lambda layer content contains hardcoded secrets.
Scans every file inside each Lambda layer version's package with the
secret scanner.
- PASS: No secrets are detected in the layer content.
- FAIL: At least one potential secret is detected in the layer content.
- MANUAL: The layer content could not be fetched or scanned.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Execute the Lambda layer secrets scan.
Returns:
list[Check_Report_AWS]: One report per Lambda layer version used by
the audited functions, or an empty list when there are no layers.
"""
findings = []
if not awslambda_client.layers:
return findings
secrets_ignore_patterns = awslambda_client.audit_config.get(
"secrets_ignore_patterns", []
)
# Glob patterns of file names inside the layer package to skip
# when scanning for secrets (e.g. "*.deps.json" for .NET layers).
secrets_ignore_files = (
awslambda_client.audit_config.get("secrets_ignore_files", []) or []
)
validate = awslambda_client.audit_config.get("secrets_validate", False)
# Scan files of every layer version's package in batched
# Kingfisher invocations instead of one subprocess per file per layer.
# Each package is extracted one at a time and its files are
# read (byte-faithfully via latin-1) before the extraction is released,
# so only a single package is on disk at a time. Findings are keyed by
# (layer index, package-relative file name) so they can be grouped
# back per layer.
layers_with_code = []
def code_payloads():
for layer, layer_code in awslambda_client._get_layers_code():
if not layer_code:
continue
with tempfile.TemporaryDirectory() as tmp_dir_name:
try:
layer_code.code_zip.extractall(tmp_dir_name)
except Exception as error:
# A corrupt or truncated package must not abort the
# scan of the remaining layers: keep this layer out of
# layers_with_code so it is reported as MANUAL below.
logger.error(
f"{layer.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
continue
index = len(layers_with_code)
layers_with_code.append(layer)
for root, _, files in os.walk(tmp_dir_name):
for file_name in files:
file_path = os.path.join(root, file_name)
relative_file_path = os.path.relpath(
file_path, tmp_dir_name
)
if any(
fnmatch.fnmatch(relative_file_path, pattern)
for pattern in secrets_ignore_files
):
continue
try:
with open(file_path, "rb") as code_file:
content = code_file.read().decode("latin-1")
except Exception:
continue
yield (index, relative_file_path), content
scan_error = None
try:
batch_results = detect_secrets_scan_batch(
code_payloads(),
excluded_secrets=secrets_ignore_patterns,
validate=validate,
)
except SecretsScanError as error:
batch_results = {}
scan_error = error
if scan_error:
# The scan failed before any layer's code could be cleared. Report
# MANUAL for every layer rather than risk a false PASS.
for layer in awslambda_client.layers.values():
report = Check_Report_AWS(metadata=self.metadata(), resource=layer)
report.status = "MANUAL"
report.status_extended = (
f"Could not scan Lambda layer {layer.name} (version "
f"{layer.version}) content for secrets: {scan_error}; "
"manual review is required."
)
findings.append(report)
return findings
findings_by_layer = defaultdict(dict)
for (index, file_name), file_findings in batch_results.items():
findings_by_layer[index][file_name] = file_findings
for index, layer in enumerate(layers_with_code):
report = Check_Report_AWS(metadata=self.metadata(), resource=layer)
report.status = "PASS"
report.status_extended = (
f"No secrets found in Lambda layer {layer.name} "
f"(version {layer.version}) content."
)
files_with_secrets = findings_by_layer.get(index)
if files_with_secrets:
all_secrets = []
secrets_findings = []
for file_name, file_findings in files_with_secrets.items():
all_secrets.extend(file_findings)
secrets_string = ", ".join(
f"{secret['type']} on line {secret['line_number']}"
for secret in file_findings
)
secrets_findings.append(f"{file_name}: {secrets_string}")
final_output_string = "; ".join(secrets_findings)
report.status = "FAIL"
report.status_extended = f"Potential {'secrets' if len(secrets_findings) > 1 else 'secret'} found in Lambda layer {layer.name} (version {layer.version}) content -> {final_output_string}."
annotate_verified_secrets(report, all_secrets)
findings.append(report)
# Layers whose content could not be fetched (network error, missing
# permissions, etc.) never reach layers_with_code above, so report
# them as MANUAL rather than silently omitting them from the scan.
fetched_arns = {layer.arn for layer in layers_with_code}
for layer in awslambda_client.layers.values():
if layer.arn in fetched_arns:
continue
report = Check_Report_AWS(metadata=self.metadata(), resource=layer)
report.status = "MANUAL"
report.status_extended = (
f"Could not retrieve content of Lambda layer {layer.name} "
f"(version {layer.version}) to scan for secrets; manual "
"review is required."
)
findings.append(report)
return findings
@@ -17,6 +17,11 @@ from prowler.lib.resource_limit import (
from prowler.lib.scan_filters.scan_filters import is_resource_filtered
from prowler.providers.aws.lib.service.service import AWSService
# Presigned code/layer download URLs are short-lived S3 URLs, not AWS API
# calls, so a hung request here would otherwise block a worker thread
# indefinitely instead of failing like the surrounding boto3 calls do.
CODE_DOWNLOAD_TIMEOUT_SECONDS = 30
class Lambda(AWSService):
def __init__(self, provider):
@@ -25,6 +30,7 @@ class Lambda(AWSService):
# Functions are listed first, then trimmed to the subset selected for
# analysis before expensive per-function detail is hydrated.
self.functions = {}
self.layers = {}
self.security_groups_in_use = set()
self.regions_with_functions = set()
self.function_limit = get_resource_scan_limit(
@@ -32,6 +38,7 @@ class Lambda(AWSService):
)
self.__threading_call__(self._list_functions)
self._select_functions_for_analysis()
self._collect_layers()
self._list_tags_for_resource()
self.__threading_call__(self._get_policy)
self.__threading_call__(self._get_function_url_config)
@@ -106,6 +113,11 @@ class Lambda(AWSService):
)
}
def _collect_layers(self):
for function in self.functions.values():
for layer in function.layers:
self.layers.setdefault(layer.arn, layer)
def _list_event_source_mappings(self, regional_client):
logger.info("Lambda - Listing Event Source Mappings...")
try:
@@ -193,6 +205,15 @@ class Lambda(AWSService):
f"{function.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _download_code(self, code_location_uri):
raw_code_zip = requests.get(
code_location_uri, timeout=CODE_DOWNLOAD_TIMEOUT_SECONDS
).content
return LambdaCode(
location=code_location_uri,
code_zip=zipfile.ZipFile(io.BytesIO(raw_code_zip)),
)
def _fetch_function_code(self, function_name, function_region):
try:
regional_client = self.regional_clients[function_region]
@@ -200,18 +221,52 @@ class Lambda(AWSService):
FunctionName=function_name
)
if "Location" in function_information["Code"]:
code_location_uri = function_information["Code"]["Location"]
raw_code_zip = requests.get(code_location_uri).content
return LambdaCode(
location=code_location_uri,
code_zip=zipfile.ZipFile(io.BytesIO(raw_code_zip)),
)
return self._download_code(function_information["Code"]["Location"])
except Exception as error:
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
raise
def _get_layers_code(self):
logger.info("Lambda - Getting Layer Code...")
# Use a thread pool to handle the queueing and execution of the
# _fetch_layer_code tasks, up to max_workers tasks concurrently.
layers_to_fetch = {
self.thread_pool.submit(
self._fetch_layer_code, layer.arn, layer.region
): layer
for layer in self.layers.values()
}
for fetched_layer_code in as_completed(layers_to_fetch):
layer = layers_to_fetch[fetched_layer_code]
try:
layer_code = fetched_layer_code.result()
if layer_code:
yield layer, layer_code
except Exception as error:
logger.error(
f"{layer.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _fetch_layer_code(self, layer_arn, layer_region):
try:
regional_client = self.regional_clients[layer_region]
# Fetch by the full layer-version ARN: layers attached to a
# function may be owned by another account (e.g. vendor or
# AWS-provided layers), where a bare layer name would resolve
# against the audited account instead.
layer_version = regional_client.get_layer_version_by_arn(Arn=layer_arn)
if "Location" in (layer_version.get("Content") or {}):
return self._download_code(layer_version["Content"]["Location"])
return None
except Exception as error:
logger.error(
f"{layer_region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
raise
def _get_policy(self, regional_client):
logger.info("Lambda - Getting Policy...")
try:
@@ -308,6 +363,28 @@ class Layer(BaseModel):
parts = self.arn.split(":")
return parts[4] if len(parts) >= 5 else ""
@property
def region(self) -> str:
"""Extract the region from the layer ARN.
A layer can only be attached to a function in the same region, so
this is always one of the regions already being audited.
"""
parts = self.arn.split(":")
return parts[3] if len(parts) >= 4 else ""
@property
def name(self) -> str:
"""Extract the layer name from the ARN."""
parts = self.arn.split(":")
return parts[6] if len(parts) >= 7 else self.arn
@property
def version(self) -> str:
"""Extract the layer version from the ARN."""
parts = self.arn.split(":")
return parts[7] if len(parts) >= 8 else ""
class DeadLetterConfig(BaseModel):
target_arn: str
@@ -0,0 +1,6 @@
"""AWS Batch service client singleton."""
from prowler.providers.aws.services.batch.batch_service import Batch
from prowler.providers.common.provider import Provider
batch_client = Batch(Provider.get_global_provider())
@@ -0,0 +1,41 @@
{
"Provider": "aws",
"CheckID": "batch_job_definition_no_secrets",
"CheckTitle": "AWS Batch job definitions have no secrets in environment variables or command parameters",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
"Sensitive Data Identifications/Passwords",
"TTPs/Credential Access"
],
"ServiceName": "batch",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "AwsBatchJobDefinition",
"ResourceGroup": "container",
"Description": "**AWS Batch job definitions** are analyzed for **plaintext secrets** placed in container `environment` variables and `command` parameters. It identifies values that resemble credentials (keys, tokens, passwords) within job definitions.",
"Risk": "Exposed secrets in env vars or command parameters undermine confidentiality via logs, job metadata, and introspection.\n\nWith container or read-only API access, attackers can reuse credentials to read databases, modify records (integrity), pivot to other services, and trigger outages or unauthorized costs (availability).",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/batch/latest/userguide/job_definition_parameters.html"
],
"Remediation": {
"Code": {
"CLI": "aws batch register-job-definition --job-definition-name <JOB_DEFINITION_NAME> --type container --container-properties '{\"image\":\"<IMAGE>\",\"secrets\":[{\"name\":\"<SECRET_NAME>\",\"valueFrom\":\"arn:aws:secretsmanager:<REGION>:<ACCOUNT_ID>:secret:<SECRET_NAME>-<RANDOM>\"}]}' # Register a new revision without plaintext secrets; reference Secrets Manager or SSM Parameter Store via valueFrom",
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::Batch::JobDefinition\n Properties:\n Type: container\n ContainerProperties:\n Image: <image>\n Environment:\n - Name: DB_PASSWORD\n Value: !Ref <secret_parameter> # Reference SSM Parameter or Secrets Manager\n```",
"Other": "1. In the AWS Console, go to Batch > Job Definitions and open your job definition\n2. Create a new revision\n3. Remove any sensitive values from Environment variables and command parameters\n4. Reference secrets from AWS Secrets Manager or SSM Parameter Store instead\n5. Save to create the new revision\n6. Update any Batch job queues to use the new job definition revision",
"Terraform": "```hcl\nresource \"aws_batch_job_definition\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n type = \"container\"\n\n container_properties = jsonencode({\n image = \"<image>\"\n environment = [\n {\n name = \"DB_PASSWORD\"\n value = var.db_password # Use variable from Secrets Manager or SSM\n }\n ]\n })\n}\n```"
},
"Recommendation": {
"Text": "Store secrets in **AWS Secrets Manager** or **SSM Parameter Store** and inject them at runtime instead of plaintext env vars.\n\nApply **least privilege** via job role, enable regular **rotation**, avoid logging secret values, and prefer **ephemeral credentials** for downstream services.",
"Url": "https://hub.prowler.com/check/batch_job_definition_no_secrets"
}
},
"Categories": [
"secrets"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Only container job definitions are evaluated (containerProperties.environment and command). Multi-node parallel (nodeProperties) and EKS (eksProperties) job definitions are not analyzed."
}
@@ -0,0 +1,120 @@
from json import dumps
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.lib.utils.utils import (
SecretsScanError,
annotate_verified_secrets,
detect_secrets_scan_batch,
)
from prowler.providers.aws.services.batch.batch_client import batch_client
class batch_job_definition_no_secrets(Check):
"""Detect secrets in AWS Batch job definition environment variables and commands."""
def execute(self) -> list[Check_Report_AWS]:
"""Scan job definitions for hardcoded secrets in env vars and commands."""
findings = []
secrets_ignore_patterns = batch_client.audit_config.get(
"secrets_ignore_patterns", []
)
validate = batch_client.audit_config.get("secrets_validate", False)
job_definitions = list(batch_client.job_definitions.values())
def scan_payloads():
"""Yield index-keyed payloads for each env var and the command."""
for jd_index, job_definition in enumerate(job_definitions):
container = job_definition.container_properties
for env_index, env_var in enumerate(container.environment):
yield (jd_index, env_index), dumps(
{env_var.name: env_var.value}, indent=2
)
if container.command:
yield (
(jd_index, "command"),
" ".join(container.command),
)
scan_error = None
try:
batch_results = detect_secrets_scan_batch(
scan_payloads(),
excluded_secrets=secrets_ignore_patterns,
validate=validate,
)
except SecretsScanError as error:
batch_results = {}
scan_error = error
for jd_index, job_definition in enumerate(job_definitions):
report = Check_Report_AWS(
metadata=self.metadata(),
resource=job_definition,
)
report.resource_id = f"{job_definition.name}:{job_definition.revision}"
report.status = "PASS"
extended_status_parts = []
all_secrets = []
container = job_definition.container_properties
if scan_error and (container.environment or container.command):
report.status = "MANUAL"
report.status_extended = (
f"Could not scan Batch job definition "
f"{job_definition.name} with revision "
f"{job_definition.revision} for secrets: "
f"{scan_error}; manual review is required."
)
findings.append(report)
continue
for env_index, env_var in enumerate(container.environment):
env_secrets = batch_results.get((jd_index, env_index))
if env_secrets:
all_secrets.extend(env_secrets)
secrets_string = ", ".join(
f"{secret['type']} on the environment variable {env_var.name}"
for secret in env_secrets
)
extended_status_parts.append(
f"Secrets in environment variables -> {secrets_string}"
)
if container.command:
command_secrets = batch_results.get((jd_index, "command"))
if command_secrets:
all_secrets.extend(command_secrets)
secrets_string = ", ".join(
secret["type"] for secret in command_secrets
)
extended_status_parts.append(
f"Secrets in command -> {secrets_string}"
)
if extended_status_parts:
report.status = "FAIL"
report.status_extended = (
f"Potential secrets found in Batch job definition "
f"{job_definition.name} with revision "
f"{job_definition.revision}: "
+ "; ".join(extended_status_parts)
+ "."
)
annotate_verified_secrets(report, all_secrets)
else:
report.status_extended = (
f"No secrets found in Batch job definition "
f"{job_definition.name} with revision "
f"{job_definition.revision}."
)
findings.append(report)
return findings
@@ -0,0 +1,105 @@
from itertools import zip_longest
from typing import Optional
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
from prowler.lib.resource_limit import get_resource_scan_limit, limit_resources
from prowler.lib.scan_filters.scan_filters import is_resource_filtered
from prowler.providers.aws.lib.service.service import AWSService
class ContainerEnvVariable(BaseModel):
"""An environment variable name-value pair."""
name: str
value: str
class BatchContainerProperties(BaseModel):
"""Container properties for an AWS Batch job definition."""
image: Optional[str]
command: list[str] = []
environment: list[ContainerEnvVariable] = []
class BatchJobDefinition(BaseModel):
"""An AWS Batch job definition with its container properties."""
name: str
arn: str
revision: int
region: str
container_properties: BatchContainerProperties
class Batch(AWSService):
"""AWS Batch service client for listing job definitions."""
def __init__(self, provider):
super().__init__(__class__.__name__, provider)
self.job_definitions = {}
self._job_definitions_by_region = {}
self.job_definition_limit = get_resource_scan_limit(
self.audit_config, "max_batch_job_definitions"
)
self.__threading_call__(self._list_job_definitions)
self._select_job_definitions_for_analysis()
def _list_job_definitions(self, regional_client):
"""List ACTIVE job definitions for a regional client."""
logger.info("Batch - Listing Job Definitions...")
try:
paginator = regional_client.get_paginator("describe_job_definitions")
regional_job_definitions = []
# Deregistered (INACTIVE) revisions are excluded: they cannot run
# new jobs, and reporting them would only produce noise.
for page in paginator.paginate(status="ACTIVE"):
for job in page.get("jobDefinitions", []):
if self.audit_resources and not is_resource_filtered(
job["jobDefinitionArn"], self.audit_resources
):
continue
container = job.get("containerProperties", {})
environment = [
ContainerEnvVariable(
name=env["name"], value=env.get("value", "")
)
for env in container.get("environment", [])
]
regional_job_definitions.append(
BatchJobDefinition(
name=job["jobDefinitionName"],
arn=job["jobDefinitionArn"],
revision=job["revision"],
region=regional_client.region,
container_properties=BatchContainerProperties(
image=container.get("image"),
command=container.get("command", []),
environment=environment,
),
)
)
self._job_definitions_by_region[regional_client.region] = (
regional_job_definitions
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _select_job_definitions_for_analysis(self):
"""Apply the global resource limit, interleaving regions fairly."""
interleaved = [
job_definition
for region_batch in zip_longest(*self._job_definitions_by_region.values())
for job_definition in region_batch
if job_definition
]
self.job_definitions = {
job_definition.arn: job_definition
for job_definition in limit_resources(
interleaved, self.job_definition_limit
)
}
@@ -1,6 +1,6 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGithub
from prowler.lib.check.models import Check, CheckReportGithub, Severity
from prowler.providers.github.services.organization.organization_client import (
organization_client,
)
@@ -15,8 +15,19 @@ def _join_human_readable(items: List[str]) -> str:
return ", ".join(items[:-1]) + f" and {items[-1]}"
PUBLIC_CREATION_TYPES = {"all", "public"}
NON_PUBLIC_CREATION_TYPES = {"private", "internal"}
PUBLIC_DISABLED_CREATION_TYPES = NON_PUBLIC_CREATION_TYPES | {"none"}
KNOWN_CREATION_TYPES = PUBLIC_CREATION_TYPES | PUBLIC_DISABLED_CREATION_TYPES
class organization_repository_creation_limited(Check):
"""Check if repository creation is limited to trusted organization members."""
"""Check if repository creation is limited to trusted organization members.
FAIL severity scales with the visibility members can create: high when public
repository creation is (or may be) allowed, low when it is provably limited to
private/internal repositories.
"""
def execute(self) -> List[CheckReportGithub]:
findings = []
@@ -48,12 +59,19 @@ class organization_repository_creation_limited(Check):
org, "members_allowed_repository_creation_type", None
)
normalized_type = creation_type.lower() if creation_type else ""
type_flags = []
enabled_types = []
if global_creation is not None:
if global_creation:
enabled_types.append("repositories of any type")
public_known_disabled = (
public_creation is False
or normalized_type in PUBLIC_DISABLED_CREATION_TYPES
)
if not public_known_disabled:
enabled_types.append("repositories of any type")
else:
type_flags.append(False)
@@ -70,7 +88,6 @@ class organization_repository_creation_limited(Check):
enabled_types.append(label)
if creation_type:
normalized_type = creation_type.lower()
if normalized_type == "none":
type_flags.append(False)
else:
@@ -97,7 +114,28 @@ class organization_repository_creation_limited(Check):
unique_enabled = list(dict.fromkeys(enabled_types))
allowed_desc = _join_human_readable(unique_enabled)
if allowed_desc:
report.status_extended = f"Organization {org.name} allows members to create {allowed_desc}."
public_allowed = (
public_creation is True
or normalized_type in PUBLIC_CREATION_TYPES
)
non_public_allowed = (
private_creation is True
or internal_creation is True
or normalized_type in NON_PUBLIC_CREATION_TYPES
)
public_known = (
public_creation is not None
or normalized_type in KNOWN_CREATION_TYPES
)
if not public_allowed and non_public_allowed and public_known:
report.check_metadata.Severity = Severity.low
report.status_extended = (
f"Organization {org.name} allows members to create {allowed_desc}. "
"Public repository creation is disabled."
)
else:
report.status_extended = f"Organization {org.name} allows members to create {allowed_desc}."
else:
report.status_extended = f"Organization {org.name} does not have enough data to confirm repository creation restrictions."
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_access_review_guest_users_configured",
"CheckTitle": "Access review for guest users is configured",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "An **access review** scoped to **guest users** should exist and be active so that external guest access is periodically recertified by reviewers. Requires a Microsoft Entra ID P2 license.",
"Risk": "Without recurring access reviews for guests, external accounts accumulate over time and retain access long after it is needed, expanding the attack surface and the risk of orphaned or abused guest accounts.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/id-governance/create-access-review"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **ID Governance** > **Access reviews** > **New access review**\n3. Set the scope to **Guest users only**\n4. Assign at least one non-guest reviewer, enable mail notifications and reminders\n5. Set a recurring schedule and create the review",
"Terraform": ""
},
"Recommendation": {
"Text": "Create a recurring access review scoped to guest users with assigned reviewers so guest access is periodically recertified.",
"Url": "https://hub.prowler.com/check/entra_access_review_guest_users_configured"
}
},
"Categories": [
"identity-access",
"e5"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Requires Microsoft Entra ID P2."
}
@@ -0,0 +1,128 @@
import re
from typing import List
from urllib.parse import unquote
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
ACTIVE_STATUSES = {"InProgress"}
ALLOWED_RECURRENCE_PATTERNS = {"weekly", "absoluteMonthly"}
GUEST_USER_PREDICATE = re.compile(r"\busertype\s+eq\s+(['\"])guest\1", re.IGNORECASE)
NEGATED_GUEST_USER_PREDICATE = re.compile(
r"\bnot\s*\(\s*usertype\s+eq\s+(['\"])guest\1\s*\)", re.IGNORECASE
)
class entra_access_review_guest_users_configured(Check):
"""Check if an access review for guest users is configured and fail-closed.
An access review scoped to guest users should exist, be active, recurring,
and have primary reviewers assigned. It should also be fail-closed: if
reviewers do not respond, access is removed
(``defaultDecisionEnabled`` with ``defaultDecision`` = Deny and
``autoApplyDecisionsEnabled``), with mail notifications and reminders enabled.
- PASS: A compliant recurring access review scoped to guest users exists.
- FAIL: No compliant recurring access review scoped to guest users exists.
"""
def _targets_guest_users(self, definition) -> bool:
"""Determine whether an access review targets guest users.
Portal-created reviews use a principal-resource-memberships scope where the
guest filter (``userType eq 'Guest'``) lives in the principal scopes and the
top-level scope query is empty, so both are inspected.
This bounded matcher recognizes the equality predicate and its direct
``not(...)`` negation. It does not interpret other compound or nested OData
boolean semantics.
Args:
definition: The access review definition to evaluate.
Returns:
bool: True if any scope contains the guest-user equality predicate.
"""
queries = [definition.scope_query] + definition.principal_scope_queries
for query in queries:
decoded_query = unquote(query)
if NEGATED_GUEST_USER_PREDICATE.search(decoded_query):
continue
if GUEST_USER_PREDICATE.search(decoded_query):
return True
return False
def _is_recurring_with_reviewers(self, definition) -> bool:
"""Determine whether recurrence and primary reviewers are configured."""
return (
definition.recurrence_pattern_type in ALLOWED_RECURRENCE_PATTERNS
and definition.recurrence_range_type == "noEnd"
and definition.has_primary_reviewers
)
def _is_fail_closed(self, definition) -> bool:
"""Determine whether an access review definition is fail-closed.
Args:
definition: The access review definition to evaluate.
Returns:
bool: True if the review enables and denies access by default,
auto-applies decisions, and has mail notifications and reminders enabled.
"""
return (
definition.default_decision == "Deny"
and definition.default_decision_enabled
and definition.auto_apply_enabled
and definition.mail_notifications_enabled
and definition.reminders_enabled
)
def execute(self) -> List[CheckReportM365]:
"""Evaluate whether a compliant access review for guest users exists.
Searches the tenant's access review definitions for an active, recurring,
reviewer-assigned, fail-closed review scoped to guest users.
Returns:
List[CheckReportM365]: A single report indicating whether a compliant
access review scoped to guest users is configured.
"""
findings = []
definitions = entra_client.access_review_definitions
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Access Review Definitions",
resource_id="accessReviewDefinitions",
)
report.status = "FAIL"
report.status_extended = (
"No compliant recurring access review scoped to guest users is configured "
"with assigned primary reviewers."
)
for definition in definitions:
if (
definition.status in ACTIVE_STATUSES
and self._targets_guest_users(definition)
and self._is_fail_closed(definition)
and self._is_recurring_with_reviewers(definition)
):
report = CheckReportM365(
metadata=self.metadata(),
resource=definition,
resource_name=definition.display_name or "Access Review",
resource_id=definition.id,
)
report.status = "PASS"
report.status_extended = (
f"Access review '{definition.display_name or definition.id}' for "
"guest users is active, recurring, reviewer-assigned, and "
"fail-closed."
)
break
findings.append(report)
return findings
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_access_review_privileged_roles_configured",
"CheckTitle": "Access review for privileged roles is configured",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "An **access review** scoped to **privileged (PIM) directory roles** should exist and be active so privileged role assignments are periodically recertified by reviewers. Requires a Microsoft Entra ID P2 license.",
"Risk": "Without recurring access reviews of privileged roles, standing privileged assignments accumulate and persist beyond their need, increasing the risk of privilege misuse and expanding the impact of a compromised administrator account.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-create-roles-and-resource-roles-review"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **ID Governance** > **Privileged Identity Management** > **Microsoft Entra Roles** > **Access reviews** > **New**\n3. Scope the review to the privileged roles, set the assignment type to Eligible and Active\n4. Assign reviewers, enable notifications, and set a recurring schedule\n5. Create the review",
"Terraform": ""
},
"Recommendation": {
"Text": "Create a recurring access review scoped to privileged roles with assigned reviewers so privileged role assignments are periodically recertified.",
"Url": "https://hub.prowler.com/check/entra_access_review_privileged_roles_configured"
}
},
"Categories": [
"identity-access",
"e5"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Requires Microsoft Entra ID P2."
}
@@ -0,0 +1,117 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
ACTIVE_STATUSES = {"InProgress"}
ALLOWED_RECURRENCE_PATTERNS = {"weekly", "absoluteMonthly"}
# Markers that indicate the review targets directory role assignments (PIM roles).
PRIVILEGED_SCOPE_MARKERS = ("roledefinition", "rolemanagement", "roleassignment")
class entra_access_review_privileged_roles_configured(Check):
"""Check if a compliant recurring access review for privileged roles exists.
An access review scoped to privileged (PIM) directory roles should exist, be
active, recurring, and have primary reviewers assigned. It should use
``defaultDecision`` = None with ``autoApplyDecisionsEnabled``, mail
notifications, and reminders enabled.
- PASS: A compliant recurring access review scoped to privileged roles exists.
- FAIL: No compliant recurring access review scoped to privileged roles exists.
"""
def _targets_privileged_roles(self, definition) -> bool:
"""Determine whether an access review targets privileged directory roles.
For PIM role reviews the role reference lives in the resource scopes, so both
the scope query and the resource scope queries are inspected for markers that
indicate directory role assignments.
Args:
definition: The access review definition to evaluate.
Returns:
bool: True if any of the review's scope queries reference privileged
(PIM) directory roles.
"""
queries = [definition.scope_query] + definition.resource_scope_queries
return any(
marker in query.lower()
for query in queries
for marker in PRIVILEGED_SCOPE_MARKERS
)
def _has_required_decision_settings(self, definition) -> bool:
"""Determine whether an access review has the required decision settings.
Args:
definition: The access review definition to evaluate.
Returns:
bool: True if the review makes no default decision, auto-applies
decisions, and has mail notifications and reminders enabled.
"""
return (
definition.default_decision == "None"
and definition.auto_apply_enabled
and definition.mail_notifications_enabled
and definition.reminders_enabled
)
def _is_recurring_with_reviewers(self, definition) -> bool:
"""Determine whether recurrence and primary reviewers are configured."""
return (
definition.recurrence_pattern_type in ALLOWED_RECURRENCE_PATTERNS
and definition.recurrence_range_type == "noEnd"
and definition.has_primary_reviewers
)
def execute(self) -> List[CheckReportM365]:
"""Evaluate whether a compliant access review for privileged roles exists.
Searches the tenant's access review definitions for an active, recurring,
reviewer-assigned review scoped to privileged (PIM) directory roles.
Returns:
List[CheckReportM365]: A single report indicating whether a compliant
access review scoped to privileged roles is configured.
"""
findings = []
definitions = entra_client.access_review_definitions
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Access Review Definitions",
resource_id="accessReviewDefinitions",
)
report.status = "FAIL"
report.status_extended = (
"No compliant recurring access review scoped to privileged roles is "
"configured with assigned primary reviewers."
)
for definition in definitions:
if (
definition.status in ACTIVE_STATUSES
and self._targets_privileged_roles(definition)
and self._has_required_decision_settings(definition)
and self._is_recurring_with_reviewers(definition)
):
report = CheckReportM365(
metadata=self.metadata(),
resource=definition,
resource_name=definition.display_name or "Access Review",
resource_id=definition.id,
)
report.status = "PASS"
report.status_extended = (
f"Access review '{definition.display_name or definition.id}' for "
"privileged roles is active, recurring, reviewer-assigned, and "
"configured with no default decision."
)
break
findings.append(report)
return findings
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_authentication_method_authenticator_show_context",
"CheckTitle": "Microsoft Authenticator shows application name and geographic location",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "The Microsoft Authenticator authentication method should have the **Show application name** (**displayAppInformationRequiredState**) and **Show geographic location** (**displayLocationInformationRequiredState**) feature settings enabled. This adds context to push and passwordless notifications so users can detect and reject fraudulent approval requests.",
"Risk": "Without application-name and location context, users approving **MFA** push notifications cannot tell a legitimate sign-in from an attacker-initiated one, making the tenant more vulnerable to MFA fatigue and prompt-bombing attacks.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/authentication/how-to-mfa-additional-context"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Authentication methods** > **Policies** > **Microsoft Authenticator**\n3. Select **Configure**\n4. Set **Show application name in push and passwordless notifications** to **Enabled**, Target **All users**\n5. Set **Show geographic location in push and passwordless notifications** to **Enabled**, Target **All users**\n6. Click **Save**",
"Terraform": ""
},
"Recommendation": {
"Text": "Enable the application-name and geographic-location context settings for Microsoft Authenticator so users receive additional information to identify and reject fraudulent sign-in approvals.",
"Url": "https://hub.prowler.com/check/entra_authentication_method_authenticator_show_context"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,61 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
class entra_authentication_method_authenticator_show_context(Check):
"""Check if Microsoft Authenticator shows application name and geographic location.
The Microsoft Authenticator method should be enabled with the
``displayAppInformationRequiredState`` and
``displayLocationInformationRequiredState`` feature settings enabled, so users see
the app name and sign-in location context in push and passwordless notifications.
- PASS: Both application name and geographic location context are shown.
- FAIL: Application name and/or geographic location context is not shown.
"""
def execute(self) -> List[CheckReportM365]:
"""Execute the Microsoft Authenticator show context check.
Verifies that the Microsoft Authenticator method is enabled and configured to
display both the application name and the geographic location of the sign-in in
push and passwordless notifications.
Returns:
List[CheckReportM365]: A list with a single report describing whether
Microsoft Authenticator shows application name and geographic location
context, or an empty list when the authentication methods policy settings
are not available.
"""
findings = []
settings = entra_client.authentication_methods_policy_settings
if not settings:
return findings
report = CheckReportM365(
metadata=self.metadata(),
resource=settings,
resource_name="Microsoft Authenticator Method",
resource_id="microsoftAuthenticator",
)
report.status = "FAIL"
report.status_extended = (
"Microsoft Authenticator does not show both application name and "
"geographic location in notifications."
)
if (
settings.authenticator_state == "enabled"
and settings.authenticator_display_app_information_state == "enabled"
and settings.authenticator_display_location_information_state == "enabled"
):
report.status = "PASS"
report.status_extended = (
"Microsoft Authenticator shows application name and geographic "
"location in notifications."
)
findings.append(report)
return findings
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_authentication_method_email_otp_disabled",
"CheckTitle": "Email One-Time Passcode authentication method is disabled in the tenant",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "Microsoft Entra tenant's authentication methods policy should have the **Email OTP** authentication method disabled. Email one-time passcodes depend on the security of the recipient mailbox, which is typically a lower-assurance channel, and should not be used as a primary or fallback multi-factor authentication method for members.",
"Risk": "**Email OTP** is vulnerable to mailbox compromise and **phishing**. If an attacker gains access to a user's mailbox, they can intercept one-time passcodes and bypass **MFA**, gaining unauthorized access to the tenant.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/authentication/howto-authentication-email-otp"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Authentication methods** > **Policies**\n3. Select **Email OTP** and set its status to **Disabled**, then click **Save**\n4. Ensure users have phishing-resistant MFA methods configured (e.g., FIDO2, Microsoft Authenticator)",
"Terraform": ""
},
"Recommendation": {
"Text": "Disable the Email OTP authentication method and require phishing-resistant MFA methods such as FIDO2 security keys or Microsoft Authenticator across the tenant.",
"Url": "https://hub.prowler.com/check/entra_authentication_method_email_otp_disabled"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,57 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
class entra_authentication_method_email_otp_disabled(Check):
"""
Ensure that the Email One-Time Passcode (OTP) authentication method is disabled.
This check verifies that the tenant's authentication methods policy has the Email OTP
method disabled. Email OTP relies on the security of the mailbox, which is often a
lower-assurance channel and is unsuitable as a primary or fallback MFA method.
- PASS: Email OTP authentication method is disabled.
- FAIL: Email OTP authentication method is enabled.
"""
def execute(self) -> List[CheckReportM365]:
"""Execute the Email OTP authentication method check.
Returns:
A list with a single report containing the result of the check.
"""
findings = []
configs = entra_client.authentication_method_configurations
email_config = configs.get("Email")
if email_config:
report = CheckReportM365(
metadata=self.metadata(),
resource=email_config,
resource_name="Email OTP Authentication Method",
resource_id=entra_client.tenant_domain,
)
if email_config.state == "disabled":
report.status = "PASS"
report.status_extended = (
"Email OTP authentication method is disabled in the tenant."
)
elif email_config.state == "enabled":
report.status = "FAIL"
report.status_extended = (
"Email OTP authentication method is enabled in the tenant."
)
else:
report.status = "FAIL"
report.status_extended = (
"Email OTP authentication method state could not be determined; "
"treating as enabled/non-compliant."
)
findings.append(report)
return findings
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_conditional_access_policy_authentication_transfer_blocked",
"CheckTitle": "Conditional Access policy blocks authentication transfer",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "At least one enabled Conditional Access policy should target the **authentication transfer** flow for **all users** and **all resources** and set the grant control to **Block**. Authentication transfer lets a user move an authenticated session from one device to another (for example, by scanning a QR code), which can be abused to move a session onto an attacker-controlled device.",
"Risk": "If authentication transfer is not blocked, an attacker who can present a transfer prompt (e.g., a malicious QR code) may capture an authenticated session and gain access to the victim's resources without needing their credentials or **MFA**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-conditions"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Conditional Access** > **Policies** > **New policy**\n3. Under **Users**, include **All users** (exclude only documented break-glass accounts)\n4. Under **Target resources**, select **All resources**\n5. Under **Conditions** > **Authentication flows**, enable and select **Authentication transfer**\n6. Under **Grant**, select **Block access**\n7. Set the policy to **On** and click **Create**",
"Terraform": ""
},
"Recommendation": {
"Text": "Create a Conditional Access policy that blocks the authentication transfer flow for all users and all resources, excluding only documented break-glass accounts.",
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_authentication_transfer_blocked"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,62 @@
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessGrantControl,
ConditionalAccessPolicyState,
TransferMethod,
)
class entra_conditional_access_policy_authentication_transfer_blocked(Check):
"""Check if at least one Conditional Access policy blocks authentication transfer.
This check ensures that at least one enabled Conditional Access policy targets
the authentication transfer flow and blocks access, preventing an authenticated
session from being seamlessly transferred to another (potentially attacker
controlled) device.
- PASS: An enabled Conditional Access policy blocks authentication transfer.
- FAIL: No Conditional Access policy restricts authentication transfer.
"""
def execute(self) -> list[CheckReportM365]:
"""Execute the check to verify authentication transfer is blocked by a Conditional Access policy.
Returns:
A list of reports containing the result of the check.
"""
findings = []
for policy in entra_client.conditional_access_policies.values():
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.display_name,
resource_id=policy.id,
)
report.status = "FAIL"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' does not block authentication transfer."
authentication_flows = policy.conditions.authentication_flows
blocks_authentication_transfer = (
policy.state != ConditionalAccessPolicyState.DISABLED
and "All" in policy.conditions.user_conditions.included_users
and "All"
in policy.conditions.application_conditions.included_applications
and not policy.conditions.application_conditions.excluded_applications
and authentication_flows
and TransferMethod.AUTHENTICATION_TRANSFER
in authentication_flows.transfer_methods
and ConditionalAccessGrantControl.BLOCK
in policy.grant_controls.built_in_controls
)
if blocks_authentication_transfer:
if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING:
report.status = "FAIL"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' reports authentication transfer but does not block it."
else:
report.status = "PASS"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' blocks authentication transfer."
findings.append(report)
return findings
@@ -0,0 +1,38 @@
{
"Provider": "m365",
"CheckID": "entra_conditional_access_policy_block_high_medium_sign_in_risk",
"CheckTitle": "Conditional Access policy blocks high and medium sign-in risk",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "At least one enabled Conditional Access policy should target **all users** and **all resources** with the **sign-in risk** condition set to **High** and **Medium** and the grant control set to **Block**. Microsoft Entra ID Protection evaluates sign-in risk in real time; blocking risky sign-ins prevents access from sessions that are likely not performed by the legitimate account owner.",
"Risk": "Without a **sign-in risk** based **Conditional Access** policy, sign-ins flagged as high or medium risk (indicative of compromised credentials, anomalous locations, or malicious IPs) are allowed, giving attackers a path to access resources with stolen credentials.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/id-protection/concept-identity-protection-policies",
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-conditions#sign-in-risk"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Conditional Access** > **Policies** > **New policy**\n3. Under **Users**, include **All users** (exclude only documented break-glass accounts)\n4. Under **Target resources**, select **All resources**\n5. Under **Conditions** > **Sign-in risk**, select **High** and **Medium**\n6. Under **Grant**, select **Block access**\n7. Set the policy to **On** and click **Create**",
"Terraform": ""
},
"Recommendation": {
"Text": "Create a risk-based Conditional Access policy that blocks high and medium sign-in risk for all users and all resources, excluding only documented break-glass accounts. Microsoft recommends configuring risk-based policies in Conditional Access rather than in the legacy Identity Protection policies.",
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_block_high_medium_sign_in_risk"
}
},
"Categories": [
"identity-access",
"e5"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,80 @@
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessGrantControl,
ConditionalAccessPolicyState,
RiskLevel,
)
class entra_conditional_access_policy_block_high_medium_sign_in_risk(Check):
"""Check if a Conditional Access policy blocks high and medium sign-in risk.
This check ensures that at least one enabled Conditional Access policy targets
all users and all resources with the sign-in risk condition set to High and
Medium, and blocks access, mitigating risky sign-in attempts detected by
Microsoft Entra ID Protection.
- PASS: An enabled Conditional Access policy blocks high and medium sign-in risk.
- FAIL: No Conditional Access policy blocks high and medium sign-in risk.
"""
def execute(self) -> list[CheckReportM365]:
"""Execute the check to verify high and medium sign-in risk is blocked.
Returns:
A list of reports containing the result of the check.
"""
findings = []
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Conditional Access Policies",
resource_id="conditionalAccessPolicies",
)
report.status = "FAIL"
report.status_extended = (
"No Conditional Access Policy blocks high and medium sign-in risk."
)
for policy in entra_client.conditional_access_policies.values():
if policy.state == ConditionalAccessPolicyState.DISABLED:
continue
if "All" not in policy.conditions.user_conditions.included_users:
continue
if (
"All"
not in policy.conditions.application_conditions.included_applications
):
continue
if policy.conditions.application_conditions.excluded_applications:
continue
if not {RiskLevel.HIGH, RiskLevel.MEDIUM}.issubset(
set(policy.conditions.sign_in_risk_levels)
):
continue
if (
ConditionalAccessGrantControl.BLOCK
in policy.grant_controls.built_in_controls
):
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.display_name,
resource_id=policy.id,
)
if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING:
report.status = "FAIL"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' reports high and medium sign-in risk but does not block it."
else:
report.status = "PASS"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' blocks high and medium sign-in risk."
break
findings.append(report)
return findings
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_conditional_access_policy_sign_in_frequency_all_users",
"CheckTitle": "Conditional Access policy enforces sign-in frequency for all users",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "At least one enabled Conditional Access policy should target **all users** and **all resources** and enforce a **sign-in frequency of 7 days or less**. Sign-in frequency defines how long an authenticated session remains valid before the user must reauthenticate. The Microsoft Entra default is a rolling 90-day window, which is too permissive.",
"Risk": "Long-lived sessions increase the window in which a stolen token or hijacked session can be used. Without an enforced reauthentication interval, a compromised session can remain valid for up to 90 days, extending an attacker's access.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-session-lifetime"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Conditional Access** > **Policies** > **New policy**\n3. Under **Users**, include **All users** (exclude only documented break-glass accounts)\n4. Under **Target resources**, select **All resources**\n5. Under **Session**, enable **Sign-in frequency** and set it to **7 days** or less\n6. Set the policy to **On** and click **Create**",
"Terraform": ""
},
"Recommendation": {
"Text": "Create a Conditional Access policy that enforces a sign-in frequency of 7 days or less for all users and all resources, excluding only documented break-glass accounts.",
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_sign_in_frequency_all_users"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,146 @@
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
from prowler.providers.m365.services.entra.entra_service import (
ClientAppType,
ConditionalAccessPolicyState,
SignInFrequencyInterval,
SignInFrequencyType,
)
# Maximum allowed reauthentication window (CIS: 7 days or less).
MAX_SIGN_IN_FREQUENCY_DAYS = 7
MAX_SIGN_IN_FREQUENCY_HOURS = MAX_SIGN_IN_FREQUENCY_DAYS * 24
class entra_conditional_access_policy_sign_in_frequency_all_users(Check):
"""Check if a Conditional Access policy enforces sign-in frequency for all users.
This check ensures that at least one enabled Conditional Access policy targets
all users and all resources and enforces a sign-in frequency of 7 days or less,
limiting how long an authenticated session remains valid before reauthentication.
- PASS: An enabled Conditional Access policy enforces sign-in frequency of 7 days
or less for all users.
- FAIL: No Conditional Access policy enforces sign-in frequency of 7 days or less
for all users.
"""
def _is_within_limit(self, sign_in_frequency) -> bool:
"""Return True if the sign-in frequency is 7 days or less."""
if not sign_in_frequency or not sign_in_frequency.is_enabled:
return False
if sign_in_frequency.interval == SignInFrequencyInterval.EVERY_TIME:
return True
if sign_in_frequency.frequency is None:
return False
if sign_in_frequency.type == SignInFrequencyType.DAYS:
return sign_in_frequency.frequency <= MAX_SIGN_IN_FREQUENCY_DAYS
if sign_in_frequency.type == SignInFrequencyType.HOURS:
return sign_in_frequency.frequency <= MAX_SIGN_IN_FREQUENCY_HOURS
return False
def _has_restricted_scope(self, conditions) -> bool:
"""Return True when policy conditions exclude part of the session scope."""
platforms = conditions.platform_conditions
platform_restricted = bool(
platforms
and (
platforms.exclude_platforms
or (
platforms.include_platforms
and "all" not in platforms.include_platforms
)
)
)
client_app_types = conditions.client_app_types or []
client_app_restricted = bool(
client_app_types and ClientAppType.ALL not in client_app_types
)
locations = conditions.locations
location_restricted = bool(
locations
and (
locations.exclude_locations
or (
locations.include_locations
and "All" not in locations.include_locations
)
)
)
device_conditions = conditions.device_conditions
device_restricted = bool(
device_conditions
and (
device_conditions.device_filter_mode
or device_conditions.device_filter_rule
)
)
authentication_flows = conditions.authentication_flows
authentication_flow_restricted = bool(
authentication_flows and authentication_flows.transfer_methods
)
user_conditions = conditions.user_conditions
user_restricted = bool(
user_conditions.excluded_users
or user_conditions.excluded_groups
or user_conditions.excluded_roles
or user_conditions.excluded_guests_or_external_users
)
application_restricted = bool(
conditions.application_conditions.included_user_actions
)
return bool(
platform_restricted
or client_app_restricted
or location_restricted
or device_restricted
or authentication_flow_restricted
or conditions.insider_risk_levels
or user_restricted
or application_restricted
)
def execute(self) -> list[CheckReportM365]:
"""Execute the check to verify sign-in frequency is enforced for all users.
Returns:
A list of reports containing the result of the check.
"""
findings = []
for policy in entra_client.conditional_access_policies.values():
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.display_name,
resource_id=policy.id,
)
report.status = "FAIL"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' does not enforce a sign-in frequency of 7 days or less for all users."
enforces_sign_in_frequency = (
policy.state != ConditionalAccessPolicyState.DISABLED
and "All" in policy.conditions.user_conditions.included_users
and "All"
in policy.conditions.application_conditions.included_applications
and not policy.conditions.application_conditions.excluded_applications
and not policy.conditions.sign_in_risk_levels
and not policy.conditions.user_risk_levels
and not self._has_restricted_scope(policy.conditions)
and self._is_within_limit(policy.session_controls.sign_in_frequency)
)
if enforces_sign_in_frequency:
if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING:
report.status_extended = f"Conditional Access Policy '{policy.display_name}' enforces sign-in frequency but is in report-only mode."
else:
report.status = "PASS"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' enforces a sign-in frequency of 7 days or less for all users."
findings.append(report)
return findings
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_conditional_access_policy_token_protection_enforced",
"CheckTitle": "Conditional Access policy enforces Token Protection",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "At least one enabled Conditional Access policy should enforce **Token Protection** (the **secureSignInSession** session control) for targeted users. Token Protection binds sign-in session tokens (such as Primary Refresh Tokens) to the device, reducing the effectiveness of token theft and replay attacks.",
"Risk": "Without **Token Protection**, stolen sign-in tokens can be replayed from an attacker's device to impersonate the user and bypass authentication, including **MFA**, because the token is not cryptographically bound to the original device.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-token-protection"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Conditional Access** > **Policies** > **New policy**\n3. Under **Users**, include the targeted users (exclude only documented break-glass accounts)\n4. Under **Target resources**, select the supported applications (e.g., Exchange Online, SharePoint Online)\n5. Under **Conditions** > **Device platforms**, include Windows\n6. Under **Session**, enable **Require token protection for sign-in sessions**\n7. Set the policy to **On** and click **Create**",
"Terraform": ""
},
"Recommendation": {
"Text": "Create a Conditional Access policy that enforces Token Protection (secure sign-in session) for supported applications on Windows devices to mitigate token theft and replay.",
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_token_protection_enforced"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,115 @@
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
from prowler.providers.m365.services.entra.entra_service import (
ClientAppType,
ConditionalAccessPolicyState,
)
# Applications that must be covered by the Token Protection policy.
EXCHANGE_ONLINE_APP_ID = "00000002-0000-0ff1-ce00-000000000000"
SHAREPOINT_ONLINE_APP_ID = "00000003-0000-0ff1-ce00-000000000000"
TEAMS_APP_ID = "cc15fd57-2c6c-4117-a88c-83b1d56b4bbe"
REQUIRED_APP_IDS = {EXCHANGE_ONLINE_APP_ID, SHAREPOINT_ONLINE_APP_ID, TEAMS_APP_ID}
class entra_conditional_access_policy_token_protection_enforced(Check):
"""Check if a Conditional Access policy enforces Token Protection.
Token Protection is a Conditional Access session control that reduces token
replay attacks by requiring device-bound sign-in session tokens. At least one
enabled Conditional Access policy should enable the ``secureSignInSession``
session control for the supported applications (Exchange Online, SharePoint
Online, Teams), target the Windows platform, and apply to mobile apps and desktop
clients.
- PASS: An enabled Conditional Access policy enforces Token Protection for the
supported applications on Windows desktop/mobile clients.
- FAIL: No Conditional Access policy enforces Token Protection with the required
scope.
"""
def _applications_covered(
self, included_applications, excluded_applications
) -> bool:
"""Check whether the policy covers every required application.
Args:
included_applications: Application IDs included by the policy.
excluded_applications: Application IDs excluded by the policy.
Returns:
True if all required applications are included and none are excluded.
"""
if REQUIRED_APP_IDS.intersection(excluded_applications):
return False
if "All" in included_applications:
return True
return REQUIRED_APP_IDS.issubset(set(included_applications))
def _windows_targeted(self, conditions) -> bool:
"""Check whether the policy targets Windows devices.
Args:
conditions: Conditional Access policy conditions.
Returns:
True if Windows is included in the targeted platforms.
"""
platform_conditions = conditions.platform_conditions
if not platform_conditions:
return False
return "windows" in platform_conditions.include_platforms
def _desktop_clients_targeted(self, conditions) -> bool:
"""Check whether the policy targets desktop-capable client apps.
Args:
conditions: Conditional Access policy conditions.
Returns:
True if all clients or mobile apps and desktop clients are targeted.
"""
client_app_types = conditions.client_app_types or []
return (
ClientAppType.ALL in client_app_types
or ClientAppType.MOBILE_APPS_AND_DESKTOP_CLIENTS in client_app_types
)
def execute(self) -> list[CheckReportM365]:
"""Execute the Token Protection Conditional Access policy check.
Returns:
A list containing the Token Protection policy evaluation report.
"""
findings = []
for policy in entra_client.conditional_access_policies.values():
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.display_name,
resource_id=policy.id,
)
report.status = "FAIL"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' does not enforce Token Protection for the supported applications on Windows desktop and mobile clients."
enforces_token_protection = (
policy.state != ConditionalAccessPolicyState.DISABLED
and policy.conditions.user_conditions.included_users
and policy.session_controls.secure_sign_in_session_enabled
and self._applications_covered(
policy.conditions.application_conditions.included_applications,
policy.conditions.application_conditions.excluded_applications,
)
and self._windows_targeted(policy.conditions)
and self._desktop_clients_targeted(policy.conditions)
)
if enforces_token_protection:
if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING:
report.status_extended = f"Conditional Access Policy '{policy.display_name}' enables Token Protection but is in report-only mode."
else:
report.status = "PASS"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' enforces Token Protection for the supported applications on Windows desktop and mobile clients."
findings.append(report)
return findings
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_conditional_access_policy_untrusted_locations_blocked",
"CheckTitle": "Conditional Access policy blocks access from untrusted locations",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "At least one enabled Conditional Access policy should target **all users** and **all resources**, include **all network locations**, exclude **trusted locations**, and set the grant control to **Block**. This blocks access originating from locations that are not explicitly trusted by the organization.",
"Risk": "Without a location-based block policy, users can authenticate from any geography or network, including regions where the organization has no presence, giving attackers operating from those locations a viable path to access resources with stolen credentials.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-assignment-network"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Conditional Access** > **Policies** > **New policy**\n3. Under **Users**, include **All users** (exclude only documented break-glass accounts)\n4. Under **Target resources**, select **All resources**\n5. Under **Network**, include **Any location** and exclude **All trusted locations** (or selected trusted named locations)\n6. Under **Grant**, select **Block access**\n7. Set the policy to **On** and click **Create**",
"Terraform": ""
},
"Recommendation": {
"Text": "Create a Conditional Access policy that blocks access from all locations except trusted named locations, targeting all users and resources, excluding only documented break-glass accounts.",
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_untrusted_locations_blocked"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,118 @@
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessGrantControl,
ConditionalAccessPolicyState,
)
class entra_conditional_access_policy_untrusted_locations_blocked(Check):
"""Check if a Conditional Access policy blocks access from untrusted locations.
At least one enabled Conditional Access policy should target all users and all
resources, include all network locations, exclude trusted locations, and block
access, so that access from untrusted locations is denied.
- PASS: An enabled Conditional Access policy blocks access from untrusted
locations.
- FAIL: No Conditional Access policy blocks access from untrusted locations.
"""
def _excludes_only_trusted(self, exclude_locations, trusted_location_ids) -> bool:
"""Check whether every excluded location is trusted.
Args:
exclude_locations: Location IDs excluded by the policy.
trusted_location_ids: IDs of collected trusted named locations.
Returns:
True if at least one location is excluded and all exclusions are trusted.
"""
if not exclude_locations:
return False
return all(
location_id == "AllTrusted" or location_id in trusted_location_ids
for location_id in exclude_locations
)
def execute(self) -> list[CheckReportM365]:
"""Execute the untrusted-locations Conditional Access policy check.
Returns:
A list containing the untrusted-locations policy evaluation report.
"""
findings = []
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Conditional Access Policies",
resource_id="conditionalAccessPolicies",
)
report.status = "FAIL"
report.status_extended = (
"No Conditional Access Policy blocks access from untrusted locations."
)
trusted_location_ids = {
location.id
for location in entra_client.named_locations
if location.is_trusted
}
untrusted_location_ids = {
location.id
for location in entra_client.named_locations
if not location.is_trusted
}
for policy in entra_client.conditional_access_policies.values():
if policy.state == ConditionalAccessPolicyState.DISABLED:
continue
if "All" not in policy.conditions.user_conditions.included_users:
continue
if (
"All"
not in policy.conditions.application_conditions.included_applications
or policy.conditions.application_conditions.excluded_applications
):
continue
locations = policy.conditions.locations
if not locations:
continue
if "All" not in locations.include_locations and not any(
location_id in untrusted_location_ids
for location_id in locations.include_locations
):
continue
# A trusted-location exclusion must exist so trusted networks keep access.
if not self._excludes_only_trusted(
locations.exclude_locations, trusted_location_ids
):
continue
if (
ConditionalAccessGrantControl.BLOCK
not in policy.grant_controls.built_in_controls
):
continue
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.display_name,
resource_id=policy.id,
)
if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING:
report.status = "FAIL"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' blocks untrusted locations but is in report-only mode."
else:
report.status = "PASS"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' blocks access from untrusted locations."
break
findings.append(report)
return findings

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